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.
The two systems
Section titled “The two systems”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 shapeEMAIL_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).createEditing 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_tracking — there 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).
Merge fields are validated at save time
Section titled “Merge fields are validated at save time”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;.parsestays forgiving for the render path. (The validation existed before but was dead code —.parseswallowedLiquid::SyntaxErrorinternally, so the model’srescue Liquid::SyntaxErrorwas unreachable.) - Merge fields.
EmailTemplate::MergeFieldsholds the allowlist: globals fromCommunicationBuilder#merge_options, the resource under its own model name, and per-system_codeextras. 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.
2. ActionMailer classes
Section titled “2. ActionMailer classes”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).
Quote & pre-sale
Section titled “Quote & pre-sale”| 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.yml
→ send_quote_expiration_emails.
Order & payment
Section titled “Order & payment”| 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 | — |
Billing & receipts
Section titled “Billing & receipts”| 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.
Returns (RMA)
Section titled “Returns (RMA)”| 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.
Cart recovery & loyalty
Section titled “Cart recovery & loyalty”| 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.
PRO / installer certification
Section titled “PRO / installer certification”| 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 = 14 → FINAL_LEAD_DAYS = 14 → EXPIRE_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 locator
Section titled “Dealer locator”DEALER_INVITE (4), DEALER_CONFIRMED (2), and the deletion ladder
DEALER_SET_TO_DELETE → DEALER_DELETE_IN7DAY → DEALER_DELETED (all dormant)
— every one driven from app/models/locator_record.rb.
Support cases
Section titled “Support cases”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 |
Service appointments (SmartFit)
Section titled “Service appointments (SmartFit)”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) |
Content, reviews & service bulletins
Section titled “Content, reviews & service bulletins”| 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 | — | — |
Partner / trade (not end-consumer)
Section titled “Partner / trade (not end-consumer)”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 (—).
Rep-composed, not automated
Section titled “Rep-composed, not automated”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.
Dormant templates
Section titled “Dormant templates”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 caller —
COLLECTIONS_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:
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;"Delivery, tracking & measurement
Section titled “Delivery, tracking & measurement”All outbound mail goes through SendGrid subusers. Per-recipient state lives
on communication_recipients.state (processed → delivered → opened →
clicked, or bounced/dropped/deferred), updated by the event webhook at
/webhooks/v1/sendgrid → WebhookProcessors::SendgridProcessor →
Communication::EventParser.
Two traps when measuring engagement:
webhook_eventsretains only ~3 months. Anything longer must come from the durablecommunication_recipients.state, or open rates collapse toward zero for older sends.stateis terminal, not cumulative. A recipient who clicked readsclicked, notopened— count opens asstate IN ('opened','clicked').machine_openflags Apple MPP-style opens.
The engagement rollup
Section titled “The engagement rollup”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).
Adding a new automated communication
Section titled “Adding a new automated communication”- Create the
EmailTemplaterow in CRM with a new uniquesystem_code(≤20 chars) and the rightcategory— the category is the customer’s only opt-out lever. - Reference it from code by
system_code, never byid. - Send through
CommunicationBuilderso suppression, merge fields, tracking consent, and theCommunicationaudit trail all apply. - If it’s scheduled, add the entry to
config/sidekiq_production_schedule.ymland make the query’s forward window wider than the longest gap between run days — seeQuotes::QuoteExpirationMessage::NOTICE_WINDOWand its test for the failure mode. - Add a row to this inventory.
Related
Section titled “Related”.agents/skills/mailers/— ActionMailer conventions.agents/skills/background-jobs/— worker + schedule patternsdoc/infrastructure/README-POSTFIX.md— inbound/relay MTA