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

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
Create a new NestJS app
Install the Azure SDK and a couple helpers
Implement a minimal
MailServicethat can send via ACS or local SMTP (dev)Add simple Handlebars templating and a local dev flow (MailHog)
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_PROVIDERso 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)
Verify senders/domains in Azure: unverified senders often fail silently.
Secrets: use Key Vault or your cloud secret store and rotate keys regularly.
Retries: transient network errors happen. Add an exponential backoff retry strategy.
Observability: log Azure message ids and connect Application Insights — it pays off when debugging delivery issues.
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!




