The inbound test driver (ITEST-06): drives the real synchronous persist + route + execute write path and captures the outcome in the current test process's mailbox.
This is the inbound analog of outbound's Fake.Storage → {:mail, _} →
Mailglass.TestAssertions triangle. Outbound has a Fake.Storage that
emits {:mail, %Message{}} on every delivery; inbound has no delivery row,
so the capture seam lives here in the driver: after Execution.execute/2
returns, Test.Ingress does
send(self(), {:inbound, message, outcome, route})so that MailglassInbound.TestAssertions can read the captured tuple with
assert_received (the inbound mirror of assert_mail_sent).
Why this ships in lib/ (not test/support/)
Adopters import MailglassInbound.TestAssertions and drive captures via this
module from their own suites, so it ships in the mailglass_inbound Hex
package via the files: ~w(lib …) manifest — despite the Test in its name,
lib/mailglass_inbound/test/ingress.ex is a lib/ path. Because it ships in
lib/, it references ONLY core/runtime modules (Persist, Execution, the
inbound providers) — never Oban, ExAws, or Plug.Test (Pitfall 6), so it
compiles under mix compile --no-optional-deps --warnings-as-errors.
Two entry points
receive_inbound/2— drive a code-built%InboundMessage{}straight through persist + execute (skips the provider parse seam; use it when you already have a canonical message, e.g. fromMailglassInbound.Fixtures).receive_provider_payload/3— run the real providerverify!/normalizeseam first (the same seam the production plug drives), then the same persist + execute + capture chain. Use it to exercise provider parsing + verification end to end.
SES cross-test hygiene: reset the cert cache
receive_provider_payload(:ses, …) consumes an SES fixture built by
MailglassInbound.Fixtures.build_ses_sns_payload/1, which primes the
process-global ETS cert cache (Mailglass.Webhook.Providers.SES.CertCache,
shared across concurrent async tests) with one never-evicted entry per call.
The driver deliberately does NOT reset that cache itself (it ships in lib/
and stays free of an :ex_unit runtime dependency). If you drive SES fixtures
from a plain ExUnit.Case, reset the cache between tests:
setup do: Mailglass.Webhook.Providers.SES.CertCache.reset()MailglassInbound.MailboxCase already resets it in its setup, so suites that
use it need no extra step. The Postmark/SendGrid/Mailgun lanes touch no
process-global state.
The captured outcome slot
The third element of the captured tuple is the normalized execute/2 result
map — the actual return value of MailglassInbound.Execution.execute/2
(%{outcome: :accept}, %{outcome: :reject, outcome_reason: "..."},
%{outcome: :no_match}, %{status: :skipped} on a duplicate, …), NOT the raw
%MailglassInbound.Mailbox{} outcome atom. Keeping the real seam's return as
the captured value means MailglassInbound.TestAssertions matches on the same
enum the persisted ExecutionRun.outcome carries (:accept | :ignore | :reject | :bounce | :no_match | :failed), so an assertion can never drift
from what was actually persisted.
Synchronous only — never dispatch/2
The driver drives Execution.execute/2 (SYNC) with source: :fresh. It NEVER
calls Execution.dispatch/2 (async — Oban or a detached Task.Supervisor
child), which produces non-deterministic ExecutionRun counts (D-47-03/04,
proven by the replay-convergence property). Replaying the same message
converges: persist dedupes (DB unique index — provider-id for Postmark,
md5(raw_mime) for SendGrid/SES/Mailgun), and execute/2 on a :duplicate
persist result short-circuits to {:ok, %{status: :skipped}}, inserting zero
fresh ExecutionRun rows.
PII posture
The driver writes to the adopter's sandboxed test DB (rolled back per test)
and sends the capture tuple only to the current test process. It adds no
telemetry of its own (the Execution.execute/2 it drives emits the normal
PII-free execution span) and adds no PII spans (T-47-09/12).
Summary
Types
The capture tuple sent to the current process on every receive.
The success return of both entry points.
Functions
Drives a code-built %InboundMessage{} through the real persist + execute
path, captures {:inbound, message, outcome, route} in the current process,
and returns {:ok, %{message:, outcome:, route:, persisted:}}.
Runs the real provider verify!/normalize seam for provider over a
fixture payload, then drives the same persist + execute + capture chain as
receive_inbound/2.
Types
@type capture() :: {:inbound, MailglassInbound.InboundMessage.t(), map(), map()}
The capture tuple sent to the current process on every receive.
@type result() :: %{ message: MailglassInbound.InboundMessage.t(), outcome: map(), route: map(), persisted: map() }
The success return of both entry points.
Functions
@spec receive_inbound( MailglassInbound.InboundMessage.t(), keyword() ) :: {:ok, result()} | {:error, term()}
Drives a code-built %InboundMessage{} through the real persist + execute
path, captures {:inbound, message, outcome, route} in the current process,
and returns {:ok, %{message:, outcome:, route:, persisted:}}.
Options
:repo— the Ecto repo (defaultMailglassInbound.Repo; tests passMailglassInbound.TestRepo).:router— a compileduse MailglassInbound.Routermodule (the same router your endpoint mounts). This is the adopter-facing way to drive routing: define routes with theroute/2DSL once and reuse the module here. Mutually exclusive with:routes.:routes— the package-internal lower-level input: a pre-built list of route data. The route-data shape is@moduledoc falseinternal and is not part of the stable or testing contract, so prefer:routerin adopter suites;:routesexists mainly for the package's own tests.:evidence— the evidence map persisted alongside the record (default%{raw_payload: %{}}). For raw_mime-dedupe providers driven viareceive_inbound/2, passevidence: %{raw_mime: ...}so replays dedupe.
The message carries its own :tenant_id and :provider.
Runs the real provider verify!/normalize seam for provider over a
fixture payload, then drives the same persist + execute + capture chain as
receive_inbound/2.
payload is the raw fixture shape produced by MailglassInbound.Fixtures.
The Fixtures builders for :sendgrid, :mailgun, and :ses self-sign their
payloads against documented defaults, so those three providers verify out of
the box with no extra options. :postmark is the one provider whose fixture
carries no auth, so it needs an explicit config: + headers: pair (see below):
:postmark— a JSON binary (build_postmark_payload/1); needsconfig:+headers:.:sendgrid—%{raw_mime:, headers:, params:, config:}(build_sendgrid_payload/1); the fixture self-signs anauthorizationheader againstFixtures.sendgrid_fixture_config/0, and the driver defaultsconfig:to the fixture's:config.:mailgun—%{params:, headers:, config:}(build_mailgun_payload/1); the fixture HMAC-signs thetimestamp/token/signaturetriple againstFixtures.mailgun_fixture_config/0, and the driver defaultsconfig:to the fixture's:config.:ses—%{raw_body:, headers:, config:}(build_ses_sns_payload/1); the fixture primes the realCertCacheand the driver defaultsconfig:to the fixture's:config.
Options
:tenant_id— the tenant assigned to the normalized message. The production plug resolves this from the request viaTenancy, which needs a%Plug.Conn{}we deliberately do NOT depend on here (Pitfall 6); the driver takes it as an option instead (default"fixture-tenant").:config— the provider config passed straight toverify!. The real verifier is never weakened (T-47-11), so the config must satisfy it. For:sendgrid/:mailgun/:sesthe driver defaults this to the fixture's own self-signed:config, so you only pass it to override (e.g. to sign against your own credentials/key).:postmarkhas no fixture config and requiresconfig: %{basic_auth: {user, pass}}AND a matchingauthorizationheader. For:ses,s3_fetcher:defaults toMailglassInbound.S3Fetcher.Fakeif absent.:headers— request headers. For:postmark(the fixture JSON body carries none) pass the{"authorization", "Basic …"}header matching:config'sbasic_auth. For:sendgrid/:mailgunthe fixture already supplies the headers verify! needs; pass:headersonly to override them.:repo,:routes,:router,:evidence— as inreceive_inbound/2.