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

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

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

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

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

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

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

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)

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

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

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 locator

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.

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)

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

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)

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

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

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

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

All outbound mail goes through SendGrid subusers. Per-recipient state lives
on communication_recipients.state (processeddeliveredopened
clicked, or bounced/dropped/deferred), updated by the event webhook at
/webhooks/v1/sendgridWebhookProcessors::SendgridProcessor
Communication::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.

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

  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