Skip to content
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
596f5f5
feat(inbound): scaffold Message + Parser + Router for inbound email
mpge Apr 24, 2026
7199817
feat(inbound): PostmarkParser + InboundEmailController
mpge Apr 24, 2026
8aa9783
feat(inbound): Service orchestrates reply/create with tests
mpge Apr 24, 2026
0c059f8
feat(inbound): AttachmentDownloader for provider-hosted attachments
mpge Apr 24, 2026
73e6383
feat(inbound): SESParser (AWS SES via SNS HTTP subscription)
mpge Apr 24, 2026
a6a6086
test(inbound): parser equivalence across Postmark / Mailgun / SES
mpge Apr 24, 2026
8863e38
style(phoenix): wrap long line for mix format
mpge Apr 27, 2026
060a589
style(phoenix): wrap long lines for mix format
mpge Apr 27, 2026
3585acf
style(phoenix): alphabetize aliases + extract pending_downloads helpers
mpge Apr 27, 2026
6ae662d
fix(phoenix): restore @secret reference in router_test
mpge Apr 27, 2026
2c22844
style(phoenix): sync router.ex to master
mpge Apr 27, 2026
777fc30
style(phoenix): wrap long lines for mix format
mpge Apr 27, 2026
8404eee
style(phoenix): wrap long line in local_file_storage for mix format
mpge Apr 27, 2026
1e771e2
style(phoenix): wrap content-type stub for mix format
mpge Apr 27, 2026
f8cf517
style(phoenix): wrap hello-pdf stub for mix format
mpge Apr 27, 2026
c3c07fa
style(phoenix): wrap reply_id assertion line for mix format
mpge Apr 27, 2026
c6a9556
style(phoenix): replace single-condition cond with if
mpge Apr 27, 2026
4d164c5
style(phoenix): wrap long lines in ses_parser + downloader tests
mpge Apr 27, 2026
3aa5847
style(phoenix): blank-line wrap for stub_response calls
mpge Apr 27, 2026
947d4f7
style(phoenix): wrap nested map literals in ses_parser_test for mix f…
mpge Apr 27, 2026
8f30197
refactor(phoenix): extract walk_multipart helpers to satisfy Credo
mpge Apr 27, 2026
3646be7
refactor(phoenix): unwind pipe-into-anonymous in decode_quoted_printable
mpge Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions test/escalated/services/email/inbound/parser_equivalence_test.exs
Original file line number Diff line number Diff line change
@@ -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: "<external-reply-xyz@mail.alice.com>",
in_reply_to: "<ticket-42@support.example.com>",
references: "<ticket-42@support.example.com>"
}

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
Loading