# API Stability — mailglass

This document is the canonical `v1.x` stability inventory for the core
`mailglass` package.

For compatibility, deprecation, support-matrix, and upgrade-horizon policy, use
[`guides/compatibility-and-deprecations.md`](../guides/compatibility-and-deprecations.md).
This file stays inventory-shaped on purpose.

It answers two distinct questions:

1. What adopters may treat as stable for the `v1.x` line.
2. What is merely reachable or exported for framework wiring, sibling-package
   integration, or internal implementation.

`Boundary` exports, generated docs visibility, and module reachability are not
the contract by themselves. The contract is the explicit inventory in this
document plus the `@since` / deprecation metadata on the stable APIs named
here.

`mailglass_admin` has its own narrow contract surface and is documented
separately in `mailglass_admin/docs/api_stability.md`. `mailglass_inbound`
is not part of the `v1.x` stability promise for this milestone.

## Contract Posture

### `stable`

These surfaces are part of the documented `v1.x` adopter contract. Breaking
them requires a major-version change.

- Root adopter entrypoint: `Mailglass.deliver/2`, `deliver!/2`,
  `deliver_later/2`, `deliver_many/2`, and `deliver_many!/2`.
- Core message and mailable surface: `Mailglass.Message`,
  `Mailglass.Mailable`, and `Mailglass.Renderer`.
- Delivery and provider seams: `Mailglass.Outbound`, `Mailglass.Adapter`,
  `Mailglass.Adapters.Fake`, and `Mailglass.Adapters.Swoosh`.
- Config and tenancy seams: `Mailglass.Config`, `Mailglass.Tenancy`,
  `Mailglass.TenancyError`, `Mailglass.Clock`, `Mailglass.Stream`,
  `Mailglass.RateLimiter`, `Mailglass.Suppression`, `Mailglass.Tracking`,
  `Mailglass.Compliance`, and `Mailglass.Compliance.Unsubscribe`.
- Webhook/public routing seams: `Mailglass.Webhook`,
  `Mailglass.Webhook.CachingBodyReader`, `Mailglass.Webhook.Plug`, and
  `Mailglass.Webhook.Router`.
- Event and telemetry seams: `Mailglass.Events`, `Mailglass.Events.Event`,
  and the named telemetry families under `[:mailglass, ...]` documented in
  this file.
- Stable operator/read-model query surfaces used by adopters and sibling
  packages: `Mailglass.Operator.Deliveries`,
  `Mailglass.Operator.ReplayHistory`, `Mailglass.Operator.ReplayTargets`,
  `Mailglass.Operator.Timeline`, and `Mailglass.Operator.Suppressions`.
- Stable Mix tasks: `mix mailglass.install`, `mix mailglass.reconcile`,
  `mix mail.doctor`, `mix mailglass.publish.check`,
  `mix mailglass.docs.check`, and `mix mailglass.stability.check`.
- Stable errors and closed atom/type sets documented below, including
  `Mailglass.Error`, `Mailglass.SendError`, `Mailglass.TemplateError`,
  `Mailglass.SignatureError`, `Mailglass.SuppressedError`,
  `Mailglass.RateLimitError`, `Mailglass.ConfigError`,
  `Mailglass.EventLedgerImmutableError`, `Mailglass.TenancyError`, and
  `Mailglass.PublishError`.

### `internal`

These surfaces may be exported, visible in docs, or reachable in source, but
they are not promised as stable adopter API for `v1.x`.

- Internal implementation helpers and infrastructure such as
  `Mailglass.Outbound.Projector`, `Mailglass.PubSub`,
  `Mailglass.PubSub.Topics`, `Mailglass.Repo`, `Mailglass.Schema`,
  `Mailglass.IdempotencyKey`, `Mailglass.OptionalDeps.Oban`, and Oban worker
  modules exported only because the runtime or sibling packages need them.
- Internal singleton names, ETS tables, storage processes, trigger helpers,
  migration runners, and other library-owned machinery documented later in
  this file.
- Internal HTML/controller/component modules and implementation-only helpers,
  even when they contribute to a stable semantic seam.
- Docs or source comments that explain internals are explanatory only; they do
  not promote those modules to public contract status.

### `sibling-package-only`

These surfaces are intentionally reachable for first-party sibling packages or
framework integration but are not part of the general adopter promise.

- Root exports retained so `mailglass_admin` can integrate with the production
  render, operator, webhook, and projection pipelines without depending on
  private code paths.
- Oban-facing modules and optional-dependency shims required for async or
  scheduler integration.
- Internal query/read-model surfaces that first-party packages may call while
  the maintainers keep the right to refine their shape outside the documented
  stable subset above.

## Stable Inventory

### Root `Mailglass` contract

`Mailglass` itself is a narrow convenience entrypoint, not a promise that every
root export is public API. The stable root promise is:

- delivery delegates on `Mailglass`
- the module/behaviour seams listed in `stable`
- the closed struct/type/atom sets documented below
- the stable Mix tasks listed in this document

If a root-exported module is not called out here as `stable`, treat it as
`internal` or `sibling-package-only`.

### Stable categories

#### Modules and behaviours

- Delivery: `Mailglass`, `Mailglass.Outbound`, `Mailglass.Adapter`,
  `Mailglass.Adapters.Fake`, `Mailglass.Adapters.Swoosh`
- Message authoring/rendering: `Mailglass.Message`, `Mailglass.Mailable`,
  `Mailglass.Renderer`
- Config/runtime: `Mailglass.Config`, `Mailglass.Clock`, `Mailglass.Tenancy`,
  `Mailglass.Stream`, `Mailglass.RateLimiter`, `Mailglass.Suppression`,
  `Mailglass.Tracking`, `Mailglass.Compliance`,
  `Mailglass.Compliance.Unsubscribe`
- Event/webhook: `Mailglass.Events`, `Mailglass.Events.Event`,
  `Mailglass.Webhook`, `Mailglass.Webhook.CachingBodyReader`,
  `Mailglass.Webhook.Plug`, `Mailglass.Webhook.Router`
- Stable operator semantics: `Mailglass.Operator.Deliveries`,
  `Mailglass.Operator.ReplayHistory`, `Mailglass.Operator.ReplayTargets`,
  `Mailglass.Operator.Timeline`, `Mailglass.Operator.Suppressions`

#### Mix tasks

- `mix mailglass.install` — installer and first-app bootstrap contract
- `mix mailglass.reconcile` — stable reconciliation operator task
- `mix mail.doctor` — DNS-only deliverability doctor contract
- `mix mailglass.publish.check` — release-facing publish drift check
- `mix mailglass.docs.check` — light docs-contract drift check
- `mix mailglass.stability.check` — light public-surface drift check

Generator and legacy-upgrade tasks remain useful tooling, but they are not part
of the narrow `v1.x` stable contract unless and until they are listed here.

#### Telemetry families

The stable telemetry contract is semantic and family-based:

- delivery spans and events under `[:mailglass, :outbound, ...]`
- webhook ingest spans and events under `[:mailglass, :webhook, ...]`
- render/tracking/compliance-related events under documented
  `[:mailglass, ...]` names in this file

Telemetry remains part of the stable contract only at the documented event-name
and metadata-shape level. Internal helper functions that emit those events are
not themselves promoted to stable API.

#### Structs, errors, and documented field promises

The stable data contract includes the closed struct/type/atom sets documented in
this file, plus any field-level promises called out in those sections. In
particular:

- stable error `type` atoms are closed sets unless this document says a section
  is extended in a minor release
- documented per-kind fields and stable JSON serialization fields are part of
  the contract
- callers should pattern-match by struct and `type`, never by exception message

## Inventory Notes

- Stable does not mean "everything ExDoc renders". This file is narrower than
  generated docs by design.
- Compatibility and deprecation lifecycle rules live in
  `guides/compatibility-and-deprecations.md`, not in this inventory.
- Exported does not mean stable. Root `Boundary` exports include framework and
  sibling-package hooks that remain outside the adopter contract.
- Hidden docs do not make a surface private. If a reachable helper is omitted
  from the stable inventory, it is intentionally non-contract.
- Future `v1.x` minor releases may add stable APIs, atoms, fields, or tasks,
  but only with matching doc metadata and updates to this inventory.

## `mailglass_admin` Contract Summary

The sibling `mailglass_admin` package has a separate package-local contract
page at `mailglass_admin/docs/api_stability.md`. The core contract only relies
on these admin-facing truths:

### Stable admin seams

- `MailglassAdmin.Router.mailglass_admin_routes/2` and
  `MailglassAdmin.Router.mailglass_operator_routes/2` are the stable mount
  macros and option contracts.
- `MailglassAdmin.Auth` is the stable adopter-owned authorization behaviour.
- Operator semantics are stable at the level of auth/session/replay/read-model
  behavior, not at the level of LiveView implementation modules.
- Stable admin docs point adopters to core read-model/query seams such as
  `Mailglass.Operator.Deliveries`, `Mailglass.Operator.Timeline`,
  `Mailglass.Operator.ReplayHistory`, `Mailglass.Operator.ReplayTargets`, and
  `Mailglass.Operator.Suppressions`.

### Internal admin surfaces

- LiveView modules, component modules, layouts, DOM shape, CSS classes, asset
  file names, preview assigns plumbing, and internal `on_mount` hook wiring are
  implementation details.
- Exported functions required by Phoenix `live_session` callbacks or router
  code generation are not stable unless they are called out in the admin
  contract page.
- `MailglassAdmin.Operator.Mount` remains internal even though framework wiring
  requires it to stay reachable.

## Error Hierarchy

### `Mailglass.Error`

Namespace + behaviour module. Not a struct.

- `@type t` — union of the six error structs
- `@callback type(t()) :: atom()`
- `@callback retryable?(t()) :: boolean()`
- Helpers: `is_error?/1`, `kind/1`, `retryable?/1`, `root_cause/1`

Since: 0.1.0.

### `Mailglass.SendError`

Raised when email delivery fails.

Type atom set (per `Mailglass.SendError.__types__/0`):

- `:adapter_failure`
- `:rendering_failed`
- `:preflight_rejected`
- `:serialization_failed`

Per-kind fields: `delivery_id :: binary() | nil`.

Retryable: `true` for `:adapter_failure`, `false` otherwise.

Since: 0.1.0.

### `Mailglass.TemplateError`

Raised when a template cannot be compiled or rendered.

Type atom set:

