feat(inbound): SESParser (AWS SES via SNS HTTP subscription)#42
Merged
feat(inbound): SESParser (AWS SES via SNS HTTP subscription)#42
Conversation
This was referenced Apr 24, 2026
Greenfield inbound-email foundation for Elixir/Phoenix. Transport-
agnostic Message struct, Parser behaviour, and Router that resolves
an inbound email to an existing ticket via canonical Message-ID
parsing + signed Reply-To verification.
Mirrors the NestJS reference and the per-framework inbound-verify
PRs plus the greenfield .NET / Spring / Go routers.
Resolution order (first match wins):
1. in_reply_to parsed via MessageIdUtil — cold-start path.
2. references parsed via MessageIdUtil, each id in order.
3. Signed Reply-To on to_email verified via MessageIdUtil.
Survives clients that strip threading headers; forged
signatures are rejected via Plug.Crypto.secure_compare/2.
4. Subject-line reference tag [{PREFIX}-...], with a custom
regex override via options[:subject_pattern].
Framework-agnostic lookup contract: caller passes a
%{get_ticket_by_id: fn, get_ticket_by_reference: fn} map so the
router doesn't depend on a specific Ecto schema or repo.
13 ExUnit tests verify every branch, the forged-signature rejection,
the blank-secret skip, the string-keyed message map support (for
webhook payload pass-through), the custom subject pattern override,
and the Message.body/1 preference logic.
Follow-up PRs:
- Per-provider parser implementations (Postmark, Mailgun, SES)
- Phoenix controller / plug
- Orchestration service (full process() with attachments)
Closes the core inbound webhook for Phoenix end-to-end (stacked on
counterparts.
- PostmarkParser (implements Parser behaviour): normalizes
Postmark's JSON webhook payload (FromFull / ToFull / Headers /
Attachments) into Message. Extracts threading headers from the
Headers array. Decodes base64 attachment content inline, falling
back to padding=false for non-strict base64.
- InboundEmailController (Phoenix.Controller with JSON format):
POST /escalated/webhook/email/inbound
- Dispatches to the matching parser by ?adapter=... query or
x-escalated-adapter header.
- Signature-guarded by x-escalated-inbound-secret (constant-time
compare via Plug.Crypto.secure_compare/2). Same
:email_inbound_secret config that signs Reply-To — symmetric.
- Returns 200 with %{status, ticket_id} on success, 401 on secret
mismatch, 400 on unknown adapter / invalid payload.
- Supplies a default repo-backed lookup so the router doesn't
have to know about Ecto.
Host apps can register additional parsers via
config :escalated, inbound_parsers: [Parser1, Parser2]
Defaults to [PostmarkParser] when unset.
6 parser tests exercise field extraction, threading header parsing,
base64 attachment decoding, the minimal-payload path, the adapter
name contract, and the non-map rejection.
Mirrors the .NET/Spring/Go greenfield orchestration port:
- Service.process/4 composes Router.resolve_ticket with a
writer function-map, returning {outcome, ticket_id, reply_id,
pending_attachment_downloads}.
- noise_email?/1 exposes the SNS/empty-body skip predicate.
- Controller now calls Service.process and returns the richer
response (status mirrors the outcome atom).
The lookup + writer contracts stay as plain function-maps so
tests don't need a live Repo or TicketService.
Ports the Go (escalated-go#35), .NET (escalated-dotnet#29), and
Spring (escalated-spring#32) references to Phoenix/Elixir. Host
apps now have a ready-to-wire worker for Mailgun-style
provider-hosted attachments surfaced in
Service.process/4's pending_attachment_downloads list.
AttachmentDownloader
- download/6(pending, ticket_id, reply_id, storage, writer, opts)
— HTTP GET + persist via the storage + writer function-maps.
- download_all/6 continues past per-attachment failures; returns
%{pending, persisted, error} per input.
- :max_bytes rejects with {:error, {:too_large, actual, max}};
:basic_auth adds HTTP basic auth for Mailgun API-key URLs.
- safe_filename/1 strips path traversal via Path.basename.
- Response Content-Type fallback when pending :content_type is
blank or nil.
- Defaults to :httpc from stdlib (no external HTTP dep); host
apps can swap in Finch / HTTPoison / Req via the :http_client
option.
LocalFileStorage.new/1 — reference storage writing to local FS with
timestamp+microseconds prefixing to avoid collisions. Host apps
wanting S3 / GCS / Azure build their own storage function-map.
13 ExUnit cases cover: happy path (ticket + reply_id targets),
missing URL guard, 404 status, oversize rejection, client errors,
response Content-Type fallback, safe_filename traversal
neutralization, download_all partial-failure batching,
LocalFileStorage write / empty-root rejection / unique path per
call.
Mirrors the service test style (function-map contracts, no Ecto
repo or DB needed).
Ports escalated-go#36 + escalated-dotnet#30 + escalated-spring#33
to Phoenix/Elixir. AWS SES receipt rules publish to an SNS topic;
host apps subscribe via HTTP and SNS POSTs the envelope to the
unified /support/webhook/email/inbound?adapter=ses webhook.
SESParser handles:
1. "SubscriptionConfirmation" — returns
{:error, {:ses_subscription_confirmation, %{subscribe_url, ...}}}
so the inbound controller can surface the SubscribeURL the host
must GET out-of-band to activate the subscription.
2. "Notification" — parses the JSON-encoded Message field for
mail.commonHeaders (from/to/subject) and the mail.headers
array (Message-ID / In-Reply-To / References threading).
Falls back to mail.headers when commonHeaders doesn't surface
a threading field.
3. Best-effort MIME body extraction from the base64 content field
when SES is configured with action.type=SNS / encoding=BASE64.
Hand-rolled splitter (no external MIME dep) handles
single-part text/plain, text/html, multipart/alternative, and
quoted-printable transfer encoding.
10 ExUnit cases cover: name check, subscription confirmation,
threading metadata, plain body decode, multipart body decode,
missing-content fallback, unknown envelope type, missing/malformed
Message, headers-array fallback, raw JSON string input, and
non-map/non-binary payload rejection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ports escalated-go#36 + escalated-dotnet#30 + escalated-spring#33 to Phoenix/Elixir. AWS SES receipt rules publish to an SNS topic; host apps subscribe via HTTP and SNS POSTs the envelope to the unified `/support/webhook/email/inbound?adapter=ses` webhook.
What's handled
Tests
10 ExUnit cases cover name check, subscription confirmation, threading metadata extraction, plain body decode, multipart body decode, missing-content fallback, unknown envelope type, missing/malformed Message, headers-array threading fallback, raw JSON string input, and non-map/non-binary payload rejection.
Stacked PR
Based on `feat/attachment-downloader` (#41). Merge order: #35 → #36 → #37 → #38 → #41 → this PR.