test(inbound): parser equivalence across Postmark / Mailgun / SES#39
Merged
test(inbound): parser equivalence across Postmark / Mailgun / SES#39
Conversation
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.
…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).
2d1a57f to
6bfa823
Compare
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
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)`):
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.