- `:heex_compile`
- `:missing_assign`
- `:helper_undefined`
- `:inliner_failed`

Per-kind fields: none.

Retryable: `false`.

Since: 0.1.0.

### `Mailglass.SignatureError`

Raised when webhook signature verification fails.

**Phase 4 extension (CONTEXT D-21): the closed atom set is seven atoms.**

| Atom | When raised |
|------|-------------|
| `:missing_header` | Signature header (Authorization / X-Twilio-Email-Event-Webhook-Signature) absent |
| `:malformed_header` | Header present but unparseable (bad Base64, missing prefix, malformed structure) |
| `:bad_credentials` | Postmark Basic Auth `Plug.Crypto.secure_compare/2` returned false |
| `:ip_disallowed` | Postmark IP allowlist (opt-in) — source IP not in configured CIDR list |
| `:bad_signature` | ECDSA / HMAC math returned false; collapses the `:tampered_body` case per D-21 rationale (tampered body + wrong key both produce the same failure branch) |
| `:timestamp_skew` | SendGrid timestamp outside the 300-second tolerance window (configurable via `:timestamp_tolerance_seconds`) |
| `:malformed_key` | PEM/DER decode failure (either at boot via `validate_at_boot!/0` OR at request time during verify) |

**Legacy atoms retained for backward compatibility:** `:missing`, `:malformed`,
`:mismatch`. These predate Phase 4 and still appear in `__types__/0` so any
existing raise sites keep compiling. New code MUST use the seven D-21 atoms
above — the legacy three are aliases in all but name.

Per-kind fields: `provider :: atom() | nil`.

Retryable: `false` — the caller is either misconfigured (wrong secret) or the
request is a forgery. Pattern-match the struct + atom; NEVER the message
string. Atom set is closed — additions are minor-version API extensions.

Since: 0.1.0.

### `Mailglass.SuppressedError`

Raised when delivery is blocked by the suppression list. Atom set mirrors
`Mailglass.Suppression.scope` (lands Phase 2) for a 1:1 pattern match.

Type atom set:

- `:address`
- `:domain`
- `:address_stream`

Per-kind fields: none.

Retryable: `false` (permanent policy block).

**Pre-0.1.0 refinement (D-09):** the atom set was refined from
`:tenant_address` → `:address_stream` before 0.1.0 shipped to match the
`mailglass_suppressions.scope` column. No deprecation cycle owed because
0.1.0 has not shipped.

Since: 0.1.0.

### `Mailglass.RateLimitError`

Raised when a rate limit is exceeded.

Type atom set:

- `:per_domain`
- `:per_tenant`
- `:per_stream`

Per-kind fields: `retry_after_ms :: non_neg_integer()` (default `0`).

Retryable: `true` — caller waits `retry_after_ms` and retries.

Since: 0.1.0.

### `Mailglass.ConfigError`

Raised when mailglass is misconfigured. `Mailglass.Config.validate_at_boot!/0`
raises this at application startup (Phase 1). Webhook config surface
extensions raise at boot or first-request time (Phase 4).

**Base atom set (Phase 1):**

- `:missing`
- `:invalid`
- `:conflicting`
- `:optional_dep_missing`

**Phase 3 additions** (documented in §ConfigError Extensions below):

- `:tracking_on_auth_stream`
- `:tracking_host_missing`
- `:tracking_endpoint_missing`

**Phase 4 additions (CONTEXT D-21):**

| Atom | When raised |
|------|-------------|
| `:webhook_verification_key_missing` | Per-provider signing secret missing — `:postmark.basic_auth` or `:sendgrid.public_key` not configured. Rationale per D-21: separation from `%SignatureError{}` keeps "forged request" vs "missing secret" distinguishable in admin triage. |
| `:webhook_caching_body_reader_missing` (revision B4) | `conn.private[:raw_body]` is nil — the adopter forgot to wire `Plug.Parsers` with `body_reader: {Mailglass.Webhook.CachingBodyReader, :read_body, []}`. Distinct atom from `:webhook_verification_key_missing` so adopter Grafana / Datadog alerts can differentiate plug-wiring gaps from missing secrets. |

Surfaces at boot during `Mailglass.Config` validation (key missing) OR at
request time when `Mailglass.Webhook.Plug` attempts the relevant lookup.

Per-kind fields: none.

Retryable: `false` — fix config and restart.

Since: 0.1.0.

### `Mailglass.EventLedgerImmutableError`

Raised when the `mailglass_events` immutability trigger fires
(SQLSTATE 45A01). Translation happens inside `Mailglass.Repo.transact/1`
— callers never see the raw `%Postgrex.Error{}`.

Type atom set:

- `:update_attempt`
- `:delete_attempt`

Per-kind fields: `pg_code :: String.t()` (always `"45A01"`).

Retryable: `false` (append-only invariant; the calling code has a bug).

**Translator asymmetry (Phase 2, IN-03):** both atoms are part of the
closed type set and stable, but the v0.1 translator in
the `infer_immutability_type/1` helper inside `Mailglass.Repo` always
emits `:update_attempt`. The Postgrex error message is not a stable
public API, and the v0.1 trigger function is shared between UPDATE and
DELETE rule violations. `:delete_attempt` is reserved for a future
Phase 4+ refinement that distinguishes the two actions (either via
dedicated trigger functions per action, or by pattern-matching the
constraint name) when webhook-path DELETE-attempt telemetry becomes
valuable.
Callers pattern-matching today should match either atom
(`err.type in [:update_attempt, :delete_attempt]`) to stay forward-
compatible.

Since: 0.1.0.

### `Mailglass.TenancyError`

Raised by `Mailglass.Tenancy.tenant_id!/0` when no tenant has been
stamped on the current process, and by `Mailglass.Webhook.Plug` when
the configured tenancy module fails to resolve a verified webhook to
a known tenant (Phase 4).

**Closed atom set:**

| Atom | When raised |
|------|-------------|
| `:unstamped` | No tenant stamped on the current process (Phase 2) — typically a missing `on_mount/4` callback or test setup. `Mailglass.Tenancy.tenant_id!/0` raises; `Mailglass.Tenancy.current/0` falls back to the SingleTenant default instead. |
| `:webhook_tenant_unresolved` (Phase 4 D-14) | `Mailglass.Tenancy.resolve_webhook_tenant/1` returned `{:error, _}` for a cryptographically verified webhook request. Rescued by `Mailglass.Webhook.Plug` to HTTP 422 (distinct from signature 401 / orphan 200). The `:context` map carries `:provider` and optionally `:reason` for Logger correlation. |

Per-kind fields: none.

Retryable: `false` — the caller failed to establish tenant context.

Since: 0.1.0.

### `Mailglass.PublishError`

Raised when installer golden drift is detected during the `mix mailglass.publish.check` task.

Type atom set:

- `:publish_blocked_golden_drift`

Per-kind fields: none.

Retryable: `false`.

Since: 0.2.0.

## Shared Error Serialization

Every error struct derives:

    @derive {Jason.Encoder, only: [:type, :message, :context]}

The `:cause` field is deliberately excluded to prevent recursive emission of
adapter structs that may carry provider payloads with recipient PII (T-PII-002).
Adopters that need the full cause chain walk it explicitly via
`Mailglass.Error.root_cause/1`.

## Event Type Contract

`Mailglass.Events.Event.__types__/0` is a closed atom set.

Anymail taxonomy atoms:

- `:queued`
- `:sent`
- `:rejected`
- `:failed`
- `:bounced`
- `:deferred`
- `:delivered`
- `:autoresponded`
- `:opened`
- `:clicked`
- `:complained`
- `:unsubscribed`
- `:subscribed`
- `:unknown`

Mailglass-internal atoms:

- `:dispatched`
- `:suppressed`
- `:reconciled`
- `:webhook_replay_requested`
- `:webhook_replay_succeeded`
- `:webhook_replay_failed`

## §Telemetry Extensions (Phase 3)

### New named span helpers

Added in Phase 3 (D-26). All delegate to `Mailglass.Telemetry.span/3`.

- `send_span(map(), (-> any())) :: any()` — emits `[:mailglass, :outbound, :send, :start | :stop | :exception]`.
- `dispatch_span(map(), (-> any())) :: any()` — emits `[:mailglass, :outbound, :dispatch, :start | :stop | :exception]`.
- `persist_outbound_multi_span(map(), (-> any())) :: any()` — emits `[:mailglass, :persist, :outbound, :multi, :start | :stop | :exception]`.

### New logged events (Phase 3)

Added to `@logged_events` for the default logger handler:

```
[:mailglass, :outbound, :send, :stop | :exception]
[:mailglass, :outbound, :dispatch, :stop | :exception]
[:mailglass, :outbound, :suppression, :stop]
[:mailglass, :outbound, :rate_limit, :stop]
[:mailglass, :outbound, :stream_policy, :stop]
[:mailglass, :persist, :outbound, :multi, :stop | :exception]
```

Metadata whitelist per D-31: `:tenant_id, :mailable, :stream, :delivery_id, :status, :provider, :latency_ms, :step_name, :allowed, :hit, :duration_us`.

<!-- Phase 4 adds 5 webhook spans + 1 reconcile span per 04-CONTEXT.md D-22:
       [:mailglass, :webhook, :ingest, :start | :stop | :exception]
       [:mailglass, :webhook, :signature, :verify, :start | :stop | :exception]
       [:mailglass, :webhook, :normalize, :stop]
       [:mailglass, :webhook, :orphan, :stop]
       [:mailglass, :webhook, :duplicate, :stop]
       [:mailglass, :webhook, :reconcile, :start | :stop | :exception]
     Metadata whitelist additions (D-23): :event_type, :status,
     :failure_reason, :mapped, :duplicate, :delivery_id_matched,
     :event_count, :age_seconds, :scanned_count, :linked_count,
     :remaining_orphan_count. Populated by Plans 03 (verify span),
     06 (ingest/normalize/orphan/duplicate spans), 07 (reconcile span). -->

Since: 0.1.0.

## §Repo.multi (Phase 3)

### `Mailglass.Repo.multi/1,2`

Added in Phase 3 (I-02). Executes an `Ecto.Multi` against the host-configured repo.

Locked signature: `@spec multi(Ecto.Multi.t(), keyword()) :: {:ok, map()} | {:error, atom(), any(), map()}`

Raises `%ConfigError{type: :missing}` when `:repo` is not configured. SQLSTATE 45A01 is translated via the same path as other write helpers.

