Outbound emails — 9 templates wired to lifecycle
Branded HTML emails via M17 Communications. 9 firm-vetted templates trigger automatically on lifecycle events. Per-firm SMTP. Token-merged at queue-time. Email log + delivery tracking. Test mode + recipient banner for sandbox testing.
The 9 templates
| Code | Trigger | Recipient |
|---|---|---|
| client.welcome | Lead converted to client | Client primary contact |
| client.info_request | Manual — request data from client | Client primary contact |
| client.fieldwork_start | Job status → in_progress | Client primary contact |
| client.report_delivered | Audit report sent | Client primary contact |
| quote.sent | Quote status → sent | Lead/client primary contact |
| invoice.sent | Invoice status → sent | Client primary contact |
| invoice.payment_reminder | Cron auto-flip overdue + dunning chain | Client primary contact + accountant cc |
| payment.received | Payment recorded with allocations | Client primary contact |
| compliance.expiry_reminder | Document/visa expiring (M13/M16) | Employee work email + manager cc |
Branded HTML layout
Every email follows a single 600px-wide table layout with:
- Firm logo (loaded inline from
companies.logo_path) - Gradient header in firm primary/secondary colours
- Body content area
- Signature footer with firm address + phone + VAT TRN
- "TEST MODE" yellow banner when
communication.test_mode=1 - Plain-text auto-fallback for clients without HTML rendering
Token engine
Each template carries an available_tokens documentation field. Common tokens:
| Token | Resolves to |
|---|---|
{firm.name} | companies.company_name |
{firm.address} | Full firm address one-line |
{client.contact_name} | Primary contact full name |
{client.legal_name} | clients.legal_name |
{invoice.number} | e.g. INV/2026/0042 |
{invoice.amount} | Formatted OMR 5,565.000 |
{invoice.due_date} | Formatted DD MMM YYYY |
{quote.number} | e.g. QU/2026/0017 |
{payment.amount} | Formatted OMR |
{payment.method} | Bank Transfer · Cash · etc. |
{job.number} | e.g. JOB/2026/0042 |
{partner.name} | Engagement partner name |
Caller passes a flat dict to MailerService::sendTemplate(); unknown tokens stay literal (so a typo doesn't blow up the send).
Sample — invoice.sent template
Dear Mr. Ahmed Al-Bahja,
Please find attached our tax invoice INV/2026/0042 for OMR 5,565.000 covering the Annual Audit FY 2025 engagement.
Payment terms: 30 days from issue date. Due by 12 May 2026.
Please remit to our bank account (details on the invoice). For any questions, reply to this email or call your engagement manager directly.
Thank you for choosing Al Musaaid Auditing Bureau.
Best regards,
Praveendas S · Engagement Partner
Lifecycle hooks → email triggers
EventDispatcher fires on these state transitions; each hook may queue an email:
onLeadConverted→ client.welcomeonQuoteSent→ quote.sentonInvoiceSent→ invoice.sent (with branded PDF attached)onInvoiceOverdue→ invoice.payment_reminder (cron + dunning chain)onPaymentReceived→ payment.received (with receipt PDF attached)onJobCompleted→ optional client.report_delivered (config-driven)
All hooks are post-commit + try/catch — SMTP failures never break the upstream state machine.
Email log
email_log table captures every send:
- recipient · subject · template_code · related_entity_type+id
- queued_at · sent_at · delivered_at · opened_at
- result (success / bounced / failed) · error message
- SMTP host · Message-ID
- cc · bcc
Forensic — never deleted. Available at Settings → Communication → Email Log with filters.
Test mode
For sandbox testing without spamming clients:
- Set
communication.test_mode=1 - Set
communication.test_recipient=qa@yourfirm.com - All outbound mail redirects to test_recipient regardless of intended To
- Yellow "TEST MODE — original recipient was X" banner inserted at top of HTML
- Original-recipient stamped in email_log so the audit trail is intact
Critical for staging environments and partner training. Never go to production with test_mode=1.
Step-by-step — customise a template
Open Settings → Communication → Templates
List of 9 system-seeded templates.
Click into one
Subject line · HTML body · plain-text fallback · available tokens.
Edit
Change wording, tone, signature. Preview renders in the right panel with sample data.
Save
Audit-logged. Future sends use the new wording. Old sends preserved with their original wording in
email_log.body_html.Test send
From settings → "Send test email". Use test_mode + your own email. Verify rendering.
Send a test invoice. Check Settings → Communication → Email Log — your test send appears with sent_at, SMTP host, Message-ID. Filter by template=invoice.sent to see all outbound invoices over time. This is your audit trail for "did the client get the invoice?"
Don't include hard-coded firm details in template HTML. Use {firm.name}, {firm.address} tokens — when you onboard a second firm, only the data changes, the template stays. Hard-coded names = manual rewrite per firm.
If your SMTP provider supports tracking pixels, enable them. email_log.opened_at stamps when the client opens the email — useful for invoice + dunning campaigns. "Client opened the reminder 3 times but didn't pay" is a different escalation conversation than "client never opened it" (probably an email-deliverability issue).