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.
This file stays inventory-shaped on purpose.
It answers two distinct questions:
- What adopters may treat as stable for the
v1.xline. - 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.
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, anddeliver_many!/2. - Core message and mailable surface:
Mailglass.Message,Mailglass.Mailable, andMailglass.Renderer. - Delivery and provider seams:
Mailglass.Outbound,Mailglass.Adapter,Mailglass.Adapters.Fake, andMailglass.Adapters.Swoosh. - Config and tenancy seams:
Mailglass.Config,Mailglass.Tenancy,Mailglass.TenancyError,Mailglass.Clock,Mailglass.Stream,Mailglass.RateLimiter,Mailglass.Suppression,Mailglass.Tracking,Mailglass.Compliance, andMailglass.Compliance.Unsubscribe. - Webhook/public routing seams:
Mailglass.Webhook,Mailglass.Webhook.CachingBodyReader,Mailglass.Webhook.Plug, andMailglass.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, andMailglass.Operator.Suppressions. - Stable Mix tasks:
mix mailglass.install,mix mailglass.reconcile,mix mail.doctor,mix mailglass.publish.check,mix mailglass.docs.check, andmix 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, andMailglass.PublishError.
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.
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_admincan 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.
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.
- 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 mailglass.install— installer and first-app bootstrap contractmix mailglass.reconcile— stable reconciliation operator taskmix mail.doctor— DNS-only deliverability doctor contractmix mailglass.publish.check— release-facing publish drift checkmix mailglass.docs.check— light docs-contract drift checkmix 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.
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.
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
typeatoms 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
- 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
Boundaryexports 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.xminor releases may add stable APIs, atoms, fields, or tasks, but only with matching doc metadata and updates to this inventory.
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:
MailglassAdmin.Router.mailglass_admin_routes/2andMailglassAdmin.Router.mailglass_operator_routes/2are the stable mount macros and option contracts.MailglassAdmin.Authis 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, andMailglass.Operator.Suppressions.
- LiveView modules, component modules, layouts, DOM shape, CSS classes, asset
file names, preview assigns plumbing, and internal
on_mounthook wiring are implementation details. - Exported functions required by Phoenix
live_sessioncallbacks or router code generation are not stable unless they are called out in the admin contract page. MailglassAdmin.Operator.Mountremains internal even though framework wiring requires it to stay reachable.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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].
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.
Since: 0.1.0.
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.
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.
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.
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.
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.
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 viaNoTrackingOnAuthStreamCredo 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).
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.
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.
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.
Production impl. utc_now/0 delegates to DateTime.utc_now/0.
Since: 0.1.0.
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 bymsmilliseconds. 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.
assert_stamped!() :: :ok— raises%TenancyError{type: :unstamped}when no tenant is stamped on the current process. Returns:okotherwise. Does NOT fall back to theSingleTenantdefault (unlikecurrent/0). SEND-01 precondition (D-18).
Since: 0.1.0.
@callback tracking_host(context :: term()) :: {:ok, String.t()} | :default— optional per-tenant tracking host override (D-32). Default resolution::default(use globalconfig :mailglass, :tracking, host:). Adopters returning{:ok, host}get per-tenant subdomains for strict cookie/origin isolation.
@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.SingleTenantimplementsresolve_webhook_tenant/1by returning{:ok, "default"}(zero-config default).Mailglass.Tenancy.ResolveFromPathimplementsresolve_webhook_tenant/1as opt-in URL-prefix sugar. It readscontext.path_params["tenant_id"]and returns{:ok, tid}or{:error, :missing_path_param}. The module fails CLOSED onscope/2— adopters using it for the full Tenancy contract MUST compose it with a realscope/2impl.
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— thePlug.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—nilat v0.1; reserved for v0.5 Stripe-Connect-style strategies
Since: 0.1.0.
Shipped in Phase 3 Plan 02 (TRANS-01). Single-callback behaviour every mailglass adapter implements.
Locked callback signature:
@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_idis the adapter's canonical identifier — Phase 4 webhook ingest uses it to join incoming events to the%Delivery{}row viaprovider_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 anySwoosh.Adapter, normalizes errors.
Since: 0.1.0.
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.
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):
%{
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.
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 streamMailglass.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 ownRepo.multi/1commits)Mailglass.Webhook.Plug(Phase 4 — after webhook Multi commits)
Since: 0.1.0.
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:
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.
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.
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.
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.
ETS-backed implementation of Mailglass.SuppressionStore (D-28). Behaviour parity with
Mailglass.SuppressionStore.Ecto — same check/2 and record/2 contract.
Locked behaviour callbacks:
@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):
{tenant_id, address, :address, nil}— address scope{tenant_id, domain, :domain, nil}— domain scope{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.
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.
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.
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.
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:
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;scopeis: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.
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.
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:
@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).
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.
__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:
@behaviour Mailglass.Mailable@before_compile Mailglass.Mailable@mailglass_opts opts@compile {:no_warn_undefined, Mailglass.Outbound}(forward-ref guard until Plan 05)import Swoosh.Email, except: [new: 0]import Mailglass.Componentsdef __mailglass_opts__/0def new/0def render/3def deliver/2def deliver_later/2defoverridable 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).
Every module compiled with use Mailglass.Mailable exposes:
@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.
Every module compiled with use Mailglass.Mailable exposes:
@spec __mailglass_mailable__() :: trueAlways 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.
Three new helpers added to Mailglass.Message:
@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.
@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:
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)
endSince: 0.1.0.
@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.
@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__/0exported) - Modules with
use Mailglass.Mailablebut notracking: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.
@spec assert_safe!(Mailglass.Message.t()) :: :okRaises %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):
%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:
- Phase 6
TRACK-02 NoTrackingOnAuthStreamCredo check — compile-time, catches static patterns. 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.
:idempotency_key — nullable text field added in Phase 3 Plan 05.
- Shape: any binary.
Mailglass.Outbound.send/2computessha256(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_idxON(idempotency_key) WHERE idempotency_key IS NOT NULL. The WHERE predicate matches theconflict_targetfragment"(idempotency_key) WHERE idempotency_key IS NOT NULL"used bydeliver_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.
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.
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:
Mailglass.Tenancy.assert_stamped!/0— raises%TenancyError{:unstamped}Mailglass.Tracking.Guard.assert_safe!/1— raises%ConfigError{:tracking_on_auth_stream}Mailglass.Suppression.check_before_send/1— returns{:error, %SuppressedError{}}Mailglass.RateLimiter.check/3—:transactionalbypasses; returns{:error, %RateLimitError{}}Mailglass.Stream.policy_check/1— no-op seam (v0.1)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.
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:
@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()}} | :errorOpen-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.
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:
@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#fragmenthrefs (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):
<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.
@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.
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:
# In adopter's Endpoint or router:
forward "/track", Mailglass.Tracking.PlugLocked 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/gifCache-Control: no-store, private, max-age=0Pragma: no-cacheX-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.
Shipped in Phase 3 Plan 07 (TRACK-03, D-32). Boot-time validator for tracking host configuration.
Locked function:
@spec validate_at_boot!() :: :okRaises %Mailglass.ConfigError{type: :tracking_host_missing} when:
- Any loaded
Mailglass.Mailablemodule hastracking: [opens: true]ORtracking: [clicks: true]in its__mailglass_opts__/0, AND config :mailglass, :tracking, host:isnilor"".
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.
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,1andassert_no_mail_sent/0are macros — required to support struct-pattern syntax (%{mailable: X}) andassert_received/refute_receivedwhich must be called in caller context.last_mail/0,wait_for_mail/1,assert_mail_delivered/2,assert_mail_bounced/2are regular functions — they do not require compile-time AST manipulation.
Four assert_mail_sent/1 matcher styles:
# 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)orMailglass.PubSub.Topics.events(tenant_id, delivery_id)before the assertion. Mailglass.MailerCasehandles the tenant-wide subscription in setup automatically.- Both functions accept either
delivery_id :: binary()OR%Delivery{}struct (.idis extracted automatically). - Default timeout:
100milliseconds.
Async-safe guarantees (T-3-06-01):
- Process mailbox (
{:mail, msg}) is per-process — no cross-test leakage. last_mail/0reads 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: truetests viaMailglass.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.
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 helpersalias Mailglass.{Adapters, Message, Outbound}— standard test module aliasesalias Mailglass.Adapters.Fake— direct Fake accessdefdelegate set_mailglass_global(context), to: Mailglass.MailerCase— enablessetup :set_mailglass_globalwithout module-prefix (mirrorsset_swoosh_global)
Default setup (per test):
Ecto.Adapters.SQL.Sandbox.start_owner!/2— sandbox checkout (shared: not async?)Mailglass.Adapters.Fake.checkout/0— register test process as Fake ownerMailglass.Tenancy.put_current("test-tenant")— stamp default tenant (overridable via@tag tenant:)Mailglass.Clock.Frozen.freeze/1— freeze clock when@tag frozen_at:is setPhoenix.PubSub.subscribe/2onTopics.events(tenant_id)— tenant-wide delivery broadcasts- Async adapter setup (see modes below)
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.
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 whatMailglass.Webhook.CachingBodyReader(Plan 02) writes in production.assert_webhook_ingested(pattern \\ nil, timeout \\ 100)— macro wrappingassert_receiveon 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 fromtest/support/fixtures/webhooks/{provider}/*.json.freeze_timestamp/1— re-export ofMailglass.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).
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).
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.
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).
@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 independentPlug.Crypto.secure_compare/2calls (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).
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:
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 headroomMissing wiring raises %ConfigError{type: :webhook_caching_body_reader_missing}
at request time (distinct atom from :webhook_verification_key_missing
for adopter-side alert differentiation).
Generates one POST route per provider in the :providers opt. Mounts
Mailglass.Webhook.Plug at each path.
Macro signature:
defmacro mailglass_webhook_routes(path :: String.t(), opts :: keyword())Opts:
:providers— default[:postmark, :sendgrid]. Unknown atoms raiseArgumentErrorat 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.
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).
Mailglass.Webhook.Ingest.ingest_multi/3(Plan 06) — singleEcto.Multicomposing webhook_events insert + events insert + projection update + PubSub broadcast (HOOK-06 amended, D-15).Mailglass.Webhook.Reconciler(Plan 07) — Oban cron*/5 * * * *; appends:reconciledevents without mutating orphan rows (D-17, D-18).Mailglass.Webhook.Pruner(Plan 08) — Oban daily cron; deletesmailglass_webhook_eventsrows bystatus+ age (D-16).
Since: 0.1.0 (Phase 4 wave 0 reservation; implementation lands Plans 02+).