guides/email.md

# Email

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:

```elixir
# 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.

## Semantic API

Callers send an email by type + scalar assigns map — never by
constructing a `%Swoosh.Email{}` directly:

```elixir
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

```elixir
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

```elixir
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

```elixir
config :accrue, :email_overrides,
  receipt: MyApp.Emails.CustomReceipt
```

The override module replaces the default `Accrue.Emails.Receipt`
entirely. It must implement:

```elixir
@callback subject(map()) :: String.t()
@callback render(map()) :: String.t()
@callback render_text(map()) :: String.t()
```

### Rung 4 — full pipeline replace

```elixir
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:

```elixir
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:

```elixir
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:

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

## mix accrue.mail.preview

Render every email type with canned fixtures:

```bash
# Render all 13 types as HTML + TXT
mix accrue.mail.preview

# Only specific types
mix accrue.mail.preview --only receipt,trial_ending

# Only one format
mix accrue.mail.preview --only receipt --format html
mix accrue.mail.preview --only invoice_finalized --format pdf
```

Output lands in `.accrue/previews/{type}.{html,txt,pdf}`. The
`.accrue/` directory is git-ignored by convention. Paste the HTML into
Litmus/Email on Acid/Gmail/Outlook for visual QA — Accrue does not
ship a headless rendering matrix.

## 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 job `Accrue.Jobs.DetectExpiringCards`
- `:coupon_applied` — dispatched from `Accrue.Billing.CouponActions`

The second layer of defense is Oban uniqueness on `Accrue.Workers.Mailer`:

```elixir
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.