Skip to content

feat(inbound): AttachmentDownloader for provider-hosted attachments#37

Closed
mpge wants to merge 1 commit intotest/inbound-controllerfrom
feat/attachment-downloader
Closed

feat(inbound): AttachmentDownloader for provider-hosted attachments#37
mpge wants to merge 1 commit intotest/inbound-controllerfrom
feat/attachment-downloader

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

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.

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.
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 mpge mentioned this pull request Apr 27, 2026
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
@mpge
Copy link
Copy Markdown
Member Author

mpge commented Apr 27, 2026

Superseded by #39 which merged the full inbound stack including attachment-downloader.

@mpge mpge closed this Apr 27, 2026
@mpge mpge deleted the feat/attachment-downloader branch April 27, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant