Skip to content

feat(inbound): SESParser (AWS SES via SNS HTTP subscription)#42

Merged
mpge merged 19 commits intomasterfrom
feat/ses-parser
Apr 27, 2026
Merged

feat(inbound): SESParser (AWS SES via SNS HTTP subscription)#42
mpge merged 19 commits intomasterfrom
feat/ses-parser

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

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

  1. `"SubscriptionConfirmation"` — returns `{:error, {:ses_subscription_confirmation, %{topic_arn, subscribe_url, token}}}` 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.

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.

mpge added 17 commits April 26, 2026 23:20
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.
@mpge mpge force-pushed the feat/ses-parser branch from db4adc5 to df5e7d6 Compare April 27, 2026 03:20
@mpge mpge merged commit 041f7e0 into master Apr 27, 2026
1 check passed
@mpge mpge deleted the feat/ses-parser branch April 27, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant