Skip to main content

Command Palette

Search for a command to run...

Using Azure Communication Service for Transactional Email in a New NestJS Project

Published
5 min read
Using Azure Communication Service for Transactional Email in a New NestJS Project
O
I am Oladele David, a software, DevOps & cybersecurity engineer. I build backends and cloud infrastructure and write about system design, open source, and learning in public.

If you’ve built apps that send transactional email you’ve probably used services like Mailgun, SendGrid, or Amazon SES. I ended up trying Azure Communication Service (ACS) because I had free credits and wanted something that fit neatly into my Azure workflow. The result surprised me — it’s simple, reliable, and good enough for many transactional use-cases.

This write-up is practical and hands-on: how to create a tiny NestJS app and wire ACS for email, how to test locally, and what to watch out for in production. I’ll keep explanations plain and show the exact code I used while experimenting.

What we’ll do

  1. Create a new NestJS app

  2. Install the Azure SDK and a couple helpers

  3. Implement a minimal MailService that can send via ACS or local SMTP (dev)

  4. Add simple Handlebars templating and a local dev flow (MailHog)

  5. Cover production tips (sender verification, secrets, observability)

Commands to get started

npx @nestjs/cli new acs-mail-demo
cd acs-mail-demo
pnpm add @azure/communication-email @nestjs/config handlebars nodemailer
pnpm add -D @types/node

Environment variables

Create a .env file in the project root. Keep secrets out of source control — use a secret manager in real projects.

MAIL_PROVIDER=azure
MAIL_AZURE_CONNECTION_STRING=endpoint=https://<your-resource>.communication.azure.com/;accesskey=<your_key>
MAIL_AZURE_SENDER_ADDRESS=no-reply@yourdomain.com
MAIL_AZURE_SENDER_NAME="Your App"

# For local development (MailHog)
MAIL_SMTP_HOST=localhost
MAIL_SMTP_PORT=1025
MAIL_SMTP_USER=
MAIL_SMTP_PASS=

Minimal project layout

  • src/

    • mail/

      • templates/ (HTML .html files for Handlebars)

      • mail.module.ts

      • mail.service.ts

    • app.module.ts

    • main.ts

The MailService —> code and explanation

I’ll paste the full service and explain the important parts after. This is compact and intentionally opinionated so you can copy-paste and iterate.

// src/mail/mail.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EmailClient } from '@azure/communication-email';
import * as nodemailer from 'nodemailer';
import * as fs from 'fs';
import * as path from 'path';
import * as handlebars from 'handlebars';

interface MailOptions { to: string | string[]; subject: string; text?: string; html?: string; }

@Injectable()
export class MailService {
  private readonly logger = new Logger(MailService.name);
  private azureEmailClient: EmailClient | null = null;
  private smtpTransporter: nodemailer.Transporter | null = null;
  private senderAddress: string;
  private senderName: string;
  private templates = new Map<string, handlebars.TemplateDelegate>();

  constructor(private config: ConfigService) {
    const provider = (this.config.get<string>('MAIL_PROVIDER') || 'smtp').toLowerCase();
    this.senderAddress = this.config.get<string>('MAIL_AZURE_SENDER_ADDRESS') || 'no-reply@local.dev';
    this.senderName = this.config.get<string>('MAIL_AZURE_SENDER_NAME') || 'App';

    if (provider === 'azure') {
      const cs = this.config.get<string>('MAIL_AZURE_CONNECTION_STRING');
      if (!cs) throw new Error('Azure connection string missing');
      this.azureEmailClient = new EmailClient(cs);
      this.logger.log('Azure Email client initialized');
    } else {
      this.smtpTransporter = nodemailer.createTransport({
        host: this.config.get<string>('MAIL_SMTP_HOST') || 'localhost',
        port: Number(this.config.get<string>('MAIL_SMTP_PORT') || 1025),
        secure: false,
        auth: this.config.get<string>('MAIL_SMTP_USER')
          ? { user: this.config.get<string>('MAIL_SMTP_USER')!, pass: this.config.get<string>('MAIL_SMTP_PASS')! }
          : undefined,
      });
      this.logger.log('SMTP transporter initialized (dev)');
    }

    // Try to preload templates from src/mail/templates
    try { this.preloadTemplates(path.join(process.cwd(), 'src', 'mail', 'templates')); } catch (e) { /* ignore */ }
  }

