test(inbound): HTTP-level InboundEmailController tests#36
Closed
mpge wants to merge 7 commits intofeat/inbound-email-orchestrationfrom
Closed
test(inbound): HTTP-level InboundEmailController tests#36mpge wants to merge 7 commits intofeat/inbound-email-orchestrationfrom
mpge wants to merge 7 commits intofeat/inbound-email-orchestrationfrom
Conversation
Ports the NestJS email/message-id.ts helpers to Symfony. Mirrors the
Spring / WordPress / .NET / Phoenix / Laravel / Rails / Django /
Adonis / Go ports (10 of 11 total; NestJS is the reference).
API:
buildMessageId(ticketId, replyId, domain)
parseTicketIdFromMessageId(raw)
buildReplyTo(ticketId, secret, domain)
verifyReplyTo(address, secret)
Uses hash_hmac('sha256', ...) + hash_equals for timing-safe comparison.
Pure static helpers — 13 PHPUnit tests covering round-trip, tamper
rejection, case-insensitive hex, malformed input, local-part-only.
Follow-up PR will wire the util into ThreadingHeadersService so
outbound notifications adopt the canonical Message-ID format plus
signed Reply-To headers, and add an inbound webhook that calls
verifyReplyTo.
…ned Reply-To Refactors ThreadingHeadersService to delegate Message-ID generation to MessageIdUtil (added in #28) so the format matches the canonical NestJS reference: before: escalated.{reference}@{domain} escalated.{reference}.{replyId}@{domain} after: <ticket-{id}@{domain}> (anchor) <ticket-{id}-reply-{replyId}@{domain}> (reply) The previous format used the ticket reference string (ESC-00001) which doesn't round-trip through MessageIdUtil's inbound parser. The new format is deterministic and inbound-routable. Adds optional $inboundSecret constructor arg and new buildSignedReplyTo() method. When configured, applyHeaders() adds a signed Reply-To (reply+{id}.{hmac8}@{domain}) so inbound provider webhooks can verify ticket identity independently of the Message-ID chain. Updates existing tests for the new format. Adds 4 new tests for the signed Reply-To path (blank / configured, via direct helper call and via applyHeaders integration). 11 tests pass (22 assertions).
…Router
Greenfield inbound-email foundation for Symfony. Transport-agnostic
InboundMessage + InboundAttachment DTOs, InboundEmailParser
interface, and InboundRouter that resolves an inbound email to an
existing ticket via canonical Message-ID parsing + signed Reply-To
verification.
Completes the 5-framework greenfield inbound set. Mirrors the NestJS
reference, the 5 per-framework inbound-verify PRs (Laravel, Rails,
Django, Adonis, WordPress), and the .NET / Spring / Go / Phoenix
greenfield 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 toEmail verified via MessageIdUtil. Survives
clients that strip threading headers; forged signatures are
rejected via hash_equals (timing-safe).
4. Subject-line reference tag [{PREFIX}-...].
10 PHPUnit tests verify every branch, the forged-signature rejection,
the blank-secret skip, the ordering helper, and the InboundMessage
body() text/html preference logic.
Closes the core inbound webhook for Symfony end-to-end (stacked on #30's router foundation). Completes the 5-framework greenfield inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally.
Adds Mailgun as the second supported inbound provider alongside Postmark (from #31). Completes Mailgun parity across all 5 greenfield frameworks — .NET #25, Spring #28, Go #31, Phoenix #37, Symfony. Register the service with the escalated.inbound_parser tag and the controller picks it up via TaggedIterator alongside PostmarkParser. Notes: - from: typically 'Name <email>' — extractFromName pulls the display portion, strips surrounding quotes. Falls back to sender field for the email. - Mailgun hosts attachment content behind a URL; we carry the URL in downloadUrl. A follow-up worker fetches + persists out-of-band. - Malformed attachments JSON degrades gracefully (empty list). 8 PHPUnit tests verified locally (8 tests, 19 assertions, OK).
Mirrors the .NET / Spring / Go / Phoenix orchestration port: - InboundEmailService::process() composes InboundRouter::resolveTicket with TicketService, returning a ProcessResult carrying outcome, ticketId, replyId, and pendingAttachmentDownloads. - TicketService::addInboundEmailReply() lets the service write a reply with no authenticated author (authorClass="inbound_email"). - Controller now calls the service and returns the richer response (status + outcome + ticket_id + reply_id + pending_attachment_downloads). Tests cover: matched reply, new ticket creation, empty-subject fallback, SNS skip, empty body+subject skip, provider-hosted attachment surfacing, and the IsNoiseEmail matrix.
Mirrors the Go (escalated-go#34), .NET (escalated-dotnet#28),
Spring (escalated-spring#31), and Phoenix (escalated-phoenix#40)
controller-test ports. 10 PHPUnit cases drive the real
InboundEmailController with a PHPUnit-mocked InboundEmailService
and a tiny anonymous-class InboundEmailParser stub:
- new-ticket → outcome=created_new, status=created
- matched reply → outcome=replied_to_existing + replyId
- skipped (SNS confirmation) → outcome=skipped, null ticketId
- provider-hosted attachments → pending_attachment_downloads[0]
carries name + download_url
- missing/bad secret → 401, service never called
- missing/unknown adapter → 400
- invalid JSON body → 400
- X-Escalated-Adapter header accepted as fallback to query param
Drive-by: drops `final` from InboundEmailService and InboundRouter
so PHPUnit's MockObject can double them. No behavioural change —
both classes are still concrete and autowired through the bundle
container the same way; `final` was preventing test doubles. Full
suite: 177 tests pass locally.
This was referenced Apr 24, 2026
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
Closes the core inbound webhook for Symfony end-to-end (stacked on #30's router foundation). Completes the 5-framework greenfield inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally.
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
Closes the core inbound webhook for Symfony end-to-end (stacked on #30's router foundation). Completes the 5-framework greenfield inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally.
2 tasks
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
* feat(inbound): scaffold InboundMessage + InboundEmailParser + InboundRouter
Greenfield inbound-email foundation for Symfony. Transport-agnostic
InboundMessage + InboundAttachment DTOs, InboundEmailParser
interface, and InboundRouter that resolves an inbound email to an
existing ticket via canonical Message-ID parsing + signed Reply-To
verification.
Completes the 5-framework greenfield inbound set. Mirrors the NestJS
reference, the 5 per-framework inbound-verify PRs (Laravel, Rails,
Django, Adonis, WordPress), and the .NET / Spring / Go / Phoenix
greenfield 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 toEmail verified via MessageIdUtil. Survives
clients that strip threading headers; forged signatures are
rejected via hash_equals (timing-safe).
4. Subject-line reference tag [{PREFIX}-...].
10 PHPUnit tests verify every branch, the forged-signature rejection,
the blank-secret skip, the ordering helper, and the InboundMessage
body() text/html preference logic.
* feat(inbound): PostmarkInboundParser + InboundEmailController
Closes the core inbound webhook for Symfony end-to-end (stacked on
#30's router foundation). Completes the 5-framework greenfield
inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony.
- PostmarkInboundParser: normalizes Postmark's JSON webhook payload
(FromFull / ToFull / Headers / Attachments) into InboundMessage.
Extracts threading headers from the Headers array. Decodes base64
attachment content inline via base64_decode with strict=true;
falls back gracefully to null when decoding fails.
- InboundEmailController (#[Route] -attributed): POST
/escalated/webhook/email/inbound
- Dispatches to the matching parser by ?adapter=... query or
X-Escalated-Adapter header. Parsers are injected via
#[TaggedIterator('escalated.inbound_parser')] so host apps can
register additional providers just by tagging their service.
- Signature-guarded by X-Escalated-Inbound-Secret (constant-time
hash_equals compare). Same secret that signs Reply-To —
symmetric.
- Returns 202 Accepted with { status, ticket_id } on success,
401 on secret mismatch, 400 on unknown adapter / invalid
payload.
5 parser tests cover field extraction, threading header parsing,
base64 attachment decoding, minimal-payload path, and the adapter
name contract — all pass locally.
* style(symfony): php-cs-fixer Yoda + phpdoc alignment
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
* feat(inbound): scaffold InboundMessage + InboundEmailParser + InboundRouter
Greenfield inbound-email foundation for Symfony. Transport-agnostic
InboundMessage + InboundAttachment DTOs, InboundEmailParser
interface, and InboundRouter that resolves an inbound email to an
existing ticket via canonical Message-ID parsing + signed Reply-To
verification.
Completes the 5-framework greenfield inbound set. Mirrors the NestJS
reference, the 5 per-framework inbound-verify PRs (Laravel, Rails,
Django, Adonis, WordPress), and the .NET / Spring / Go / Phoenix
greenfield 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 toEmail verified via MessageIdUtil. Survives
clients that strip threading headers; forged signatures are
rejected via hash_equals (timing-safe).
4. Subject-line reference tag [{PREFIX}-...].
10 PHPUnit tests verify every branch, the forged-signature rejection,
the blank-secret skip, the ordering helper, and the InboundMessage
body() text/html preference logic.
* feat(inbound): PostmarkInboundParser + InboundEmailController
Closes the core inbound webhook for Symfony end-to-end (stacked on
#30's router foundation). Completes the 5-framework greenfield
inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony.
- PostmarkInboundParser: normalizes Postmark's JSON webhook payload
(FromFull / ToFull / Headers / Attachments) into InboundMessage.
Extracts threading headers from the Headers array. Decodes base64
attachment content inline via base64_decode with strict=true;
falls back gracefully to null when decoding fails.
- InboundEmailController (#[Route] -attributed): POST
/escalated/webhook/email/inbound
- Dispatches to the matching parser by ?adapter=... query or
X-Escalated-Adapter header. Parsers are injected via
#[TaggedIterator('escalated.inbound_parser')] so host apps can
register additional providers just by tagging their service.
- Signature-guarded by X-Escalated-Inbound-Secret (constant-time
hash_equals compare). Same secret that signs Reply-To —
symmetric.
- Returns 202 Accepted with { status, ticket_id } on success,
401 on secret mismatch, 400 on unknown adapter / invalid
payload.
5 parser tests cover field extraction, threading header parsing,
base64 attachment decoding, minimal-payload path, and the adapter
name contract — all pass locally.
* feat(inbound): MailgunInboundParser
Adds Mailgun as the second supported inbound provider alongside
Postmark (from #31). Completes Mailgun parity across all 5
greenfield frameworks — .NET #25, Spring #28, Go #31, Phoenix #37,
Symfony.
Register the service with the escalated.inbound_parser tag and the
controller picks it up via TaggedIterator alongside PostmarkParser.
Notes:
- from: typically 'Name <email>' — extractFromName pulls the
display portion, strips surrounding quotes. Falls back to sender
field for the email.
- Mailgun hosts attachment content behind a URL; we carry the URL
in downloadUrl. A follow-up worker fetches + persists out-of-band.
- Malformed attachments JSON degrades gracefully (empty list).
8 PHPUnit tests verified locally (8 tests, 19 assertions, OK).
* style(symfony): php-cs-fixer Yoda + phpdoc alignment
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
Closes the core inbound webhook for Symfony end-to-end (stacked on inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally.
bac8899 to
31895ac
Compare
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
…ts (#46) * feat(inbound): scaffold InboundMessage + InboundEmailParser + InboundRouter Greenfield inbound-email foundation for Symfony. Transport-agnostic InboundMessage + InboundAttachment DTOs, InboundEmailParser interface, and InboundRouter that resolves an inbound email to an existing ticket via canonical Message-ID parsing + signed Reply-To verification. Completes the 5-framework greenfield inbound set. Mirrors the NestJS reference, the 5 per-framework inbound-verify PRs (Laravel, Rails, Django, Adonis, WordPress), and the .NET / Spring / Go / Phoenix greenfield 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 toEmail verified via MessageIdUtil. Survives clients that strip threading headers; forged signatures are rejected via hash_equals (timing-safe). 4. Subject-line reference tag [{PREFIX}-...]. 10 PHPUnit tests verify every branch, the forged-signature rejection, the blank-secret skip, the ordering helper, and the InboundMessage body() text/html preference logic. * feat(inbound): PostmarkInboundParser + InboundEmailController Closes the core inbound webhook for Symfony end-to-end (stacked on inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally. * feat(inbound): MailgunInboundParser Adds Mailgun as the second supported inbound provider alongside Postmark (from #31). Completes Mailgun parity across all 5 greenfield frameworks — .NET #25, Spring #28, Go #31, Phoenix #37, Symfony. Register the service with the escalated.inbound_parser tag and the controller picks it up via TaggedIterator alongside PostmarkParser. Notes: - from: typically 'Name <email>' — extractFromName pulls the display portion, strips surrounding quotes. Falls back to sender field for the email. - Mailgun hosts attachment content behind a URL; we carry the URL in downloadUrl. A follow-up worker fetches + persists out-of-band. - Malformed attachments JSON degrades gracefully (empty list). 8 PHPUnit tests verified locally (8 tests, 19 assertions, OK). * feat(inbound): InboundEmailService orchestrates reply/create with tests Mirrors the .NET / Spring / Go / Phoenix orchestration port: - InboundEmailService::process() composes InboundRouter::resolveTicket with TicketService, returning a ProcessResult carrying outcome, ticketId, replyId, and pendingAttachmentDownloads. - TicketService::addInboundEmailReply() lets the service write a reply with no authenticated author (authorClass="inbound_email"). - Controller now calls the service and returns the richer response (status + outcome + ticket_id + reply_id + pending_attachment_downloads). Tests cover: matched reply, new ticket creation, empty-subject fallback, SNS skip, empty body+subject skip, provider-hosted attachment surfacing, and the IsNoiseEmail matrix. * style(symfony): php-cs-fixer Yoda + phpdoc alignment
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
Closes the core inbound webhook for Symfony end-to-end (stacked on inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally.
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
Closes the core inbound webhook for Symfony end-to-end (stacked on inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony. - PostmarkInboundParser: normalizes Postmark's JSON webhook payload (FromFull / ToFull / Headers / Attachments) into InboundMessage. Extracts threading headers from the Headers array. Decodes base64 attachment content inline via base64_decode with strict=true; falls back gracefully to null when decoding fails. - InboundEmailController (#[Route] -attributed): POST /escalated/webhook/email/inbound - Dispatches to the matching parser by ?adapter=... query or X-Escalated-Adapter header. Parsers are injected via #[TaggedIterator('escalated.inbound_parser')] so host apps can register additional providers just by tagging their service. - Signature-guarded by X-Escalated-Inbound-Secret (constant-time hash_equals compare). Same secret that signs Reply-To — symmetric. - Returns 202 Accepted with { status, ticket_id } on success, 401 on secret mismatch, 400 on unknown adapter / invalid payload. 5 parser tests cover field extraction, threading header parsing, base64 attachment decoding, minimal-payload path, and the adapter name contract — all pass locally.
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
* feat(inbound): scaffold InboundMessage + InboundEmailParser + InboundRouter
Greenfield inbound-email foundation for Symfony. Transport-agnostic
InboundMessage + InboundAttachment DTOs, InboundEmailParser
interface, and InboundRouter that resolves an inbound email to an
existing ticket via canonical Message-ID parsing + signed Reply-To
verification.
Completes the 5-framework greenfield inbound set. Mirrors the NestJS
reference, the 5 per-framework inbound-verify PRs (Laravel, Rails,
Django, Adonis, WordPress), and the .NET / Spring / Go / Phoenix
greenfield 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 toEmail verified via MessageIdUtil. Survives
clients that strip threading headers; forged signatures are
rejected via hash_equals (timing-safe).
4. Subject-line reference tag [{PREFIX}-...].
10 PHPUnit tests verify every branch, the forged-signature rejection,
the blank-secret skip, the ordering helper, and the InboundMessage
body() text/html preference logic.
* feat(inbound): PostmarkInboundParser + InboundEmailController
Closes the core inbound webhook for Symfony end-to-end (stacked on
inbound set — .NET #24, Spring #27, Go #30, Phoenix #36, Symfony.
- PostmarkInboundParser: normalizes Postmark's JSON webhook payload
(FromFull / ToFull / Headers / Attachments) into InboundMessage.
Extracts threading headers from the Headers array. Decodes base64
attachment content inline via base64_decode with strict=true;
falls back gracefully to null when decoding fails.
- InboundEmailController (#[Route] -attributed): POST
/escalated/webhook/email/inbound
- Dispatches to the matching parser by ?adapter=... query or
X-Escalated-Adapter header. Parsers are injected via
#[TaggedIterator('escalated.inbound_parser')] so host apps can
register additional providers just by tagging their service.
- Signature-guarded by X-Escalated-Inbound-Secret (constant-time
hash_equals compare). Same secret that signs Reply-To —
symmetric.
- Returns 202 Accepted with { status, ticket_id } on success,
401 on secret mismatch, 400 on unknown adapter / invalid
payload.
5 parser tests cover field extraction, threading header parsing,
base64 attachment decoding, minimal-payload path, and the adapter
name contract — all pass locally.
* feat(inbound): MailgunInboundParser
Adds Mailgun as the second supported inbound provider alongside
Postmark (from #31). Completes Mailgun parity across all 5
greenfield frameworks — .NET #25, Spring #28, Go #31, Phoenix #37,
Symfony.
Register the service with the escalated.inbound_parser tag and the
controller picks it up via TaggedIterator alongside PostmarkParser.
Notes:
- from: typically 'Name <email>' — extractFromName pulls the
display portion, strips surrounding quotes. Falls back to sender
field for the email.
- Mailgun hosts attachment content behind a URL; we carry the URL
in downloadUrl. A follow-up worker fetches + persists out-of-band.
- Malformed attachments JSON degrades gracefully (empty list).
8 PHPUnit tests verified locally (8 tests, 19 assertions, OK).
* feat(inbound): InboundEmailService orchestrates reply/create with tests
Mirrors the .NET / Spring / Go / Phoenix orchestration port:
- InboundEmailService::process() composes InboundRouter::resolveTicket
with TicketService, returning a ProcessResult carrying outcome,
ticketId, replyId, and pendingAttachmentDownloads.
- TicketService::addInboundEmailReply() lets the service write a reply
with no authenticated author (authorClass="inbound_email").
- Controller now calls the service and returns the richer response
(status + outcome + ticket_id + reply_id + pending_attachment_downloads).
Tests cover: matched reply, new ticket creation, empty-subject
fallback, SNS skip, empty body+subject skip, provider-hosted
attachment surfacing, and the IsNoiseEmail matrix.
* test(inbound): HTTP-level InboundEmailController tests
Mirrors the Go (escalated-go#34), .NET (escalated-dotnet#28),
Spring (escalated-spring#31), and Phoenix (escalated-phoenix#40)
controller-test ports. 10 PHPUnit cases drive the real
InboundEmailController with a PHPUnit-mocked InboundEmailService
and a tiny anonymous-class InboundEmailParser stub:
- new-ticket → outcome=created_new, status=created
- matched reply → outcome=replied_to_existing + replyId
- skipped (SNS confirmation) → outcome=skipped, null ticketId
- provider-hosted attachments → pending_attachment_downloads[0]
carries name + download_url
- missing/bad secret → 401, service never called
- missing/unknown adapter → 400
- invalid JSON body → 400
- X-Escalated-Adapter header accepted as fallback to query param
Drive-by: drops `final` from InboundEmailService and InboundRouter
so PHPUnit's MockObject can double them. No behavioural change —
both classes are still concrete and autowired through the bundle
container the same way; `final` was preventing test doubles. Full
suite: 177 tests pass locally.
* feat(inbound): AttachmentDownloader for provider-hosted attachments
Ports the Go (escalated-go#35), .NET (escalated-dotnet#29), Spring
(escalated-spring#32), and Phoenix (escalated-phoenix#41) references
to Symfony. Host apps now have a ready-to-wire worker for the
Mailgun-style provider-hosted attachments surfaced in
ProcessResult::$pendingAttachmentDownloads.
AttachmentDownloader
- download(pending, ticketId, replyId?) — HTTP GET + persist via
AttachmentStorageInterface + Attachment row on the Doctrine
EntityManager.
- downloadAll continues past per-attachment failures; returns an
AttachmentDownloadResult per input (persisted or error set).
- AttachmentDownloaderOptions: maxBytes size cap (throws
AttachmentTooLargeException) + optional BasicAuth for providers
that gate URLs by API key (Mailgun).
- safeFilename() uses basename() so "../../etc/passwd" becomes
"passwd" — crafted names can't escape the storage root.
- Response Content-Type fallback when pending's own contentType
is empty.
AttachmentHttpClientInterface — tiny HTTP-client contract scoped to
what the downloader needs (single get(url, headers) method returning
AttachmentHttpResponse). Intentionally decoupled from
symfony/http-client so the bundle doesn't force a heavyweight dep on
host apps. Reference CurlAttachmentHttpClient backed by cURL ships
as the default; host apps using symfony/http-client, Guzzle, etc.
can implement the interface with a thin adapter.
AttachmentStorageInterface + reference LocalFileAttachmentStorage
that writes to local FS with microsecond-resolution timestamp
prefixing to avoid collisions.
17 PHPUnit tests cover: happy path (ticket + reply targets), 404
throw + no-persist, oversize → AttachmentTooLargeException, basic
auth header encoding, missing URL guard, response Content-Type
fallback, safeFilename data-provider with path-traversal + edge
cases, downloadAll partial-failure batching, LocalFileStorage
write / empty-root rejection / unique path per call.
Uses a StubHttpClient in-memory double implementing the new
AttachmentHttpClientInterface — no need to install
symfony/http-client just for tests.
* feat(inbound): SESInboundParser (AWS SES via SNS HTTP subscription)
Ports escalated-go#36 + escalated-dotnet#30 + escalated-spring#33
+ escalated-phoenix#42 to Symfony. AWS SES receipt rules publish
to an SNS topic; host apps subscribe via HTTP and SNS POSTs the
envelope to the unified /escalated/webhook/email/inbound?adapter=ses
webhook.
SESInboundParser handles:
1. Type=SubscriptionConfirmation — throws
SESSubscriptionConfirmationException carrying topicArn +
subscribeUrl + token. Host apps GET subscribeUrl out-of-band.
2. Type=Notification — parses the JSON-encoded Message field
for mail.commonHeaders (from/to/subject) and the mail.headers
array (Message-ID / In-Reply-To / References). Falls back to
mail.headers when commonHeaders doesn't surface a threading
field.
3. Best-effort MIME body extraction from the base64 content
field. Hand-rolled splitter (no external MIME dep) handles
single-part text/plain, text/html, multipart/alternative, and
quoted-printable transfer encoding.
10 PHPUnit cases cover all branches including subscription
confirmation detection, threading metadata extraction, plain +
multipart body decoding, missing-content fallback, unknown
envelope type, missing/malformed Message, and headers-array
threading fallback. All 10 green locally.
* test(inbound): parser equivalence across Postmark / Mailgun / SES
Completes the parser-equivalence test rollout across all 5
greenfield frameworks. Ports escalated-go#37 + escalated-dotnet#31
+ escalated-spring#34 + escalated-phoenix#43 to Symfony.
Two PHPUnit cases cover:
- testNormalizesToSameMessage: fromEmail / toEmail / subject /
inReplyTo / references match across all three parsers.
- testBodyExtractionMatches: bodyText matches — Postmark +
Mailgun forward directly, SES needs the base64 MIME dance.
Shared SAMPLE + build*Payload builders mean adding a fourth
provider validates against the existing three for free.
2/2 green locally (`vendor/bin/phpunit tests/Mail/Inbound/ParserEquivalenceTest.php` → OK, 18 assertions).
* style(symfony): php-cs-fixer Yoda + phpdoc alignment
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
Closes the HTTP-level test coverage matrix across all 5 greenfield framework ports. Mirrors Go (#34), .NET (#28), Spring (#31), and Phoenix (#40).
What's tested
10 PHPUnit cases drive the real `InboundEmailController` with a mocked `InboundEmailService` and a tiny anonymous-class `InboundEmailParser` stub — verifying the controller's dispatch, guard, and response-shape logic without needing Doctrine or a real DB.
Drive-by changes
Drops `final` from `InboundEmailService` and `InboundRouter` so PHPUnit's MockObject generator can double them. PHPUnit can't mock `final` classes, which is why the existing `tests/Mail/Inbound/InboundEmailServiceTest.php` was already broken in the full suite — this PR un-breaks it too. No behavioural change; both classes remain concrete and are wired through the bundle container the same way.
Test plan
Stacked PR
Based on `feat/inbound-email-orchestration` (#33) so the service + OUTCOME_* constants are available. Merge order: #30 → #31 → #32 → #33 → this PR.