feat(inbound): AttachmentDownloader for provider-hosted attachments#37
Closed
mpge wants to merge 1 commit intotest/inbound-controllerfrom
Closed
feat(inbound): AttachmentDownloader for provider-hosted attachments#37mpge wants to merge 1 commit intotest/inbound-controllerfrom
mpge wants to merge 1 commit intotest/inbound-controllerfrom
Conversation
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.
This was referenced Apr 24, 2026
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
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).
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.
* 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
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).
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
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).
mpge
added a commit
that referenced
this pull request
Apr 27, 2026
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).
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
Member
Author
|
Superseded by #39 which merged the full inbound stack including attachment-downloader. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ports 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 Mailgun-style provider-hosted attachments surfaced in `ProcessResult::$pendingAttachmentDownloads`.
Design
`AttachmentDownloader` — HTTP GET + persist via `AttachmentStorageInterface` + `Attachment` row on the Doctrine `EntityManager`. `downloadAll` continues past per-attachment failures. `AttachmentDownloaderOptions` controls `maxBytes` size cap (`AttachmentTooLargeException`) + optional `BasicAuth` for providers that gate URLs by API key. `safeFilename()` sanitizes against path traversal. Response Content-Type fallback.
`AttachmentHttpClientInterface` — tiny HTTP-client contract scoped to what the downloader needs. Decoupled from `symfony/http-client` so the bundle doesn't force a heavyweight dep. Reference `CurlAttachmentHttpClient` ships as the default; host apps using `symfony/http-client`, Guzzle, etc. can implement the interface with a thin adapter.
`AttachmentStorageInterface` + reference `LocalFileAttachmentStorage` with microsecond-resolution timestamp prefixing.
Tests
17 PHPUnit cases — uses a `StubHttpClient` implementing the new interface (no `symfony/http-client` install needed).
Full suite: 194/194 tests pass locally.
Stacked PR
Stacks on `test/inbound-controller` (#36) which itself stacks on `feat/inbound-email-orchestration` (#33). Merge order: #30 → #31 → #32 → #33 → #36 → this PR.