  private preloadTemplates(dir: string) {
    if (!fs.existsSync(dir)) return;
    const files = fs.readdirSync(dir).filter(f => f.endsWith('.html'));
    for (const f of files) {
      const name = path.basename(f, '.html');
      const content = fs.readFileSync(path.join(dir, f), 'utf8');
      this.templates.set(name, handlebars.compile(content));
      this.logger.log(`Loaded template: ${name}`);
    }
  }

  getTemplate(name: string, data: Record<string, any> = {}) {
    const tpl = this.templates.get(name);
    if (tpl) return tpl(data);
    const onDisk = path.join(process.cwd(), 'src', 'mail', 'templates', `${name}.html`);
    if (fs.existsSync(onDisk)) {
      const compiled = handlebars.compile(fs.readFileSync(onDisk, 'utf8'));
      this.templates.set(name, compiled);
      return compiled(data);
    }
    throw new Error(`Template not found: ${name}`);
  }

  async sendMail(opts: MailOptions) {
    if (this.azureEmailClient) return this.sendWithAzure(opts);
    if (this.smtpTransporter) return this.sendWithSMTP(opts);
    throw new Error('No mail provider configured');
  }

  private async sendWithAzure({ to, subject, text, html }: MailOptions) {
    const recipients = Array.isArray(to) ? to.map(a => ({ address: a })) : [{ address: to }];
    const message = {
      senderAddress: this.senderAddress,
      senderName: this.senderName,
      content: { subject, plainText: text || '', html: html || '' },
      recipients: { to: recipients },
    };
    const poller = await this.azureEmailClient!.beginSend(message);
    const res = await poller.pollUntilDone();
    this.logger.log(`Azure message id: ${res.id}`);
    return res;
  }

  private async sendWithSMTP({ to, subject, text, html }: MailOptions) {
    const info = await this.smtpTransporter!.sendMail({
      from: `"${this.senderName}" <${this.senderAddress}>`,
      to: Array.isArray(to) ? to.join(',') : to,
      subject,
      text,
      html,
    });
    this.logger.log(`SMTP sent: ${info.messageId}`);
    return info;
  }
}
  • Provider selection: the service checks MAIL_PROVIDER so you can keep Azure in production and use SMTP locally.
  • Azure send: the ACS SDK returns a poller; pollUntilDone() gives the final status and message id. Log it for correlation.
  • Templates: Handlebars is simple and portable. I preload templates for performance but fall back to on-disk reads during development.

Local testing (MailHog)

Run MailHog in a container to capture emails locally without sending them:

docker run -p 1025:1025 -p 8025:8025 mailhog/mailhog

Then set MAIL_PROVIDER=smtp and point the SMTP host/port to MailHog. Check http://localhost:8025 to inspect messages.

Production tips (practical)

  1. Verify senders/domains in Azure: unverified senders often fail silently.

  2. Secrets: use Key Vault or your cloud secret store and rotate keys regularly.

  3. Retries: transient network errors happen. Add an exponential backoff retry strategy.

  4. Observability: log Azure message ids and connect Application Insights — it pays off when debugging delivery issues.

  5. Attachments and limits: ACS supports richer content but watch size limits; use blob links for big files.

When to pick something elses

Use a specialized provider if you need advanced deliverability tools, managed templates, or built-in suppression lists out-of-the-box.

What I learned

ACS was easy to get working and fit my small transactional needs. If you have Azure credits or are already on Azure, it’s a solid option worth trying.

Have you implemented similar solutions or have questions about our approach? I’d love to hear from you in the comments!

More from this blog

Oladele Writes

16 posts

Backend engineering and the systems around it. I write about architecture and system design, cloud and infrastructure, security, and the patterns behind real products: multi-tenancy, auth, APIs, and the decisions that hold up in production. Mostly hands-on, drawn from actual SaaS work.