diff --git a/src/Mail/Inbound/AttachmentDownloadResult.php b/src/Mail/Inbound/AttachmentDownloadResult.php new file mode 100644 index 0000000..5629a45 --- /dev/null +++ b/src/Mail/Inbound/AttachmentDownloadResult.php @@ -0,0 +1,28 @@ +persisted; + } +} diff --git a/src/Mail/Inbound/AttachmentDownloader.php b/src/Mail/Inbound/AttachmentDownloader.php new file mode 100644 index 0000000..8db4d9f --- /dev/null +++ b/src/Mail/Inbound/AttachmentDownloader.php @@ -0,0 +1,169 @@ +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 (null !== $this->options->basicAuth) { + $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 (null === $ticket) { + 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 (null !== $replyId) { + $reply = $this->em->find(Reply::class, $replyId); + if (null === $reply) { + 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 (null === $name || '' === 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..eada438 --- /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 (2 === count($parts)) { + $collectedHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + + return $len; + }, + ]); + + $body = curl_exec($ch); + if (false === $body) { + $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/InboundEmailService.php b/src/Mail/Inbound/InboundEmailService.php index edd91f1..1d5ace9 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 332febb..622f250 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/src/Mail/Inbound/LocalFileAttachmentStorage.php b/src/Mail/Inbound/LocalFileAttachmentStorage.php new file mode 100644 index 0000000..4f12f1d --- /dev/null +++ b/src/Mail/Inbound/LocalFileAttachmentStorage.php @@ -0,0 +1,52 @@ +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/SESInboundParser.php b/src/Mail/Inbound/SESInboundParser.php new file mode 100644 index 0000000..15f509a --- /dev/null +++ b/src/Mail/Inbound/SESInboundParser.php @@ -0,0 +1,279 @@ + 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 (1 === preg_match('/^\s*"?([^<"]*?)"?\s*<([^>]+)>\s*$/', $trimmed, $m)) { + 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 (false === $raw) { + return [null, null]; + } + + $split = self::splitHeaders($raw); + if (null === $split) { + 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 (false === $pos) { + $pos = strpos($raw, "\n\n"); + $skip = 2; + } + if (false === $pos) { + 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 (false === $colon || 0 === $colon) { + 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 (1 !== preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) { + 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 (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') && null === $text) { + $text = $decoded; + } 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 ('quoted-printable' === $enc) { + return quoted_printable_decode($body); + } + if ('base64' === $enc) { + $decoded = base64_decode($body, true); + + 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; + } +} 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 @@ +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/Controller/InboundEmailControllerTest.php b/tests/Controller/InboundEmailControllerTest.php new file mode 100644 index 0000000..4ad01fa --- /dev/null +++ b/tests/Controller/InboundEmailControllerTest.php @@ -0,0 +1,310 @@ +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 (null !== $secret) { + $server['HTTP_X_ESCALATED_INBOUND_SECRET'] = $secret; + } + if (null !== $headerAdapter) { + $server['HTTP_X_ESCALATED_ADAPTER'] = $headerAdapter; + } + + $query = null !== $queryAdapter ? ['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()); + } +} diff --git a/tests/Mail/Inbound/AttachmentDownloaderTest.php b/tests/Mail/Inbound/AttachmentDownloaderTest.php new file mode 100644 index 0000000..ad1a911 --- /dev/null +++ b/tests/Mail/Inbound/AttachmentDownloaderTest.php @@ -0,0 +1,284 @@ +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; + } +} diff --git a/tests/Mail/Inbound/ParserEquivalenceTest.php b/tests/Mail/Inbound/ParserEquivalenceTest.php new file mode 100644 index 0000000..30985b5 --- /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); + } +} diff --git a/tests/Mail/Inbound/SESInboundParserTest.php b/tests/Mail/Inbound/SESInboundParserTest.php new file mode 100644 index 0000000..b8c8aa9 --- /dev/null +++ b/tests/Mail/Inbound/SESInboundParserTest.php @@ -0,0 +1,219 @@ +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); + } +}