Since: 0.1.0.

## §Events.append_multi function-form (Phase 3)

### Function-form attrs (I-03)

`Mailglass.Events.append_multi/3` now accepts `attrs :: map() | (map() -> map())`. When `attrs` is a 1-arity function, it is called inside a `Multi.run` step with the prior `changes` map. The intermediate step is named `:"<name>_attrs"`.

Since: 0.1.0.

## §PubSub (Phase 3)

### `Mailglass.PubSub`

Reserved name atom for the mailglass-owned `Phoenix.PubSub` child. The supervision tree starts `{Phoenix.PubSub, name: Mailglass.PubSub}`. This is the only valid name for mailglass-internal broadcasts.

Since: 0.1.0.

### `Mailglass.PubSub.Topics`

The only public topic builders. All outputs are prefixed `mailglass:` — Phase 6 `LINT-06 PrefixedPubSubTopics` enforces this at lint time.

- `events/1 :: String.t()` — `"mailglass:events:#{tenant_id}"` — tenant-wide event stream.
- `events/2 :: String.t()` — `"mailglass:events:#{tenant_id}:#{delivery_id}"` — per-delivery stream.
- `deliveries/1 :: String.t()` — `"mailglass:deliveries:#{tenant_id}"` — delivery-list stream.

Since: 0.1.0.

## §BatchFailed (Phase 3)

### `Mailglass.Error.BatchFailed`

Raised by `Mailglass.Outbound.deliver_many!/2` when one or more deliveries fail. Never raised by `deliver_many/2`.

Type atom set (per `Mailglass.Error.BatchFailed.__types__/0`):

- `:partial_failure` — at least one Delivery succeeded AND at least one failed
- `:all_failed` — every Delivery failed

Per-kind fields: `failures :: [Mailglass.Outbound.Delivery.t()]` — failed deliveries only. Excluded from JSON output (`@derive {Jason.Encoder, only: [:type, :message, :context]}`).

Retryable: `true` — individual deliveries may retry.

Since: 0.1.0.

## §ConfigError Extensions (Phase 3)

Two new atoms added to `Mailglass.ConfigError.__types__/0`:

- `:tracking_on_auth_stream` — (D-38, Phase 3) tracking enabled on a mailable whose function name matches an auth-stream heuristic. Forbidden at compile time via `NoTrackingOnAuthStream` Credo check (Phase 6).
- `:tracking_host_missing` — (D-32, Phase 3) a mailable enables opens or clicks but no tracking host is configured. Required for link rewriting.

Full type atom set is now: `[:missing, :invalid, :conflicting, :optional_dep_missing, :tracking_on_auth_stream, :tracking_host_missing]`.

Since: 0.1.0 (atoms added in Phase 3).

## §Message Extensions (Phase 3)

### `:mailable_function` field

Added in Phase 3. `atom() | nil`, default `nil`. Populated by the `use Mailglass.Mailable` macro's injected builder (D-38). Used by the runtime auth-stream tracking guard.

### `Mailglass.Message.put_metadata/3`

Locked signature: `@spec put_metadata(Message.t(), atom(), any()) :: Message.t()`

Returns a new `%Message{}` with `metadata[key] = value`. Used by the send pipeline (Plan 05) to stamp `delivery_id` after the Delivery row is inserted but before the adapter is called.

Since: 0.1.0.

## §Clock

### `Mailglass.Clock`

The single legitimate source of wall-clock time in mailglass (TEST-05).

- `Mailglass.Clock.utc_now/0 :: DateTime.t()` — three-tier resolution: process-frozen → configured impl → `Mailglass.Clock.System`.

Since: 0.1.0.

### `Mailglass.Clock.System`

Production impl. `utc_now/0` delegates to `DateTime.utc_now/0`.

Since: 0.1.0.

### `Mailglass.Clock.Frozen` (test-only)

Per-process clock freeze helper. Safe for `async: true` tests — frozen state is process-local.

- `freeze(DateTime.t()) :: DateTime.t()` — stamps the process dict and returns the frozen value.
- `advance(integer()) :: DateTime.t()` — advances the frozen time by `ms` milliseconds. Seeds from wall clock if no freeze is active.
- `unfreeze() :: :ok` — clears the process-dict freeze key.

**Convention:** `Mailglass.Clock.Frozen` is test-only. Calling `freeze/1` from production code paths is a bug. Phase 6 LINT-12 (`NoDirectDateTimeNow`) enforces this at lint time.

Since: 0.1.0.

## §Tenancy Extensions (Phase 3)

### `Mailglass.Tenancy.assert_stamped!/0`

- `assert_stamped!() :: :ok` — raises `%TenancyError{type: :unstamped}` when no tenant is stamped on the current process. Returns `:ok` otherwise. Does NOT fall back to the `SingleTenant` default (unlike `current/0`). SEND-01 precondition (D-18).

Since: 0.1.0.

### `Mailglass.Tenancy` optional callback: `tracking_host/1`

- `@callback tracking_host(context :: term()) :: {:ok, String.t()} | :default` — optional per-tenant tracking host override (D-32). Default resolution: `:default` (use global `config :mailglass, :tracking, host:`). Adopters returning `{:ok, host}` get per-tenant subdomains for strict cookie/origin isolation.

### `Mailglass.Tenancy` optional callback: `resolve_webhook_tenant/1` (Phase 4, CONTEXT D-12)

```elixir
@optional_callbacks tracking_host: 1, resolve_webhook_tenant: 1

@callback resolve_webhook_tenant(context :: %{
            provider: atom(),
            conn: Plug.Conn.t(),
            raw_body: binary(),
            headers: [{String.t(), String.t()}],
            path_params: map(),
            verified_payload: map() | nil
          }) :: {:ok, String.t()} | {:error, term()}
```

Called by `Mailglass.Webhook.Plug` AFTER `Provider.verify!/3` returns
`:ok` (D-13's verify-first ordering — closes the Stripe-Connect
chicken-and-egg trap). `{:ok, tenant_id}` stamps tenant context for
the rest of the ingest pipeline; `{:error, reason}` raises
`%Mailglass.TenancyError{type: :webhook_tenant_unresolved}` and
surfaces HTTP 422.

**Default impls shipped in Phase 4 Plan 05:**

- `Mailglass.Tenancy.SingleTenant` implements `resolve_webhook_tenant/1`
  by returning `{:ok, "default"}` (zero-config default).
- `Mailglass.Tenancy.ResolveFromPath` implements
  `resolve_webhook_tenant/1` as opt-in URL-prefix sugar. It reads
  `context.path_params["tenant_id"]` and returns `{:ok, tid}` or
  `{:error, :missing_path_param}`. The module fails CLOSED on `scope/2`
  — adopters using it for the full Tenancy contract MUST compose it
  with a real `scope/2` impl.

Adopter tenancy modules that do not implement this optional callback
fall through to `{:ok, "default"}` via the dispatcher's
`function_exported?/3` check.

**Context fields:**

- `:provider` — `:postmark | :sendgrid`
- `:conn` — the `Plug.Conn` (header / IP / path-param introspection)
- `:raw_body` — verified raw bytes (signature passed)
- `:headers` — `[{name, value}]` list
- `:path_params` — adopter route's path params
- `:verified_payload` — `nil` at v0.1; reserved for v0.5 Stripe-Connect-style strategies

Since: 0.1.0.

## §Adapter (Phase 3)

### `Mailglass.Adapter` behaviour

Shipped in Phase 3 Plan 02 (TRANS-01). Single-callback behaviour every mailglass adapter implements.

**Locked callback signature:**

```elixir
@callback deliver(Mailglass.Message.t(), keyword()) ::
            {:ok, %{message_id: String.t(), provider_response: term()}} | {:error, Mailglass.Error.t()}
```

**Return shape contract:**

- `{:ok, %{message_id: String.t(), provider_response: term()}}` on success.
  `:message_id` is the adapter's canonical identifier — Phase 4 webhook ingest uses it to join
  incoming events to the `%Delivery{}` row via `provider_message_id`.
- `{:error, Mailglass.Error.t()}` on failure. Return struct must be a subtype of `%Mailglass.Error{}`
  — callers pattern-match by struct, never by message string. `%Mailglass.SendError{type: :adapter_failure}`
  is the canonical wrap for downstream provider errors.

Changes to the callback signature are semver-breaking. Adopters implement custom adapters by
conforming to this behaviour.

**In-repo implementations:**

- `Mailglass.Adapters.Fake` (TRANS-02) — in-memory, merge-blocking release gate (D-13).
- `Mailglass.Adapters.Swoosh` (TRANS-03) — wraps any `Swoosh.Adapter`, normalizes errors.

Since: 0.1.0.

### `Mailglass.Adapters.Swoosh`

Bridges to any `Swoosh.Adapter` (Postmark, SendGrid, Mailgun, SES, Resend, SMTP).

**Error mapping table:**

| Swoosh error shape | Mapped `SendError` `:type` | Context keys |
|--------------------|---------------------------|--------------|
| `{:api_error, status, body}` | `:adapter_failure` | `provider_status`, `body_preview` (200 bytes), `provider_module`, `reason_class` |
| `{:error, :timeout}` | `:adapter_failure` | `provider_module`, `reason_class: :transport` |
| `{:error, {:tls_alert, _}}` | `:adapter_failure` | `provider_module`, `reason_class: :transport` |
| `{:error, other}` | `:adapter_failure` | `provider_module`, `reason_class: :other` |

**`reason_class` atoms:** `:server_error` (5xx), `:client_error` (4xx), `:unknown` (other status),
`:transport` (timeout/TLS), `:other` (unclassified).

**PII policy:** The 8 forbidden keys (`:to, :from, :body, :html_body, :subject, :headers, :recipient, :email`)
NEVER appear in error context. `body_preview` is a 200-byte head of the provider response body —
provider-emitted strings only, never user-supplied content. Phase 6 LINT-02 enforces.

Does NOT call `Swoosh.Mailer.deliver/1` — LINT-01 forbidden. Calls `Swoosh.Adapter.deliver/2`
(the behaviour callback) directly. Pure: no DB, no PubSub, no GenServer.

Since: 0.1.0.

## §Fake (Phase 3)

### `Mailglass.Adapters.Fake`

In-memory, time-advanceable test adapter (TRANS-02, D-01..D-03). The merge-blocking release gate (D-13).

