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.Rendro is the normal default path.
  • Accrue.InvoiceRenderer.ChromicPDF is 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:

TablePurpose
mailglass_deliveriesOne row per outbound message — content, recipients, status, retries.
mailglass_eventsAppend-only event ledger (sent, opened, bounced, complained, etc.). Tamper-evident via Postgres triggers.
mailglass_suppressionsRecipient-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.Repo in config/runtime.exs before 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 atomTriggerPDF attachedRequired assigns
:receiptcharge.succeeded webhooknocustomer_id, charge_id
:payment_failedcharge.failed / payment_intent.payment_failednocustomer_id, charge_id
:trial_endingcustomer.subscription.trial_will_endnocustomer_id, subscription_id
:trial_endedcronnocustomer_id, subscription_id
:invoice_finalizedinvoice.finalizedyescustomer_id, invoice_id
:invoice_paidinvoice.paidyescustomer_id, invoice_id
:invoice_payment_failedinvoice.payment_failednocustomer_id, invoice_id, hosted_invoice_url
:subscription_canceledcustomer.subscription.deletednocustomer_id, subscription_id
:subscription_pausedcustomer.subscription.updated (paused)nocustomer_id, subscription_id
:subscription_resumedcustomer.subscription.updated (resumed)nocustomer_id, subscription_id
:refund_issuedcharge.refundednocustomer_id, refund_id, charge_id
:coupon_appliedcoupon or promotion-code apply actionnocustomer_id, coupon_id
:card_expiring_sooncron (Accrue.Jobs.DetectExpiringCards)nocustomer_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_finalized and :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_adapter seam.

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: false

Accrue.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.CustomReceipt

The 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.Mailer

Point :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)
end

Match keys:

  • :to — matches assigns[:to] or assigns["to"]
  • :customer_id — matches assigns[:customer_id]
  • :assigns — subset match via Map.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:

  1. assigns[:locale] / assigns[:timezone] explicit override
  2. customer.preferred_locale / customer.preferred_timezone
  3. Accrue.Config.default_locale/0 / Accrue.Config.default_timezone/0
  4. 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):

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.