ERP Email Automation: Triggering Transactional Emails from Business Events

Photo by Unsplash

Photo by Unsplash
A business application without email notifications is frustrating to use. In our custom ERP at Commsult Indonesia, 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 we built to solve these problems.
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 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 we're in a single-process application.
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.
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 handles the actual SMTP delivery. The transporter is created once in the processor to reuse the connection pool. For production, we use a dedicated SMTP relay (configured via environment variables) rather than a direct mail server—this avoids IP reputation issues and provides delivery analytics. We 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: `"Commsult HR System" <${process.env.SMTP_FROM}>`,
to: employee.email,
subject: 'Leave Request Approved',
html,
});
}
}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.
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.
BullMQ exposes queue metrics via its API. We expose these as Prometheus metrics using a custom NestJS middleware and scrape them with our 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.