Accrue's transactional email pipeline — semantic API, override ladder, async dispatch via Oban, localization, testing, and regulatory context.
This guide documents the email + PDF pipeline as shipped today.
Quickstart
Minimal config for a host Phoenix app:
# config/config.exs
config :accrue,
mailer: Accrue.Mailer.Default,
invoice_pdf_adapter: Accrue.InvoiceRenderer.Rendro,
branding: [
business_name: "Acme Corp",
from_name: "Acme Billing",
from_email: "billing@acme.example",
support_email: "support@acme.example",
company_address: "123 Main St, San Francisco, CA 94103",
logo_url: "https://cdn.acme.example/logo.png",
accent_color: "#1F6FEB",
secondary_color: "#6B7280",
font_stack: "-apple-system, BlinkMacSystemFont, sans-serif"
]
# config/runtime.exs
config :accrue, Accrue.Mailer.Swoosh,
adapter: Swoosh.Adapters.Sendgrid,
api_key: System.fetch_env!("SENDGRID_API_KEY")The host application's supervision tree is responsible for starting
Oban and the Swoosh adapter. On the default Rendro path, invoice PDF
attachments do not require Chrome or a ChromicPDF process.
Invoice attachments follow the invoice renderer, not the lower-level HTML seam:
- invoice-carrying email types attempt to render an attachment through
:invoice_pdf_adapter. Accrue.InvoiceRenderer.Rendrois the normal default path.Accrue.InvoiceRenderer.ChromicPDFis the explicit compatibility path when a host intentionally wants the older Chrome-backed invoice renderer.- disabled or unavailable invoice renderers fall back to the hosted invoice URL note instead of pretending an attachment exists.
Mailglass migrations (staged rollout)
Starting in Accrue v1.29, the email pipeline is being migrated from
mjml_eex + phoenix_swoosh to Mailglass
— a HEEx-native transactional email framework with an append-only event
ledger, native idempotency, and a LiveView dev-preview dashboard.
Mailglass introduces three Postgres tables that the host application must create alongside Accrue's existing migrations:
| Table | Purpose |
|---|---|
mailglass_deliveries | One row per outbound message — content, recipients, status, retries. |
mailglass_events | Append-only event ledger (sent, opened, bounced, complained, etc.). Tamper-evident via Postgres triggers. |
mailglass_suppressions | Recipient-level suppression list (hard bounces, complaints, manual blocks). |
Install
From the host application root:
mix mailglass.install # generates the wrapper migration + adds router mounts
mix ecto.migrate # applies all three Mailglass migrations
The generated wrapper migration is a thin 8-line file that
delegates to Mailglass.Migration.up/0 — Mailglass owns the per-version
DDL and tracks the applied version in a pg_class comment on
mailglass_events. Host applications never edit Mailglass DDL by hand.
Requirements
- PostgreSQL 14+ — Mailglass uses immutable Postgres triggers. Accrue already requires PG 14+, so no new floor.
- Repo configured — set
config :mailglass, repo: MyApp.Repoinconfig/runtime.exsbefore running migrations.
Test-suite compatibility
Mailglass's immutable triggers compose with Ecto.Adapters.SQL.Sandbox
without special configuration: the sandbox's per-test transactions isolate
trigger-driven inserts the same way they isolate ordinary writes. No
additional :trigger mode or sandbox flag is needed.
If mix test fails inside a host app after applying Mailglass
migrations, the most common cause is a stale snapshot in
priv/repo/structure.sql (or priv/repo/migrations.sql) — regenerate via
mix ecto.dump after running migrations against the test DB.
What changes for Accrue users
The first rollout step only adds the Mailglass dependency and admin
dashboard.
The existing Accrue.Mailer.deliver/2 API, the type/assigns contract,
the override ladder, and the Oban-based async pipeline remain unchanged.
The next rollout step refactors Accrue.Workers.Mailer to dispatch via
Mailglass.deliver/1 and adds explicit idempotency keys. The final
step ports the remaining MJML templates, removes mjml_eex and
phoenix_swoosh from accrue/mix.exs, and leaves the admin preview UI
as the supported inspection surface.
See Mailglass getting started for the upstream install reference.
Semantic API
Callers send an email by type + scalar assigns map — never by
constructing a %Swoosh.Email{} directly:
Accrue.Mailer.deliver(:receipt, %{
customer_id: "cus_abc",
charge_id: "ch_xyz"
})Full catalogue of supported transactional types:
| Type atom | Trigger | PDF attached | Required assigns |
|---|---|---|---|
:receipt | charge.succeeded webhook | no | customer_id, charge_id |
:payment_failed | charge.failed / payment_intent.payment_failed | no | customer_id, charge_id |
:trial_ending | customer.subscription.trial_will_end | no | customer_id, subscription_id |
:trial_ended | cron | no | customer_id, subscription_id |
:invoice_finalized | invoice.finalized | yes | customer_id, invoice_id |
:invoice_paid | invoice.paid | yes | customer_id, invoice_id |
:invoice_payment_failed | invoice.payment_failed | no | customer_id, invoice_id, hosted_invoice_url |
:subscription_canceled | customer.subscription.deleted | no | customer_id, subscription_id |
:subscription_paused | customer.subscription.updated (paused) | no | customer_id, subscription_id |
:subscription_resumed | customer.subscription.updated (resumed) | no | customer_id, subscription_id |
:refund_issued | charge.refunded | no | customer_id, refund_id, charge_id |
:coupon_applied | coupon or promotion-code apply action | no | customer_id, coupon_id |
:card_expiring_soon | cron (Accrue.Jobs.DetectExpiringCards) | no | customer_id, payment_method_id |
Scalar-only assigns: pass IDs, not %Ecto.Schema{} structs.
The worker rehydrates entities at delivery time. Accrue.Mailer.Default
raises ArgumentError on non-scalar values to fail loud at the call
site.
Invoice PDF attachments
Invoice-carrying email types use the same invoice renderer contract documented
in guides/pdf.md.
- Invoice-carrying email types (
:invoice_finalizedand:invoice_paid) attempt to attach a PDF through:invoice_pdf_adapter. - With
config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.Rendro, the attachment uses the default Rendro renderer. - With
config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.ChromicPDF, the attachment uses the explicit ChromicPDF compatibility path instead. - If rendering is disabled or the ChromicPDF renderer is unavailable, Accrue falls back to the hosted invoice URL rather than pretending a PDF binary exists.
- This behavior is separate from the lower-level
:pdf_adapterseam.
Only hosts that explicitly choose Accrue.InvoiceRenderer.ChromicPDF need to
start ChromicPDF and size the runtime around Chrome-backed rendering.
Override ladder
Accrue follows a Pay-inspired three-rung override ladder for template customization:
Rung 1 — per-type kill switch
config :accrue, :emails,
trial_ending: falseAccrue.Mailer.deliver(:trial_ending, ...) short-circuits with
{:ok, :skipped} before any adapter dispatch.
Rung 2 — MFA conditional module
config :accrue, :email_overrides,
receipt: {MyApp.TemplatePicker, :pick, []}At render time the worker calls
MyApp.TemplatePicker.pick(:receipt). Extra args are passed through:
{Mod, :fun, [arg1, arg2]} becomes Mod.fun(:receipt, arg1, arg2).
Return a module implementing the same subject/1, render/1, and
render_text/1 contract as the default template.
Rung 3 — atom module swap
config :accrue, :email_overrides,
receipt: MyApp.Emails.CustomReceiptThe override module replaces the default Accrue.Emails.Receipt
entirely. It must implement:
@callback subject(map()) :: String.t()
@callback render(map()) :: String.t()
@callback render_text(map()) :: String.t()Rung 4 — full pipeline replace
config :accrue, :mailer, MyApp.MailerPoint :mailer at any module implementing the Accrue.Mailer
behaviour. Use this for integrations with non-Swoosh delivery layers
(e.g., a third-party transactional-email SDK that manages its own
templates).
Testing
Accrue ships a test adapter Accrue.Mailer.Test that intercepts
Accrue.Mailer.deliver/2 calls before Oban enqueue and sends an intent
tuple {:accrue_email_delivered, type, assigns} to the calling
process. Use Accrue.Test.MailerAssertions for ExUnit assertions:
use ExUnit.Case, async: true
use Accrue.Test.MailerAssertions
test "subscribing sends receipt" do
{:ok, _} = Accrue.Billing.subscribe(customer, "price_monthly")
assert_email_sent(:receipt, customer_id: customer.id)
endMatch keys:
:to— matchesassigns[:to]orassigns["to"]:customer_id— matchesassigns[:customer_id]:assigns— subset match viaMap.take/2:matches— 1-arity predicate escape hatch
For tests that need a rendered %Swoosh.Email{} body (subject / HTML
assertions), swap to Accrue.Mailer.Default + Swoosh.Adapters.Test
in that specific test module.
CAN-SPAM / CASL / GDPR exemption
Accrue's transactional emails do NOT include unsubscribe links. This is intentional and legally-grounded:
- CAN-SPAM (US): transactional messages whose "primary purpose" is a transaction the recipient initiated are exempt from the unsubscribe requirement.
- CASL (Canada): transactional messages are subject to reduced obligations — no express consent and no unsubscribe required, but sender identification + postal address recommended.
- GDPR (EU): transactional emails are based on contract necessity (Art. 6(1)(b)) — no opt-in / opt-out required. Postal address required for B2B senders under national implementations.
For EU/CA senders set :branding[:company_address] — Accrue's boot
check (warn_company_address_locale_mismatch/0) logs a warning when
customer locales indicate EU/CA audiences and the address is unset.
RFC 8058 opt-in (advanced)
Some hosts ship a list_unsubscribe_url even on transactional emails
for deliverability reasons (Gmail Promotions tab demotion). Accrue's
templates do NOT add one by default. To opt in, supply
:list_unsubscribe_url in the branding config and override the
template rung-3 style to inject the List-Unsubscribe header.
Async dispatch via Oban
Configure :accrue_mailers in the host Oban config:
config :accrue, Oban,
repo: MyApp.Repo,
queues: [
accrue_mailers: 20,
accrue_webhooks: 10
]Recommended concurrency: 20. Pitfall 4: set
accrue_mailers concurrency ≤ chromic_pdf_pool_size (default 3)
when you explicitly choose Accrue.InvoiceRenderer.ChromicPDF for invoice
attachments, otherwise invoice emails can back-pressure the PDF pool. The
boot-time warning for this invariant keys off :attach_invoice_pdf.
Localization
Email rendering honors customer.preferred_locale and
customer.preferred_timezone via this precedence order:
assigns[:locale]/assigns[:timezone]explicit overridecustomer.preferred_locale/customer.preferred_timezoneAccrue.Config.default_locale/0/Accrue.Config.default_timezone/0- Hardcoded
"en"/"Etc/UTC"fallback
Unknown locales/timezones emit
[:accrue, :email, :locale_fallback] and
[:accrue, :email, :timezone_fallback] telemetry and fall back to
"en" / "Etc/UTC". The worker's enrich/2 NEVER raises — Pitfall
5 defense.
Override the CLDR backend via config :accrue, :cldr_backend, MyApp.Cldr.
Previewing emails
Use the admin dev preview at /dev/email-preview to inspect the current
fixture set. It renders the same canned assigns used by ExUnit, so any
template or fixture drift shows up immediately in the browser.
For a full sweep, run the email test suite; it exercises the rendered HTML/text outputs directly and keeps the preview surface honest.
Pitfall 7 — single dispatch discipline
The webhook reducer (Accrue.Webhook.DefaultHandler) is the single
dispatch point for state-change emails in the catalogue. Do NOT call
Accrue.Mailer.deliver/2 from Accrue.Billing.* action modules for
these types — double dispatch causes duplicate emails on webhook
replay.
Exceptions (action-dispatched types):
:card_expiring_soon— dispatched from cron jobAccrue.Jobs.DetectExpiringCards:coupon_applied— dispatched fromAccrue.Billing.CouponActions
The second layer of defense is Oban uniqueness on Accrue.Workers.Mailer:
unique: [period: 60, fields: [:args, :worker]]A duplicate enqueue within 60 seconds is silently dropped. DO NOT remove this option — it's the only guard against action + webhook double-dispatch.