**Stored record shape** (JSON-compatible per TRANS-02):

```elixir
%{
  message: %Mailglass.Message{},
  delivery_id: Ecto.UUID.t(),
  provider_message_id: String.t(),
  recorded_at: DateTime.t()
}
```

**Locked public API:**

| Function | Signature | Description |
|----------|-----------|-------------|
| `deliveries/0,1` | `(keyword()) :: [map()]` | List recorded deliveries; opts: `:owner`, `:tenant`, `:mailable`, `:recipient` |
| `last_delivery/0,1` | `(keyword()) :: map() \| nil` | Most recently inserted delivery |
| `clear/0,1` | `(keyword() \| :all) :: :ok` | Wipe owner bucket; `:all` flushes entire ETS table |
| `trigger_event/3` | `(String.t(), atom(), keyword()) :: {:ok, Event.t()} \| {:error, term()}` | Simulate webhook event via real write path |
| `advance_time/1` | `(integer()) :: DateTime.t()` | Advances process-local frozen clock (delegates to `Clock.Frozen.advance/1`) |
| `checkout/0` | `() :: :ok` | Register current process as owner |
| `checkin/0` | `() :: :ok` | Unregister current process as owner |
| `allow/2` | `(pid(), pid()) :: :ok` | Allow `allowed_pid` to deliver into `owner_pid`'s bucket |
| `set_shared/1` | `(pid() \| nil) :: :ok` | Set global shared owner (for non-async E2E tests) |
| `get_shared/0` | `() :: pid() \| nil` | Returns current shared owner |

**ETS table name:** `:mailglass_fake_mailbox` — library-reserved. Adopters must not register a
process or table under this name.

**GenServer name:** `Mailglass.Adapters.Fake.Storage` — library-reserved singleton (LINT-07
exception: library-internal per D-02). Unconditionally started by `Mailglass.Application`.

**`trigger_event/3` write-path guarantee (D-03):** Looks up the `%Delivery{}` by
`provider_message_id`, then runs `Events.append_multi/3 + Projector.update_projections/2` inside
`Repo.multi/1` — the SAME write path Phase 4 webhook ingest uses. The Fake proves the production
write path in every CI run.

**Ownership model:** Mirrors `Swoosh.Adapters.Sandbox`. Each test process is its own owner via
`checkout/0`. `$callers` inheritance (Task.async) works automatically. Cross-process delegation
(LiveView, Oban workers, Playwright) uses `allow/2`. Global mode uses `set_shared/1`.

Since: 0.1.0.

## §Projector.broadcast_delivery_updated (Phase 3)

### `Mailglass.Outbound.Projector.broadcast_delivery_updated/3`

Locked signature: `@spec broadcast_delivery_updated(Delivery.t(), atom(), map()) :: :ok`

**Payload shape:** `{:delivery_updated, delivery_id :: binary, event_type :: atom, meta :: map}`

**Broadcast topics (SEND-05, D-27):**
- `Mailglass.PubSub.Topics.events(tenant_id)` — tenant-wide stream
- `Mailglass.PubSub.Topics.events(tenant_id, delivery_id)` — per-delivery stream

**Semantics:** Best-effort, fire-and-forget. Broadcast failure NEVER rolls back (broadcast runs
AFTER `Repo.transact/1` commits). If Phoenix.PubSub is unreachable, logs a debug message and
returns `:ok`. The event ledger is the durable source of truth; PubSub is the realtime fan-out.

