From abdea3742d385312da226ed365a1d29d0aeb1ddf Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:17:42 -0400 Subject: [PATCH 1/9] feat(inbound): scaffold InboundMessage + InboundEmailParser + InboundRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Mail/Inbound/InboundMessage.php | 6 +++--- src/Mail/Inbound/InboundRouter.php | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Mail/Inbound/InboundMessage.php b/src/Mail/Inbound/InboundMessage.php index dd5787c..a9a42d8 100644 --- a/src/Mail/Inbound/InboundMessage.php +++ b/src/Mail/Inbound/InboundMessage.php @@ -16,8 +16,8 @@ final class InboundMessage { /** - * @param array $headers - * @param list $attachments + * @param array $headers + * @param list $attachments */ public function __construct( public readonly string $fromEmail, @@ -39,7 +39,7 @@ public function __construct( */ public function body(): string { - if (null !== $this->bodyText && '' !== $this->bodyText) { + if ($this->bodyText !== null && $this->bodyText !== '') { return $this->bodyText; } diff --git a/src/Mail/Inbound/InboundRouter.php b/src/Mail/Inbound/InboundRouter.php index 332febb..8d44905 100644 --- a/src/Mail/Inbound/InboundRouter.php +++ b/src/Mail/Inbound/InboundRouter.php @@ -44,7 +44,7 @@ public function resolveTicket(InboundMessage $message): ?Ticket // 1 + 2. Parse canonical Message-IDs out of our own headers. foreach (self::candidateHeaderMessageIds($message) as $raw) { $ticketId = MessageIdUtil::parseTicketIdFromMessageId($raw); - if (null !== $ticketId) { + if ($ticketId !== null) { $ticket = $this->ticketRepository->find($ticketId); if ($ticket instanceof Ticket) { return $ticket; @@ -53,9 +53,9 @@ public function resolveTicket(InboundMessage $message): ?Ticket } // 3. Signed Reply-To on the recipient address. - if ('' !== $this->inboundSecret && '' !== $message->toEmail) { + if ($this->inboundSecret !== '' && $message->toEmail !== '') { $verified = MessageIdUtil::verifyReplyTo($message->toEmail, $this->inboundSecret); - if (null !== $verified) { + if ($verified !== null) { $ticket = $this->ticketRepository->find($verified); if ($ticket instanceof Ticket) { return $ticket; @@ -83,13 +83,13 @@ public function resolveTicket(InboundMessage $message): ?Ticket public static function candidateHeaderMessageIds(InboundMessage $message): array { $ids = []; - if (!empty($message->inReplyTo)) { + if (! empty($message->inReplyTo)) { $ids[] = trim($message->inReplyTo); } - if (!empty($message->references)) { + if (! empty($message->references)) { foreach (preg_split('/\s+/', trim($message->references)) ?: [] as $ref) { $ref = trim($ref); - if ('' !== $ref) { + if ($ref !== '') { $ids[] = $ref; } } From e7c29e4bb46f5e1bd5c3b8d973d3c0ca1da037eb Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:32:25 -0400 Subject: [PATCH 2/9] feat(inbound): PostmarkInboundParser + InboundEmailController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Controller/InboundEmailController.php | 41 +++++++--------------- src/Mail/Inbound/PostmarkInboundParser.php | 22 ++++++------ 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/Controller/InboundEmailController.php b/src/Controller/InboundEmailController.php index c7c5350..02e0e07 100644 --- a/src/Controller/InboundEmailController.php +++ b/src/Controller/InboundEmailController.php @@ -4,8 +4,9 @@ namespace Escalated\Symfony\Controller; +use Escalated\Symfony\Entity\Ticket; use Escalated\Symfony\Mail\Inbound\InboundEmailParser; -use Escalated\Symfony\Mail\Inbound\InboundEmailService; +use Escalated\Symfony\Mail\Inbound\InboundRouter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\HttpFoundation\JsonResponse; @@ -29,10 +30,10 @@ final class InboundEmailController extends AbstractController { /** - * @param iterable $parsers + * @param iterable $parsers */ public function __construct( - private readonly InboundEmailService $service, + private readonly InboundRouter $router, #[TaggedIterator('escalated.inbound_parser')] private readonly iterable $parsers, private readonly string $inboundSecret = '', @@ -42,7 +43,7 @@ public function __construct( #[Route('/inbound', name: 'inbound', methods: ['POST'])] public function inbound(Request $request): JsonResponse { - if (!$this->verifySecret($request)) { + if (! $this->verifySecret($request)) { return new JsonResponse( ['error' => 'missing or invalid inbound secret'], JsonResponse::HTTP_UNAUTHORIZED @@ -50,12 +51,12 @@ public function inbound(Request $request): JsonResponse } $adapter = (string) ($request->query->get('adapter') ?? $request->headers->get('X-Escalated-Adapter') ?? ''); - if ('' === $adapter) { + if ($adapter === '') { return new JsonResponse(['error' => 'missing adapter'], JsonResponse::HTTP_BAD_REQUEST); } $parser = $this->findParser($adapter); - if (null === $parser) { + if ($parser === null) { return new JsonResponse( ['error' => "unknown adapter: {$adapter}"], JsonResponse::HTTP_BAD_REQUEST @@ -63,7 +64,7 @@ public function inbound(Request $request): JsonResponse } $payload = json_decode($request->getContent(), true); - if (!is_array($payload)) { + if (! is_array($payload)) { return new JsonResponse(['error' => 'invalid json body'], JsonResponse::HTTP_BAD_REQUEST); } @@ -73,27 +74,11 @@ public function inbound(Request $request): JsonResponse return new JsonResponse(['error' => 'invalid payload'], JsonResponse::HTTP_BAD_REQUEST); } - $result = $this->service->process($message); + $ticket = $this->router->resolveTicket($message); return new JsonResponse([ - 'status' => match ($result->outcome) { - InboundEmailService::OUTCOME_REPLIED_TO_EXISTING => 'matched', - InboundEmailService::OUTCOME_CREATED_NEW => 'created', - InboundEmailService::OUTCOME_SKIPPED => 'skipped', - default => 'unknown', - }, - 'outcome' => $result->outcome, - 'ticket_id' => $result->ticketId, - 'reply_id' => $result->replyId, - 'pending_attachment_downloads' => array_map( - static fn ($p) => [ - 'name' => $p->name, - 'content_type' => $p->contentType, - 'size_bytes' => $p->sizeBytes, - 'download_url' => $p->downloadUrl, - ], - $result->pendingAttachmentDownloads, - ), + 'status' => $ticket instanceof Ticket ? 'matched' : 'unmatched', + 'ticket_id' => $ticket instanceof Ticket ? $ticket->getId() : null, ], JsonResponse::HTTP_ACCEPTED); } @@ -110,13 +95,13 @@ private function findParser(string $adapter): ?InboundEmailParser private function verifySecret(Request $request): bool { - if ('' === $this->inboundSecret) { + if ($this->inboundSecret === '') { // Inbound signing not configured → disable the webhook // (prevents accidental unauthenticated routing). return false; } $provided = (string) ($request->headers->get('X-Escalated-Inbound-Secret') ?? ''); - if ('' === $provided) { + if ($provided === '') { return false; } diff --git a/src/Mail/Inbound/PostmarkInboundParser.php b/src/Mail/Inbound/PostmarkInboundParser.php index 77cae5d..af7d811 100644 --- a/src/Mail/Inbound/PostmarkInboundParser.php +++ b/src/Mail/Inbound/PostmarkInboundParser.php @@ -58,13 +58,13 @@ public function parse(array $rawPayload): InboundMessage private static function firstToEmail(array $payload): ?string { $toFull = $payload['ToFull'] ?? null; - if (!is_array($toFull)) { + if (! is_array($toFull)) { return null; } foreach ($toFull as $entry) { if (is_array($entry)) { $email = $entry['Email'] ?? null; - if (is_string($email) && '' !== $email) { + if (is_string($email) && $email !== '') { return $email; } } @@ -80,16 +80,16 @@ private static function extractHeaders(array $payload): array { $out = []; $arr = $payload['Headers'] ?? null; - if (!is_array($arr)) { + if (! is_array($arr)) { return $out; } foreach ($arr as $entry) { - if (!is_array($entry)) { + if (! is_array($entry)) { continue; } $name = $entry['Name'] ?? null; $value = $entry['Value'] ?? null; - if (is_string($name) && '' !== $name && is_string($value)) { + if (is_string($name) && $name !== '' && is_string($value)) { $out[$name] = $value; } } @@ -104,17 +104,17 @@ private static function extractAttachments(array $payload): array { $list = []; $arr = $payload['Attachments'] ?? null; - if (!is_array($arr)) { + if (! is_array($arr)) { return $list; } foreach ($arr as $entry) { - if (!is_array($entry)) { + if (! is_array($entry)) { continue; } $content = null; - if (isset($entry['Content']) && is_string($entry['Content']) && '' !== $entry['Content']) { + if (isset($entry['Content']) && is_string($entry['Content']) && $entry['Content'] !== '') { $decoded = base64_decode($entry['Content'], true); - if (false !== $decoded) { + if ($decoded !== false) { $content = $decoded; } } @@ -137,7 +137,7 @@ private static function extractAttachments(array $payload): array private static function firstNonEmpty(mixed ...$values): ?string { foreach ($values as $value) { - if (is_string($value) && '' !== $value) { + if (is_string($value) && $value !== '') { return $value; } } @@ -147,7 +147,7 @@ private static function firstNonEmpty(mixed ...$values): ?string private static function blankToNull(mixed $value): ?string { - if (null === $value || '' === $value) { + if ($value === null || $value === '') { return null; } From 493bbff95ad3e25d6c279389f1a67be427efb731 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:41:54 -0400 Subject: [PATCH 3/9] feat(inbound): MailgunInboundParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' — 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). --- src/Mail/Inbound/MailgunInboundParser.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Mail/Inbound/MailgunInboundParser.php b/src/Mail/Inbound/MailgunInboundParser.php index 9f928ba..996d411 100644 --- a/src/Mail/Inbound/MailgunInboundParser.php +++ b/src/Mail/Inbound/MailgunInboundParser.php @@ -51,7 +51,7 @@ public function parse(array $rawPayload): InboundMessage 'In-Reply-To' => $inReplyTo, 'References' => $references, ], - static fn ($v) => is_string($v) && '' !== $v, + static fn ($v) => is_string($v) && $v !== '', ); return new InboundMessage( @@ -78,15 +78,15 @@ private static function field(array $payload, string $key): ?string private static function extractFromName(?string $raw): ?string { - if (null === $raw || '' === $raw) { + if ($raw === null || $raw === '') { return null; } $angle = strpos($raw, '<'); - if (false === $angle || 0 === $angle) { + if ($angle === false || $angle === 0) { return null; } $name = trim(substr($raw, 0, $angle)); - if ('' === $name) { + if ($name === '') { return null; } // Strip surrounding quotes if present. @@ -94,7 +94,7 @@ private static function extractFromName(?string $raw): ?string $name = substr($name, 1, -1); } - return '' === $name ? null : $name; + return $name === '' ? null : $name; } /** @@ -102,16 +102,16 @@ private static function extractFromName(?string $raw): ?string */ private static function parseAttachments(?string $json): array { - if (null === $json || '' === $json) { + if ($json === null || $json === '') { return []; } $decoded = json_decode($json, true); - if (!is_array($decoded)) { + if (! is_array($decoded)) { return []; } $list = []; foreach ($decoded as $entry) { - if (!is_array($entry)) { + if (! is_array($entry)) { continue; } $size = $entry['size'] ?? null; @@ -129,6 +129,6 @@ private static function parseAttachments(?string $json): array private static function blankToNull(?string $value): ?string { - return null === $value || '' === $value ? null : $value; + return $value === null || $value === '' ? null : $value; } } From d5d6249a54ca2c430cc441b636ee3b45c60c772f Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 06:13:38 -0400 Subject: [PATCH 4/9] 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. --- src/Controller/InboundEmailController.php | 27 ++++++++++++++----- src/Mail/Inbound/InboundEmailService.php | 8 +++--- src/Mail/Inbound/ProcessResult.php | 2 +- src/Service/TicketService.php | 26 ++++++++++++++++++ .../Mail/Inbound/InboundEmailServiceTest.php | 8 +++--- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/Controller/InboundEmailController.php b/src/Controller/InboundEmailController.php index 02e0e07..de330da 100644 --- a/src/Controller/InboundEmailController.php +++ b/src/Controller/InboundEmailController.php @@ -4,9 +4,8 @@ namespace Escalated\Symfony\Controller; -use Escalated\Symfony\Entity\Ticket; use Escalated\Symfony\Mail\Inbound\InboundEmailParser; -use Escalated\Symfony\Mail\Inbound\InboundRouter; +use Escalated\Symfony\Mail\Inbound\InboundEmailService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\HttpFoundation\JsonResponse; @@ -33,7 +32,7 @@ final class InboundEmailController extends AbstractController * @param iterable $parsers */ public function __construct( - private readonly InboundRouter $router, + private readonly InboundEmailService $service, #[TaggedIterator('escalated.inbound_parser')] private readonly iterable $parsers, private readonly string $inboundSecret = '', @@ -74,11 +73,27 @@ public function inbound(Request $request): JsonResponse return new JsonResponse(['error' => 'invalid payload'], JsonResponse::HTTP_BAD_REQUEST); } - $ticket = $this->router->resolveTicket($message); + $result = $this->service->process($message); return new JsonResponse([ - 'status' => $ticket instanceof Ticket ? 'matched' : 'unmatched', - 'ticket_id' => $ticket instanceof Ticket ? $ticket->getId() : null, + 'status' => match ($result->outcome) { + InboundEmailService::OUTCOME_REPLIED_TO_EXISTING => 'matched', + InboundEmailService::OUTCOME_CREATED_NEW => 'created', + InboundEmailService::OUTCOME_SKIPPED => 'skipped', + default => 'unknown', + }, + 'outcome' => $result->outcome, + 'ticket_id' => $result->ticketId, + 'reply_id' => $result->replyId, + 'pending_attachment_downloads' => array_map( + static fn ($p) => [ + 'name' => $p->name, + 'content_type' => $p->contentType, + 'size_bytes' => $p->sizeBytes, + 'download_url' => $p->downloadUrl, + ], + $result->pendingAttachmentDownloads, + ), ], JsonResponse::HTTP_ACCEPTED); } diff --git a/src/Mail/Inbound/InboundEmailService.php b/src/Mail/Inbound/InboundEmailService.php index edd91f1..191b103 100644 --- a/src/Mail/Inbound/InboundEmailService.php +++ b/src/Mail/Inbound/InboundEmailService.php @@ -70,7 +70,7 @@ public function process(InboundMessage $message): ProcessResult ); } - $subject = '' !== trim($message->subject) ? $message->subject : '(no subject)'; + $subject = trim($message->subject) !== '' ? $message->subject : '(no subject)'; $newTicket = $this->tickets->create([ 'subject' => $subject, 'description' => $message->body(), @@ -98,11 +98,11 @@ public function process(InboundMessage $message): ProcessResult */ public static function isNoiseEmail(InboundMessage $message): bool { - if (0 === strcasecmp($message->fromEmail, 'no-reply@sns.amazonaws.com')) { + if (strcasecmp($message->fromEmail, 'no-reply@sns.amazonaws.com') === 0) { return true; } - return '' === trim($message->body()) && '' === trim($message->subject); + return trim($message->body()) === '' && trim($message->subject) === ''; } /** @@ -116,7 +116,7 @@ private static function pendingDownloads(InboundMessage $message): array { $pending = []; foreach ($message->attachments as $attachment) { - if (null !== $attachment->downloadUrl && '' !== $attachment->downloadUrl && null === $attachment->content) { + if ($attachment->downloadUrl !== null && $attachment->downloadUrl !== '' && $attachment->content === null) { $pending[] = new PendingAttachment( name: $attachment->name, contentType: $attachment->contentType, diff --git a/src/Mail/Inbound/ProcessResult.php b/src/Mail/Inbound/ProcessResult.php index 392ff92..e382e8a 100644 --- a/src/Mail/Inbound/ProcessResult.php +++ b/src/Mail/Inbound/ProcessResult.php @@ -15,7 +15,7 @@ final class ProcessResult { /** - * @param list $pendingAttachmentDownloads + * @param list $pendingAttachmentDownloads */ public function __construct( public readonly string $outcome, diff --git a/src/Service/TicketService.php b/src/Service/TicketService.php index 7444197..3b8e74c 100644 --- a/src/Service/TicketService.php +++ b/src/Service/TicketService.php @@ -221,6 +221,32 @@ public function addInboundEmailReply(Ticket $ticket, string $body): Reply return $reply; } + /** + * Add a reply to a ticket from an inbound email (no authenticated + * author — the sender is identified by email address alone). + * + * {@code authorClass} is tagged with {@code "inbound_email"} so + * consumers can distinguish these from agent/customer replies. + */ + public function addInboundEmailReply(Ticket $ticket, string $body): Reply + { + $reply = new Reply(); + $reply->setTicket($ticket); + $reply->setAuthorId(null); + $reply->setAuthorClass('inbound_email'); + $reply->setBody($body); + $reply->setIsInternalNote(false); + $reply->setType('reply'); + + $ticket->addReply($reply); + $this->em->persist($reply); + $this->em->flush(); + + $this->logActivity($ticket, TicketActivity::TYPE_REPLIED, null); + + return $reply; + } + /** * Find a ticket by ID or reference. */ diff --git a/tests/Mail/Inbound/InboundEmailServiceTest.php b/tests/Mail/Inbound/InboundEmailServiceTest.php index 83ff81e..3cfe306 100644 --- a/tests/Mail/Inbound/InboundEmailServiceTest.php +++ b/tests/Mail/Inbound/InboundEmailServiceTest.php @@ -90,9 +90,9 @@ public function testNoMatchWithRealContentCreatesNewTicket(): void $this->tickets->expects($this->once()) ->method('create') ->with($this->callback(function (array $data) { - return 'New issue' === $data['subject'] - && 'real' === $data['description'] - && 'customer@example.com' === $data['guest_email']; + return $data['subject'] === 'New issue' + && $data['description'] === 'real' + && $data['guest_email'] === 'customer@example.com'; })) ->willReturn($newTicket); $this->tickets->expects($this->never())->method('addInboundEmailReply'); @@ -110,7 +110,7 @@ public function testEmptySubjectFallsBackToPlaceholder(): void $this->router->method('resolveTicket')->willReturn(null); $this->tickets->expects($this->once()) ->method('create') - ->with($this->callback(fn (array $data) => '(no subject)' === $data['subject'])) + ->with($this->callback(fn (array $data) => $data['subject'] === '(no subject)')) ->willReturn($this->ticket(1)); $svc = new InboundEmailService($this->router, $this->tickets); From 40d22ec0f7a7131f2fef371183be529ef4405114 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:27:04 -0400 Subject: [PATCH 5/9] test(inbound): HTTP-level InboundEmailController tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Mail/Inbound/InboundEmailService.php | 2 +- src/Mail/Inbound/InboundRouter.php | 2 +- .../Controller/InboundEmailControllerTest.php | 303 ++++++++++++++++++ 3 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 tests/Controller/InboundEmailControllerTest.php diff --git a/src/Mail/Inbound/InboundEmailService.php b/src/Mail/Inbound/InboundEmailService.php index 191b103..889cde7 100644 --- a/src/Mail/Inbound/InboundEmailService.php +++ b/src/Mail/Inbound/InboundEmailService.php @@ -25,7 +25,7 @@ * {@see ProcessResult::$pendingAttachmentDownloads} so a follow-up * worker can fetch + persist out-of-band. */ -final class InboundEmailService +class InboundEmailService { public const OUTCOME_REPLIED_TO_EXISTING = 'replied_to_existing'; public const OUTCOME_CREATED_NEW = 'created_new'; diff --git a/src/Mail/Inbound/InboundRouter.php b/src/Mail/Inbound/InboundRouter.php index 8d44905..4a06056 100644 --- a/src/Mail/Inbound/InboundRouter.php +++ b/src/Mail/Inbound/InboundRouter.php @@ -25,7 +25,7 @@ * Mirrors the NestJS reference and the per-framework inbound-verify * PRs plus the greenfield .NET / Spring / Go / Phoenix routers. */ -final class InboundRouter +class InboundRouter { private const SUBJECT_REF_PATTERN = '/\[([A-Z]+-[0-9A-Z-]+)\]/'; diff --git a/tests/Controller/InboundEmailControllerTest.php b/tests/Controller/InboundEmailControllerTest.php new file mode 100644 index 0000000..328bd61 --- /dev/null +++ b/tests/Controller/InboundEmailControllerTest.php @@ -0,0 +1,303 @@ +service = $this->createMock(InboundEmailService::class); + } + + private function controller(array $parsers = []): InboundEmailController + { + if ($parsers === []) { + $parsers = [$this->stubParser('postmark')]; + } + + return new InboundEmailController($this->service, $parsers, self::SECRET); + } + + private function stubParser(string $name): InboundEmailParser + { + return new class($name) implements InboundEmailParser { + public function __construct(private readonly string $n) {} + public function name(): string { return $this->n; } + public function parse(array $rawPayload): InboundMessage + { + return new InboundMessage( + fromEmail: $rawPayload['From'] ?? '', + fromName: $rawPayload['FromName'] ?? null, + toEmail: $rawPayload['To'] ?? '', + subject: $rawPayload['Subject'] ?? '', + bodyText: $rawPayload['TextBody'] ?? null, + ); + } + }; + } + + private function request(array $options): Request + { + $queryAdapter = $options['adapter'] ?? null; + $headerAdapter = $options['header_adapter'] ?? null; + $secret = $options['secret'] ?? null; + $body = $options['body'] ?? '{}'; + + $server = []; + if ($secret !== null) { + $server['HTTP_X_ESCALATED_INBOUND_SECRET'] = $secret; + } + if ($headerAdapter !== null) { + $server['HTTP_X_ESCALATED_ADAPTER'] = $headerAdapter; + } + + $query = $queryAdapter !== null ? ['adapter' => $queryAdapter] : []; + + return new Request( + query: $query, + request: [], + attributes: [], + cookies: [], + files: [], + server: $server, + content: $body, + ); + } + + public function testNewTicketReturnsCreatedOutcome(): void + { + $this->service->method('process')->willReturn( + new ProcessResult( + outcome: InboundEmailService::OUTCOME_CREATED_NEW, + ticketId: 101, + replyId: null, + pendingAttachmentDownloads: [], + ) + ); + + $controller = $this->controller(); + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + 'secret' => self::SECRET, + 'body' => '{"From":"alice@example.com","To":"support@example.com","Subject":"Help","TextBody":"Broken"}', + ])); + + $this->assertSame(202, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('created_new', $body['outcome']); + $this->assertSame('created', $body['status']); + $this->assertSame(101, $body['ticket_id']); + $this->assertNull($body['reply_id']); + $this->assertSame([], $body['pending_attachment_downloads']); + } + + public function testMatchedReplyReturnsRepliedToExisting(): void + { + $this->service->method('process')->willReturn( + new ProcessResult( + outcome: InboundEmailService::OUTCOME_REPLIED_TO_EXISTING, + ticketId: 55, + replyId: 202, + pendingAttachmentDownloads: [], + ) + ); + + $controller = $this->controller(); + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + 'secret' => self::SECRET, + 'body' => '{"From":"alice@example.com","To":"support@example.com","Subject":"Re: Help","TextBody":"More"}', + ])); + + $this->assertSame(202, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('replied_to_existing', $body['outcome']); + $this->assertSame('matched', $body['status']); + $this->assertSame(55, $body['ticket_id']); + $this->assertSame(202, $body['reply_id']); + } + + public function testSkippedReturnsSkippedOutcome(): void + { + $this->service->method('process')->willReturn( + new ProcessResult( + outcome: InboundEmailService::OUTCOME_SKIPPED, + ticketId: null, + replyId: null, + pendingAttachmentDownloads: [], + ) + ); + + $controller = $this->controller(); + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + 'secret' => self::SECRET, + 'body' => '{"From":"no-reply@sns.amazonaws.com","To":"support@example.com","Subject":"SubscriptionConfirmation","TextBody":""}', + ])); + + $this->assertSame(202, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('skipped', $body['outcome']); + $this->assertSame('skipped', $body['status']); + $this->assertNull($body['ticket_id']); + } + + public function testSurfacesProviderHostedAttachments(): void + { + $this->service->method('process')->willReturn( + new ProcessResult( + outcome: InboundEmailService::OUTCOME_CREATED_NEW, + ticketId: 101, + replyId: null, + pendingAttachmentDownloads: [ + new PendingAttachment( + name: 'large.pdf', + contentType: 'application/pdf', + sizeBytes: 10_000_000, + downloadUrl: 'https://mailgun.example/att/large', + ), + ], + ) + ); + + $controller = $this->controller(); + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + 'secret' => self::SECRET, + 'body' => '{"From":"alice@example.com","To":"support@example.com","Subject":"Attached","TextBody":"x"}', + ])); + + $this->assertSame(202, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertCount(1, $body['pending_attachment_downloads']); + $this->assertSame('large.pdf', $body['pending_attachment_downloads'][0]['name']); + $this->assertSame( + 'https://mailgun.example/att/large', + $body['pending_attachment_downloads'][0]['download_url'] + ); + } + + public function testMissingSecretReturns401(): void + { + $this->service->expects($this->never())->method('process'); + $controller = $this->controller(); + + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + // no secret + 'body' => '{"From":"a@b.com"}', + ])); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testBadSecretReturns401(): void + { + $this->service->expects($this->never())->method('process'); + $controller = $this->controller(); + + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + 'secret' => 'wrong-secret', + 'body' => '{"From":"a@b.com"}', + ])); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testMissingAdapterReturns400(): void + { + $this->service->expects($this->never())->method('process'); + $controller = $this->controller(); + + $response = $controller->inbound($this->request([ + // no adapter + 'secret' => self::SECRET, + 'body' => '{}', + ])); + + $this->assertSame(400, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('missing adapter', $body['error']); + } + + public function testUnknownAdapterReturns400(): void + { + $this->service->expects($this->never())->method('process'); + $controller = $this->controller(); + + $response = $controller->inbound($this->request([ + 'adapter' => 'nonesuch', + 'secret' => self::SECRET, + 'body' => '{}', + ])); + + $this->assertSame(400, $response->getStatusCode()); + $body = json_decode((string) $response->getContent(), true); + $this->assertStringContainsString('nonesuch', $body['error']); + } + + public function testInvalidJsonBodyReturns400(): void + { + $this->service->expects($this->never())->method('process'); + $controller = $this->controller(); + + $response = $controller->inbound($this->request([ + 'adapter' => 'postmark', + 'secret' => self::SECRET, + 'body' => 'not json at all', + ])); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testAdapterHeaderIsAcceptedAsFallback(): void + { + $this->service->method('process')->willReturn( + new ProcessResult( + outcome: InboundEmailService::OUTCOME_SKIPPED, + ticketId: null, + replyId: null, + pendingAttachmentDownloads: [], + ) + ); + + $controller = $this->controller(); + $response = $controller->inbound($this->request([ + 'header_adapter' => 'postmark', + 'secret' => self::SECRET, + 'body' => '{"From":"no-reply@sns.amazonaws.com","To":"s@x.com","Subject":"","TextBody":""}', + ])); + + $this->assertSame(202, $response->getStatusCode()); + } +} From dd2b283123bdd5b91ae639236f9baf53c0e93d39 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:46:21 -0400 Subject: [PATCH 6/9] feat(inbound): AttachmentDownloader for provider-hosted attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Mail/Inbound/AttachmentDownloadResult.php | 28 ++ src/Mail/Inbound/AttachmentDownloader.php | 170 +++++++++++ .../Inbound/AttachmentDownloaderOptions.php | 36 +++ .../Inbound/AttachmentHttpClientInterface.php | 24 ++ src/Mail/Inbound/AttachmentHttpResponse.php | 28 ++ .../Inbound/AttachmentStorageInterface.php | 24 ++ .../Inbound/AttachmentTooLargeException.php | 26 ++ src/Mail/Inbound/CurlAttachmentHttpClient.php | 68 +++++ .../Inbound/LocalFileAttachmentStorage.php | 51 ++++ .../Mail/Inbound/AttachmentDownloaderTest.php | 282 ++++++++++++++++++ 10 files changed, 737 insertions(+) create mode 100644 src/Mail/Inbound/AttachmentDownloadResult.php create mode 100644 src/Mail/Inbound/AttachmentDownloader.php create mode 100644 src/Mail/Inbound/AttachmentDownloaderOptions.php create mode 100644 src/Mail/Inbound/AttachmentHttpClientInterface.php create mode 100644 src/Mail/Inbound/AttachmentHttpResponse.php create mode 100644 src/Mail/Inbound/AttachmentStorageInterface.php create mode 100644 src/Mail/Inbound/AttachmentTooLargeException.php create mode 100644 src/Mail/Inbound/CurlAttachmentHttpClient.php create mode 100644 src/Mail/Inbound/LocalFileAttachmentStorage.php create mode 100644 tests/Mail/Inbound/AttachmentDownloaderTest.php diff --git a/src/Mail/Inbound/AttachmentDownloadResult.php b/src/Mail/Inbound/AttachmentDownloadResult.php new file mode 100644 index 0000000..ca861d4 --- /dev/null +++ b/src/Mail/Inbound/AttachmentDownloadResult.php @@ -0,0 +1,28 @@ +persisted !== null; + } +} diff --git a/src/Mail/Inbound/AttachmentDownloader.php b/src/Mail/Inbound/AttachmentDownloader.php new file mode 100644 index 0000000..50dda8f --- /dev/null +++ b/src/Mail/Inbound/AttachmentDownloader.php @@ -0,0 +1,170 @@ +logger = $logger ?? new NullLogger(); + } + + /** + * Download one {@see PendingAttachment} and persist it. + * + * @throws AttachmentTooLargeException when the body exceeds + * {@link AttachmentDownloaderOptions::$maxBytes}. + * @throws \RuntimeException on any other failure (HTTP non-2xx, + * storage write error, missing ticket row, etc.). + */ + public function download( + PendingAttachment $pending, + int $ticketId, + ?int $replyId = null, + ): Attachment { + if ($pending->downloadUrl === '') { + throw new \InvalidArgumentException('Pending attachment has no download URL.'); + } + + $headers = []; + if ($this->options->basicAuth !== null) { + $encoded = base64_encode( + $this->options->basicAuth->username . ':' . $this->options->basicAuth->password + ); + $headers['Authorization'] = 'Basic ' . $encoded; + } + + $response = $this->httpClient->get($pending->downloadUrl, $headers); + + if ($response->status < 200 || $response->status >= 300) { + throw new \RuntimeException(sprintf( + 'Attachment download failed: %s → HTTP %d', + $pending->downloadUrl, + $response->status + )); + } + + $size = strlen($response->body); + if ($this->options->maxBytes > 0 && $size > $this->options->maxBytes) { + throw new AttachmentTooLargeException($pending->name, $size, $this->options->maxBytes); + } + + $contentType = $pending->contentType !== '' + ? $pending->contentType + : ($response->headerValue('content-type') ?? 'application/octet-stream'); + $content = $response->body; + + $filename = self::safeFilename($pending->name); + $path = $this->storage->put($filename, $content, $contentType); + + $ticket = $this->em->find(Ticket::class, $ticketId); + if ($ticket === null) { + throw new \RuntimeException("Ticket #{$ticketId} not found"); + } + + $attachment = new Attachment(); + $attachment->setOriginalFilename($filename); + $attachment->setStoredFilename(basename($path)); + $attachment->setMimeType($contentType); + $attachment->setSize($size); + $attachment->setDisk($this->storage->name()); + $attachment->setPath($path); + $attachment->setTicket($ticket); + + if ($replyId !== null) { + $reply = $this->em->find(Reply::class, $replyId); + if ($reply === null) { + throw new \RuntimeException("Reply #{$replyId} not found"); + } + $attachment->setReply($reply); + } + + $this->em->persist($attachment); + $this->em->flush(); + + $this->logger->info( + '[AttachmentDownloader] Persisted {filename} ({size} bytes) for ticket #{ticketId}', + ['filename' => $filename, 'size' => $size, 'ticketId' => $ticketId] + ); + + return $attachment; + } + + /** + * Download a batch of {@see PendingAttachment}s. Continues past + * per-attachment failures so a single bad URL doesn't prevent + * the rest from persisting. + * + * @param PendingAttachment[] $pending + * @return AttachmentDownloadResult[] + */ + public function downloadAll(array $pending, int $ticketId, ?int $replyId = null): array + { + $results = []; + foreach ($pending as $p) { + try { + $attachment = $this->download($p, $ticketId, $replyId); + $results[] = new AttachmentDownloadResult($p, $attachment, null); + } catch (\Throwable $ex) { + $this->logger->warning( + '[AttachmentDownloader] Failed to download {url}: {message}', + ['url' => $p->downloadUrl, 'message' => $ex->getMessage()] + ); + $results[] = new AttachmentDownloadResult($p, null, $ex); + } + } + return $results; + } + + /** + * Strip path separators so a crafted attachment name like + * {@code ../../etc/passwd} can't escape the storage root. Falls + * back to {@code "attachment"} when the input is unusable. + */ + public static function safeFilename(?string $name): string + { + if ($name === null || trim($name) === '') { + return 'attachment'; + } + $normalized = str_replace('\\', '/', trim($name)); + $base = basename($normalized); + if ($base === '' || $base === '.' || $base === '..') { + return 'attachment'; + } + return $base; + } +} diff --git a/src/Mail/Inbound/AttachmentDownloaderOptions.php b/src/Mail/Inbound/AttachmentDownloaderOptions.php new file mode 100644 index 0000000..1e5a8a3 --- /dev/null +++ b/src/Mail/Inbound/AttachmentDownloaderOptions.php @@ -0,0 +1,36 @@ + $headers + */ + public function get(string $url, array $headers = []): AttachmentHttpResponse; +} diff --git a/src/Mail/Inbound/AttachmentHttpResponse.php b/src/Mail/Inbound/AttachmentHttpResponse.php new file mode 100644 index 0000000..8994aeb --- /dev/null +++ b/src/Mail/Inbound/AttachmentHttpResponse.php @@ -0,0 +1,28 @@ + $headers lower-cased header names + * → first value. + */ + public function __construct( + public readonly int $status, + public readonly string $body, + public readonly array $headers = [], + ) { + } + + public function headerValue(string $name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } +} diff --git a/src/Mail/Inbound/AttachmentStorageInterface.php b/src/Mail/Inbound/AttachmentStorageInterface.php new file mode 100644 index 0000000..25f5850 --- /dev/null +++ b/src/Mail/Inbound/AttachmentStorageInterface.php @@ -0,0 +1,24 @@ + $value) { + $curlHeaders[] = "{$name}: {$value}"; + } + + $collectedHeaders = []; + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_HTTPHEADER => $curlHeaders, + CURLOPT_HEADERFUNCTION => function ($_handle, $rawHeader) use (&$collectedHeaders) { + $len = strlen($rawHeader); + $parts = explode(':', $rawHeader, 2); + if (count($parts) === 2) { + $collectedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + return $len; + }, + ]); + + $body = curl_exec($ch); + if ($body === false) { + $err = curl_error($ch); + curl_close($ch); + throw new \RuntimeException("HTTP request failed for {$url}: {$err}"); + } + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + return new AttachmentHttpResponse((int) $status, (string) $body, $collectedHeaders); + } +} diff --git a/src/Mail/Inbound/LocalFileAttachmentStorage.php b/src/Mail/Inbound/LocalFileAttachmentStorage.php new file mode 100644 index 0000000..d733faf --- /dev/null +++ b/src/Mail/Inbound/LocalFileAttachmentStorage.php @@ -0,0 +1,51 @@ +root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $storedName; + + if (false === file_put_contents($fullPath, $content)) { + throw new \RuntimeException("Cannot write file: {$fullPath}"); + } + return $fullPath; + } +} diff --git a/tests/Mail/Inbound/AttachmentDownloaderTest.php b/tests/Mail/Inbound/AttachmentDownloaderTest.php new file mode 100644 index 0000000..41a353c --- /dev/null +++ b/tests/Mail/Inbound/AttachmentDownloaderTest.php @@ -0,0 +1,282 @@ +httpClient = new StubHttpClient(); + $this->storage = new RecordingStorage(); + $this->em = $this->createMock(EntityManagerInterface::class); + + $this->ticket = new Ticket(); + $this->reply = new Reply(); + + $this->em->method('find')->willReturnCallback( + fn (string $class, int $id) => match ($class) { + Ticket::class => $this->ticket, + Reply::class => $this->reply, + default => null, + } + ); + } + + private function downloader(?AttachmentDownloaderOptions $options = null): AttachmentDownloader + { + return new AttachmentDownloader( + $this->httpClient, + $this->storage, + $this->em, + $options ?? new AttachmentDownloaderOptions(), + ); + } + + private static function pending( + string $url = 'https://provider/att/1', + string $name = 'report.pdf', + string $contentType = 'application/pdf', + ): PendingAttachment { + return new PendingAttachment( + name: $name, + contentType: $contentType, + sizeBytes: null, + downloadUrl: $url, + ); + } + + public function testDownloadHappyPathPersistsAttachment(): void + { + $this->httpClient->enqueue(new AttachmentHttpResponse(200, 'hello pdf', ['content-type' => 'application/pdf'])); + + $a = $this->downloader()->download(self::pending(), ticketId: 42, replyId: null); + + $this->assertSame('report.pdf', $a->getOriginalFilename()); + $this->assertSame('application/pdf', $a->getMimeType()); + $this->assertSame(9, $a->getSize()); + $this->assertSame($this->ticket, $a->getTicket()); + $this->assertNull($a->getReply()); + $this->assertSame('local', $a->getDisk()); + $this->assertSame('hello pdf', $this->storage->lastContent); + } + + public function testDownloadWithReplyIdSetsReply(): void + { + $this->httpClient->enqueue(new AttachmentHttpResponse(200, 'x')); + $a = $this->downloader()->download(self::pending(), 42, 7); + $this->assertSame($this->reply, $a->getReply()); + } + + public function testDownload404ThrowsAndDoesNotPersist(): void + { + $this->httpClient->enqueue(new AttachmentHttpResponse(404, 'not found')); + + try { + $this->downloader()->download(self::pending(), 1); + $this->fail('expected RuntimeException'); + } catch (\RuntimeException $ex) { + $this->assertStringContainsString('HTTP 404', $ex->getMessage()); + } + $this->assertSame(0, $this->storage->putCount); + } + + public function testDownloadOverSizeLimitThrowsAttachmentTooLarge(): void + { + $big = str_repeat('x', 100); + $this->httpClient->enqueue(new AttachmentHttpResponse(200, $big)); + + try { + $this->downloader(new AttachmentDownloaderOptions(maxBytes: 10)) + ->download(self::pending(), 1); + $this->fail('expected AttachmentTooLargeException'); + } catch (AttachmentTooLargeException $ex) { + $this->assertSame(100, $ex->actualBytes); + $this->assertSame(10, $ex->maxBytes); + } + $this->assertSame(0, $this->storage->putCount); + } + + public function testDownloadSendsBasicAuthHeader(): void + { + $this->httpClient->enqueue(new AttachmentHttpResponse(200, 'ok')); + + $this->downloader(new AttachmentDownloaderOptions(basicAuth: new BasicAuth('api', 'key-secret'))) + ->download(self::pending(), 1); + + $auth = $this->httpClient->lastHeaders['Authorization'] ?? null; + $this->assertNotNull($auth, 'Authorization header missing'); + $this->assertStringStartsWith('Basic ', $auth); + $this->assertSame('api:key-secret', base64_decode(substr($auth, strlen('Basic ')))); + } + + public function testDownloadMissingUrlThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->downloader()->download(self::pending(url: ''), 1); + } + + public function testDownloadFallsBackToResponseContentType(): void + { + $this->httpClient->enqueue(new AttachmentHttpResponse(200, "\0\0\0", ['content-type' => 'image/png'])); + + $a = $this->downloader()->download(self::pending(contentType: ''), 1); + + $this->assertSame('image/png', $a->getMimeType()); + } + + /** + * @dataProvider safeFilenameProvider + */ + public function testSafeFilenameStripsPathTraversal(?string $input, string $expected): void + { + $this->assertSame($expected, AttachmentDownloader::safeFilename($input)); + } + + public static function safeFilenameProvider(): array + { + return [ + 'parent dirs' => ['../../etc/passwd', 'passwd'], + 'absolute path' => ['/tmp/evil.txt', 'evil.txt'], + 'empty' => ['', 'attachment'], + 'null' => [null, 'attachment'], + 'dotdot' => ['..', 'attachment'], + 'dot' => ['.', 'attachment'], + ]; + } + + public function testDownloadAllContinuesPastFailures(): void + { + $this->httpClient + ->enqueue(new AttachmentHttpResponse(200, 'ok')) + ->enqueue(new AttachmentHttpResponse(500, 'nope')) + ->enqueue(new AttachmentHttpResponse(200, 'ok')); + + $results = $this->downloader()->downloadAll( + [ + self::pending('https://x/1', 'a'), + self::pending('https://x/2', 'b'), + self::pending('https://x/3', 'c'), + ], + ticketId: 1 + ); + + $this->assertCount(3, $results); + $this->assertTrue($results[0]->succeeded()); + $this->assertFalse($results[1]->succeeded()); + $this->assertNotNull($results[1]->error); + $this->assertTrue($results[2]->succeeded()); + } + + public function testLocalFileStorageWritesFile(): void + { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'esc-tests-' . uniqid(); + $storage = new LocalFileAttachmentStorage($root); + + $path = $storage->put('hello.txt', 'payload', 'text/plain'); + + $this->assertStringStartsWith($root, $path); + $this->assertStringEndsWith('hello.txt', $path); + $this->assertSame('payload', file_get_contents($path)); + + @unlink($path); + @rmdir($root); + } + + public function testLocalFileStorageRejectsEmptyRoot(): void + { + $this->expectException(\InvalidArgumentException::class); + new LocalFileAttachmentStorage(''); + } + + public function testLocalFileStorageProducesUniquePaths(): void + { + $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'esc-tests-' . uniqid(); + $storage = new LocalFileAttachmentStorage($root); + + $p1 = $storage->put('x.txt', 'a', 'text/plain'); + usleep(2); + $p2 = $storage->put('x.txt', 'b', 'text/plain'); + + $this->assertNotSame($p1, $p2); + + @unlink($p1); + @unlink($p2); + @rmdir($root); + } +} + +/** + * Minimal {@see AttachmentHttpClientInterface} for tests — returns a + * FIFO queue of pre-staged responses and records the headers of the + * most recent call. + */ +class StubHttpClient implements AttachmentHttpClientInterface +{ + /** @var AttachmentHttpResponse[] */ + public array $queue = []; + public array $lastHeaders = []; + + public function enqueue(AttachmentHttpResponse $response): self + { + $this->queue[] = $response; + return $this; + } + + public function get(string $url, array $headers = []): AttachmentHttpResponse + { + $this->lastHeaders = $headers; + return array_shift($this->queue) ?? new AttachmentHttpResponse(200, ''); + } +} + +/** + * In-memory {@see AttachmentStorageInterface} that records the last + * put call. + */ +class RecordingStorage implements AttachmentStorageInterface +{ + public string $lastFilename = ''; + public string $lastContent = ''; + public string $lastContentType = ''; + public int $putCount = 0; + public string $returnPath = '/stored/path/report.pdf'; + + public function name(): string + { + return 'local'; + } + + public function put(string $filename, string $content, string $contentType): string + { + $this->lastFilename = $filename; + $this->lastContent = $content; + $this->lastContentType = $contentType; + $this->putCount++; + return $this->returnPath; + } +} From 9807ad16e0825f922ddcd7deadc0866e85a8b476 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:05:33 -0400 Subject: [PATCH 7/9] feat(inbound): SESInboundParser (AWS SES via SNS HTTP subscription) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Mail/Inbound/SESInboundParser.php | 267 ++++++++++++++++++ .../SESSubscriptionConfirmationException.php | 27 ++ tests/Mail/Inbound/SESInboundParserTest.php | 219 ++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 src/Mail/Inbound/SESInboundParser.php create mode 100644 src/Mail/Inbound/SESSubscriptionConfirmationException.php create mode 100644 tests/Mail/Inbound/SESInboundParserTest.php diff --git a/src/Mail/Inbound/SESInboundParser.php b/src/Mail/Inbound/SESInboundParser.php new file mode 100644 index 0000000..a530529 --- /dev/null +++ b/src/Mail/Inbound/SESInboundParser.php @@ -0,0 +1,267 @@ + throw new SESSubscriptionConfirmationException( + topicArn: (string) ($rawPayload['TopicArn'] ?? ''), + subscribeUrl: (string) ($rawPayload['SubscribeURL'] ?? ''), + token: (string) ($rawPayload['Token'] ?? ''), + ), + 'Notification' => null, + default => throw new \InvalidArgumentException( + "Unsupported SNS envelope type: \"{$type}\"", + ), + }; + + $messageJson = (string) ($rawPayload['Message'] ?? ''); + if ($messageJson === '') { + throw new \InvalidArgumentException('SES notification has no Message body'); + } + + $notification = json_decode($messageJson, true); + if (! is_array($notification)) { + throw new \InvalidArgumentException( + 'SES notification Message is not valid JSON: ' + . json_last_error_msg() + ); + } + + $mail = is_array($notification['mail'] ?? null) ? $notification['mail'] : []; + $common = is_array($mail['commonHeaders'] ?? null) ? $mail['commonHeaders'] : []; + + [$fromEmail, $fromName] = self::parseFirstAddressList($common['from'] ?? null); + [$toEmail] = self::parseFirstAddressList($common['to'] ?? null); + + $subject = (string) ($common['subject'] ?? ''); + $headers = self::extractHeaders($mail); + + $messageId = self::blankToNull($common['messageId'] ?? null) ?? ($headers['Message-ID'] ?? null); + $inReplyTo = self::blankToNull($common['inReplyTo'] ?? null) ?? ($headers['In-Reply-To'] ?? null); + $references = self::blankToNull($common['references'] ?? null) ?? ($headers['References'] ?? null); + + [$bodyText, $bodyHtml] = self::extractBody((string) ($notification['content'] ?? '')); + + return new InboundMessage( + fromEmail: $fromEmail, + fromName: $fromName, + toEmail: $toEmail, + subject: $subject, + bodyText: $bodyText, + bodyHtml: $bodyHtml, + messageId: $messageId, + inReplyTo: $inReplyTo, + references: $references, + headers: $headers, + attachments: [], + ); + } + + /** + * SES's {@code commonHeaders.from} / {@code .to} are arrays of + * RFC 5322 strings. Returns the first usable entry's + * {@code [email, display_name|null]}. + * + * @return array{0: string, 1: ?string} + */ + private static function parseFirstAddressList(mixed $list): array + { + if (! is_array($list) || $list === []) { + return ['', null]; + } + foreach ($list as $entry) { + if (! is_string($entry) || trim($entry) === '') continue; + $trimmed = trim($entry); + if (preg_match('/^\s*"?([^<"]*?)"?\s*<([^>]+)>\s*$/', $trimmed, $m) === 1) { + return [trim($m[2]), self::blankToNull(trim($m[1]))]; + } + // Bare address. + return [$trimmed, null]; + } + return ['', null]; + } + + /** + * Flatten {@code mail.headers} into a case-sensitive string map. + * SES entries have shape {@code {"name":"X","value":"Y"}}. + * + * @return array + */ + private static function extractHeaders(array $mail): array + { + $out = []; + $arr = $mail['headers'] ?? null; + if (! is_array($arr)) { + return $out; + } + foreach ($arr as $entry) { + if (! is_array($entry)) continue; + $name = $entry['name'] ?? null; + $value = $entry['value'] ?? null; + if (is_string($name) && is_string($value) && $name !== '') { + $out[$name] = $value; + } + } + return $out; + } + + /** + * Decode the base64 {@code content} field and extract + * {@code text/plain} + {@code text/html} parts. Returns + * {@code [null, null]} when content is absent, malformed, or the + * MIME parse fails. + * + * @return array{0: ?string, 1: ?string} + */ + private static function extractBody(string $contentBase64): array + { + if ($contentBase64 === '') { + return [null, null]; + } + $raw = base64_decode($contentBase64, true); + if ($raw === false) { + return [null, null]; + } + + $split = self::splitHeaders($raw); + if ($split === null) { + return [null, null]; + } + [$headers, $body] = $split; + + $contentType = $headers['content-type'] ?? 'text/plain'; + $transferEnc = $headers['content-transfer-encoding'] ?? '7bit'; + + $lowerCt = strtolower($contentType); + if (str_starts_with($lowerCt, 'multipart/')) { + return self::walkMultipart($body, $contentType); + } + if (str_starts_with($lowerCt, 'text/html')) { + return [null, self::decodeBody($body, $transferEnc)]; + } + return [self::decodeBody($body, $transferEnc), null]; + } + + /** + * @return array{0: array, 1: string}|null + */ + private static function splitHeaders(string $raw): ?array + { + $pos = strpos($raw, "\r\n\r\n"); + $skip = 4; + if ($pos === false) { + $pos = strpos($raw, "\n\n"); + $skip = 2; + } + if ($pos === false) { + return null; + } + $headerBlock = substr($raw, 0, $pos); + $body = substr($raw, $pos + $skip); + + $headers = []; + foreach (preg_split('/\r?\n/', $headerBlock) ?: [] as $line) { + if ($line === '') continue; + $colon = strpos($line, ':'); + if ($colon === false || $colon === 0) continue; + $name = strtolower(trim(substr($line, 0, $colon))); + $value = trim(substr($line, $colon + 1)); + $headers[$name] = $value; + } + return [$headers, $body]; + } + + /** + * @return array{0: ?string, 1: ?string} + */ + private static function walkMultipart(string $body, string $contentType): array + { + if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m) !== 1) { + return [null, null]; + } + $delimiter = '--' . $m[1]; + $parts = explode($delimiter, $body); + // Drop preamble (before first delimiter) + closing epilogue. + array_shift($parts); + $text = null; + $html = null; + + foreach ($parts as $part) { + $trimmed = ltrim($part, "\r\n"); + if ($trimmed === '' || str_starts_with($trimmed, '--')) { + continue; + } + $partSplit = self::splitHeaders($trimmed); + if ($partSplit === null) continue; + [$partHeaders, $partBody] = $partSplit; + $partType = strtolower($partHeaders['content-type'] ?? ''); + $partEnc = $partHeaders['content-transfer-encoding'] ?? '7bit'; + $decoded = self::decodeBody(rtrim($partBody, "\r\n"), $partEnc); + + if (str_starts_with($partType, 'text/plain') && $text === null) { + $text = $decoded; + } elseif (str_starts_with($partType, 'text/html') && $html === null) { + $html = $decoded; + } + } + return [$text, $html]; + } + + private static function decodeBody(string $body, string $transferEnc): string + { + $enc = strtolower(trim($transferEnc)); + if ($enc === 'quoted-printable') { + return quoted_printable_decode($body); + } + if ($enc === 'base64') { + $decoded = base64_decode($body, true); + return $decoded === false ? $body : $decoded; + } + return $body; + } + + private static function blankToNull(mixed $value): ?string + { + if (! is_string($value)) return null; + return trim($value) === '' ? null : $value; + } +} diff --git a/src/Mail/Inbound/SESSubscriptionConfirmationException.php b/src/Mail/Inbound/SESSubscriptionConfirmationException.php new file mode 100644 index 0000000..e5c8de9 --- /dev/null +++ b/src/Mail/Inbound/SESSubscriptionConfirmationException.php @@ -0,0 +1,27 @@ +parser = new SESInboundParser(); + } + + public function testNameIsSes(): void + { + $this->assertSame('ses', $this->parser->name()); + } + + public function testSubscriptionConfirmationThrowsWithSubscribeUrl(): void + { + $envelope = [ + 'Type' => 'SubscriptionConfirmation', + 'TopicArn' => 'arn:aws:sns:us-east-1:123:escalated-inbound', + 'SubscribeURL' => 'https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&Token=x', + 'Token' => 'abc', + ]; + + try { + $this->parser->parse($envelope); + $this->fail('expected SESSubscriptionConfirmationException'); + } catch (SESSubscriptionConfirmationException $ex) { + $this->assertSame('arn:aws:sns:us-east-1:123:escalated-inbound', $ex->topicArn); + $this->assertStringContainsString('ConfirmSubscription', $ex->subscribeUrl); + $this->assertSame('abc', $ex->token); + } + } + + public function testNotificationExtractsThreadingMetadata(): void + { + $sesMessage = [ + 'notificationType' => 'Received', + 'mail' => [ + 'source' => 'alice@example.com', + 'destination' => ['support@example.com'], + 'headers' => [ + ['name' => 'From', 'value' => 'Alice '], + ['name' => 'To', 'value' => 'support@example.com'], + ['name' => 'Subject', 'value' => '[ESC-42] Re: Help'], + ['name' => 'Message-ID', 'value' => ''], + ['name' => 'In-Reply-To', 'value' => ''], + ['name' => 'References', 'value' => ' '], + ], + 'commonHeaders' => [ + 'from' => ['Alice '], + 'to' => ['support@example.com'], + 'subject' => '[ESC-42] Re: Help', + ], + ], + ]; + $envelope = [ + 'Type' => 'Notification', + 'Message' => json_encode($sesMessage), + ]; + + $msg = $this->parser->parse($envelope); + + $this->assertSame('alice@example.com', $msg->fromEmail); + $this->assertSame('Alice', $msg->fromName); + $this->assertSame('support@example.com', $msg->toEmail); + $this->assertSame('[ESC-42] Re: Help', $msg->subject); + $this->assertSame('', $msg->messageId); + $this->assertSame('', $msg->inReplyTo); + $this->assertStringContainsString('ticket-42@support.example.com', (string) $msg->references); + $this->assertSame('Alice ', $msg->headers['From']); + } + + public function testNotificationDecodesPlainTextBody(): void + { + $mime = "From: alice@example.com\r\n" + . "To: support@example.com\r\n" + . "Subject: Hi\r\n" + . "Content-Type: text/plain; charset=\"utf-8\"\r\n" + . "\r\n" + . "This is the plain text body."; + + $envelope = [ + 'Type' => 'Notification', + 'Message' => json_encode([ + 'mail' => [ + 'commonHeaders' => [ + 'from' => ['alice@example.com'], + 'to' => ['support@example.com'], + 'subject' => 'Hi', + ], + ], + 'content' => base64_encode($mime), + ]), + ]; + + $msg = $this->parser->parse($envelope); + + $this->assertStringContainsString('plain text body', (string) $msg->bodyText); + } + + public function testNotificationDecodesMultipartBody(): void + { + $boundary = 'boundary-abc'; + $mime = "From: alice@example.com\r\n" + . "To: support@example.com\r\n" + . "Subject: Hi\r\n" + . "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n" + . "\r\n" + . "--{$boundary}\r\n" + . "Content-Type: text/plain; charset=\"utf-8\"\r\n" + . "\r\n" + . "Plain body\r\n" + . "--{$boundary}\r\n" + . "Content-Type: text/html; charset=\"utf-8\"\r\n" + . "\r\n" + . "

HTML body

\r\n" + . "--{$boundary}--\r\n"; + + $envelope = [ + 'Type' => 'Notification', + 'Message' => json_encode([ + 'mail' => [ + 'commonHeaders' => [ + 'from' => ['alice@example.com'], + 'to' => ['support@example.com'], + 'subject' => 'Hi', + ], + ], + 'content' => base64_encode($mime), + ]), + ]; + + $msg = $this->parser->parse($envelope); + + $this->assertStringContainsString('Plain body', (string) $msg->bodyText); + $this->assertStringContainsString('

HTML body

', (string) $msg->bodyHtml); + } + + public function testNotificationMissingContentLeavesBodyNull(): void + { + $envelope = [ + 'Type' => 'Notification', + 'Message' => json_encode([ + 'mail' => [ + 'commonHeaders' => [ + 'from' => ['alice@example.com'], + 'to' => ['support@example.com'], + 'subject' => 'Hi', + ], + ], + ]), + ]; + + $msg = $this->parser->parse($envelope); + + $this->assertNull($msg->bodyText); + $this->assertNull($msg->bodyHtml); + $this->assertSame('alice@example.com', $msg->fromEmail); + } + + public function testUnknownEnvelopeTypeThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unsupported SNS envelope type/'); + $this->parser->parse(['Type' => 'UnknownType']); + } + + public function testMissingMessageFieldThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/no Message body/'); + $this->parser->parse(['Type' => 'Notification']); + } + + public function testMalformedMessageJsonThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/not valid JSON/'); + $this->parser->parse([ + 'Type' => 'Notification', + 'Message' => 'not json at all', + ]); + } + + public function testFallsBackToHeadersArrayForThreadingFields(): void + { + $envelope = [ + 'Type' => 'Notification', + 'Message' => json_encode([ + 'mail' => [ + 'headers' => [ + ['name' => 'Message-ID', 'value' => ''], + ['name' => 'In-Reply-To', 'value' => ''], + ], + 'commonHeaders' => [ + 'from' => ['alice@example.com'], + 'to' => ['support@example.com'], + 'subject' => 'Fallback', + ], + ], + ]), + ]; + + $msg = $this->parser->parse($envelope); + + $this->assertSame('', $msg->messageId); + $this->assertSame('', $msg->inReplyTo); + } +} From 6bfa82375a7ee2e8c3b67e2065a5ccc6c657daa4 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:23:08 -0400 Subject: [PATCH 8/9] test(inbound): parser equivalence across Postmark / Mailgun / SES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tests/Mail/Inbound/ParserEquivalenceTest.php | 133 +++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/Mail/Inbound/ParserEquivalenceTest.php diff --git a/tests/Mail/Inbound/ParserEquivalenceTest.php b/tests/Mail/Inbound/ParserEquivalenceTest.php new file mode 100644 index 0000000..6fb3638 --- /dev/null +++ b/tests/Mail/Inbound/ParserEquivalenceTest.php @@ -0,0 +1,133 @@ + 'alice@example.com', + 'fromName' => 'Alice', + 'toEmail' => 'support@example.com', + 'subject' => 'Re: Help with invoice', + 'bodyText' => 'Thanks for the quick response.', + 'messageId' => '', + 'inReplyTo' => '', + 'references' => '', + ]; + + private static function buildPostmarkPayload(array $e): array + { + return [ + 'FromFull' => ['Email' => $e['fromEmail'], 'Name' => $e['fromName']], + 'To' => $e['toEmail'], + 'Subject' => $e['subject'], + 'TextBody' => $e['bodyText'], + 'Headers' => [ + ['Name' => 'Message-ID', 'Value' => $e['messageId']], + ['Name' => 'In-Reply-To', 'Value' => $e['inReplyTo']], + ['Name' => 'References', 'Value' => $e['references']], + ], + ]; + } + + private static function buildMailgunPayload(array $e): array + { + return [ + 'sender' => $e['fromEmail'], + 'from' => $e['fromName'] . ' <' . $e['fromEmail'] . '>', + 'recipient' => $e['toEmail'], + 'subject' => $e['subject'], + 'body-plain' => $e['bodyText'], + 'Message-Id' => $e['messageId'], + 'In-Reply-To' => $e['inReplyTo'], + 'References' => $e['references'], + ]; + } + + private static function buildSesPayload(array $e): array + { + // Include full raw MIME as base64 so body extraction is + // exercised — keeps the payload close to a real SES delivery. + $mime = "From: {$e['fromName']} <{$e['fromEmail']}>\r\n" + . "To: {$e['toEmail']}\r\n" + . "Subject: {$e['subject']}\r\n" + . "Message-ID: {$e['messageId']}\r\n" + . "In-Reply-To: {$e['inReplyTo']}\r\n" + . "References: {$e['references']}\r\n" + . "Content-Type: text/plain; charset=\"utf-8\"\r\n" + . "\r\n" + . $e['bodyText']; + + $sesMessage = [ + 'notificationType' => 'Received', + 'mail' => [ + 'source' => $e['fromEmail'], + 'destination' => [$e['toEmail']], + 'headers' => [ + ['name' => 'From', 'value' => $e['fromName'] . ' <' . $e['fromEmail'] . '>'], + ['name' => 'To', 'value' => $e['toEmail']], + ['name' => 'Subject', 'value' => $e['subject']], + ['name' => 'Message-ID', 'value' => $e['messageId']], + ['name' => 'In-Reply-To', 'value' => $e['inReplyTo']], + ['name' => 'References', 'value' => $e['references']], + ], + 'commonHeaders' => [ + 'from' => [$e['fromName'] . ' <' . $e['fromEmail'] . '>'], + 'to' => [$e['toEmail']], + 'subject' => $e['subject'], + ], + ], + 'content' => base64_encode($mime), + ]; + + return [ + 'Type' => 'Notification', + 'Message' => json_encode($sesMessage), + ]; + } + + public function testNormalizesToSameMessage(): void + { + $postmark = (new PostmarkInboundParser())->parse(self::buildPostmarkPayload(self::SAMPLE)); + $mailgun = (new MailgunInboundParser())->parse(self::buildMailgunPayload(self::SAMPLE)); + $ses = (new SESInboundParser())->parse(self::buildSesPayload(self::SAMPLE)); + + foreach (['postmark' => $postmark, 'mailgun' => $mailgun, 'ses' => $ses] as $name => $msg) { + $this->assertSame(self::SAMPLE['fromEmail'], $msg->fromEmail, "{$name}: fromEmail"); + $this->assertSame(self::SAMPLE['toEmail'], $msg->toEmail, "{$name}: toEmail"); + $this->assertSame(self::SAMPLE['subject'], $msg->subject, "{$name}: subject"); + $this->assertSame(self::SAMPLE['inReplyTo'], $msg->inReplyTo, "{$name}: inReplyTo"); + $this->assertSame(self::SAMPLE['references'], $msg->references, "{$name}: references"); + } + } + + public function testBodyExtractionMatches(): void + { + $postmark = (new PostmarkInboundParser())->parse(self::buildPostmarkPayload(self::SAMPLE)); + $mailgun = (new MailgunInboundParser())->parse(self::buildMailgunPayload(self::SAMPLE)); + $ses = (new SESInboundParser())->parse(self::buildSesPayload(self::SAMPLE)); + + $this->assertSame(self::SAMPLE['bodyText'], $postmark->bodyText); + $this->assertSame(self::SAMPLE['bodyText'], $mailgun->bodyText); + $this->assertSame(self::SAMPLE['bodyText'], $ses->bodyText); + } +} From d5515592a45bea765fcfa5dc864c9614b9f4a064 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:26:54 -0400 Subject: [PATCH 9/9] style(symfony): php-cs-fixer Yoda + phpdoc alignment --- src/Controller/InboundEmailController.php | 14 +-- src/Mail/Inbound/AttachmentDownloadResult.php | 2 +- src/Mail/Inbound/AttachmentDownloader.php | 35 ++++---- src/Mail/Inbound/AttachmentHttpResponse.php | 2 +- src/Mail/Inbound/CurlAttachmentHttpClient.php | 9 +- src/Mail/Inbound/InboundEmailService.php | 8 +- src/Mail/Inbound/InboundMessage.php | 6 +- src/Mail/Inbound/InboundRouter.php | 12 +-- .../Inbound/LocalFileAttachmentStorage.php | 9 +- src/Mail/Inbound/MailgunInboundParser.php | 18 ++-- src/Mail/Inbound/PostmarkInboundParser.php | 22 ++--- src/Mail/Inbound/ProcessResult.php | 2 +- src/Mail/Inbound/SESInboundParser.php | 88 +++++++++++-------- .../Controller/InboundEmailControllerTest.php | 19 ++-- .../Mail/Inbound/AttachmentDownloaderTest.php | 10 ++- .../Mail/Inbound/InboundEmailServiceTest.php | 8 +- tests/Mail/Inbound/ParserEquivalenceTest.php | 22 ++--- tests/Mail/Inbound/SESInboundParserTest.php | 36 ++++---- 18 files changed, 172 insertions(+), 150 deletions(-) diff --git a/src/Controller/InboundEmailController.php b/src/Controller/InboundEmailController.php index de330da..c7c5350 100644 --- a/src/Controller/InboundEmailController.php +++ b/src/Controller/InboundEmailController.php @@ -29,7 +29,7 @@ final class InboundEmailController extends AbstractController { /** - * @param iterable $parsers + * @param iterable $parsers */ public function __construct( private readonly InboundEmailService $service, @@ -42,7 +42,7 @@ public function __construct( #[Route('/inbound', name: 'inbound', methods: ['POST'])] public function inbound(Request $request): JsonResponse { - if (! $this->verifySecret($request)) { + if (!$this->verifySecret($request)) { return new JsonResponse( ['error' => 'missing or invalid inbound secret'], JsonResponse::HTTP_UNAUTHORIZED @@ -50,12 +50,12 @@ public function inbound(Request $request): JsonResponse } $adapter = (string) ($request->query->get('adapter') ?? $request->headers->get('X-Escalated-Adapter') ?? ''); - if ($adapter === '') { + if ('' === $adapter) { return new JsonResponse(['error' => 'missing adapter'], JsonResponse::HTTP_BAD_REQUEST); } $parser = $this->findParser($adapter); - if ($parser === null) { + if (null === $parser) { return new JsonResponse( ['error' => "unknown adapter: {$adapter}"], JsonResponse::HTTP_BAD_REQUEST @@ -63,7 +63,7 @@ public function inbound(Request $request): JsonResponse } $payload = json_decode($request->getContent(), true); - if (! is_array($payload)) { + if (!is_array($payload)) { return new JsonResponse(['error' => 'invalid json body'], JsonResponse::HTTP_BAD_REQUEST); } @@ -110,13 +110,13 @@ private function findParser(string $adapter): ?InboundEmailParser private function verifySecret(Request $request): bool { - if ($this->inboundSecret === '') { + if ('' === $this->inboundSecret) { // Inbound signing not configured → disable the webhook // (prevents accidental unauthenticated routing). return false; } $provided = (string) ($request->headers->get('X-Escalated-Inbound-Secret') ?? ''); - if ($provided === '') { + if ('' === $provided) { return false; } diff --git a/src/Mail/Inbound/AttachmentDownloadResult.php b/src/Mail/Inbound/AttachmentDownloadResult.php index ca861d4..5629a45 100644 --- a/src/Mail/Inbound/AttachmentDownloadResult.php +++ b/src/Mail/Inbound/AttachmentDownloadResult.php @@ -23,6 +23,6 @@ public function __construct( public function succeeded(): bool { - return $this->persisted !== null; + return null !== $this->persisted; } } diff --git a/src/Mail/Inbound/AttachmentDownloader.php b/src/Mail/Inbound/AttachmentDownloader.php index 50dda8f..8db4d9f 100644 --- a/src/Mail/Inbound/AttachmentDownloader.php +++ b/src/Mail/Inbound/AttachmentDownloader.php @@ -47,35 +47,31 @@ public function __construct( * Download one {@see PendingAttachment} and persist it. * * @throws AttachmentTooLargeException when the body exceeds - * {@link AttachmentDownloaderOptions::$maxBytes}. - * @throws \RuntimeException on any other failure (HTTP non-2xx, - * storage write error, missing ticket row, etc.). + * {@link AttachmentDownloaderOptions::$maxBytes} + * @throws \RuntimeException on any other failure (HTTP non-2xx, + * storage write error, missing ticket row, etc.). */ public function download( PendingAttachment $pending, int $ticketId, ?int $replyId = null, ): Attachment { - if ($pending->downloadUrl === '') { + if ('' === $pending->downloadUrl) { throw new \InvalidArgumentException('Pending attachment has no download URL.'); } $headers = []; - if ($this->options->basicAuth !== null) { + if (null !== $this->options->basicAuth) { $encoded = base64_encode( - $this->options->basicAuth->username . ':' . $this->options->basicAuth->password + $this->options->basicAuth->username.':'.$this->options->basicAuth->password ); - $headers['Authorization'] = 'Basic ' . $encoded; + $headers['Authorization'] = 'Basic '.$encoded; } $response = $this->httpClient->get($pending->downloadUrl, $headers); if ($response->status < 200 || $response->status >= 300) { - throw new \RuntimeException(sprintf( - 'Attachment download failed: %s → HTTP %d', - $pending->downloadUrl, - $response->status - )); + throw new \RuntimeException(sprintf('Attachment download failed: %s → HTTP %d', $pending->downloadUrl, $response->status)); } $size = strlen($response->body); @@ -83,7 +79,7 @@ public function download( throw new AttachmentTooLargeException($pending->name, $size, $this->options->maxBytes); } - $contentType = $pending->contentType !== '' + $contentType = '' !== $pending->contentType ? $pending->contentType : ($response->headerValue('content-type') ?? 'application/octet-stream'); $content = $response->body; @@ -92,7 +88,7 @@ public function download( $path = $this->storage->put($filename, $content, $contentType); $ticket = $this->em->find(Ticket::class, $ticketId); - if ($ticket === null) { + if (null === $ticket) { throw new \RuntimeException("Ticket #{$ticketId} not found"); } @@ -105,9 +101,9 @@ public function download( $attachment->setPath($path); $attachment->setTicket($ticket); - if ($replyId !== null) { + if (null !== $replyId) { $reply = $this->em->find(Reply::class, $replyId); - if ($reply === null) { + if (null === $reply) { throw new \RuntimeException("Reply #{$replyId} not found"); } $attachment->setReply($reply); @@ -130,6 +126,7 @@ public function download( * the rest from persisting. * * @param PendingAttachment[] $pending + * * @return AttachmentDownloadResult[] */ public function downloadAll(array $pending, int $ticketId, ?int $replyId = null): array @@ -147,6 +144,7 @@ public function downloadAll(array $pending, int $ticketId, ?int $replyId = null) $results[] = new AttachmentDownloadResult($p, null, $ex); } } + return $results; } @@ -157,14 +155,15 @@ public function downloadAll(array $pending, int $ticketId, ?int $replyId = null) */ public static function safeFilename(?string $name): string { - if ($name === null || trim($name) === '') { + if (null === $name || '' === trim($name)) { return 'attachment'; } $normalized = str_replace('\\', '/', trim($name)); $base = basename($normalized); - if ($base === '' || $base === '.' || $base === '..') { + if ('' === $base || '.' === $base || '..' === $base) { return 'attachment'; } + return $base; } } diff --git a/src/Mail/Inbound/AttachmentHttpResponse.php b/src/Mail/Inbound/AttachmentHttpResponse.php index 8994aeb..eada438 100644 --- a/src/Mail/Inbound/AttachmentHttpResponse.php +++ b/src/Mail/Inbound/AttachmentHttpResponse.php @@ -12,7 +12,7 @@ final class AttachmentHttpResponse { /** * @param array $headers lower-cased header names - * → first value. + * → first value */ public function __construct( public readonly int $status, diff --git a/src/Mail/Inbound/CurlAttachmentHttpClient.php b/src/Mail/Inbound/CurlAttachmentHttpClient.php index 4505ae6..6f6ec35 100644 --- a/src/Mail/Inbound/CurlAttachmentHttpClient.php +++ b/src/Mail/Inbound/CurlAttachmentHttpClient.php @@ -22,12 +22,12 @@ public function __construct( public function get(string $url, array $headers = []): AttachmentHttpResponse { - if (! function_exists('curl_init')) { + if (!function_exists('curl_init')) { throw new \RuntimeException('cURL extension is not available.'); } $ch = curl_init($url); - if ($ch === false) { + if (false === $ch) { throw new \RuntimeException("Failed to initialize cURL for {$url}"); } @@ -47,15 +47,16 @@ public function get(string $url, array $headers = []): AttachmentHttpResponse CURLOPT_HEADERFUNCTION => function ($_handle, $rawHeader) use (&$collectedHeaders) { $len = strlen($rawHeader); $parts = explode(':', $rawHeader, 2); - if (count($parts) === 2) { + if (2 === count($parts)) { $collectedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); } + return $len; }, ]); $body = curl_exec($ch); - if ($body === false) { + if (false === $body) { $err = curl_error($ch); curl_close($ch); throw new \RuntimeException("HTTP request failed for {$url}: {$err}"); diff --git a/src/Mail/Inbound/InboundEmailService.php b/src/Mail/Inbound/InboundEmailService.php index 889cde7..1d5ace9 100644 --- a/src/Mail/Inbound/InboundEmailService.php +++ b/src/Mail/Inbound/InboundEmailService.php @@ -70,7 +70,7 @@ public function process(InboundMessage $message): ProcessResult ); } - $subject = trim($message->subject) !== '' ? $message->subject : '(no subject)'; + $subject = '' !== trim($message->subject) ? $message->subject : '(no subject)'; $newTicket = $this->tickets->create([ 'subject' => $subject, 'description' => $message->body(), @@ -98,11 +98,11 @@ public function process(InboundMessage $message): ProcessResult */ public static function isNoiseEmail(InboundMessage $message): bool { - if (strcasecmp($message->fromEmail, 'no-reply@sns.amazonaws.com') === 0) { + if (0 === strcasecmp($message->fromEmail, 'no-reply@sns.amazonaws.com')) { return true; } - return trim($message->body()) === '' && trim($message->subject) === ''; + return '' === trim($message->body()) && '' === trim($message->subject); } /** @@ -116,7 +116,7 @@ private static function pendingDownloads(InboundMessage $message): array { $pending = []; foreach ($message->attachments as $attachment) { - if ($attachment->downloadUrl !== null && $attachment->downloadUrl !== '' && $attachment->content === null) { + if (null !== $attachment->downloadUrl && '' !== $attachment->downloadUrl && null === $attachment->content) { $pending[] = new PendingAttachment( name: $attachment->name, contentType: $attachment->contentType, diff --git a/src/Mail/Inbound/InboundMessage.php b/src/Mail/Inbound/InboundMessage.php index a9a42d8..dd5787c 100644 --- a/src/Mail/Inbound/InboundMessage.php +++ b/src/Mail/Inbound/InboundMessage.php @@ -16,8 +16,8 @@ final class InboundMessage { /** - * @param array $headers - * @param list $attachments + * @param array $headers + * @param list $attachments */ public function __construct( public readonly string $fromEmail, @@ -39,7 +39,7 @@ public function __construct( */ public function body(): string { - if ($this->bodyText !== null && $this->bodyText !== '') { + if (null !== $this->bodyText && '' !== $this->bodyText) { return $this->bodyText; } diff --git a/src/Mail/Inbound/InboundRouter.php b/src/Mail/Inbound/InboundRouter.php index 4a06056..622f250 100644 --- a/src/Mail/Inbound/InboundRouter.php +++ b/src/Mail/Inbound/InboundRouter.php @@ -44,7 +44,7 @@ public function resolveTicket(InboundMessage $message): ?Ticket // 1 + 2. Parse canonical Message-IDs out of our own headers. foreach (self::candidateHeaderMessageIds($message) as $raw) { $ticketId = MessageIdUtil::parseTicketIdFromMessageId($raw); - if ($ticketId !== null) { + if (null !== $ticketId) { $ticket = $this->ticketRepository->find($ticketId); if ($ticket instanceof Ticket) { return $ticket; @@ -53,9 +53,9 @@ public function resolveTicket(InboundMessage $message): ?Ticket } // 3. Signed Reply-To on the recipient address. - if ($this->inboundSecret !== '' && $message->toEmail !== '') { + if ('' !== $this->inboundSecret && '' !== $message->toEmail) { $verified = MessageIdUtil::verifyReplyTo($message->toEmail, $this->inboundSecret); - if ($verified !== null) { + if (null !== $verified) { $ticket = $this->ticketRepository->find($verified); if ($ticket instanceof Ticket) { return $ticket; @@ -83,13 +83,13 @@ public function resolveTicket(InboundMessage $message): ?Ticket public static function candidateHeaderMessageIds(InboundMessage $message): array { $ids = []; - if (! empty($message->inReplyTo)) { + if (!empty($message->inReplyTo)) { $ids[] = trim($message->inReplyTo); } - if (! empty($message->references)) { + if (!empty($message->references)) { foreach (preg_split('/\s+/', trim($message->references)) ?: [] as $ref) { $ref = trim($ref); - if ($ref !== '') { + if ('' !== $ref) { $ids[] = $ref; } } diff --git a/src/Mail/Inbound/LocalFileAttachmentStorage.php b/src/Mail/Inbound/LocalFileAttachmentStorage.php index d733faf..4f12f1d 100644 --- a/src/Mail/Inbound/LocalFileAttachmentStorage.php +++ b/src/Mail/Inbound/LocalFileAttachmentStorage.php @@ -20,10 +20,10 @@ final class LocalFileAttachmentStorage implements AttachmentStorageInterface public function __construct( private readonly string $root, ) { - if (trim($root) === '') { + if ('' === trim($root)) { throw new \InvalidArgumentException('Local file storage root is required.'); } - if (! is_dir($root) && ! mkdir($root, 0o755, true) && ! is_dir($root)) { + if (!is_dir($root) && !mkdir($root, 0o755, true) && !is_dir($root)) { throw new \RuntimeException("Cannot create storage root: {$root}"); } } @@ -39,13 +39,14 @@ public function put(string $filename, string $content, string $contentType): str // rapid concurrent writes with the same original filename. [$usec, $sec] = explode(' ', microtime()); $micros = (int) (((float) $usec) * 1_000_000); - $prefix = gmdate('YmdHis', (int) $sec) . '-' . str_pad((string) $micros, 6, '0', STR_PAD_LEFT); + $prefix = gmdate('YmdHis', (int) $sec).'-'.str_pad((string) $micros, 6, '0', STR_PAD_LEFT); $storedName = "{$prefix}-{$filename}"; - $fullPath = rtrim($this->root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $storedName; + $fullPath = rtrim($this->root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$storedName; if (false === file_put_contents($fullPath, $content)) { throw new \RuntimeException("Cannot write file: {$fullPath}"); } + return $fullPath; } } diff --git a/src/Mail/Inbound/MailgunInboundParser.php b/src/Mail/Inbound/MailgunInboundParser.php index 996d411..9f928ba 100644 --- a/src/Mail/Inbound/MailgunInboundParser.php +++ b/src/Mail/Inbound/MailgunInboundParser.php @@ -51,7 +51,7 @@ public function parse(array $rawPayload): InboundMessage 'In-Reply-To' => $inReplyTo, 'References' => $references, ], - static fn ($v) => is_string($v) && $v !== '', + static fn ($v) => is_string($v) && '' !== $v, ); return new InboundMessage( @@ -78,15 +78,15 @@ private static function field(array $payload, string $key): ?string private static function extractFromName(?string $raw): ?string { - if ($raw === null || $raw === '') { + if (null === $raw || '' === $raw) { return null; } $angle = strpos($raw, '<'); - if ($angle === false || $angle === 0) { + if (false === $angle || 0 === $angle) { return null; } $name = trim(substr($raw, 0, $angle)); - if ($name === '') { + if ('' === $name) { return null; } // Strip surrounding quotes if present. @@ -94,7 +94,7 @@ private static function extractFromName(?string $raw): ?string $name = substr($name, 1, -1); } - return $name === '' ? null : $name; + return '' === $name ? null : $name; } /** @@ -102,16 +102,16 @@ private static function extractFromName(?string $raw): ?string */ private static function parseAttachments(?string $json): array { - if ($json === null || $json === '') { + if (null === $json || '' === $json) { return []; } $decoded = json_decode($json, true); - if (! is_array($decoded)) { + if (!is_array($decoded)) { return []; } $list = []; foreach ($decoded as $entry) { - if (! is_array($entry)) { + if (!is_array($entry)) { continue; } $size = $entry['size'] ?? null; @@ -129,6 +129,6 @@ private static function parseAttachments(?string $json): array private static function blankToNull(?string $value): ?string { - return $value === null || $value === '' ? null : $value; + return null === $value || '' === $value ? null : $value; } } diff --git a/src/Mail/Inbound/PostmarkInboundParser.php b/src/Mail/Inbound/PostmarkInboundParser.php index af7d811..77cae5d 100644 --- a/src/Mail/Inbound/PostmarkInboundParser.php +++ b/src/Mail/Inbound/PostmarkInboundParser.php @@ -58,13 +58,13 @@ public function parse(array $rawPayload): InboundMessage private static function firstToEmail(array $payload): ?string { $toFull = $payload['ToFull'] ?? null; - if (! is_array($toFull)) { + if (!is_array($toFull)) { return null; } foreach ($toFull as $entry) { if (is_array($entry)) { $email = $entry['Email'] ?? null; - if (is_string($email) && $email !== '') { + if (is_string($email) && '' !== $email) { return $email; } } @@ -80,16 +80,16 @@ private static function extractHeaders(array $payload): array { $out = []; $arr = $payload['Headers'] ?? null; - if (! is_array($arr)) { + if (!is_array($arr)) { return $out; } foreach ($arr as $entry) { - if (! is_array($entry)) { + if (!is_array($entry)) { continue; } $name = $entry['Name'] ?? null; $value = $entry['Value'] ?? null; - if (is_string($name) && $name !== '' && is_string($value)) { + if (is_string($name) && '' !== $name && is_string($value)) { $out[$name] = $value; } } @@ -104,17 +104,17 @@ private static function extractAttachments(array $payload): array { $list = []; $arr = $payload['Attachments'] ?? null; - if (! is_array($arr)) { + if (!is_array($arr)) { return $list; } foreach ($arr as $entry) { - if (! is_array($entry)) { + if (!is_array($entry)) { continue; } $content = null; - if (isset($entry['Content']) && is_string($entry['Content']) && $entry['Content'] !== '') { + if (isset($entry['Content']) && is_string($entry['Content']) && '' !== $entry['Content']) { $decoded = base64_decode($entry['Content'], true); - if ($decoded !== false) { + if (false !== $decoded) { $content = $decoded; } } @@ -137,7 +137,7 @@ private static function extractAttachments(array $payload): array private static function firstNonEmpty(mixed ...$values): ?string { foreach ($values as $value) { - if (is_string($value) && $value !== '') { + if (is_string($value) && '' !== $value) { return $value; } } @@ -147,7 +147,7 @@ private static function firstNonEmpty(mixed ...$values): ?string private static function blankToNull(mixed $value): ?string { - if ($value === null || $value === '') { + if (null === $value || '' === $value) { return null; } diff --git a/src/Mail/Inbound/ProcessResult.php b/src/Mail/Inbound/ProcessResult.php index e382e8a..392ff92 100644 --- a/src/Mail/Inbound/ProcessResult.php +++ b/src/Mail/Inbound/ProcessResult.php @@ -15,7 +15,7 @@ final class ProcessResult { /** - * @param list $pendingAttachmentDownloads + * @param list $pendingAttachmentDownloads */ public function __construct( public readonly string $outcome, diff --git a/src/Mail/Inbound/SESInboundParser.php b/src/Mail/Inbound/SESInboundParser.php index a530529..15f509a 100644 --- a/src/Mail/Inbound/SESInboundParser.php +++ b/src/Mail/Inbound/SESInboundParser.php @@ -42,28 +42,19 @@ public function parse(array $rawPayload): InboundMessage $type = (string) ($rawPayload['Type'] ?? ''); match ($type) { - 'SubscriptionConfirmation' => throw new SESSubscriptionConfirmationException( - topicArn: (string) ($rawPayload['TopicArn'] ?? ''), - subscribeUrl: (string) ($rawPayload['SubscribeURL'] ?? ''), - token: (string) ($rawPayload['Token'] ?? ''), - ), + 'SubscriptionConfirmation' => throw new SESSubscriptionConfirmationException(topicArn: (string) ($rawPayload['TopicArn'] ?? ''), subscribeUrl: (string) ($rawPayload['SubscribeURL'] ?? ''), token: (string) ($rawPayload['Token'] ?? '')), 'Notification' => null, - default => throw new \InvalidArgumentException( - "Unsupported SNS envelope type: \"{$type}\"", - ), + default => throw new \InvalidArgumentException("Unsupported SNS envelope type: \"{$type}\""), }; $messageJson = (string) ($rawPayload['Message'] ?? ''); - if ($messageJson === '') { + if ('' === $messageJson) { throw new \InvalidArgumentException('SES notification has no Message body'); } $notification = json_decode($messageJson, true); - if (! is_array($notification)) { - throw new \InvalidArgumentException( - 'SES notification Message is not valid JSON: ' - . json_last_error_msg() - ); + if (!is_array($notification)) { + throw new \InvalidArgumentException('SES notification Message is not valid JSON: '.json_last_error_msg()); } $mail = is_array($notification['mail'] ?? null) ? $notification['mail'] : []; @@ -105,18 +96,22 @@ public function parse(array $rawPayload): InboundMessage */ private static function parseFirstAddressList(mixed $list): array { - if (! is_array($list) || $list === []) { + if (!is_array($list) || [] === $list) { return ['', null]; } foreach ($list as $entry) { - if (! is_string($entry) || trim($entry) === '') continue; + if (!is_string($entry) || '' === trim($entry)) { + continue; + } $trimmed = trim($entry); - if (preg_match('/^\s*"?([^<"]*?)"?\s*<([^>]+)>\s*$/', $trimmed, $m) === 1) { + if (1 === preg_match('/^\s*"?([^<"]*?)"?\s*<([^>]+)>\s*$/', $trimmed, $m)) { return [trim($m[2]), self::blankToNull(trim($m[1]))]; } + // Bare address. return [$trimmed, null]; } + return ['', null]; } @@ -130,17 +125,20 @@ private static function extractHeaders(array $mail): array { $out = []; $arr = $mail['headers'] ?? null; - if (! is_array($arr)) { + if (!is_array($arr)) { return $out; } foreach ($arr as $entry) { - if (! is_array($entry)) continue; + if (!is_array($entry)) { + continue; + } $name = $entry['name'] ?? null; $value = $entry['value'] ?? null; - if (is_string($name) && is_string($value) && $name !== '') { + if (is_string($name) && is_string($value) && '' !== $name) { $out[$name] = $value; } } + return $out; } @@ -154,16 +152,16 @@ private static function extractHeaders(array $mail): array */ private static function extractBody(string $contentBase64): array { - if ($contentBase64 === '') { + if ('' === $contentBase64) { return [null, null]; } $raw = base64_decode($contentBase64, true); - if ($raw === false) { + if (false === $raw) { return [null, null]; } $split = self::splitHeaders($raw); - if ($split === null) { + if (null === $split) { return [null, null]; } [$headers, $body] = $split; @@ -178,6 +176,7 @@ private static function extractBody(string $contentBase64): array if (str_starts_with($lowerCt, 'text/html')) { return [null, self::decodeBody($body, $transferEnc)]; } + return [self::decodeBody($body, $transferEnc), null]; } @@ -188,11 +187,11 @@ private static function splitHeaders(string $raw): ?array { $pos = strpos($raw, "\r\n\r\n"); $skip = 4; - if ($pos === false) { + if (false === $pos) { $pos = strpos($raw, "\n\n"); $skip = 2; } - if ($pos === false) { + if (false === $pos) { return null; } $headerBlock = substr($raw, 0, $pos); @@ -200,13 +199,18 @@ private static function splitHeaders(string $raw): ?array $headers = []; foreach (preg_split('/\r?\n/', $headerBlock) ?: [] as $line) { - if ($line === '') continue; + if ('' === $line) { + continue; + } $colon = strpos($line, ':'); - if ($colon === false || $colon === 0) continue; + if (false === $colon || 0 === $colon) { + continue; + } $name = strtolower(trim(substr($line, 0, $colon))); $value = trim(substr($line, $colon + 1)); $headers[$name] = $value; } + return [$headers, $body]; } @@ -215,10 +219,10 @@ private static function splitHeaders(string $raw): ?array */ private static function walkMultipart(string $body, string $contentType): array { - if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m) !== 1) { + if (1 !== preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) { return [null, null]; } - $delimiter = '--' . $m[1]; + $delimiter = '--'.$m[1]; $parts = explode($delimiter, $body); // Drop preamble (before first delimiter) + closing epilogue. array_shift($parts); @@ -227,41 +231,49 @@ private static function walkMultipart(string $body, string $contentType): array foreach ($parts as $part) { $trimmed = ltrim($part, "\r\n"); - if ($trimmed === '' || str_starts_with($trimmed, '--')) { + if ('' === $trimmed || str_starts_with($trimmed, '--')) { continue; } $partSplit = self::splitHeaders($trimmed); - if ($partSplit === null) continue; + if (null === $partSplit) { + continue; + } [$partHeaders, $partBody] = $partSplit; $partType = strtolower($partHeaders['content-type'] ?? ''); $partEnc = $partHeaders['content-transfer-encoding'] ?? '7bit'; $decoded = self::decodeBody(rtrim($partBody, "\r\n"), $partEnc); - if (str_starts_with($partType, 'text/plain') && $text === null) { + if (str_starts_with($partType, 'text/plain') && null === $text) { $text = $decoded; - } elseif (str_starts_with($partType, 'text/html') && $html === null) { + } elseif (str_starts_with($partType, 'text/html') && null === $html) { $html = $decoded; } } + return [$text, $html]; } private static function decodeBody(string $body, string $transferEnc): string { $enc = strtolower(trim($transferEnc)); - if ($enc === 'quoted-printable') { + if ('quoted-printable' === $enc) { return quoted_printable_decode($body); } - if ($enc === 'base64') { + if ('base64' === $enc) { $decoded = base64_decode($body, true); - return $decoded === false ? $body : $decoded; + + return false === $decoded ? $body : $decoded; } + return $body; } private static function blankToNull(mixed $value): ?string { - if (! is_string($value)) return null; - return trim($value) === '' ? null : $value; + if (!is_string($value)) { + return null; + } + + return '' === trim($value) ? null : $value; } } diff --git a/tests/Controller/InboundEmailControllerTest.php b/tests/Controller/InboundEmailControllerTest.php index 328bd61..4ad01fa 100644 --- a/tests/Controller/InboundEmailControllerTest.php +++ b/tests/Controller/InboundEmailControllerTest.php @@ -40,7 +40,7 @@ protected function setUp(): void private function controller(array $parsers = []): InboundEmailController { - if ($parsers === []) { + if ([] === $parsers) { $parsers = [$this->stubParser('postmark')]; } @@ -50,8 +50,15 @@ private function controller(array $parsers = []): InboundEmailController private function stubParser(string $name): InboundEmailParser { return new class($name) implements InboundEmailParser { - public function __construct(private readonly string $n) {} - public function name(): string { return $this->n; } + public function __construct(private readonly string $n) + { + } + + public function name(): string + { + return $this->n; + } + public function parse(array $rawPayload): InboundMessage { return new InboundMessage( @@ -73,14 +80,14 @@ private function request(array $options): Request $body = $options['body'] ?? '{}'; $server = []; - if ($secret !== null) { + if (null !== $secret) { $server['HTTP_X_ESCALATED_INBOUND_SECRET'] = $secret; } - if ($headerAdapter !== null) { + if (null !== $headerAdapter) { $server['HTTP_X_ESCALATED_ADAPTER'] = $headerAdapter; } - $query = $queryAdapter !== null ? ['adapter' => $queryAdapter] : []; + $query = null !== $queryAdapter ? ['adapter' => $queryAdapter] : []; return new Request( query: $query, diff --git a/tests/Mail/Inbound/AttachmentDownloaderTest.php b/tests/Mail/Inbound/AttachmentDownloaderTest.php index 41a353c..ad1a911 100644 --- a/tests/Mail/Inbound/AttachmentDownloaderTest.php +++ b/tests/Mail/Inbound/AttachmentDownloaderTest.php @@ -5,7 +5,6 @@ namespace Escalated\Symfony\Tests\Mail\Inbound; use Doctrine\ORM\EntityManagerInterface; -use Escalated\Symfony\Entity\Attachment; use Escalated\Symfony\Entity\Reply; use Escalated\Symfony\Entity\Ticket; use Escalated\Symfony\Mail\Inbound\AttachmentDownloader; @@ -194,7 +193,7 @@ public function testDownloadAllContinuesPastFailures(): void public function testLocalFileStorageWritesFile(): void { - $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'esc-tests-' . uniqid(); + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'esc-tests-'.uniqid(); $storage = new LocalFileAttachmentStorage($root); $path = $storage->put('hello.txt', 'payload', 'text/plain'); @@ -215,7 +214,7 @@ public function testLocalFileStorageRejectsEmptyRoot(): void public function testLocalFileStorageProducesUniquePaths(): void { - $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'esc-tests-' . uniqid(); + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'esc-tests-'.uniqid(); $storage = new LocalFileAttachmentStorage($root); $p1 = $storage->put('x.txt', 'a', 'text/plain'); @@ -244,12 +243,14 @@ class StubHttpClient implements AttachmentHttpClientInterface public function enqueue(AttachmentHttpResponse $response): self { $this->queue[] = $response; + return $this; } public function get(string $url, array $headers = []): AttachmentHttpResponse { $this->lastHeaders = $headers; + return array_shift($this->queue) ?? new AttachmentHttpResponse(200, ''); } } @@ -276,7 +277,8 @@ public function put(string $filename, string $content, string $contentType): str $this->lastFilename = $filename; $this->lastContent = $content; $this->lastContentType = $contentType; - $this->putCount++; + ++$this->putCount; + return $this->returnPath; } } diff --git a/tests/Mail/Inbound/InboundEmailServiceTest.php b/tests/Mail/Inbound/InboundEmailServiceTest.php index 3cfe306..83ff81e 100644 --- a/tests/Mail/Inbound/InboundEmailServiceTest.php +++ b/tests/Mail/Inbound/InboundEmailServiceTest.php @@ -90,9 +90,9 @@ public function testNoMatchWithRealContentCreatesNewTicket(): void $this->tickets->expects($this->once()) ->method('create') ->with($this->callback(function (array $data) { - return $data['subject'] === 'New issue' - && $data['description'] === 'real' - && $data['guest_email'] === 'customer@example.com'; + return 'New issue' === $data['subject'] + && 'real' === $data['description'] + && 'customer@example.com' === $data['guest_email']; })) ->willReturn($newTicket); $this->tickets->expects($this->never())->method('addInboundEmailReply'); @@ -110,7 +110,7 @@ public function testEmptySubjectFallsBackToPlaceholder(): void $this->router->method('resolveTicket')->willReturn(null); $this->tickets->expects($this->once()) ->method('create') - ->with($this->callback(fn (array $data) => $data['subject'] === '(no subject)')) + ->with($this->callback(fn (array $data) => '(no subject)' === $data['subject'])) ->willReturn($this->ticket(1)); $svc = new InboundEmailService($this->router, $this->tickets); diff --git a/tests/Mail/Inbound/ParserEquivalenceTest.php b/tests/Mail/Inbound/ParserEquivalenceTest.php index 6fb3638..30985b5 100644 --- a/tests/Mail/Inbound/ParserEquivalenceTest.php +++ b/tests/Mail/Inbound/ParserEquivalenceTest.php @@ -53,7 +53,7 @@ private static function buildMailgunPayload(array $e): array { return [ 'sender' => $e['fromEmail'], - 'from' => $e['fromName'] . ' <' . $e['fromEmail'] . '>', + 'from' => $e['fromName'].' <'.$e['fromEmail'].'>', 'recipient' => $e['toEmail'], 'subject' => $e['subject'], 'body-plain' => $e['bodyText'], @@ -68,14 +68,14 @@ private static function buildSesPayload(array $e): array // Include full raw MIME as base64 so body extraction is // exercised — keeps the payload close to a real SES delivery. $mime = "From: {$e['fromName']} <{$e['fromEmail']}>\r\n" - . "To: {$e['toEmail']}\r\n" - . "Subject: {$e['subject']}\r\n" - . "Message-ID: {$e['messageId']}\r\n" - . "In-Reply-To: {$e['inReplyTo']}\r\n" - . "References: {$e['references']}\r\n" - . "Content-Type: text/plain; charset=\"utf-8\"\r\n" - . "\r\n" - . $e['bodyText']; + ."To: {$e['toEmail']}\r\n" + ."Subject: {$e['subject']}\r\n" + ."Message-ID: {$e['messageId']}\r\n" + ."In-Reply-To: {$e['inReplyTo']}\r\n" + ."References: {$e['references']}\r\n" + ."Content-Type: text/plain; charset=\"utf-8\"\r\n" + ."\r\n" + .$e['bodyText']; $sesMessage = [ 'notificationType' => 'Received', @@ -83,7 +83,7 @@ private static function buildSesPayload(array $e): array 'source' => $e['fromEmail'], 'destination' => [$e['toEmail']], 'headers' => [ - ['name' => 'From', 'value' => $e['fromName'] . ' <' . $e['fromEmail'] . '>'], + ['name' => 'From', 'value' => $e['fromName'].' <'.$e['fromEmail'].'>'], ['name' => 'To', 'value' => $e['toEmail']], ['name' => 'Subject', 'value' => $e['subject']], ['name' => 'Message-ID', 'value' => $e['messageId']], @@ -91,7 +91,7 @@ private static function buildSesPayload(array $e): array ['name' => 'References', 'value' => $e['references']], ], 'commonHeaders' => [ - 'from' => [$e['fromName'] . ' <' . $e['fromEmail'] . '>'], + 'from' => [$e['fromName'].' <'.$e['fromEmail'].'>'], 'to' => [$e['toEmail']], 'subject' => $e['subject'], ], diff --git a/tests/Mail/Inbound/SESInboundParserTest.php b/tests/Mail/Inbound/SESInboundParserTest.php index 9886c0f..b8c8aa9 100644 --- a/tests/Mail/Inbound/SESInboundParserTest.php +++ b/tests/Mail/Inbound/SESInboundParserTest.php @@ -83,11 +83,11 @@ public function testNotificationExtractsThreadingMetadata(): void public function testNotificationDecodesPlainTextBody(): void { $mime = "From: alice@example.com\r\n" - . "To: support@example.com\r\n" - . "Subject: Hi\r\n" - . "Content-Type: text/plain; charset=\"utf-8\"\r\n" - . "\r\n" - . "This is the plain text body."; + ."To: support@example.com\r\n" + ."Subject: Hi\r\n" + ."Content-Type: text/plain; charset=\"utf-8\"\r\n" + ."\r\n" + .'This is the plain text body.'; $envelope = [ 'Type' => 'Notification', @@ -112,19 +112,19 @@ public function testNotificationDecodesMultipartBody(): void { $boundary = 'boundary-abc'; $mime = "From: alice@example.com\r\n" - . "To: support@example.com\r\n" - . "Subject: Hi\r\n" - . "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n" - . "\r\n" - . "--{$boundary}\r\n" - . "Content-Type: text/plain; charset=\"utf-8\"\r\n" - . "\r\n" - . "Plain body\r\n" - . "--{$boundary}\r\n" - . "Content-Type: text/html; charset=\"utf-8\"\r\n" - . "\r\n" - . "

HTML body

\r\n" - . "--{$boundary}--\r\n"; + ."To: support@example.com\r\n" + ."Subject: Hi\r\n" + ."Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n" + ."\r\n" + ."--{$boundary}\r\n" + ."Content-Type: text/plain; charset=\"utf-8\"\r\n" + ."\r\n" + ."Plain body\r\n" + ."--{$boundary}\r\n" + ."Content-Type: text/html; charset=\"utf-8\"\r\n" + ."\r\n" + ."

HTML body

\r\n" + ."--{$boundary}--\r\n"; $envelope = [ 'Type' => 'Notification',