From 596f5f505cfed10a9556fcbc9f4bf2bde5d97b1f Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:15:20 -0400 Subject: [PATCH 01/22] feat(inbound): scaffold Message + Parser + Router for inbound email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../services/email/inbound/router.ex | 26 ++++++++++--------- .../services/email/inbound/router_test.exs | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/escalated/services/email/inbound/router.ex b/lib/escalated/services/email/inbound/router.ex index 452f157..536706b 100644 --- a/lib/escalated/services/email/inbound/router.ex +++ b/lib/escalated/services/email/inbound/router.ex @@ -55,18 +55,20 @@ defmodule Escalated.Services.Email.Inbound.Router do # 1 + 2. Parse canonical Message-IDs out of our own headers. ticket = resolve_by_header_message_ids(message, lookup) - if is_nil(ticket) do - # 3. Signed Reply-To on the recipient address. - case resolve_by_signed_reply_to(message, lookup, options) do - nil -> - # 4. Subject-line reference tag. - resolve_by_subject_reference(message, lookup, options) - - signed_ticket -> - signed_ticket - end - else - ticket + cond do + not is_nil(ticket) -> + ticket + + true -> + # 3. Signed Reply-To on the recipient address. + case resolve_by_signed_reply_to(message, lookup, options) do + nil -> + # 4. Subject-line reference tag. + resolve_by_subject_reference(message, lookup, options) + + signed_ticket -> + signed_ticket + end end end diff --git a/test/escalated/services/email/inbound/router_test.exs b/test/escalated/services/email/inbound/router_test.exs index 2f9717d..554d410 100644 --- a/test/escalated/services/email/inbound/router_test.exs +++ b/test/escalated/services/email/inbound/router_test.exs @@ -54,9 +54,7 @@ defmodule Escalated.Services.Email.Inbound.RouterTest do to = MessageIdUtil.build_reply_to(42, @secret, @domain) m = message(to_email: to) - result = - Router.resolve_ticket(m, lookup(by_id: %{42 => ticket}), %{inbound_secret: @secret}) - + result = Router.resolve_ticket(m, lookup(by_id: %{42 => ticket}), %{inbound_secret: @secret}) assert result == ticket end From 7199817d880948f7ae1cb6b275e7e6971ac3a2d2 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:30:08 -0400 Subject: [PATCH 02/22] feat(inbound): PostmarkParser + InboundEmailController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../controllers/inbound_email_controller.ex | 35 ++++--------------- .../services/email/inbound/postmark_parser.ex | 3 +- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/lib/escalated/controllers/inbound_email_controller.ex b/lib/escalated/controllers/inbound_email_controller.ex index a96c769..1a3203e 100644 --- a/lib/escalated/controllers/inbound_email_controller.ex +++ b/lib/escalated/controllers/inbound_email_controller.ex @@ -33,9 +33,8 @@ defmodule Escalated.Controllers.InboundEmailController do use Phoenix.Controller, formats: [:json] import Plug.Conn + alias Escalated.Services.Email.Inbound.Router alias Escalated.Schemas.Ticket - alias Escalated.Services.Email.Inbound.Service - alias Escalated.Services.TicketService @default_parsers [Escalated.Services.Email.Inbound.PostmarkParser] @@ -81,23 +80,14 @@ defmodule Escalated.Controllers.InboundEmailController do case parser.parse(params) do {:ok, message} -> lookup = default_lookup() - writer = default_writer() options = %{inbound_secret: inbound_secret()} - case Service.process(message, lookup, writer, options) do - {:ok, result} -> - json(conn, %{ - "status" => status_string(result.outcome), - "outcome" => Atom.to_string(result.outcome), - "ticket_id" => result.ticket_id, - "reply_id" => result.reply_id, - "pending_attachment_downloads" => result.pending_attachment_downloads - }) - - {:error, _reason} -> - conn - |> put_status(500) - |> json(%{error: "processing failed"}) + case Router.resolve_ticket(message, lookup, options) do + %Ticket{id: id} -> + json(conn, %{"status" => "matched", "ticket_id" => id}) + + nil -> + json(conn, %{"status" => "unmatched", "ticket_id" => nil}) end {:error, _reason} -> @@ -107,10 +97,6 @@ defmodule Escalated.Controllers.InboundEmailController do end end - defp status_string(:replied_to_existing), do: "matched" - defp status_string(:created_new), do: "created" - defp status_string(:skipped), do: "skipped" - defp default_lookup do repo = Escalated.repo() @@ -120,13 +106,6 @@ defmodule Escalated.Controllers.InboundEmailController do } end - defp default_writer do - %{ - create: fn attrs -> TicketService.create(attrs) end, - add_reply: fn ticket, attrs -> TicketService.reply(ticket, attrs) end - } - end - defp parsers, do: Application.get_env(:escalated, :inbound_parsers, @default_parsers) defp inbound_secret, do: Application.get_env(:escalated, :email_inbound_secret, "") || "" diff --git a/lib/escalated/services/email/inbound/postmark_parser.ex b/lib/escalated/services/email/inbound/postmark_parser.ex index 1187fe0..c2c7de3 100644 --- a/lib/escalated/services/email/inbound/postmark_parser.ex +++ b/lib/escalated/services/email/inbound/postmark_parser.ex @@ -50,7 +50,8 @@ defmodule Escalated.Services.Email.Inbound.PostmarkParser do subject: Map.get(payload, "Subject") || "", body_text: blank_to_nil(Map.get(payload, "TextBody")), body_html: blank_to_nil(Map.get(payload, "HtmlBody")), - message_id: first_nonempty(Map.get(payload, "MessageID"), Map.get(headers, "Message-ID")), + message_id: + first_nonempty(Map.get(payload, "MessageID"), Map.get(headers, "Message-ID")), in_reply_to: Map.get(headers, "In-Reply-To"), references: Map.get(headers, "References"), headers: headers, From 8aa97839ac43d7e472bc8a3ffc4bda2eaf7defb1 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:08:49 -0400 Subject: [PATCH 03/22] feat(inbound): Service orchestrates reply/create with tests 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. --- .../controllers/inbound_email_controller.ex | 35 +++++++++++++++---- .../services/email/inbound/service.ex | 30 +++++++--------- .../services/email/inbound/service_test.exs | 4 +-- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/lib/escalated/controllers/inbound_email_controller.ex b/lib/escalated/controllers/inbound_email_controller.ex index 1a3203e..8be1191 100644 --- a/lib/escalated/controllers/inbound_email_controller.ex +++ b/lib/escalated/controllers/inbound_email_controller.ex @@ -33,7 +33,8 @@ defmodule Escalated.Controllers.InboundEmailController do use Phoenix.Controller, formats: [:json] import Plug.Conn - alias Escalated.Services.Email.Inbound.Router + alias Escalated.Services.Email.Inbound.Service + alias Escalated.Services.TicketService alias Escalated.Schemas.Ticket @default_parsers [Escalated.Services.Email.Inbound.PostmarkParser] @@ -80,14 +81,23 @@ defmodule Escalated.Controllers.InboundEmailController do case parser.parse(params) do {:ok, message} -> lookup = default_lookup() + writer = default_writer() options = %{inbound_secret: inbound_secret()} - case Router.resolve_ticket(message, lookup, options) do - %Ticket{id: id} -> - json(conn, %{"status" => "matched", "ticket_id" => id}) - - nil -> - json(conn, %{"status" => "unmatched", "ticket_id" => nil}) + case Service.process(message, lookup, writer, options) do + {:ok, result} -> + json(conn, %{ + "status" => status_string(result.outcome), + "outcome" => Atom.to_string(result.outcome), + "ticket_id" => result.ticket_id, + "reply_id" => result.reply_id, + "pending_attachment_downloads" => result.pending_attachment_downloads + }) + + {:error, _reason} -> + conn + |> put_status(500) + |> json(%{error: "processing failed"}) end {:error, _reason} -> @@ -97,6 +107,10 @@ defmodule Escalated.Controllers.InboundEmailController do end end + defp status_string(:replied_to_existing), do: "matched" + defp status_string(:created_new), do: "created" + defp status_string(:skipped), do: "skipped" + defp default_lookup do repo = Escalated.repo() @@ -106,6 +120,13 @@ defmodule Escalated.Controllers.InboundEmailController do } end + defp default_writer do + %{ + create: fn attrs -> TicketService.create(attrs) end, + add_reply: fn ticket, attrs -> TicketService.reply(ticket, attrs) end + } + end + defp parsers, do: Application.get_env(:escalated, :inbound_parsers, @default_parsers) defp inbound_secret, do: Application.get_env(:escalated, :email_inbound_secret, "") || "" diff --git a/lib/escalated/services/email/inbound/service.ex b/lib/escalated/services/email/inbound/service.ex index 2c2a7bc..89fd13a 100644 --- a/lib/escalated/services/email/inbound/service.ex +++ b/lib/escalated/services/email/inbound/service.ex @@ -188,23 +188,19 @@ defmodule Escalated.Services.Email.Inbound.Service do attachments = Map.get(message, :attachments) || Map.get(message, "attachments") || [] attachments - |> Enum.filter(&provider_hosted?/1) - |> Enum.map(&to_pending_download/1) - end - - defp provider_hosted?(a) do - url = Map.get(a, :download_url) || Map.get(a, "download_url") - content = Map.get(a, :content) || Map.get(a, "content") - is_binary(url) and url != "" and (is_nil(content) or content == "") - end - - defp to_pending_download(a) do - %{ - name: Map.get(a, :name) || Map.get(a, "name"), - content_type: Map.get(a, :content_type) || Map.get(a, "content_type"), - size_bytes: Map.get(a, :size_bytes) || Map.get(a, "size_bytes"), - download_url: Map.get(a, :download_url) || Map.get(a, "download_url") - } + |> Enum.filter(fn a -> + url = Map.get(a, :download_url) || Map.get(a, "download_url") + content = Map.get(a, :content) || Map.get(a, "content") + is_binary(url) and url != "" and (is_nil(content) or content == "") + end) + |> Enum.map(fn a -> + %{ + name: Map.get(a, :name) || Map.get(a, "name"), + content_type: Map.get(a, :content_type) || Map.get(a, "content_type"), + size_bytes: Map.get(a, :size_bytes) || Map.get(a, "size_bytes"), + download_url: Map.get(a, :download_url) || Map.get(a, "download_url") + } + end) end defp ticket_id(%{id: id}), do: id diff --git a/test/escalated/services/email/inbound/service_test.exs b/test/escalated/services/email/inbound/service_test.exs index 7e4cdbf..ea3f1c2 100644 --- a/test/escalated/services/email/inbound/service_test.exs +++ b/test/escalated/services/email/inbound/service_test.exs @@ -108,9 +108,7 @@ defmodule Escalated.Services.Email.Inbound.ServiceTest do test "SNS confirmation → skipped" do w = writer() - - m = - message(%{from_email: "no-reply@sns.amazonaws.com", subject: "SubscriptionConfirmation"}) + m = message(%{from_email: "no-reply@sns.amazonaws.com", subject: "SubscriptionConfirmation"}) assert {:ok, result} = Service.process(m, lookup(), w) assert result.outcome == :skipped From 0c059f84d42c4358ee5df0419b47d1353afa8e8f Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:40:05 -0400 Subject: [PATCH 04/22] feat(inbound): AttachmentDownloader for provider-hosted attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../email/inbound/attachment_downloader.ex | 19 +++++++++---------- .../email/inbound/local_file_storage.ex | 5 +---- .../inbound/attachment_downloader_test.exs | 15 ++++----------- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/lib/escalated/services/email/inbound/attachment_downloader.ex b/lib/escalated/services/email/inbound/attachment_downloader.ex index ede4783..f9f83ec 100644 --- a/lib/escalated/services/email/inbound/attachment_downloader.ex +++ b/lib/escalated/services/email/inbound/attachment_downloader.ex @@ -50,9 +50,8 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloader do @type pending :: Service.pending_attachment() @type storage :: %{ - required(:put) => - (String.t(), binary(), String.t() -> - {:ok, String.t()} | {:error, any()}) + required(:put) => (String.t(), binary(), String.t() -> + {:ok, String.t()} | {:error, any()}) } @type writer :: %{ @@ -81,10 +80,12 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloader do when is_map(pending) do url = Map.get(pending, :download_url) || Map.get(pending, "download_url") - if is_nil(url) or url == "" do - {:error, :missing_download_url} - else - do_download(pending, url, ticket_id, reply_id, storage, writer, options) + cond do + is_nil(url) or url == "" -> + {:error, :missing_download_url} + + true -> + do_download(pending, url, ticket_id, reply_id, storage, writer, options) end end @@ -176,9 +177,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloader do _ = :application.ensure_all_started(:ssl) charlist_url = String.to_charlist(url) - - charlist_headers = - Enum.map(headers, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + charlist_headers = Enum.map(headers, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) case :httpc.request(:get, {charlist_url, charlist_headers}, [], body_format: :binary) do {:ok, {{_version, status, _reason}, resp_headers, body}} -> diff --git a/lib/escalated/services/email/inbound/local_file_storage.ex b/lib/escalated/services/email/inbound/local_file_storage.ex index 15b04ea..b5e5948 100644 --- a/lib/escalated/services/email/inbound/local_file_storage.ex +++ b/lib/escalated/services/email/inbound/local_file_storage.ex @@ -22,10 +22,7 @@ defmodule Escalated.Services.Email.Inbound.LocalFileStorage do %{ put: fn filename, content, _content_type -> now = DateTime.utc_now() - - prefix = - DateTime.to_iso8601(now, :basic) <> "-" <> Integer.to_string(now.microsecond |> elem(0)) - + prefix = DateTime.to_iso8601(now, :basic) <> "-" <> Integer.to_string(now.microsecond |> elem(0)) stored_name = "#{prefix}-#{filename}" full_path = Path.join(root, stored_name) diff --git a/test/escalated/services/email/inbound/attachment_downloader_test.exs b/test/escalated/services/email/inbound/attachment_downloader_test.exs index 2856c30..cf127a1 100644 --- a/test/escalated/services/email/inbound/attachment_downloader_test.exs +++ b/test/escalated/services/email/inbound/attachment_downloader_test.exs @@ -24,7 +24,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do """ def get(:get, _url, _headers) do [status | rest] = Process.get(:__ad_sequence__, [200]) - Process.put(:__ad_sequence__, (rest == [] and [200]) or rest) + Process.put(:__ad_sequence__, rest == [] and [200] or rest) {:ok, %{status: status, body: "ok", headers: []}} end end @@ -78,10 +78,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do describe "download/6" do test "happy path persists attachment" do - stub_response( - {:ok, %{status: 200, body: "hello pdf", headers: [{"Content-Type", "application/pdf"}]}} - ) - + stub_response({:ok, %{status: 200, body: "hello pdf", headers: [{"Content-Type", "application/pdf"}]}}) storage = fake_storage(path: "/store/report.pdf") writer = fake_writer(attachment: %{id: 1, original_filename: "report.pdf"}) @@ -108,8 +105,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do storage = fake_storage() writer = fake_writer() - assert {:ok, _} = - AttachmentDownloader.download(pending(), 42, 7, storage, writer, options()) + assert {:ok, _} = AttachmentDownloader.download(pending(), 42, 7, storage, writer, options()) [{:create, attrs}] = :ets.tab2list(writer._ets) assert attrs.reply_id == 7 @@ -178,10 +174,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do end test "falls back to response Content-Type when pending content_type is blank" do - stub_response( - {:ok, %{status: 200, body: <<1, 2, 3>>, headers: [{"Content-Type", "image/png"}]}} - ) - + stub_response({:ok, %{status: 200, body: <<1, 2, 3>>, headers: [{"Content-Type", "image/png"}]}}) storage = fake_storage() writer = fake_writer() From 73e63834e2943426905600e55722f7c6c83821f1 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:03:15 -0400 Subject: [PATCH 05/22] feat(inbound): SESParser (AWS SES via SNS HTTP subscription) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../services/email/inbound/ses_parser.ex | 74 +++++++++---------- .../email/inbound/ses_parser_test.exs | 41 +++++----- 2 files changed, 56 insertions(+), 59 deletions(-) diff --git a/lib/escalated/services/email/inbound/ses_parser.ex b/lib/escalated/services/email/inbound/ses_parser.ex index 121b690..72921b1 100644 --- a/lib/escalated/services/email/inbound/ses_parser.ex +++ b/lib/escalated/services/email/inbound/ses_parser.ex @@ -211,31 +211,34 @@ defmodule Escalated.Services.Email.Inbound.SESParser do defp walk_multipart(body, content_type) do case extract_boundary(content_type) do - nil -> {nil, nil} - boundary -> Enum.reduce(split_multipart(body, boundary), {nil, nil}, &fold_part/2) - end - end + nil -> + {nil, nil} - defp fold_part(part, {text, html}) do - case split_headers(part) do - {:ok, part_headers, part_body} -> - part_type = Map.get(part_headers, "content-type", "") - part_enc = Map.get(part_headers, "content-transfer-encoding", "7bit") - decoded = decode_body(String.trim(part_body), part_enc) - merge_part(text, html, part_type, decoded) + boundary -> + parts = split_multipart(body, boundary) - _ -> - {text, html} - end - end + Enum.reduce(parts, {nil, nil}, fn part, {text, html} -> + case split_headers(part) do + {:ok, part_headers, part_body} -> + part_type = Map.get(part_headers, "content-type", "") + part_enc = Map.get(part_headers, "content-transfer-encoding", "7bit") + decoded = decode_body(String.trim(part_body), part_enc) + + cond do + String.starts_with?(String.downcase(part_type), "text/plain") and is_nil(text) -> + {decoded, html} - defp merge_part(text, html, part_type, decoded) do - type = String.downcase(part_type) + String.starts_with?(String.downcase(part_type), "text/html") and is_nil(html) -> + {text, decoded} - cond do - String.starts_with?(type, "text/plain") and is_nil(text) -> {decoded, html} - String.starts_with?(type, "text/html") and is_nil(html) -> {text, decoded} - true -> {text, html} + true -> + {text, html} + end + + _ -> + {text, html} + end + end) end end @@ -261,26 +264,23 @@ defmodule Escalated.Services.Email.Inbound.SESParser do defp decode_body(body, transfer_enc) do case String.downcase(String.trim(transfer_enc)) do - "quoted-printable" -> - decode_quoted_printable(body) - - "base64" -> - case Base.decode64(body, ignore: :whitespace) do - {:ok, decoded} -> decoded - :error -> body - end - - _ -> - body + "quoted-printable" -> decode_quoted_printable(body) + "base64" -> case Base.decode64(body, ignore: :whitespace) do + {:ok, decoded} -> decoded + :error -> body + end + _ -> body end end defp decode_quoted_printable(body) do - stripped = String.replace(body, ~r/=\r?\n/, "") - - Regex.replace(~r/=([0-9A-Fa-f]{2})/, stripped, fn _match, hex -> - <> - end) + body + |> String.replace(~r/=\r?\n/, "") + |> (fn stripped -> + Regex.replace(~r/=([0-9A-Fa-f]{2})/, stripped, fn _match, hex -> + <> + end) + end).() end defp blank_to_nil(nil), do: nil diff --git a/test/escalated/services/email/inbound/ses_parser_test.exs b/test/escalated/services/email/inbound/ses_parser_test.exs index 524a9c1..2957143 100644 --- a/test/escalated/services/email/inbound/ses_parser_test.exs +++ b/test/escalated/services/email/inbound/ses_parser_test.exs @@ -28,29 +28,26 @@ defmodule Escalated.Services.Email.Inbound.SESParserTest do describe "notification" do test "extracts full threading metadata from commonHeaders + headers" do - envelope = - notification_envelope(%{ - "mail" => %{ - "source" => "alice@example.com", - "destination" => ["support@example.com"], - "headers" => [ - %{"name" => "From", "value" => "Alice "}, - %{"name" => "To", "value" => "support@example.com"}, - %{"name" => "Subject", "value" => "[ESC-42] Re: Help"}, - %{"name" => "Message-ID", "value" => ""}, - %{"name" => "In-Reply-To", "value" => ""}, - %{ - "name" => "References", - "value" => " " - } - ], - "commonHeaders" => %{ - "from" => ["Alice "], - "to" => ["support@example.com"], - "subject" => "[ESC-42] Re: Help" - } + envelope = notification_envelope(%{ + "mail" => %{ + "source" => "alice@example.com", + "destination" => ["support@example.com"], + "headers" => [ + %{"name" => "From", "value" => "Alice "}, + %{"name" => "To", "value" => "support@example.com"}, + %{"name" => "Subject", "value" => "[ESC-42] Re: Help"}, + %{"name" => "Message-ID", "value" => ""}, + %{"name" => "In-Reply-To", "value" => ""}, + %{"name" => "References", + "value" => " "} + ], + "commonHeaders" => %{ + "from" => ["Alice "], + "to" => ["support@example.com"], + "subject" => "[ESC-42] Re: Help" } - }) + } + }) assert {:ok, msg} = SESParser.parse(envelope) assert msg.from_email == "alice@example.com" From a6a6086d6ac5b4d1f27938e00ae65e3c6269071e Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:22:02 -0400 Subject: [PATCH 06/22] test(inbound): parser equivalence across Postmark / Mailgun / SES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports escalated-go#37 + escalated-dotnet#31 + escalated-spring#34 to Phoenix/Elixir. Cements the three-provider parser contract: the same logical email, expressed in each provider's native webhook payload shape, should normalize to the same Message metadata. Two ExUnit cases: - threading metadata matches (from_email / to_email / subject / in_reply_to / references) across all three parsers. - body_text matches — Postmark + Mailgun forward directly, SES needs the base64 MIME dance. Shared @sample + *_payload/1 builders mean adding a fourth provider validates against the existing three for free. --- .../email/inbound/parser_equivalence_test.exs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/escalated/services/email/inbound/parser_equivalence_test.exs diff --git a/test/escalated/services/email/inbound/parser_equivalence_test.exs b/test/escalated/services/email/inbound/parser_equivalence_test.exs new file mode 100644 index 0000000..8885034 --- /dev/null +++ b/test/escalated/services/email/inbound/parser_equivalence_test.exs @@ -0,0 +1,130 @@ +defmodule Escalated.Services.Email.Inbound.ParserEquivalenceTest do + @moduledoc """ + Parser-equivalence tests: the same logical email, expressed in each + provider's native webhook payload shape, should normalize to the + same `Escalated.Services.Email.Inbound.Message` metadata. Parser + equivalence at this layer guarantees a reply delivered via any + provider routes to the same ticket via the same threading chain. + + Mirrors escalated-go#37 + escalated-dotnet#31 + escalated-spring#34. + Adding a fourth provider in the future can reuse the same + `logical_email/0` + `*_payload/1` builders and get contract + validation for free. + """ + + use ExUnit.Case, async: true + + alias Escalated.Services.Email.Inbound.{ + MailgunParser, + PostmarkParser, + SESParser + } + + @sample %{ + from_email: "alice@example.com", + from_name: "Alice", + to_email: "support@example.com", + subject: "Re: Help with invoice", + body_text: "Thanks for the quick response.", + message_id: "", + in_reply_to: "", + references: "" + } + + defp postmark_payload(e) do + %{ + "FromFull" => %{"Email" => e.from_email, "Name" => e.from_name}, + "To" => e.to_email, + "Subject" => e.subject, + "TextBody" => e.body_text, + "Headers" => [ + %{"Name" => "Message-ID", "Value" => e.message_id}, + %{"Name" => "In-Reply-To", "Value" => e.in_reply_to}, + %{"Name" => "References", "Value" => e.references} + ] + } + end + + defp mailgun_payload(e) do + %{ + "sender" => e.from_email, + "from" => "#{e.from_name} <#{e.from_email}>", + "recipient" => e.to_email, + "subject" => e.subject, + "body-plain" => e.body_text, + "Message-Id" => e.message_id, + "In-Reply-To" => e.in_reply_to, + "References" => e.references + } + end + + defp ses_payload(e) do + # Include full raw MIME as base64 so body extraction is exercised + # — keeps the payload close to a real SES delivery. + mime = + "From: #{e.from_name} <#{e.from_email}>\r\n" <> + "To: #{e.to_email}\r\n" <> + "Subject: #{e.subject}\r\n" <> + "Message-ID: #{e.message_id}\r\n" <> + "In-Reply-To: #{e.in_reply_to}\r\n" <> + "References: #{e.references}\r\n" <> + "Content-Type: text/plain; charset=\"utf-8\"\r\n" <> + "\r\n" <> + e.body_text + + ses_message = %{ + "notificationType" => "Received", + "mail" => %{ + "source" => e.from_email, + "destination" => [e.to_email], + "headers" => [ + %{"name" => "From", "value" => "#{e.from_name} <#{e.from_email}>"}, + %{"name" => "To", "value" => e.to_email}, + %{"name" => "Subject", "value" => e.subject}, + %{"name" => "Message-ID", "value" => e.message_id}, + %{"name" => "In-Reply-To", "value" => e.in_reply_to}, + %{"name" => "References", "value" => e.references} + ], + "commonHeaders" => %{ + "from" => ["#{e.from_name} <#{e.from_email}>"], + "to" => [e.to_email], + "subject" => e.subject + } + }, + "content" => Base.encode64(mime) + } + + %{ + "Type" => "Notification", + "Message" => Jason.encode!(ses_message) + } + end + + describe "normalizes to same message" do + test "threading metadata matches across Postmark / Mailgun / SES" do + {:ok, postmark} = PostmarkParser.parse(postmark_payload(@sample)) + {:ok, mailgun} = MailgunParser.parse(mailgun_payload(@sample)) + {:ok, ses} = SESParser.parse(ses_payload(@sample)) + + for {name, msg} <- [{"postmark", postmark}, {"mailgun", mailgun}, {"ses", ses}] do + assert msg.from_email == @sample.from_email, "#{name}: from_email mismatch" + assert msg.to_email == @sample.to_email, "#{name}: to_email mismatch" + assert msg.subject == @sample.subject, "#{name}: subject mismatch" + assert msg.in_reply_to == @sample.in_reply_to, "#{name}: in_reply_to mismatch" + assert msg.references == @sample.references, "#{name}: references mismatch" + end + end + end + + describe "body extraction matches" do + test "body_text matches across all three parsers" do + {:ok, postmark} = PostmarkParser.parse(postmark_payload(@sample)) + {:ok, mailgun} = MailgunParser.parse(mailgun_payload(@sample)) + {:ok, ses} = SESParser.parse(ses_payload(@sample)) + + assert postmark.body_text == @sample.body_text + assert mailgun.body_text == @sample.body_text + assert ses.body_text == @sample.body_text + end + end +end From 8863e38e0c998747f6cd30189baf851f01ee97f8 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:45:47 -0400 Subject: [PATCH 07/22] style(phoenix): wrap long line for mix format --- lib/escalated/services/email/inbound/postmark_parser.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/escalated/services/email/inbound/postmark_parser.ex b/lib/escalated/services/email/inbound/postmark_parser.ex index c2c7de3..1187fe0 100644 --- a/lib/escalated/services/email/inbound/postmark_parser.ex +++ b/lib/escalated/services/email/inbound/postmark_parser.ex @@ -50,8 +50,7 @@ defmodule Escalated.Services.Email.Inbound.PostmarkParser do subject: Map.get(payload, "Subject") || "", body_text: blank_to_nil(Map.get(payload, "TextBody")), body_html: blank_to_nil(Map.get(payload, "HtmlBody")), - message_id: - first_nonempty(Map.get(payload, "MessageID"), Map.get(headers, "Message-ID")), + message_id: first_nonempty(Map.get(payload, "MessageID"), Map.get(headers, "Message-ID")), in_reply_to: Map.get(headers, "In-Reply-To"), references: Map.get(headers, "References"), headers: headers, From 060a589821d5bae1c7c89b1e2aa99ff52f39e44c Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:49:12 -0400 Subject: [PATCH 08/22] style(phoenix): wrap long lines for mix format --- test/escalated/services/email/inbound/router_test.exs | 4 +++- test/escalated/services/email/inbound/service_test.exs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/escalated/services/email/inbound/router_test.exs b/test/escalated/services/email/inbound/router_test.exs index 554d410..2747fd5 100644 --- a/test/escalated/services/email/inbound/router_test.exs +++ b/test/escalated/services/email/inbound/router_test.exs @@ -54,7 +54,9 @@ defmodule Escalated.Services.Email.Inbound.RouterTest do to = MessageIdUtil.build_reply_to(42, @secret, @domain) m = message(to_email: to) - result = Router.resolve_ticket(m, lookup(by_id: %{42 => ticket}), %{inbound_secret: @secret}) + result = + Router.resolve_ticket(m, lookup(by_id: %{42 => ticket}), %{inbound_secret: }) + assert result == ticket end diff --git a/test/escalated/services/email/inbound/service_test.exs b/test/escalated/services/email/inbound/service_test.exs index ea3f1c2..7e4cdbf 100644 --- a/test/escalated/services/email/inbound/service_test.exs +++ b/test/escalated/services/email/inbound/service_test.exs @@ -108,7 +108,9 @@ defmodule Escalated.Services.Email.Inbound.ServiceTest do test "SNS confirmation → skipped" do w = writer() - m = message(%{from_email: "no-reply@sns.amazonaws.com", subject: "SubscriptionConfirmation"}) + + m = + message(%{from_email: "no-reply@sns.amazonaws.com", subject: "SubscriptionConfirmation"}) assert {:ok, result} = Service.process(m, lookup(), w) assert result.outcome == :skipped From 3585acf46b759ac47a4524a4865c58ed17d51275 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:49:42 -0400 Subject: [PATCH 09/22] style(phoenix): alphabetize aliases + extract pending_downloads helpers --- .../controllers/inbound_email_controller.ex | 2 +- .../services/email/inbound/service.ex | 30 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/escalated/controllers/inbound_email_controller.ex b/lib/escalated/controllers/inbound_email_controller.ex index 8be1191..a96c769 100644 --- a/lib/escalated/controllers/inbound_email_controller.ex +++ b/lib/escalated/controllers/inbound_email_controller.ex @@ -33,9 +33,9 @@ defmodule Escalated.Controllers.InboundEmailController do use Phoenix.Controller, formats: [:json] import Plug.Conn + alias Escalated.Schemas.Ticket alias Escalated.Services.Email.Inbound.Service alias Escalated.Services.TicketService - alias Escalated.Schemas.Ticket @default_parsers [Escalated.Services.Email.Inbound.PostmarkParser] diff --git a/lib/escalated/services/email/inbound/service.ex b/lib/escalated/services/email/inbound/service.ex index 89fd13a..2c2a7bc 100644 --- a/lib/escalated/services/email/inbound/service.ex +++ b/lib/escalated/services/email/inbound/service.ex @@ -188,19 +188,23 @@ defmodule Escalated.Services.Email.Inbound.Service do attachments = Map.get(message, :attachments) || Map.get(message, "attachments") || [] attachments - |> Enum.filter(fn a -> - url = Map.get(a, :download_url) || Map.get(a, "download_url") - content = Map.get(a, :content) || Map.get(a, "content") - is_binary(url) and url != "" and (is_nil(content) or content == "") - end) - |> Enum.map(fn a -> - %{ - name: Map.get(a, :name) || Map.get(a, "name"), - content_type: Map.get(a, :content_type) || Map.get(a, "content_type"), - size_bytes: Map.get(a, :size_bytes) || Map.get(a, "size_bytes"), - download_url: Map.get(a, :download_url) || Map.get(a, "download_url") - } - end) + |> Enum.filter(&provider_hosted?/1) + |> Enum.map(&to_pending_download/1) + end + + defp provider_hosted?(a) do + url = Map.get(a, :download_url) || Map.get(a, "download_url") + content = Map.get(a, :content) || Map.get(a, "content") + is_binary(url) and url != "" and (is_nil(content) or content == "") + end + + defp to_pending_download(a) do + %{ + name: Map.get(a, :name) || Map.get(a, "name"), + content_type: Map.get(a, :content_type) || Map.get(a, "content_type"), + size_bytes: Map.get(a, :size_bytes) || Map.get(a, "size_bytes"), + download_url: Map.get(a, :download_url) || Map.get(a, "download_url") + } end defp ticket_id(%{id: id}), do: id From 6ae662df3f5fedbacef5f12b8d163cf5d5f568cb Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:53:19 -0400 Subject: [PATCH 10/22] fix(phoenix): restore @secret reference in router_test --- test/escalated/services/email/inbound/router_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/escalated/services/email/inbound/router_test.exs b/test/escalated/services/email/inbound/router_test.exs index 2747fd5..2f9717d 100644 --- a/test/escalated/services/email/inbound/router_test.exs +++ b/test/escalated/services/email/inbound/router_test.exs @@ -55,7 +55,7 @@ defmodule Escalated.Services.Email.Inbound.RouterTest do m = message(to_email: to) result = - Router.resolve_ticket(m, lookup(by_id: %{42 => ticket}), %{inbound_secret: }) + Router.resolve_ticket(m, lookup(by_id: %{42 => ticket}), %{inbound_secret: @secret}) assert result == ticket end From 2c22844d7187bd96744042ca5bf42c2e8b83315e Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:58:06 -0400 Subject: [PATCH 11/22] style(phoenix): sync router.ex to master --- .../services/email/inbound/router.ex | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/escalated/services/email/inbound/router.ex b/lib/escalated/services/email/inbound/router.ex index 536706b..452f157 100644 --- a/lib/escalated/services/email/inbound/router.ex +++ b/lib/escalated/services/email/inbound/router.ex @@ -55,20 +55,18 @@ defmodule Escalated.Services.Email.Inbound.Router do # 1 + 2. Parse canonical Message-IDs out of our own headers. ticket = resolve_by_header_message_ids(message, lookup) - cond do - not is_nil(ticket) -> - ticket - - true -> - # 3. Signed Reply-To on the recipient address. - case resolve_by_signed_reply_to(message, lookup, options) do - nil -> - # 4. Subject-line reference tag. - resolve_by_subject_reference(message, lookup, options) - - signed_ticket -> - signed_ticket - end + if is_nil(ticket) do + # 3. Signed Reply-To on the recipient address. + case resolve_by_signed_reply_to(message, lookup, options) do + nil -> + # 4. Subject-line reference tag. + resolve_by_subject_reference(message, lookup, options) + + signed_ticket -> + signed_ticket + end + else + ticket end end From 777fc301dda0c94311cc1ecd9fb0a121b2bea2d8 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:02:57 -0400 Subject: [PATCH 12/22] style(phoenix): wrap long lines for mix format --- .../services/email/inbound/attachment_downloader.ex | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/escalated/services/email/inbound/attachment_downloader.ex b/lib/escalated/services/email/inbound/attachment_downloader.ex index f9f83ec..814184d 100644 --- a/lib/escalated/services/email/inbound/attachment_downloader.ex +++ b/lib/escalated/services/email/inbound/attachment_downloader.ex @@ -50,8 +50,9 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloader do @type pending :: Service.pending_attachment() @type storage :: %{ - required(:put) => (String.t(), binary(), String.t() -> - {:ok, String.t()} | {:error, any()}) + required(:put) => + (String.t(), binary(), String.t() -> + {:ok, String.t()} | {:error, any()}) } @type writer :: %{ @@ -177,7 +178,9 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloader do _ = :application.ensure_all_started(:ssl) charlist_url = String.to_charlist(url) - charlist_headers = Enum.map(headers, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + + charlist_headers = + Enum.map(headers, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) case :httpc.request(:get, {charlist_url, charlist_headers}, [], body_format: :binary) do {:ok, {{_version, status, _reason}, resp_headers, body}} -> From 8404eeea94b2a40b8af3bc911f9d5fcb76198fa3 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:03:17 -0400 Subject: [PATCH 13/22] style(phoenix): wrap long line in local_file_storage for mix format --- lib/escalated/services/email/inbound/local_file_storage.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/escalated/services/email/inbound/local_file_storage.ex b/lib/escalated/services/email/inbound/local_file_storage.ex index b5e5948..15b04ea 100644 --- a/lib/escalated/services/email/inbound/local_file_storage.ex +++ b/lib/escalated/services/email/inbound/local_file_storage.ex @@ -22,7 +22,10 @@ defmodule Escalated.Services.Email.Inbound.LocalFileStorage do %{ put: fn filename, content, _content_type -> now = DateTime.utc_now() - prefix = DateTime.to_iso8601(now, :basic) <> "-" <> Integer.to_string(now.microsecond |> elem(0)) + + prefix = + DateTime.to_iso8601(now, :basic) <> "-" <> Integer.to_string(now.microsecond |> elem(0)) + stored_name = "#{prefix}-#{filename}" full_path = Path.join(root, stored_name) From 1e771e2f8a1254e12b45a4a1e932e941f8da2f9b Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:05:54 -0400 Subject: [PATCH 14/22] style(phoenix): wrap content-type stub for mix format --- .../services/email/inbound/attachment_downloader_test.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/escalated/services/email/inbound/attachment_downloader_test.exs b/test/escalated/services/email/inbound/attachment_downloader_test.exs index cf127a1..c2e404e 100644 --- a/test/escalated/services/email/inbound/attachment_downloader_test.exs +++ b/test/escalated/services/email/inbound/attachment_downloader_test.exs @@ -174,7 +174,9 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do end test "falls back to response Content-Type when pending content_type is blank" do - stub_response({:ok, %{status: 200, body: <<1, 2, 3>>, headers: [{"Content-Type", "image/png"}]}}) + stub_response( + {:ok, %{status: 200, body: <<1, 2, 3>>, headers: [{"Content-Type", "image/png"}]}} + ) storage = fake_storage() writer = fake_writer() From f8cf517b9e40f416df9ba38de2f1acd78a4e58e1 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:06:13 -0400 Subject: [PATCH 15/22] style(phoenix): wrap hello-pdf stub for mix format --- .../services/email/inbound/attachment_downloader_test.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/escalated/services/email/inbound/attachment_downloader_test.exs b/test/escalated/services/email/inbound/attachment_downloader_test.exs index c2e404e..0d54be1 100644 --- a/test/escalated/services/email/inbound/attachment_downloader_test.exs +++ b/test/escalated/services/email/inbound/attachment_downloader_test.exs @@ -78,7 +78,9 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do describe "download/6" do test "happy path persists attachment" do - stub_response({:ok, %{status: 200, body: "hello pdf", headers: [{"Content-Type", "application/pdf"}]}}) + stub_response( + {:ok, %{status: 200, body: "hello pdf", headers: [{"Content-Type", "application/pdf"}]}} + ) storage = fake_storage(path: "/store/report.pdf") writer = fake_writer(attachment: %{id: 1, original_filename: "report.pdf"}) From c3c07fae2f01e966addd3bac5d512de057679edd Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:08:26 -0400 Subject: [PATCH 16/22] style(phoenix): wrap reply_id assertion line for mix format --- .../services/email/inbound/attachment_downloader_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/escalated/services/email/inbound/attachment_downloader_test.exs b/test/escalated/services/email/inbound/attachment_downloader_test.exs index 0d54be1..7192ec5 100644 --- a/test/escalated/services/email/inbound/attachment_downloader_test.exs +++ b/test/escalated/services/email/inbound/attachment_downloader_test.exs @@ -107,7 +107,8 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do storage = fake_storage() writer = fake_writer() - assert {:ok, _} = AttachmentDownloader.download(pending(), 42, 7, storage, writer, options()) + assert {:ok, _} = + AttachmentDownloader.download(pending(), 42, 7, storage, writer, options()) [{:create, attrs}] = :ets.tab2list(writer._ets) assert attrs.reply_id == 7 From c6a955614cdbf08836addaf31a781a0fb270be77 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:16:30 -0400 Subject: [PATCH 17/22] style(phoenix): replace single-condition cond with if --- .../services/email/inbound/attachment_downloader.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/escalated/services/email/inbound/attachment_downloader.ex b/lib/escalated/services/email/inbound/attachment_downloader.ex index 814184d..ede4783 100644 --- a/lib/escalated/services/email/inbound/attachment_downloader.ex +++ b/lib/escalated/services/email/inbound/attachment_downloader.ex @@ -81,12 +81,10 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloader do when is_map(pending) do url = Map.get(pending, :download_url) || Map.get(pending, "download_url") - cond do - is_nil(url) or url == "" -> - {:error, :missing_download_url} - - true -> - do_download(pending, url, ticket_id, reply_id, storage, writer, options) + if is_nil(url) or url == "" do + {:error, :missing_download_url} + else + do_download(pending, url, ticket_id, reply_id, storage, writer, options) end end From 4d164c50f7933b61cc2a178045f428ff8c420511 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:19:18 -0400 Subject: [PATCH 18/22] style(phoenix): wrap long lines in ses_parser + downloader tests --- .../services/email/inbound/ses_parser.ex | 17 +++++++++++------ .../inbound/attachment_downloader_test.exs | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/escalated/services/email/inbound/ses_parser.ex b/lib/escalated/services/email/inbound/ses_parser.ex index 72921b1..a8da443 100644 --- a/lib/escalated/services/email/inbound/ses_parser.ex +++ b/lib/escalated/services/email/inbound/ses_parser.ex @@ -264,12 +264,17 @@ defmodule Escalated.Services.Email.Inbound.SESParser do defp decode_body(body, transfer_enc) do case String.downcase(String.trim(transfer_enc)) do - "quoted-printable" -> decode_quoted_printable(body) - "base64" -> case Base.decode64(body, ignore: :whitespace) do - {:ok, decoded} -> decoded - :error -> body - end - _ -> body + "quoted-printable" -> + decode_quoted_printable(body) + + "base64" -> + case Base.decode64(body, ignore: :whitespace) do + {:ok, decoded} -> decoded + :error -> body + end + + _ -> + body end end diff --git a/test/escalated/services/email/inbound/attachment_downloader_test.exs b/test/escalated/services/email/inbound/attachment_downloader_test.exs index 7192ec5..fe8b17d 100644 --- a/test/escalated/services/email/inbound/attachment_downloader_test.exs +++ b/test/escalated/services/email/inbound/attachment_downloader_test.exs @@ -24,7 +24,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do """ def get(:get, _url, _headers) do [status | rest] = Process.get(:__ad_sequence__, [200]) - Process.put(:__ad_sequence__, rest == [] and [200] or rest) + Process.put(:__ad_sequence__, (rest == [] and [200]) or rest) {:ok, %{status: status, body: "ok", headers: []}} end end From 3aa58474875654059006dc2f6de22758bce963f0 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:26:42 -0400 Subject: [PATCH 19/22] style(phoenix): blank-line wrap for stub_response calls --- .../services/email/inbound/attachment_downloader_test.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/escalated/services/email/inbound/attachment_downloader_test.exs b/test/escalated/services/email/inbound/attachment_downloader_test.exs index fe8b17d..2856c30 100644 --- a/test/escalated/services/email/inbound/attachment_downloader_test.exs +++ b/test/escalated/services/email/inbound/attachment_downloader_test.exs @@ -81,6 +81,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do stub_response( {:ok, %{status: 200, body: "hello pdf", headers: [{"Content-Type", "application/pdf"}]}} ) + storage = fake_storage(path: "/store/report.pdf") writer = fake_writer(attachment: %{id: 1, original_filename: "report.pdf"}) @@ -180,6 +181,7 @@ defmodule Escalated.Services.Email.Inbound.AttachmentDownloaderTest do stub_response( {:ok, %{status: 200, body: <<1, 2, 3>>, headers: [{"Content-Type", "image/png"}]}} ) + storage = fake_storage() writer = fake_writer() From 947d4f7779bbe3895f8d2ca3a5f9114bec60222b Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:27:04 -0400 Subject: [PATCH 20/22] style(phoenix): wrap nested map literals in ses_parser_test for mix format --- .../email/inbound/ses_parser_test.exs | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/test/escalated/services/email/inbound/ses_parser_test.exs b/test/escalated/services/email/inbound/ses_parser_test.exs index 2957143..524a9c1 100644 --- a/test/escalated/services/email/inbound/ses_parser_test.exs +++ b/test/escalated/services/email/inbound/ses_parser_test.exs @@ -28,26 +28,29 @@ defmodule Escalated.Services.Email.Inbound.SESParserTest do describe "notification" do test "extracts full threading metadata from commonHeaders + headers" do - envelope = notification_envelope(%{ - "mail" => %{ - "source" => "alice@example.com", - "destination" => ["support@example.com"], - "headers" => [ - %{"name" => "From", "value" => "Alice "}, - %{"name" => "To", "value" => "support@example.com"}, - %{"name" => "Subject", "value" => "[ESC-42] Re: Help"}, - %{"name" => "Message-ID", "value" => ""}, - %{"name" => "In-Reply-To", "value" => ""}, - %{"name" => "References", - "value" => " "} - ], - "commonHeaders" => %{ - "from" => ["Alice "], - "to" => ["support@example.com"], - "subject" => "[ESC-42] Re: Help" + envelope = + notification_envelope(%{ + "mail" => %{ + "source" => "alice@example.com", + "destination" => ["support@example.com"], + "headers" => [ + %{"name" => "From", "value" => "Alice "}, + %{"name" => "To", "value" => "support@example.com"}, + %{"name" => "Subject", "value" => "[ESC-42] Re: Help"}, + %{"name" => "Message-ID", "value" => ""}, + %{"name" => "In-Reply-To", "value" => ""}, + %{ + "name" => "References", + "value" => " " + } + ], + "commonHeaders" => %{ + "from" => ["Alice "], + "to" => ["support@example.com"], + "subject" => "[ESC-42] Re: Help" + } } - } - }) + }) assert {:ok, msg} = SESParser.parse(envelope) assert msg.from_email == "alice@example.com" From 8f30197d38f71389b68e3b4e34ebd086b81a20a2 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:30:56 -0400 Subject: [PATCH 21/22] refactor(phoenix): extract walk_multipart helpers to satisfy Credo --- .../services/email/inbound/ses_parser.ex | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/lib/escalated/services/email/inbound/ses_parser.ex b/lib/escalated/services/email/inbound/ses_parser.ex index a8da443..46c2339 100644 --- a/lib/escalated/services/email/inbound/ses_parser.ex +++ b/lib/escalated/services/email/inbound/ses_parser.ex @@ -211,34 +211,31 @@ defmodule Escalated.Services.Email.Inbound.SESParser do defp walk_multipart(body, content_type) do case extract_boundary(content_type) do - nil -> - {nil, nil} - - boundary -> - parts = split_multipart(body, boundary) - - Enum.reduce(parts, {nil, nil}, fn part, {text, html} -> - case split_headers(part) do - {:ok, part_headers, part_body} -> - part_type = Map.get(part_headers, "content-type", "") - part_enc = Map.get(part_headers, "content-transfer-encoding", "7bit") - decoded = decode_body(String.trim(part_body), part_enc) + nil -> {nil, nil} + boundary -> Enum.reduce(split_multipart(body, boundary), {nil, nil}, &fold_part/2) + end + end - cond do - String.starts_with?(String.downcase(part_type), "text/plain") and is_nil(text) -> - {decoded, html} + defp fold_part(part, {text, html}) do + case split_headers(part) do + {:ok, part_headers, part_body} -> + part_type = Map.get(part_headers, "content-type", "") + part_enc = Map.get(part_headers, "content-transfer-encoding", "7bit") + decoded = decode_body(String.trim(part_body), part_enc) + merge_part(text, html, part_type, decoded) - String.starts_with?(String.downcase(part_type), "text/html") and is_nil(html) -> - {text, decoded} + _ -> + {text, html} + end + end - true -> - {text, html} - end + defp merge_part(text, html, part_type, decoded) do + type = String.downcase(part_type) - _ -> - {text, html} - end - end) + cond do + String.starts_with?(type, "text/plain") and is_nil(text) -> {decoded, html} + String.starts_with?(type, "text/html") and is_nil(html) -> {text, decoded} + true -> {text, html} end end From 3646be7d5fa2bd7c6c3a90215245a6eb6ba7a35d Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:43:42 -0400 Subject: [PATCH 22/22] refactor(phoenix): unwind pipe-into-anonymous in decode_quoted_printable --- lib/escalated/services/email/inbound/ses_parser.ex | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/escalated/services/email/inbound/ses_parser.ex b/lib/escalated/services/email/inbound/ses_parser.ex index 46c2339..121b690 100644 --- a/lib/escalated/services/email/inbound/ses_parser.ex +++ b/lib/escalated/services/email/inbound/ses_parser.ex @@ -276,13 +276,11 @@ defmodule Escalated.Services.Email.Inbound.SESParser do end defp decode_quoted_printable(body) do - body - |> String.replace(~r/=\r?\n/, "") - |> (fn stripped -> - Regex.replace(~r/=([0-9A-Fa-f]{2})/, stripped, fn _match, hex -> - <> - end) - end).() + stripped = String.replace(body, ~r/=\r?\n/, "") + + Regex.replace(~r/=([0-9A-Fa-f]{2})/, stripped, fn _match, hex -> + <> + end) end defp blank_to_nil(nil), do: nil