**Callers:**
- `Mailglass.Outbound.send/2` (Plan 05 Multi#2 success path)
- `Mailglass.Outbound.Worker.perform/1` (Plan 05 async Multi#2 success)
- `Mailglass.Adapters.Fake.trigger_event/3` (after its own `Repo.multi/1` commits)
- `Mailglass.Webhook.Plug` (Phase 4 — after webhook Multi commits)

Since: 0.1.0.

## §RateLimiter (Phase 3 Plan 03)

### `Mailglass.RateLimiter.check/3`

Locked signature: `@spec check(String.t(), String.t(), atom()) :: :ok | {:error, Mailglass.RateLimitError.t()}`

**`:transactional` bypass invariant (D-24):** When `stream == :transactional`, `check/3` returns `:ok`
immediately WITHOUT reading ETS. This is a reserved invariant — NOT a tunable. Password-reset,
magic-link, and verify-email flows MUST NOT be throttled by bulk campaign saturation.

**Token bucket math (D-23):** Continuous leaky-bucket refill at `per_minute / 60_000` tokens/ms.
Default: 100 capacity @ 100/min. After an over-limit event (counter at -1), refill restores the
bucket on the next call using `restore + elapsed_refill` delta, capped at `capacity`.

**Configuration shape:**

```elixir
config :mailglass, :rate_limit,
  default: [capacity: 100, per_minute: 100],
  overrides: [
    {{"tenant-id", "domain.com"}, [capacity: 500, per_minute: 500]}
  ]
```

Missing `:rate_limit` key uses built-in defaults (`capacity: 100, per_minute: 100`).

**Telemetry:** Single-emit `[:mailglass, :outbound, :rate_limit, :stop]`
- Measurements: `%{duration_us: integer()}`
- Metadata: `%{allowed: boolean(), tenant_id: String.t()}` — no recipient domain (D-31 PII whitelist)

Since: 0.1.0.

### `Mailglass.RateLimiter.Supervisor` (library-reserved singleton)

Registered under `name: __MODULE__` (`Mailglass.RateLimiter.Supervisor`). Library-internal machinery.
Started unconditionally by `Mailglass.Application` via `Code.ensure_loaded?/1` gate (I-08).

Phase 6 `LINT-07 NoDefaultModuleNameSingleton` has an allowlist entry for this module.

Since: 0.1.0.

### `:mailglass_rate_limit` ETS table (library-reserved)

Named ETS table owned by `Mailglass.RateLimiter.TableOwner`. Key shape: `{tenant_id, domain}`.
Value shape: `{key, tokens :: integer(), last_refill_ms :: integer()}`.

OTP 27 opts: `:set, :public, :named_table, read_concurrency: true, write_concurrency: :auto, decentralized_counters: true`.

Adopters MUST NOT register a process or table under this name. Crash semantics (D-22): if
`TableOwner` crashes, BEAM deletes the table; supervisor restarts and recreates it empty.
Counter reset is acceptable — worst case is 1 minute of burst allowance.

### `Mailglass.RateLimiter.TableOwner` (library-reserved singleton)

Registered under `name: __MODULE__` (`Mailglass.RateLimiter.TableOwner`). Init-and-idle GenServer —
no `handle_call/3`, `handle_cast/2`, or `handle_info/2`. All hot-path reads/writes happen directly
from caller processes via `:ets.update_counter/4`.

Phase 6 `LINT-07 NoDefaultModuleNameSingleton` has an allowlist entry for this module.

Since: 0.1.0.

## §SuppressionStore.ETS (Phase 3 Plan 03)

### `Mailglass.SuppressionStore.ETS`

ETS-backed implementation of `Mailglass.SuppressionStore` (D-28). Behaviour parity with
`Mailglass.SuppressionStore.Ecto` — same `check/2` and `record/2` contract.

**Locked behaviour callbacks:**

```elixir
@callback check(lookup_key(), keyword()) ::
            {:suppressed, Entry.t()} | :not_suppressed | {:error, term()}
@callback record(record_attrs(), keyword()) ::
            {:ok, Entry.t()} | {:error, term()}
```

**Lookup algorithm (3-branch OR-union, matching Ecto):**
1. `{tenant_id, address, :address, nil}` — address scope
2. `{tenant_id, domain, :domain, nil}` — domain scope
3. `{tenant_id, address, :address_stream, stream}` — only when stream is provided

**UPSERT behaviour:** `record/2` with same key `{tenant_id, address, scope, stream}` overwrites
the existing entry (equivalent to Ecto's `on_conflict: {:replace, [...]}`).

**Expiry filter:** expired entries (where `expires_at < Clock.utc_now()`) are silently skipped
at read time — they are NOT returned by `check/2`.

**Test override pattern:** configure via `Application.put_env/3` in test `setup`, restore in
`on_exit`. Scope tests by unique `tenant_id` to avoid cross-test leakage. Call `reset/0` in
`setup` for a guaranteed clean slate.

**`reset/0` (test-only helper):** `@spec reset() :: :ok` — clears all entries from the ETS
suppression table. MUST NOT be called from production code.

Since: 0.1.0.

### `:mailglass_suppression_store` ETS table (library-reserved)

Named ETS table owned by `Mailglass.SuppressionStore.ETS.TableOwner`. Key shape:
`{tenant_id, address, scope, stream_or_nil}`. Value shape: `{key, %Mailglass.Suppression.Entry{}}`.

OTP 27 opts: `:set, :public, :named_table, read_concurrency: true, write_concurrency: :auto`.

Adopters MUST NOT register a process or table under this name. Crash semantics (D-22): if
`TableOwner` crashes, BEAM deletes the table; supervisor restarts and recreates it empty.

### `Mailglass.SuppressionStore.ETS.Supervisor` (library-reserved singleton)

Registered under `name: __MODULE__`. Library-internal machinery. Started unconditionally by
`Mailglass.Application` via `Code.ensure_loaded?/1` gate (I-08).

Phase 6 `LINT-07 NoDefaultModuleNameSingleton` has an allowlist entry for this module.

Since: 0.1.0.

### `Mailglass.SuppressionStore.ETS.TableOwner` (library-reserved singleton)

Registered under `name: __MODULE__`. Init-and-idle GenServer — no `handle_call/3`,
`handle_cast/2`, or `handle_info/2`.

Phase 6 `LINT-07 NoDefaultModuleNameSingleton` has an allowlist entry for this module.

Since: 0.1.0.

## §Suppression (Phase 3 Plan 03)

### `Mailglass.Suppression.check_before_send/1`

Locked signature: `@spec check_before_send(Mailglass.Message.t()) :: :ok | {:error, Mailglass.SuppressedError.t()}`

**Store-indirection pattern:** Delegates to the module configured at runtime via:

```elixir
Application.get_env(:mailglass, :suppression_store, Mailglass.SuppressionStore.Ecto)
```

Default is `Mailglass.SuppressionStore.Ecto`. Tests override to `Mailglass.SuppressionStore.ETS`
for in-memory speed.

**Recipient extraction:** Reads `msg.swoosh_email.to` — first element (primary recipient).
Returns `""` when the `to` list is empty (store will return `:not_suppressed`).

**Return shape:**
- `:ok` — recipient is not suppressed
- `{:error, %SuppressedError{type: scope}}` — recipient is suppressed; `scope` is `:address | :domain | :address_stream`
- `{:error, term()}` — store infrastructure failure (passed through)

**Telemetry:** Single-emit `[:mailglass, :outbound, :suppression, :stop]`
- Measurements: `%{duration_us: integer()}`
- Metadata: `%{hit: boolean(), tenant_id: String.t()}` — no PII (D-31 whitelist)

**`SuppressedError` context keys:** `%{tenant_id: String.t(), stream: atom()}` — no recipient
address, no email headers. (T-3-03-02 mitigation.)

Since: 0.1.0.

## §Stream (Phase 3 Plan 03)

### `Mailglass.Stream.policy_check/1`

Locked signature: `@spec policy_check(Mailglass.Message.t()) :: :ok`

**No-op at v0.1.** Returns `:ok` for all valid streams (`:transactional | :operational | :bulk`).
Pattern-matches on `%Mailglass.Message{}` only — passing a raw map raises `FunctionClauseError`.

**v0.5 DELIV-02 contract stability:** The v0.5 implementation swaps this no-op in place.
The function signature, telemetry event name, and return type are stable across the swap.
Callers in `Mailglass.Outbound.send/2` do not change. Do not extend this module from adopter
code — the implementation contract is internal.

**Telemetry:** Single-emit `[:mailglass, :outbound, :stream_policy, :stop]`
- Measurements: `%{duration_us: integer()}`
- Metadata: `%{tenant_id: String.t(), stream: atom()}` — no PII (D-31 whitelist)

Stream atom is enum-narrow (one of three known values) — not recipient-identifying.

Since: 0.1.0.

## §Mailable (Phase 3 Plan 04)

### `Mailglass.Mailable` behaviour

Shipped in Phase 3 Plan 04 (AUTHOR-01). The adopter entry point — `use Mailglass.Mailable, stream: …`
injects the mailable boilerplate in ≤20 top-level AST forms.

**Locked behaviour callbacks:**

```elixir
@callback new() :: Mailglass.Message.t()
@callback render(Mailglass.Message.t(), atom(), map()) ::
            {:ok, Mailglass.Message.t()} | {:error, Mailglass.TemplateError.t()}
@callback deliver(Mailglass.Message.t(), keyword()) ::
            {:ok, term()} | {:error, Mailglass.Error.t()}
@callback deliver_later(Mailglass.Message.t(), keyword()) ::
            {:ok, term()} | {:error, Mailglass.Error.t()}
@optional_callbacks preview_props: 0
@callback preview_props() :: [{atom(), map()}]
```

**`preview_props/0` is optional.** Adopters who want Phase 5 admin preview discovery implement it;
omitting it produces no compiler warning.

**`defoverridable` surface (stable):** `new/0`, `render/3`, `deliver/2`, `deliver_later/2` — all
four injected functions are overridable via `defoverridable`. Adopters who override `deliver/2` to
bypass `Mailglass.Outbound` lose telemetry + projection writes (T-3-04-04 accepted risk; documented).

### `use` opts vocabulary (compile-time tier, D-11)

The locked `use` opts passed to `use Mailglass.Mailable`:

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `:stream` | `:transactional \| :operational \| :bulk` | `:transactional` | Compile-time stream classification. Required for Phase 6 LINT AST check. |
| `:tracking` | `[opens: boolean, clicks: boolean]` | `[]` (all false) | Open/click tracking opt-in (TRACK-01, D-08). Off by default. Phase 6 TRACK-02 + Phase 3 Guard enforce. |
| `:from_default` | `{name :: String.t(), address :: String.t()} \| nil` | `nil` | Default `from` header applied at `new/0` time. Per-call `Swoosh.Email.from/2` overrides. |
| `:reply_to_default` | `{name :: String.t(), address :: String.t()} \| nil` | `nil` | Default Reply-To header applied at `new/0` time. |

Adding new `use` opts is semver-minor. Removing or changing the type of an existing opt is
semver-major.

### Injection budget (LINT-05, D-09)

`__using__/1` injects exactly 12 top-level AST forms (budget: ≤20 per LINT-05; target: 15 per D-09).
Phase 6 `NoOversizedUseInjection` Credo check enforces this at lint time. A runtime AST-counting
test in `test/mailglass/mailable_test.exs` asserts the budget on every CI run.

**What is injected:**
1. `@behaviour Mailglass.Mailable`
2. `@before_compile Mailglass.Mailable`
3. `@mailglass_opts opts`
4. `@compile {:no_warn_undefined, Mailglass.Outbound}` (forward-ref guard until Plan 05)
5. `import Swoosh.Email, except: [new: 0]`
6. `import Mailglass.Components`
7. `def __mailglass_opts__/0`
8. `def new/0`
9. `def render/3`
10. `def deliver/2`
11. `def deliver_later/2`
12. `defoverridable new: 0, render: 3, deliver: 2, deliver_later: 2`

**What is NOT injected:** `import Phoenix.Component` (adopters opt in per-mailable to avoid HEEx
collision), `preview_props/0` default, module attributes `@subject` / `@from` (D-11 rationale).

### `__mailglass_opts__/0` reflection contract

Every module compiled with `use Mailglass.Mailable` exposes:

```elixir
@spec __mailglass_opts__() :: keyword()
```

Returns the keyword list passed to `use`. Phase 6 Credo reads this via AST introspection of the
`@mailglass_opts` attribute. Phase 3 `Mailglass.Tracking.Guard.assert_safe!/1` reads it at runtime
via `module.__mailglass_opts__()`.

**Stability:** This function is library-internal machinery. Adopters MUST NOT define
`def __mailglass_opts__` manually outside `use Mailglass.Mailable` — Phase 6 LINT will catch this.

### `__mailglass_mailable__/0` discovery marker

Every module compiled with `use Mailglass.Mailable` exposes:

```elixir
@spec __mailglass_mailable__() :: true
```

Always returns `true`. Phase 5 admin dashboard discovers mailable modules by probing
`function_exported?(mod, :__mailglass_mailable__, 0)` across loaded modules.

**Stability:** Locked. Must return `true` — Phase 5 admin uses this as a boolean gate.

Since: 0.1.0.

## §Message Helpers (Phase 3 Plan 04)

Three new helpers added to `Mailglass.Message`:

### `Mailglass.Message.new_from_use/2`

```elixir
@spec new_from_use(module(), keyword()) :: Mailglass.Message.t()
```

Creates a `%Mailglass.Message{}` from a mailable module and its `use` opts. Called by the
injected `new/0` function. Seeds `:stream`, `:mailable`, `:tenant_id` from opts; applies
`:from_default` to the inner `%Swoosh.Email{}` when present.

Since: 0.1.0.

### `Mailglass.Message.update_swoosh/2`

```elixir
@spec update_swoosh(Message.t(), (Swoosh.Email.t() -> Swoosh.Email.t())) :: Message.t()
```

Applies a transformation function to the inner `%Swoosh.Email{}`. As of the v0.2 API freeze, `Swoosh` is an internal implementation detail hidden from the native setters to prevent namespace pollution. This function serves as the explicitly documented **escape hatch** for advanced Swoosh functionality not covered by the native setters. Adopters use this to pipe through Swoosh builder functions while keeping the `%Message{}` wrapper intact.

The canonical pattern for building mailable functions using native setters and the escape hatch:

```elixir
def welcome(user) do
  new()
  |> Mailglass.Message.to(user.email)
  |> Mailglass.Message.subject("Welcome!")
  |> Mailglass.Message.update_swoosh(fn e ->
       # Escape hatch for advanced Swoosh functionality (e.g., custom provider headers)
       Swoosh.Email.header(e, "X-Custom-Feature", "true")
     end)
  |> Mailglass.Message.put_function(:welcome)
end
```

Since: 0.1.0.

### `Mailglass.Message.put_function/2`

```elixir
@spec put_function(Message.t(), atom()) :: Message.t()
```

Stamps the `:mailable_function` field. Required for the D-38 runtime tracking guard
(`Mailglass.Tracking.Guard.assert_safe!/1`) to perform its auth-stream heuristic check.
Adopters who omit `put_function/2` get `mailable_function: nil` — the Guard returns `:ok`
(can't check without the function name); Phase 6 Credo TRACK-02 catches this statically.

Since: 0.1.0.

## §Tracking (Phase 3 Plan 04)

### `Mailglass.Tracking.enabled?/1`

```elixir
@spec enabled?(mailable: module()) :: %{opens: boolean(), clicks: boolean()}
```

Returns the compile-time tracking flags for a mailable module. Reads
`module.__mailglass_opts__/0` to inspect the `tracking:` keyword list from `use` opts.

**Off-by-default semantics (TRACK-01):** Returns `%{opens: false, clicks: false}` for:
- Modules without `use Mailglass.Mailable` (no `__mailglass_opts__/0` exported)
- Modules with `use Mailglass.Mailable` but no `tracking:` opt (default is all false)

**Locked return shape:** Always a two-key map with `:opens` and `:clicks` boolean values.
Used by Plan 06's `Mailglass.Tracking.Rewriter` (pixel injection + link rewriting) and by
`Mailglass.Tracking.Guard.assert_safe!/1` (D-38 runtime enforcement).

Since: 0.1.0.

### `Mailglass.Tracking.Guard.assert_safe!/1`

```elixir
@spec assert_safe!(Mailglass.Message.t()) :: :ok
```

Raises `%Mailglass.ConfigError{type: :tracking_on_auth_stream}` when the mailable's
compile-time tracking opts enable opens or clicks AND the `mailable_function` field
matches the auth-stream regex. Returns `:ok` otherwise.

**Locked contract:** Returns `:ok` or raises — no `{:error, _}` return path. This is a
fail-loud guard, not a preflight stage.

**Auth-stream regex (locked):** `^(magic_link|password_reset|verify_email|confirm_account)`

Matches the four canonical auth-carrying function-name prefixes. Variant names starting
with these prefixes (e.g. `magic_link_verify_otp`, `password_reset_confirm`) ALSO match —
prefix matching prevents Outlook SafeLinks pre-fetch from triggering auth-stream tracking.

Adding new prefix patterns to the regex is **semver-minor** (new mailables are newly
prevented from enabling tracking). Removing patterns is **semver-major**.

**`nil` mailable_function (T-3-04-01):** When `mailable_function` is `nil`, the guard
returns `:ok` — it cannot perform the heuristic without a function name. Phase 6 Credo
`TRACK-02 NoTrackingOnAuthStream` is the primary enforcement for this case via AST
inspection of the mailable module's function heads.

**Error context (PII-free, T-3-04-03):**
```elixir
%Mailglass.ConfigError{
  type: :tracking_on_auth_stream,
  context: %{
    mailable: MyApp.UserMailer,   # module atom — not PII
    function: :magic_link         # function atom — not PII
  }
}
```

**Dual enforcement layers:**
1. Phase 6 `TRACK-02 NoTrackingOnAuthStream` Credo check — compile-time, catches static patterns.
2. `Mailglass.Tracking.Guard.assert_safe!/1` — runtime, catches dynamically-named mailables.

**Callers:** `Mailglass.Outbound.send/2` (Plan 05) — called as a precondition before
the Delivery row is inserted.

Since: 0.1.0.

## §Delivery — idempotency_key + status + last_error (Phase 3 Plan 05)

### New fields on `Mailglass.Outbound.Delivery` (I-01, Task 1)

**`:idempotency_key`** — nullable text field added in Phase 3 Plan 05.

- Shape: any binary. `Mailglass.Outbound.send/2` computes
  `sha256(tenant_id | mailable | recipient | content_hash)` (D-15). Adopters may
  supply any string; the partial UNIQUE index enforces uniqueness only when non-nil.
- Partial UNIQUE index: `mailglass_deliveries_idempotency_key_unique_idx`
  ON `(idempotency_key) WHERE idempotency_key IS NOT NULL`.
  The WHERE predicate matches the `conflict_target` fragment
  `"(idempotency_key) WHERE idempotency_key IS NOT NULL"` used by
  `deliver_many/2` — character-for-character (Pitfall 1).

**`:status`** — `Ecto.Enum` field with values `:queued | :sent | :dispatched | :failed | :suppressed`.
Default: `:queued` (NOT NULL at the DB layer). This is the stable public snapshot adopters
pattern-match on (ROADMAP success criterion 1: `{:ok, %Delivery{status: :sent}}`). It is
distinct from `:last_event_type` (the most-recent ledger projection). They diverge at dispatch:
Multi#2 sets `status: :sent` AND `last_event_type: :dispatched`.

**`:last_error`** — `:map` column. Populated when `status: :failed` via `serialize_error/1`
in `Mailglass.Outbound`. Shape at read time: `%{type: atom, message: binary, module: binary}`.
Never a raw exception message string — adopters pattern-match on `:type`, never on `:message`.

### Idempotency key computation (D-15)

```
sha256(tenant_id <> "|" <> inspect(mailable) <> "|" <> recipient <> "|" <> content_hash)
```

where `content_hash = sha256(html_body <> text_body)`. The SHA-256 output is hex-encoded
(lowercase). Cross-tenant collision probability is ~2^-256 per batch (cryptographic-strength).

Adopters using `deliver_many/2` who want deterministic replay safety should ensure the
message content is stable across retries (no timestamp interpolation in the body).

Since: 0.1.0.

## §Outbound (Phase 3 Plan 05)

### `Mailglass.Outbound` — public surface

**Locked functions:**

| Function | Return shape | Notes |
|----------|-------------|-------|
| `send/2` | `{:ok, %Delivery{status: :sent}}` or `{:error, %Error{}}` | Canonical internal verb |
| `deliver/2` | same as `send/2` | `defdelegate` alias (D-13) |
| `deliver!/2` | `%Delivery{}` or raises | Bang variant; raises the error struct directly |
| `deliver_later/2` | `{:ok, %Delivery{status: :queued}}` or `{:error, %Error{}}` | Always returns Delivery, never `%Oban.Job{}` (D-14) |
| `deliver_many/2` | `{:ok, [%Delivery{}]}` or `{:error, %Error{}}` | v0.1 async-only |
| `deliver_many!/2` | `[%Delivery{}]` or raises `%BatchFailed{}` | Bang batch variant |
| `dispatch_by_id/1` | `{:ok, %Delivery{}}` or `{:error, %Error{}}` | Called by Outbound.Worker |

Top-level `Mailglass` module re-exports all five public verbs as `defdelegate`.

**Preflight pipeline order (D-18, SEND-01) — locked:**

0. `Mailglass.Tenancy.assert_stamped!/0` — raises `%TenancyError{:unstamped}`
1. `Mailglass.Tracking.Guard.assert_safe!/1` — raises `%ConfigError{:tracking_on_auth_stream}`
2. `Mailglass.Suppression.check_before_send/1` — returns `{:error, %SuppressedError{}}`
3. `Mailglass.RateLimiter.check/3` — `:transactional` bypasses; returns `{:error, %RateLimitError{}}`
4. `Mailglass.Stream.policy_check/1` — no-op seam (v0.1)
5. `Mailglass.Renderer.render/1` — returns `{:error, %TemplateError{}}`

**Two-Multi sync pattern invariant (D-20 — critical, T-3-05-03):**

Adapter call is OUTSIDE any `Repo.transact/1` transaction. Adapter-in-transaction
causes Postgres connection-pool starvation under provider latency. Any PR inlining
the adapter call inside `Repo.transact` is a blocking defect.

**Telemetry events (Phase 1 D-31 whitelist — no PII):**

| Event | Metadata keys |
|-------|--------------|
| `[:mailglass, :outbound, :send, :start\|:stop]` | `tenant_id, mailable, stream` |
| `[:mailglass, :outbound, :dispatch, :start\|:stop]` | `tenant_id, mailable, provider` |
| `[:mailglass, :persist, :outbound, :multi, :start\|:stop]` | `step_name, tenant_id` |

**PII exclusion list** (verified by property test across 100 generated sends):
`:to, :from, :body, :html_body, :subject, :headers, :recipient, :email`

**`deliver_later/2` return shape invariant (D-14):** ALWAYS `{:ok, %Delivery{status: :queued}}`
or `{:error, %Error{}}`. Never `%Oban.Job{}`. Oban types never leak into the public API.

**`deliver_many/2` v0.1 scope:** Async-only. Each message produces one Oban job (or one
`Task.Supervisor` spawn when Oban absent). Sync-batch fan-out deferred to v0.5.
`[ASSUMED — Plan 05 Task 4 decision]`

Since: 0.1.0.

## §Tracking.Token (Phase 3 Plan 07)

### `Mailglass.Tracking.Token`

Shipped in Phase 3 Plan 07 (TRACK-03, D-33..D-35). Phoenix.Token-signed tokens for
open-pixel and click-redirect URLs.

**Token shapes:**

- Open pixel: `{:open, delivery_id, tenant_id}`
- Click redirect: `{:click, delivery_id, tenant_id, target_url}`

**Locked function signatures:**

```elixir
@spec sign_open(endpoint :: atom() | binary(), delivery_id :: String.t(), tenant_id :: String.t()) :: binary()
@spec verify_open(endpoint :: atom() | binary(), binary()) ::
        {:ok, %{delivery_id: String.t(), tenant_id: String.t()}} | :error
@spec sign_click(endpoint :: atom() | binary(), String.t(), String.t(), String.t()) :: binary()
@spec verify_click(endpoint :: atom() | binary(), binary()) ::
        {:ok, %{delivery_id: String.t(), tenant_id: String.t(), target_url: String.t()}} | :error
```

**Open-redirect prevention (D-35 pattern a):** `target_url` lives INSIDE the signed token
payload, never as a query parameter. The CVE class is structurally unreachable — there is
no parameter to tamper with. A tampered token fails Phoenix.Token HMAC → `:error`.

**Scheme validation at sign time:** `sign_click/4` raises `%ConfigError{type: :invalid,
context: %{rejected_url: url, reason: :scheme}}` when `target_url` scheme is not
`http` or `https`. Defense-in-depth re-check at `verify_click/2` time (T-3-07-10).

**Salts rotation (D-33):** `config :mailglass, :tracking, salts: ["q2-2026", "q1-2026"]`.
HEAD of list signs; ALL salts in list are tried at verify (early-return iteration).
Rotating = prepend new salt; remove old salt to invalidate tokens signed with it.

**`tenant_id` in payload only (D-39):** Never exposed in URL path or query string.
Corporate proxy logs, referrer headers, and shared-link screenshots cannot leak it.

**Token max_age:** Default 2 years (`2 * 365 * 86_400` seconds). Configurable via
`config :mailglass, :tracking, max_age: seconds`.

**Sign opts:** `[key_iterations: 1000, key_length: 32, digest: :sha256]` — matches
Phoenix.Token security recommendations.

Since: 0.1.0.

## §Tracking.Rewriter (Phase 3 Plan 07)

### `Mailglass.Tracking.Rewriter`

Shipped in Phase 3 Plan 07 (TRACK-03, D-36..D-37). Pure Floki-based HTML transform
for open-pixel injection and click link rewriting.

**Locked function signature:**

```elixir
@spec rewrite(html_body :: String.t(), opts :: keyword()) :: String.t()
```

**Options:**
- `:flags` — `%{opens: boolean, clicks: boolean}` (required)
- `:delivery_id` — delivery UUID for token encoding (required)
- `:tenant_id` — tenant scope for token encoding (required)
- `:endpoint` — Phoenix.Token endpoint or secret binary (optional, falls back to config)

**Skip list (D-36):** The following hrefs are NEVER rewritten:
- `mailto:`, `tel:`, `sms:`, `data:`, `javascript:` schemes
- `#fragment` hrefs (same-page anchors)
- Scheme-less relative URLs (e.g. `/signup`, `../path`)
- `<a data-mg-notrack>` — attribute stripped from final HTML, href preserved
- `<a>` tags inside `<head>` (prefetch, canonical)
- List-Unsubscribe URL (v0.5 hook reserved — not yet implemented)

**Pixel markup (D-37):**
```html
<img src="https://track.host/o/<token>.gif" width="1" height="1" alt=""
     style="display:block;width:1px;height:1px;border:0;" />
```
Position: LAST child of `<body>`. Missing `<body>` → appended at document root.
`alt=""` prevents screen-reader announcement.

**Plaintext body invariant:** NEVER modified. The rewriter only operates on
`html_body`. `text_body` is passed through untouched (D-36).

**Floki parse failure:** When `Floki.parse_document/1` returns `{:error, _}`, the
original HTML string is returned unchanged and a `Logger.debug` crumb is emitted.

Since: 0.1.0.

### `Mailglass.Tracking.rewrite_if_enabled/1`

```elixir
@spec rewrite_if_enabled(Mailglass.Message.t()) :: Mailglass.Message.t()
```

Facade function that dispatches on `Mailglass.Tracking.enabled?/1` flags and calls
`Mailglass.Tracking.Rewriter.rewrite/2` when any flag is true. Returns the message
unchanged when tracking is disabled (D-10). Never touches `text_body` (D-36).

`delivery_id` is read from `message.metadata[:delivery_id]`; falls back to
`"pre-delivery"` when not yet stamped (render-preview mode).

**Gap-closure note:** `Mailglass.Outbound.send/2` (Plan 05) does not yet call
`rewrite_if_enabled/1`. Adopters can invoke it manually between `Renderer.render/1`
and `deliver/2`. Wiring into the Outbound pipeline is a gap-closure item for Phase 3.1.

Since: 0.1.0.

## §Tracking.Plug (Phase 3 Plan 07)

### `Mailglass.Tracking.Plug`

Shipped in Phase 3 Plan 07 (TRACK-03, D-34..D-35..D-39). Mountable `Plug.Router`
for open-pixel and click-redirect endpoints.

**Mount pattern:**

```elixir
# In adopter's Endpoint or router:
forward "/track", Mailglass.Tracking.Plug
```

**Locked routes:**

| Route | Success | Failure |
|-------|---------|---------|
| `GET /o/:token.gif` | `200 image/gif` — 43-byte GIF89a body | `204` (D-39: no enumeration) |
| `GET /c/:token` | `302 Location: <target_url>` | `404` |
| Any other | `404` | — |

**Pixel response headers (D-34):**
- `Content-Type: image/gif`
- `Cache-Control: no-store, private, max-age=0`
- `Pragma: no-cache`
- `X-Robots-Tag: noindex`

**GIF89a body:** Exactly 43 bytes. First 6 bytes: `<<71, 73, 70, 56, 57, 97>>` (GIF89a magic).

**No-enumeration contract (D-39):** Failed `verify_open/2` returns HTTP 204 (empty body),
NOT 404. An attacker scanning URLs cannot distinguish a valid-but-expired token from an
invalid one — both return 204. Failed `verify_click/2` returns 404 (users expect a redirect
or error page for click links, so 404 is the appropriate non-redirecting response).

**`target_url` never appears in URL path/query (D-39):** The redirect target lives in the
signed token payload. There is no surface for open-redirect attacks.

**Event recording:** On successful verify, calls `Mailglass.Events.append/1` with
`type: :opened` / `type: :clicked`. DB write failures are swallowed — the pixel and
redirect responses ALWAYS succeed. Click events store `target_url_hash` (SHA-256 hex)
in `normalized_payload`, never the raw URL (D-31 PII whitelist).

**Telemetry:**
- `[:mailglass, :tracking, :open, :recorded]` — measurements: `%{count: 1}`, metadata: `%{delivery_id, tenant_id}`
- `[:mailglass, :tracking, :click, :recorded]` — same shape

Since: 0.1.0.

## §Tracking.ConfigValidator (Phase 3 Plan 07)

### `Mailglass.Tracking.ConfigValidator`

Shipped in Phase 3 Plan 07 (TRACK-03, D-32). Boot-time validator for tracking host
configuration.

**Locked function:**

```elixir
@spec validate_at_boot!() :: :ok
```

Raises `%Mailglass.ConfigError{type: :tracking_host_missing}` when:
1. Any loaded `Mailglass.Mailable` module has `tracking: [opens: true]` OR `tracking: [clicks: true]` in its `__mailglass_opts__/0`, AND
2. `config :mailglass, :tracking, host:` is `nil` or `""`.

Returns `:ok` otherwise.

**Detection algorithm:** Iterates `:code.all_loaded()`, checks `__mailglass_mailable__/0`
(discovery marker) + `__mailglass_opts__/0` (compile-time opts) presence, reads `:tracking`
opts. Modules without `use Mailglass.Mailable` are skipped.

**Adopter usage (v0.1):** Call explicitly from `Application.start/2` after
`Mailglass.Config.validate_at_boot!/0`. Auto-wiring into `Mailglass.Config.validate_at_boot!/0`
is a v0.5 gap-closure item.

Since: 0.1.0.

## §TestAssertions (Phase 3 Plan 06)

### `Mailglass.TestAssertions`

Shipped in Phase 3 Plan 06 (TEST-01, D-05). Adopter-facing test assertion helpers.
Lives in `lib/` (not `test/support/`) so adopters can `import Mailglass.TestAssertions`
in their own test helpers.

**Locked function surface:**

| Function / Macro | Signature | Description |
|-----------------|-----------|-------------|
| `assert_mail_sent/0` | macro | Asserts any `{:mail, _}` in process mailbox |
| `assert_mail_sent/1` | macro (4 dispatch forms) | See matcher styles below |
| `assert_no_mail_sent/0` | macro | `refute_received {:mail, _}` |
| `last_mail/0` | `() :: Message.t() \| nil` | Most recent msg from Fake ETS bucket |
| `wait_for_mail/1` | `(timeout()) :: Message.t()` | Blocks until mail or timeout |
| `assert_mail_delivered/2` | `(Delivery.t() \| binary(), timeout()) :: :ok` | Consumes PubSub `:delivered` broadcast |
| `assert_mail_bounced/2` | `(Delivery.t() \| binary(), timeout()) :: :ok` | Consumes PubSub `:bounced` broadcast |

**Macro vs function dispatch rule:**
- `assert_mail_sent/0,1` and `assert_no_mail_sent/0` are **macros** — required to
  support struct-pattern syntax (`%{mailable: X}`) and `assert_received`/`refute_received`
  which must be called in caller context.
- `last_mail/0`, `wait_for_mail/1`, `assert_mail_delivered/2`, `assert_mail_bounced/2`
  are **regular functions** — they do not require compile-time AST manipulation.

**Four `assert_mail_sent/1` matcher styles:**

```elixir
# Style 1: presence (bare call)
assert_mail_sent()

# Style 2: keyword list (Swoosh familiarity)
assert_mail_sent(subject: "Welcome", to: "user@example.com")
assert_mail_sent(mailable: MyApp.UserMailer, stream: :transactional)

# Style 3: struct pattern (macro — no explicit quoting)
assert_mail_sent(%{mailable: MyApp.UserMailer})

# Style 4: predicate fn
assert_mail_sent(fn msg -> msg.stream == :transactional end)
```

**Supported keyword matcher keys (Style 2):**

| Key | Matches against |
|-----|----------------|
| `:subject` | `msg.swoosh_email.subject` |
| `:to` | any address in `msg.swoosh_email.to` |
| `:mailable` | `msg.mailable` |
| `:stream` | `msg.stream` |
| `:tenant` | `msg.tenant_id` |

Any unsupported key raises `ExUnit.AssertionError` with descriptive message.
Extensible in future versions (new keys are semver-minor).

**PubSub-backed assertions (assert_mail_delivered/2, assert_mail_bounced/2):**
- Consume `{:delivery_updated, delivery_id, :delivered | :bounced, meta}` from PubSub.
- Require the test process to be subscribed to `Mailglass.PubSub.Topics.events(tenant_id)`
  or `Mailglass.PubSub.Topics.events(tenant_id, delivery_id)` before the assertion.
- `Mailglass.MailerCase` handles the tenant-wide subscription in setup automatically.
- Both functions accept either `delivery_id :: binary()` OR `%Delivery{}` struct
  (`.id` is extracted automatically).
- Default timeout: `100` milliseconds.

**Async-safe guarantees (T-3-06-01):**
- Process mailbox (`{:mail, msg}`) is per-process — no cross-test leakage.
- `last_mail/0` reads the Fake ETS bucket keyed by owner pid — per-process isolation.
- PubSub subscription is per-process and cleaned up on process exit.
- All helpers are safe for `async: true` tests via `Mailglass.MailerCase`.

**PII policy (T-3-06-01):** Failure messages embed caller-supplied values (e.g. `subject`,
`to` address) because adopter test failures need that context. These values appear only in
the adopter's own test output — never in telemetry, log streams, or cross-tenant surfaces.

Since: 0.1.0.

## §MailerCase (Phase 3 Plan 06)

### `Mailglass.MailerCase`

Shipped in Phase 3 Plan 06 (TEST-02, D-06). ExUnit CaseTemplate for adopter tests that
exercise Mailable + Outbound code. Lives in `test/support/` — not exported as a library
file; adopters copy or reference it after calling `use Mailglass.TestSupport`.

**`using` block injects (into adopter test module):**

- `import Mailglass.TestAssertions` — all 7 assertion helpers
- `alias Mailglass.{Adapters, Message, Outbound}` — standard test module aliases
- `alias Mailglass.Adapters.Fake` — direct Fake access
- `defdelegate set_mailglass_global(context), to: Mailglass.MailerCase` — enables
  `setup :set_mailglass_global` without module-prefix (mirrors `set_swoosh_global`)

**Default setup (per test):**

1. `Ecto.Adapters.SQL.Sandbox.start_owner!/2` — sandbox checkout (`shared: not async?`)
2. `Mailglass.Adapters.Fake.checkout/0` — register test process as Fake owner
3. `Mailglass.Tenancy.put_current("test-tenant")` — stamp default tenant (overridable via `@tag tenant:`)
4. `Mailglass.Clock.Frozen.freeze/1` — freeze clock when `@tag frozen_at:` is set
5. `Phoenix.PubSub.subscribe/2` on `Topics.events(tenant_id)` — tenant-wide delivery broadcasts
6. Async adapter setup (see modes below)
7. `on_exit/1` — restores all state: `Fake.checkin`, `Fake.set_shared(nil)`, `Clock.Frozen.unfreeze`, restore `:async_adapter`, `Sandbox.stop_owner`

**Async delivery modes:**

| Condition | Mode | Behavior |
|-----------|------|----------|
| Default (no `@tag oban:`) | `:task_supervisor` | `deliver_later/2` spawns a supervised Task; `set_shared(self())` enables delivery into test bucket |
| `@tag oban: :inline` | Oban + `:inline` engine | Job executes synchronously in same process |
| `@tag oban: :manual` | Oban + Basic engine | Job enqueued but NOT executed; requires `assert_enqueued/1` |

**Supported tags:**

| Tag | Effect |
|-----|--------|
| `@tag tenant: "acme"` | Override default `"test-tenant"` |
| `@tag tenant: :unset` | Disable tenant stamping (test unstamped-fail paths) |
| `@tag frozen_at: ~U[2026-01-01 00:00:00Z]` | Freeze clock for this test |
| `@tag oban: :manual` | Use Oban `:manual` mode (MUST be `async: false` — I-12) |
| `@tag oban: :inline` | Use Oban `:inline` mode (MUST be `async: false` — I-12) |

**I-12 guard:** Any test combining `@tag oban: ...` with `async: true` raises `RuntimeError`
at setup time with an actionable message. Oban.Testing mode is a process-global setting;
concurrent async tests with different `:oban` modes would stomp each other.

**`set_mailglass_global/1`:** Opt-in global mode. Call via `setup :set_mailglass_global`.
Requires `async: false`. Sets `Fake.set_shared(self())` so any process (without explicit
`allow/2`) delivers into this test's ETS bucket. `on_exit` calls `Fake.set_shared(nil)`.
Use sparingly — prefer `Fake.allow/2` for targeted cross-process delegation.

### `Mailglass.WebhookCase`

Extended in Phase 4 Plan 01 (HOOK-01..07, TEST-03). Inherits
`Mailglass.MailerCase` and adds webhook-specific helpers per 04-CONTEXT.md
D-26:

- `mailglass_webhook_conn(provider, raw_body, opts \\ [])` — builds a
  `%Plug.Conn{}` targeting `/webhooks/#{provider}` with the correct
  signature header attached (Basic Auth for Postmark, ECDSA for SendGrid).
  `conn.private[:raw_body]` is populated to mirror what
  `Mailglass.Webhook.CachingBodyReader` (Plan 02) writes in production.
- `assert_webhook_ingested(pattern \\ nil, timeout \\ 100)` — macro
  wrapping `assert_receive` on the Phase 3 Projector broadcast
  `{:delivery_updated, delivery_id, event_type, meta}`. Three forms:
  bare (presence), event-type atom, or meta map pattern.
- `stub_postmark_fixture/1` + `stub_sendgrid_fixture/1` — load payload-only
  fixture JSON from `test/support/fixtures/webhooks/{provider}/*.json`.
- `freeze_timestamp/1` — re-export of `Mailglass.Clock.Frozen.freeze/1`.

Setup mints a fresh ECDSA P-256 keypair per test (via
`Mailglass.WebhookFixtures.generate_sendgrid_keypair/0`) and installs
per-test SendGrid + Postmark config in `Application.put_env/3`, restored
on_exit. `@tag webhook_config: false` opts out of the config mutation.

**Async safety:** `async: false` recommended (global Application env).
Tests that exercise pure CachingBodyReader can use `async: true` with
`@tag webhook_config: false`.

Since: 0.1.0 (stub in Phase 3; Wave 0 helpers in Phase 4).

### `Mailglass.WebhookFixtures`

Shipped in Phase 4 Plan 01. Test-only helpers for webhook fixture signing
and loading. Lives in `test/support/` — not part of the public Hex package
surface.

**Public API:**

| Function | Signature | Description |
|----------|-----------|-------------|
| `generate_sendgrid_keypair/0` | `() :: {spki_der_b64 :: String.t(), priv :: binary()}` | Mints fresh P-256 keypair; first element is the base64-encoded SubjectPublicKeyInfo DER (SendGrid dashboard format) |
| `sign_sendgrid_payload/3` | `(timestamp :: String.t(), raw_body :: binary(), priv :: binary()) :: String.t()` | Signs `timestamp <> raw_body` via `:crypto.sign(:ecdsa, :sha256, _, [priv, :secp256r1])`; returns base64 signature |
| `decode_sendgrid_spki_der!/1` | `(b64_der :: String.t()) :: {:SubjectPublicKeyInfo, _, _}` | Round-trip helper — decodes the b64 DER via `:public_key.der_decode/2` exactly as the production verifier will |
| `postmark_basic_auth_header/2` | `(user, pass) :: {"authorization", "Basic " <> encoded}` | Plug header tuple ready for `put_req_header/3` |
| `load_postmark_fixture/1` | `(name :: String.t()) :: binary()` | Reads `fixtures/webhooks/postmark/#{name}.json` byte-exact |
| `load_sendgrid_fixture/1` | `(name :: String.t()) :: binary()` | Reads `fixtures/webhooks/sendgrid/#{name}.json` byte-exact |
| `fixture_root/0` | `() :: String.t()` | Absolute path to `test/support/fixtures/webhooks` |

**Crypto choice:** Signing uses `:crypto.sign/4` directly (raw ECDSA) to
avoid OTP 27's awkward `{:ECPrivateKey, _, _, _, _, _}` record shape.
Verification on the server side (Plan 03) uses `:public_key.verify/4` with
`{{:ECPoint, _}, {:namedCurve, _}}` tuples — the canonical OTP surface.
Round-trip verified at Phase 4 Wave 0 build time.

Since: 0.1.0 (test support only).

### `Mailglass.AdminCase`

Stub shipped in Phase 3 (TEST-02). `use ExUnit.CaseTemplate` delegating to
`use Mailglass.MailerCase, opts`. Phase 5 (PREV-01..06) extends with Phoenix.LiveViewTest
helpers, endpoint stub, session cookie fixtures, and device toggle assertion helpers.

Since: 0.1.0 (stub). Phase 5 extension.

## §Webhook (added in Phase 4)

### `Mailglass.Webhook.Provider` behaviour (sealed at v0.1)

Two-callback contract — see `Mailglass.Webhook.Provider` for the source
of truth. `@moduledoc false` enforces the v0.1 sealed lock (PROJECT D-10
defers Mailgun/SES/Resend to v0.5).

```elixir
@callback verify!(raw_body :: binary(), headers :: [{String.t(), String.t()}], config :: map()) :: :ok
@callback normalize(raw_body :: binary(), headers :: [{String.t(), String.t()}]) :: [Mailglass.Events.Event.t()]
```

`verify!/3` raises `%Mailglass.SignatureError{}` on failure (closed 7-atom
set per D-21). `normalize/2` is pure — no network, no DB. Conn-free so
v0.5 SES SQS polling and inbound testing paths can reuse the behaviour
without adapter work.

**Shipped v0.1 implementations:**

- `Mailglass.Webhook.Providers.Postmark` — Basic Auth via two
  independent `Plug.Crypto.secure_compare/2` calls (timing-safe per
  D-04); opt-in IPv4 CIDR allowlist; exhaustive RecordType → Anymail
  normalizer.
- `Mailglass.Webhook.Providers.SendGrid` — ECDSA P-256 verify chain
  (`:public_key.der_decode/2` + `:public_key.verify/4`) with 300-second
  timestamp tolerance; batch-array normalizer (1..128 events).

### `Mailglass.Webhook.CachingBodyReader.read_body/2`

Plug `:body_reader` MFA. Accumulates iodata across `{:more, _, _}`
chunks and flattens on final `{:ok, _, _}`. Stores raw bytes in
`conn.private[:raw_body]` (library-reserved key per D-09).

Adopters wire it in their endpoint:

```elixir
plug Plug.Parsers,
  parsers: [:json],
  pass: ["*/*"],
  json_decoder: Jason,
  body_reader: {Mailglass.Webhook.CachingBodyReader, :read_body, []},
  length: 10_000_000   # 10 MB cap — SendGrid batches up to 128 events
                       # fit comfortably with 2 MB headroom
```

Missing wiring raises `%ConfigError{type: :webhook_caching_body_reader_missing}`
at request time (distinct atom from `:webhook_verification_key_missing`
for adopter-side alert differentiation).

### `Mailglass.Webhook.Router.mailglass_webhook_routes/2` macro

Generates one POST route per provider in the `:providers` opt. Mounts
`Mailglass.Webhook.Plug` at each path.

**Macro signature:**

```elixir
defmacro mailglass_webhook_routes(path :: String.t(), opts :: keyword())
```

**Opts:**

- `:providers` — default `[:postmark, :sendgrid]`. Unknown atoms raise
  `ArgumentError` at compile time (D-07 — invalid config fails at
  router-mount, not request time).
- `:as` — default `:mailglass_webhook` (CONTEXT D-08 — shared vocab
  lock with Phase 5 admin). Each generated helper is
  `:"\#{as}_\#{provider}"`.

**Stability:** locked at v0.1. Adding new provider atoms to the
validated set at v0.5 is a minor-version extension (additive) — v0.1
adopters passing the default list are never broken.

### `Mailglass.Webhook.Plug` (Phase 4 Plan 04)

Single-ingress orchestrator (`@behaviour Plug`) implementing the
response-code matrix:

| Status | Raised error | Meaning |
|--------|--------------|---------|
| 200 | _none_ (success) | Events ingested OR duplicate replay (same `(provider, provider_event_id)`) |
| 401 | `%SignatureError{}` | Any of the 7 D-21 failure atoms |
| 422 | `%TenancyError{:webhook_tenant_unresolved}` | Verified request but tenant resolver returned `{:error, _}` |
| 500 | `%ConfigError{}` or ingest failure | Plug wiring gap OR missing provider secret OR DB error |

Emits two telemetry spans: outer `[:mailglass, :webhook, :ingest, _]`
with per-request stop metadata; inner
`[:mailglass, :webhook, :signature, :verify, _]` around the provider
verify call. D-23 metadata whitelist enforced (no PII).

### Deferred to later Phase 4 plans

- `Mailglass.Webhook.Ingest.ingest_multi/3` (Plan 06) — single
  `Ecto.Multi` composing webhook_events insert + events insert +
  projection update + PubSub broadcast (HOOK-06 amended, D-15).
- `Mailglass.Webhook.Reconciler` (Plan 07) — Oban cron `*/5 * * * *`;
  appends `:reconciled` events without mutating orphan rows (D-17,
  D-18).
- `Mailglass.Webhook.Pruner` (Plan 08) — Oban daily cron; deletes
  `mailglass_webhook_events` rows by `status` + age (D-16).

Since: 0.1.0 (Phase 4 wave 0 reservation; implementation lands Plans 02+).
