Skip to content

Customer communications inventory

Every automated email a customer, PRO, or dealer can receive from Heatwave — what fires it, when, where the template lives, and how much it actually sends.

Volumes are the trailing 12 months from production (communications joined to email_templates), measured 2026-07-25. They are the fastest way to tell a live path from a dead one.

Two independent send systems. Read this first or the inventory won’t make sense — roughly 90% of customer mail comes from the first one, and its templates are not in the repo.


1. DB-backed templates (EmailTemplate + system_code)

Section titled “1. DB-backed templates (EmailTemplate + system_code)”

The dominant path. The body, subject, and styling live as rows in the email_templates table, not as files in the repo. Application code addresses a template by its stable system_code and hands it to CommunicationBuilder, which resolves recipients, applies EmailPreference suppression, renders the Liquid-style {{ }} merge fields, and writes a Communication + CommunicationRecipient row per address.

# app/services/quotes/quote_expiration_message.rb — the canonical shape
EMAIL_TEMPLATE_QUOTE_EXPIRATION = 'QUOTEEXPIRATION'
@email_template = EmailTemplate.find_by(system_code: EMAIL_TEMPLATE_QUOTE_EXPIRATION)
CommunicationBuilder.new(
sender_party:, recipient_party:, template: email_template,
resource: quote, recipient_contact_points: emails,
transmit_at: 1.hour.from_now
).create

Editing a template means editing production data, not shipping code — CRM at /crm/email_templates/:id (config/routes/crm.rb:1416). This is deliberate: marketing and support change copy without a deploy. The tradeoff is that the usual safety net (review, CI, rollback) doesn’t apply, so the guardrails have to live in the editor instead — see Merge fields are validated at save time. system_code is unique and validated (app/models/email_template.rb). A template carrying one may be active or archived, never draft — code resolves it by that code, so a half-finished draft must not be reachable. Archiving is how a code-addressed template is retired without deleting the row or its communications history; if the send path resolves one anyway, EmailTemplate#assert_sendable! reports to AppSignal rather than dropping the email.

94 templates carry a system_code. Their category drives suppression:

Category Templates Suppressible
transactional 77 No — see below
announcements 9 Yes
reviews 4 Yes
promotions 3 Yes
newsletters 1 Yes

Suppression is EmailPreference.can_receive_email_of_category (app/models/email_preference.rb:79), which resolves disable_#{category} on the preference row. email_preferences has opt-out columns for reviews, promotions, newsletters, announcements, events, webinars, and email_trackingthere is no disable_transactional column.

So transactional mail is always deliverable, which is correct. But note how it gets there: respond_to?(:disable_transactional) returns false, and the method treats an unrecognised category as allowed. The guarantee is implicit, not enforced. Adding a disable_transactional column for any reason would instantly make every order confirmation, invoice, and receipt suppressible, with no other code change and no test to catch it.

Two more defaults worth knowing: no EmailPreference row at all means everything is allowed, and events/webinars opt-outs exist but no system_code template uses those categories (they’re campaign-only).

Bodies render through the liquid gem, and the render path deliberately does not use strict variables (app/lib/liquid/renderer.rb) — a template already in the database must still send, so an unknown name resolves to an empty string rather than raising mid-send.

That leniency used to mean a typo shipped silently. {{receipt_reference_number}} sat in the Check-payment branch of PURCHASE_RECEIPT and AUTH_RECEIPT — every sibling branch used {{receipt_reference}}, and nothing ever set the longer name — so customers paying by check received a receipt with a blank “Ref:” line. No error, no log entry.

Two gates now catch that class of bug at save time:

Check Scope Behaviour
Liquid syntax All templates Blocks save
Unknown merge field system_code templates only Blocks save
  • Syntax. Liquid::ParseEnvironment.parse! raises; .parse stays forgiving for the render path. (The validation existed before but was dead code — .parse swallowed Liquid::SyntaxError internally, so the model’s rescue Liquid::SyntaxError was unreachable.)
  • Merge fields. EmailTemplate::MergeFields holds the allowlist: globals from CommunicationBuilder#merge_options, the resource under its own model name, and per-system_code extras. Campaign templates are exempt — their merge options are ad-hoc per sender, so there’s no knowable allowlist.

When a developer adds a new merge option, register it under that system_code in BY_SYSTEM_CODE or the template that uses it won’t save. The validation error says so explicitly.

29 mailers in app/mailers/, views in app/views/<mailer>/. These ship with the code and are reviewable in a PR. The large majority are internal-staff notifications, not customer mail — delivery_mailer, orders_mailer, internal_notification_mailer, internal_reports_mailer, payment_reconciliation_mailer, and most of rma_mailer all address reps and ops. The genuinely customer-facing ones are called out in the inventory below.


Inventory — customer-facing, by lifecycle

Section titled “Inventory — customer-facing, by lifecycle”

Sends = trailing 12 months. means the template exists but sent nothing in that window (see Dormant templates).

Code Trigger Timing Sends
QUOTE Rep sends a quote On demand 10,522
QUOTEEXPIRATION Quotes::QuoteExpirationMessage Cron Tue/Wed/Thu 03:25 CT, 8-day forward window; one send per quote via expiration_notice_sent 2,354
OPPORTUNITY_RECEIVED Opportunity created from a web quote request Immediate 444
QUOTE_ONLINE Self-serve online quote Immediate 298
PLAN_REQUEST_CONF RoomConfiguration plan request Immediate 40
QUOTE_SMARTSERVICE SmartService quote Immediate

QUOTEEXPIRATION is the only quote mail on a schedule. It fires ~3–7 days before quotes.expiration_date, which is itself created_at + 60 days or the earliest attached coupon expiry, whichever is sooner (Quote#calculate_expiration_date). See config/sidekiq_production_schedule.ymlsend_quote_expiration_emails.

Code Trigger Timing Sends
ORDER_TRACKING Shipment tracking registered (Order) On carrier pickup scan 5,563
ONLINE_ORDER_CONFIRM Web order placed (Order) Immediate 2,676
ORDER_PAYMENT OrdersController — payment pending Immediate 1,320
ORDER_BO_NOTIFY Order::BackOrderClientNotification Cron Mon–Fri 06:00 CT (backorder_reminder_worker) 478
ORDER_PICKUP Order ready for will-call (Order) Immediate 301
ORDER_INVOICED Post-invoice tips email Immediate
Code Trigger Timing Sends
INVOICE FinancialsMailer On invoice issue 4,228
PURCHASE_RECEIPT Payment captured Immediate 2,689
AUTH_RECEIPT Card authorization Immediate 716
STATEMENTOFACCOUNT Statement run On demand 187
CREDITMEMO Credit memo issued Immediate 52
INVOICE_PAYMENT InvoicesController Immediate 10
WIRE_TRANSFER_INFO Payment — wire selected Immediate 8
COLLECTIONS_30 Invoice 30d overdue Dunning 6
COLLECTIONS_DUE Invoice past due Dunning 2
COLLECTIONS / COLLECTIONS_90 Dunning ladder Dunning

The collections ladder is near-dormant (8 sends in 12 months across four templates) and none of the four resolve to a caller in app/ or lib/ — worth confirming whether dunning is still driven from Heatwave at all.

Code Trigger Timing Sends
RMA RMA created Immediate 1,232
RMARC Rma — return being processed Immediate 956
RMA_REMINDER_1 Returns::SendFirstReminder Advance-replacement invoice 10 days unpaid, RMA still awaiting_return. Cron daily 12:00 CT (rma_reminder_worker) 386
RMAINSPECT Return received at warehouse On receipt 345
RMA_REMINDER_2 Returns::SendSecondReminder Same, at 20 days 220
RMA_CHARGE Returns::SendChargeNotice Same, at 30 days 107
RMAITEMREJECTED RmaItem evaluation failed On inspection 4

The 10/20/30-day ladder lives in Returns::ReminderLadder. It matches invoices at least N days old that haven’t been notified at that stage, marked per-RMA on rmas.reminder_1_sent_at / reminder_2_sent_at / charge_notice_sent_at — the same shape as quotes.expiration_notice_sent. Stages are strictly ordered: stage N only fires once stage N-1 has, so an invoice already 30 days old can’t trigger all three notices in one run.

It previously matched document_date = N.days.ago.to_date exactly, so a single missed daily run dropped that day’s cohort permanently, with no catch-up and nothing logged.

Code Trigger Timing Sends
ANNIVERSARY25OFF Customer::CustomerAnniversaryMessage Cron daily 03:30 CT 5,523
ABANDONED_CART Order::SendAbandonedCartEmails Cart idle >24h and <7d 764
ABANDONED_CART_REM Same service, second touch Cart idle >48h and <7d 38
ABANDONED_CART_REM_2 Final cart touch Within the 7-day window 31

AbandonedCartReminderWorker runs every 30 minutes, not on a cron — the 24h/48h/7d bounds in app/services/order/send_abandoned_cart_emails.rb:37,50 do the gating. The 7-day upper bound means a cart older than a week is never recovered.

Code Trigger Timing Sends
CERT_PROGRESS CourseEnrollment stalled Cron daily 06:50 CT (course_enrollment_worker) 174
CERT_EXPIRED Certification lapsed Cron daily 06:25 CT (certification_check_worker) 17
CERT_EXPIRES_10 Certification expiring 10 days before 17
CERT_EXPIRES_30 Certification expiring 30 days before 3
CERT_INS_WARN Certification::InsuranceEscalation 14 days before card expiry
LIAB_INSUR_EXPIRES10 Insurance expiring 10 days before 2
LIAB_INSUR_EXPIRED Insurance lapsed On expiry 3
CERT_INS_FINAL Certification::InsuranceEscalation 14 days after expiry 3
CERT_INS_LAPSED Suspension notice 14 days after final notice
COURSE_EXAM_PASSED Exam passed Immediate 1
NEW_INSTALLER / NEW_CERT_REMINDER Certification granted / reminder

The insurance ladder is a clean, documented escalation — WARN_LEAD_DAYS = 14FINAL_LEAD_DAYS = 14EXPIRE_GRACE_DAYS = 14 (app/services/certification/insurance_escalation.rb:31-35), driven daily by CertificationCheckWorker. A separate liability_insurance_check_worker runs 06:10 CT.

DEALER_INVITE (4), DEALER_CONFIRMED (2), and the deletion ladder DEALER_SET_TO_DELETEDEALER_DELETE_IN7DAYDEALER_DELETED (all dormant) — every one driven from app/models/locator_record.rb.

Case updates reach the customer — they carry the case number in the subject. The assignment/queue templates with similar names do not; see Internal-only.

Code Trigger Timing Sends
SUPPORTCASE_UPDATE SupportCase updated Immediate 1,454
ECOM_TCKT_UPDATE Ecommerce ticket updated Immediate 83

Driven from SupportCase, but these confirm an appointment rather than report case progress.

Code Trigger Timing Sends
SMARTFIT_APPOINTMENT SmartFit appointment booked Immediate
SMARTFIT_CONFIRMED Appointment time confirmed Immediate — (archived)
Code Trigger Timing Sends
BULLETIN_UPDATE Article service bulletin edited Immediate 378
BULLETIN_CREATE New service bulletin Immediate 27
ORDER_REVIEW_THX Review submitted Immediate 23
REVIEWIO_COMPANY Reviews.io company request Via reviews_io_* workers (feed every 6h, import daily 04:00 CT) 1
BLOG_UPDATE BlogUpdateWorker Cron Mon 14:00 CT
BLOG_CONFIRM BlogSubscriptionMailer double opt-in Immediate
REVIEWIO_PRODUCT, PRODUCT_REVIEW_THX, COMPANY_REVIEW_THX Review flows

INVENTORY_FEED (2,487 — Edi::ResellerInventory::Sender, reseller stock feeds), PURCHASEORDER (313 — outbound to suppliers), PRICING_TERMS (—), SPIFF_ENROLL / SPIFF_UPDATE / SPIFF_END (all dormant), EXPORTED_CATALOG (—).

BLANK (7,185), BLANK_REPLY (8,204), and BLANK_TECH (17) are the shells a rep types into from the CRM. High volume, but a human sends every one — they belong in this list only so nobody mistakes the volume for automation.


Internal-only — never reaches a customer

Section titled “Internal-only — never reaches a customer”

Listed so nobody wires a customer onto them. Support/ticket routing — assignment and queue notices addressed to reps: SUPPORTCASE_ASSIGN (1,659), SUPPORTCASE_UNASSGND (851), SUPPORTCASE_OPEN (453), ECOM_TCKT_ASSIGN (170), ECOM_TCKT_UNASSIGNED, ACCT_TCKT_ASSIGN, ACCT_TCKT_UNASSIGNED, TICKET_UNASSIGNED, NEW_SUPPORT_CASE.

Note the split: the routing templates above are internal, but the case update templates go to the customer and are listed under Support cases. SUPPORTCASE_* is not a single audience.

Most app/mailers/ classes are also internal: delivery_mailer, orders_mailer (profit review, release authorization, insufficient payment), internal_notification_mailer, internal_reports_mailer, payment_reconciliation_mailer, problematic_delivery_alert_mailer, express_hold_alert_mailer, oauth_credential_mailer, brain_maintenance_mailer, scheduler_admin_mailer, video_transcription_mailer, and the reporting half of rma_mailer.

Customer-facing mailer classes: account_mailer (password changed, email changed, username reminder, account created), blog_subscription_mailer (confirmation), financials_mailer (invoice, tax exemption), privacy_mailer, warranty_mailer, scheduler_booking_mailer, training_mailer.


32 of the 94 system templates sent nothing in 12 months. Some are genuinely seasonal; others are dead paths whose calling code was removed.

Ten have been archived, selected on two signals — no reference anywhere in app/ or lib/, and no send in two years:

  • Never sent: BLOG_COMMENT, COLLECTIONS, NEW_DEALER_TOOL, NEW_SUPPORT_CASE, ORDER_INVOICED, TICKET_UNASSIGNED
  • Silent 2+ years: SMARTFIT_CONFIRMED (2023-10), COMPANY_REVIEW_THX (2024-01), PRODUCT_REVIEW_THX (2023-10), COLLECTIONS_90 (2024-08)

Three deliberately left active despite having no resolvable callerCOLLECTIONS_30 and COLLECTIONS_DUE both sent in May 2026, and ORDER_REVIEW_THX in November 2025. Something triggers them that a code grep doesn’t find (a rake task, a CRM action, an operator). Send history beats static analysis here; don’t archive them on the grep alone.

Reproduce the check:

Terminal window
psql "$POSTGRES_REPLICA_URI" -c "
SELECT et.system_code, count(c.id) AS sends_12mo
FROM email_templates et
LEFT JOIN communications c
ON c.email_template_id = et.id AND c.transmit_at >= now() - interval '12 months'
WHERE et.system_code IS NOT NULL AND et.system_code <> ''
GROUP BY et.system_code ORDER BY 2 DESC;"

All outbound mail goes through SendGrid subusers. Per-recipient state lives on communication_recipients.state (processeddeliveredopenedclicked, or bounced/dropped/deferred), updated by the event webhook at /webhooks/v1/sendgridWebhookProcessors::SendgridProcessorCommunication::EventParser.

Two traps when measuring engagement:

  • webhook_events retains only ~3 months. Anything longer must come from the durable communication_recipients.state, or open rates collapse toward zero for older sends.
  • state is terminal, not cumulative. A recipient who clicked reads clicked, not opened — count opens as state IN ('opened','clicked'). machine_open flags Apple MPP-style opens.

Counts survive the 3-month retention because recipient state is durable. Event timing does not — and timing is what showed Saturday quote-expiration notices sitting ~50h before being read against 6–8h on weekdays. That analysis was only possible inside the retention window; past it, the data was gone.

EmailEngagementRollupWorker (daily, 04:00 CT) writes per-template daily metrics to email_template_data_points, the same fact-table shape as site_map_data_points and catalog_data_points, so the whole read/trend API comes from Models::DataPointMetrics.

Metrics Why
DURABLE_METRICS sends, delivered, opened, clicked, bounced, dropped, unsubscribed, spammed, machine_opened Recomputable from recipient state at any time
PERISHABLE_METRICS total_opens, total_clicks, median_hours_to_open, opened_within_24h Derived from webhook_events — unrecoverable once they age out

The rollup upserts on (email_template_id, metric_type, period, reference), so re-running a day overwrites rather than duplicates; backfilling is a loop over dates. History accrues from launch — it can’t be reconstructed retroactively beyond the current retention window, so the sooner it runs the more you keep.

Open/click tracking is disabled per-recipient when EmailPreference.can_track_email? is false (CNIL/Garante prior-consent compliance).


  1. Create the EmailTemplate row in CRM with a new unique system_code (≤20 chars) and the right category — the category is the customer’s only opt-out lever.
  2. Reference it from code by system_code, never by id.
  3. Send through CommunicationBuilder so suppression, merge fields, tracking consent, and the Communication audit trail all apply.
  4. If it’s scheduled, add the entry to config/sidekiq_production_schedule.yml and make the query’s forward window wider than the longest gap between run days — see Quotes::QuoteExpirationMessage::NOTICE_WINDOW and its test for the failure mode.
  5. Add a row to this inventory.
  • .agents/skills/mailers/ — ActionMailer conventions
  • .agents/skills/background-jobs/ — worker + schedule patterns
  • doc/infrastructure/README-POSTFIX.md — inbound/relay MTA