Key Takeaway
ERP email automation works best when business logic never calls the mail service directly. An approved leave request writes an event, and a separate worker renders and delivers the message. Decoupling this way stops a failed SMTP call from rolling back an approved transaction, and makes delivery retryable without touching business code at all.
A business application without email notifications is frustrating to use. In the custom ERP systems I build as a freelance ERP consultant, significant business events—a leave request approved, an invoice overdue, a purchase order ready for pickup—should notify the relevant people automatically. The naive approach of sending emails directly inside business logic service methods works until it doesn't: what happens when the SMTP server is temporarily unavailable? This post explains the decoupled, queue-backed email system I built to solve these problems.
The Architecture: Event-Driven with a Queue
The core design principle is decoupling: business logic should not know or care about email delivery. The service that approves a leave request should not need to call an email service directly—it should emit an event and continue. A separate listener picks up the event, queues an email job, and returns immediately. The email worker then processes the job asynchronously, with retries on failure. This means an SMTP outage never affects the core business transaction.
NestJS EventEmitter2 as the Event Bus
NestJS has a built-in EventEmitter module based on EventEmitter2 that provides a simple in-process event bus. Business services emit typed events (e.g., 'leave.approved', 'invoice.due', 'po.ready') after their core transaction commits. Event listeners in the email module subscribe to these events and enqueue the corresponding email job. The in-process bus is appropriate here because this is a single-process application.
BullMQ for Reliable Job Processing
BullMQ is a Redis-backed job queue for Node.js. Jobs are persisted in Redis, so they survive application restarts. Each job has configurable retry logic (number of attempts, backoff strategy—fixed or exponential). Failed jobs that exhaust retries move to a failed queue where they can be inspected and retried manually. BullMQ also provides a dashboard (Bull Board) for visualizing queue state.
ERP Email Automation Architecture
───────────────────────────────────
ERP Event (leave approved, invoice due, etc.)
│
▼
┌─────────────────────────────────────────┐
│ Event Emitter (NestJS EventEmitter2) │
│ this.eventEmitter.emit('invoice.due', │
│ { invoiceId, recipientId }) │
└───────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Email Queue (BullMQ + Redis) │
│ Decouples sending from event handler │
│ Retries on failure (3x, exp backoff) │
└───────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Email Worker │
│ 1. Load template from DB or .hbs file │
│ 2. Render with Handlebars │
│ 3. Attach PDF if needed │
│ 4. Send via Nodemailer (SMTP / SES) │
└───────────────┬─────────────────────────┘
│
┌───────┴────────┐
│ │
┌─────▼──────┐ ┌──────▼──────┐
│ Sent log │ │ Retry │
│ (DB) │ │ (BullMQ) │
└────────────┘ └─────────────┘Use BullMQ's job deduplication feature (the 'jobId' option) for emails that should only be sent once per business object per state. For example, set jobId to 'invoice-due-[invoiceId]' when enqueuing an invoice due reminder—if the reminder is enqueued twice, BullMQ will only process it once. This prevents duplicate emails without application-level deduplication logic.
Email Templates with Handlebars
Each email type has a corresponding Handlebars template (.hbs file) that produces the HTML email body. Templates are compiled once at module initialization and cached. Template helpers handle formatting: Indonesian date format, Rupiah currency formatting, and pluralization for day counts. Keeping templates in .hbs files means non-developers (designers) can update the email layout without touching TypeScript code.
Nodemailer: The SMTP Transport
Nodemailer handles the actual SMTP delivery. The transporter is created once in the processor to reuse the connection pool. For production, I use a dedicated SMTP relay (configured via environment variables) rather than a direct mail server—this avoids IP reputation issues and provides delivery analytics. I tested with SendGrid and SMTP2Go; both work well with Nodemailer.
// email.module.ts - BullMQ queue setup
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.registerQueue({ name: 'email' }),
],
providers: [EmailService, EmailProcessor],
exports: [EmailService],
})
export class EmailModule {}
// email.service.ts - enqueue emails from ERP events
@Injectable()
export class EmailService {
constructor(@InjectQueue('email') private emailQueue: Queue) {}
async sendLeaveApproved(payload: LeaveApprovedPayload): Promise<void> {
await this.emailQueue.add('leave-approved', payload, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: 100,
removeOnFail: 500,
});
}
async sendInvoiceDueReminder(payload: InvoiceDuePayload): Promise<void> {
await this.emailQueue.add('invoice-due', payload, {
attempts: 3,
delay: 0,
backoff: { type: 'fixed', delay: 30000 },
});
}
}
// email.processor.ts - the actual sender
@Processor('email')
export class EmailProcessor {
private transporter: nodemailer.Transporter;
constructor(private templateService: TemplateService) {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
@Process('leave-approved')
async handleLeaveApproved(job: Job<LeaveApprovedPayload>) {
const { employee, leaveRequest } = job.data;
const html = await this.templateService.render('leave-approved', {
employeeName: employee.name,
startDate: leaveRequest.startDate,
endDate: leaveRequest.endDate,
daysCount: leaveRequest.daysCount,
});
await this.transporter.sendMail({
from: `"HR System" <${process.env.SMTP_FROM}>`,
to: employee.email,
subject: 'Leave Request Approved',
html,
});
}
}Tracking Sent Emails
Every sent email is logged to an email_logs table in Postgres: the recipient, subject, event type, related entity, send timestamp, and BullMQ job ID. This provides an audit trail for compliance ('was the invoice reminder sent?') and debugging. BullMQ's job completion and failure callbacks update the log entry with the final status.
Always use a mail catcher (Mailhog or Mailpit) in your local and staging environments—never route staging emails to real addresses. In Docker Compose, add a mailhog service and point your SMTP settings to it. This prevents accidental email sends to real customers during testing and lets you inspect the rendered email templates before deploying.
Scheduled Emails: Invoice Due Reminders
Not all emails are triggered by user actions. Invoice due reminders are scheduled: a cron job (using NestJS's @nestjs/schedule module) runs daily and queries for invoices due in 7 days and 1 day that haven't been paid and haven't had a reminder sent recently. For each qualifying invoice, it enqueues a reminder job. The daily cron is idempotent: running it twice produces no duplicate reminders because the email_logs check prevents re-sending.
Monitoring and Alerting
BullMQ exposes queue metrics via its API. I expose these as Prometheus metrics using a custom NestJS middleware and scrape them with my Grafana/Prometheus stack. Alerts fire when the failed queue count exceeds a threshold. The dashboard also shows the email throughput trend, which is useful for capacity planning as the ERP user base grows.

