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,
pdf_adapter: Accrue.PDF.ChromicPDF,
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, ChromicPDF, and the Swoosh adapter — Accrue does not start them.
Mailglass migrations (Phase 88+ pipeline)
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
Phase 88 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.
Phase 89 refactors Accrue.Workers.Mailer to dispatch via
Mailglass.deliver/1 and adds explicit idempotency keys. Phase 90 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.
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 :attach_invoice_pdf is enabled, otherwise invoice emails can
back-pressure the PDF pool. Accrue emits a boot-time warning when
this invariant is violated.
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.