# ERP Email Automation: Triggering Transactional Emails from Business Events

> How I built a reliable, queue-backed email automation layer for our custom ERP using NestJS EventEmitter2, BullMQ, and Nodemailer—with Handlebars templates and retry logic.

URL: https://www.matthewswong.com/en/blog/erp-email-automation/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2025-06-10
- Updated: 2025-06-10
- Category: ERP

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.

> 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.

> **Test Email Delivery in Staging**

> 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.

## 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.

## 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.

## Sources & Further Reading

- BullMQ — Documentation — https://docs.bullmq.io/
- Nodemailer — About — https://nodemailer.com/about/
- NestJS — Queues — https://docs.nestjs.com/techniques/queues

> **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.

## Source links

- https://docs.bullmq.io/
- https://nodemailer.com/about/
- https://docs.nestjs.com/techniques/queues

## FAQ

### Why use a queue (BullMQ) instead of sending emails directly in the business logic?

Sending emails directly inside service methods couples email delivery to the core transaction, so an SMTP outage would cause the business operation to fail. With BullMQ backed by Redis, email jobs are persisted and retried independently, meaning a temporary SMTP failure never rolls back or blocks the underlying business event.

### How does the system prevent duplicate emails from being sent for the same business event?

BullMQ's job deduplication feature is used by setting a deterministic jobId (e.g., 'invoice-due-{invoiceId}') when enqueuing a job. If the same job is enqueued twice, BullMQ processes it only once. For scheduled reminders, an additional check against the email_logs table ensures a reminder is not re-sent to an invoice that was already notified recently.

### How are email templates managed, and why was Handlebars chosen?

Each email type has a dedicated Handlebars (.hbs) template that produces the HTML body. Templates are compiled once at module initialisation and cached. Template helpers handle locale-specific formatting such as Indonesian dates and Rupiah currency. Keeping layout in .hbs files lets designers update the email appearance without touching TypeScript code.

### How are scheduled emails like invoice due reminders triggered?

A NestJS @nestjs/schedule cron job runs daily and queries for invoices due in 7 or 1 day that are unpaid and have not had a recent reminder. For each qualifying invoice it enqueues a BullMQ reminder job. The daily cron is idempotent—running it multiple times produces no duplicate reminders because the email_logs check prevents re-sending.

### How is the email pipeline monitored in production?

BullMQ exposes queue metrics via its API, which are converted to Prometheus metrics through a custom NestJS middleware and scraped by the Grafana/Prometheus stack. Alerts fire when the failed queue count exceeds a configured threshold. Every sent email is also logged to an email_logs table in Postgres, providing an audit trail for compliance and debugging.

---

- HTML version: https://www.matthewswong.com/en/blog/erp-email-automation/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/erp-email-automation/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
