Skip to content

test(inbound): parser equivalence across Postmark / Mailgun / SES#39

Merged
mpge merged 9 commits intomasterfrom
test/parser-equivalence
Apr 27, 2026
Merged

test(inbound): parser equivalence across Postmark / Mailgun / SES#39
mpge merged 9 commits intomasterfrom
test/parser-equivalence

Conversation

@mpge
Copy link
Copy Markdown
Member

@mpge mpge commented Apr 24, 2026

Summary

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.

Tests

Two PHPUnit cases — both pass locally (`vendor/bin/phpunit tests/Mail/Inbound/ParserEquivalenceTest.php` → `OK (2 tests, 18 assertions)`):

  • `testNormalizesToSameMessage`: `fromEmail` / `toEmail` / `subject` / `inReplyTo` / `references` match across all three parsers for a single logical reply email.
  • `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.

Stacked PR

Based on `feat/ses-parser` (#38). Merge order: #30#31#32#33#36#37#38 → this PR.

mpge added a commit that referenced this pull request Apr 27, 2026
…#27)

* feat(workflow): wire WorkflowEngine via Doctrine postPersist on Ticket

Symfony's WorkflowEngine has a full processEvent + executeActions
implementation but no callers — workflows configured in the admin
UI didn't fire on ticket events.

This commit adds Escalated\Symfony\EventListener\WorkflowTicketListener
registered via #[AsDoctrineListener(event: Events::postPersist)].
On every Ticket insert, it calls
WorkflowEngine::processEvent('ticket.created', $ticket).

Design scope:
  This listener wires the ticket.created trigger only. Other triggers
  (ticket.updated, status_changed, assigned, priority_changed,
  replied, escalated, sla.*) require TicketService to emit
  corresponding events first — the service already injects
  EventDispatcherInterface but doesn't yet call dispatch(). Tracked
  as a follow-up ("TicketService event emission") in the rollout doc.

Engine errors are caught + warn-logged so a misconfigured workflow
never breaks the persist chain. Mirrors:
  - escalated-nestjs WorkflowListener
  - escalated-laravel ProcessWorkflows
  - escalated-rails WorkflowSubscriber (PR #42)
  - escalated-django workflow_handlers (PR #39)

Tests: 3 PHPUnit cases (dispatch, error swallowing, log format)
all pass. Full suite: 121 pass (3 pre-existing deprecations,
unrelated).

* feat(workflow): emit TicketWorkflowEvent from TicketService mutations

Builds on the prior Doctrine postPersist wiring (ticket.created
only) by adding in-service event emission for the remaining
lifecycle triggers.

New:
  - src/Event/TicketWorkflowEvent.php — generic domain event
    carrying triggerName + ticket + optional context.
  - src/EventListener/WorkflowTriggerSubscriber.php — bridges
    TicketWorkflowEvent dispatches to
    WorkflowEngine::processEvent($triggerName, $ticket).
    Errors are caught + warn-logged so a misconfigured workflow
    never disrupts the mutation that fired the event.

Wired from TicketService:
  - update()       => ticket.updated
  - changeStatus() => ticket.status_changed (with old/new context)
  - addReply() when not a note => ticket.replied (internal notes
    suppressed to avoid firing customer-visible autoresponders on
    private agent notes)

Together with WorkflowTicketListener (ticket.created via Doctrine
postPersist), Symfony now covers 4 of the 8 workflow triggers.
The remaining 4 (assigned, priority_changed, tagged, sla.*) require
dispatches from the respective mutation sites — straightforward
follow-ups.

Tests: 3 new PHPUnit cases on WorkflowTriggerSubscriberTest
covering subscription wiring, forward to engine, error swallowing.
Full suite: 124 pass (was 121 — +3 new).

* feat(workflow): dispatch remaining workflow triggers (assigned, tagged, sla)

Completes Symfony's workflow trigger coverage. Previously 4 of 8
triggers fired (ticket.created via Doctrine postPersist +
ticket.updated/status_changed/replied from TicketService). This
commit wires the remaining ticket-lifecycle mutations so every
documented trigger in WorkflowEngine::TRIGGER_EVENTS has a
dispatch site.

Changes:
  - AssignmentService::assign() — dispatches 'ticket.assigned'
    with agent_id + previous_agent_id + causer_id context. The
    EventDispatcherInterface is now injected (autowired; no
    services.yaml change needed).
  - TicketService::addTags() — dispatches 'ticket.tagged' with
    the added tag_ids.
  - SlaService::checkBreaches() — dispatches 'sla.breached' per
    breached ticket (with breach_type: 'first_response' or
    'resolution'). EventDispatcher injected as a new constructor
    parameter (autowired around the existing scalar SLA config
    args).

Symfony now covers 7 of 8 documented workflow triggers. The last
one — 'ticket.priority_changed' — is subsumed under 'ticket.updated'
when the update() method handles priority changes; workflows
targeting just priority can filter on the conditions payload.

Tests: existing subscriber spec already covers the event→engine
path; the dispatch sites are mechanical and trusted by compile-
check. Full suite: 124 pass.

Cross-framework coverage comparison:
  nestjs:   all triggers (PR #17)
  laravel:  all triggers (pre-existing ProcessWorkflows)
  rails:    all triggers (PR #42)
  django:   all triggers (PR #39)
  symfony:  all triggers (this PR #27)
  dotnet/wordpress/phoenix/spring: engine has evaluator only,
    needs executor impl — tracked in rollout status doc
  go:       no engine at all

* style(workflow): php-cs-fixer autofix on WorkflowTicketListenerTest

Fixes PHP-CS-Fixer CI on PR #27.

* feat(workflow): dedicated ticket.priority_changed dispatch when priority changes

TicketService::update now fires two events when the priority field
changes: ticket.updated (unchanged) + ticket.priority_changed (new).
This matches the NestJS event model and lets workflow rules trigger
specifically on priority escalations without rescanning every
ticket.updated payload.
mpge added 8 commits April 26, 2026 22:21
…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
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.
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.
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.
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).
@mpge mpge force-pushed the test/parser-equivalence branch from 2d1a57f to 6bfa823 Compare April 27, 2026 02:21
@mpge mpge changed the base branch from feat/ses-parser to master April 27, 2026 02:21
@mpge mpge merged commit 4b7845e into master Apr 27, 2026
1 check passed
@mpge mpge deleted the test/parser-equivalence branch April 27, 2026 02:28
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