From 025812fe4e7741b569cb93deb046ec7c12ec45dd Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 26 Sep 2026 08:43:32 -0400 Subject: [PATCH] feat: add fetch message size limit Signed-off-by: Sebastian Krupinski --- .../Command/Argument/FetchOptions.php | 9 +- .../Protocol/Parser/FetchMessageParser.php | 50 ++++++++-- lib/Service/Remote/RemoteMailService.php | 4 +- tests/php/Unit/FetchMessageParserTest.php | 91 +++++++++++++++++++ 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 tests/php/Unit/FetchMessageParserTest.php diff --git a/lib/Client/Protocol/Command/Argument/FetchOptions.php b/lib/Client/Protocol/Command/Argument/FetchOptions.php index ef5ccb0..9550a6f 100644 --- a/lib/Client/Protocol/Command/Argument/FetchOptions.php +++ b/lib/Client/Protocol/Command/Argument/FetchOptions.php @@ -84,8 +84,15 @@ final class FetchOptions return $this->with('BODYSTRUCTURE'); } - public function withBodyText(): self + /** + * @param int|null $limit Maximum number of octets to fetch (partial fetch `<0.limit>`), null for all + */ + public function withBodyText(?int $limit = null): self { + if ($limit !== null && $limit > 0) { + return $this->with(sprintf('BODY.PEEK[TEXT]<0.%d>', $limit)); + } + return $this->with('BODY.PEEK[TEXT]'); } diff --git a/lib/Client/Protocol/Parser/FetchMessageParser.php b/lib/Client/Protocol/Parser/FetchMessageParser.php index a2381e1..91cb858 100644 --- a/lib/Client/Protocol/Parser/FetchMessageParser.php +++ b/lib/Client/Protocol/Parser/FetchMessageParser.php @@ -102,7 +102,14 @@ final class FetchMessageParser $depth--; if ($depth === 0) { $offset++; - return substr($payload, $start, $offset - $start); + $name = substr($payload, $start, $offset - $start); + + // partial fetch responses carry the origin octet, e.g. BODY[TEXT]<0> + if (preg_match('/\G<\d+>/A', $payload, $originMatches, 0, $offset) === 1) { + $offset += strlen($originMatches[0]); + } + + return $name; } } @@ -599,13 +606,15 @@ final class FetchMessageParser } $sections = []; - $segments = self::splitMultipartBody($content, $boundary); + $segments = self::splitMultipartBody($content, $boundary, $truncated); + $lastIndex = count($segments) - 1; foreach ($part->parts() as $index => $childPart) { if (!isset($segments[$index])) { break; } - foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) { + $segmentTruncated = $truncated && $index === $lastIndex; + foreach (self::sectionsFromMimeEntity($segments[$index], $childPart, $segmentTruncated) as $section => $childContent) { $sections[$section] = $childContent; } } @@ -623,8 +632,13 @@ final class FetchMessageParser /** * @return array */ - private static function sectionsFromMimeEntity(string $content, MessagePart $part): array + private static function sectionsFromMimeEntity(string $content, MessagePart $part, bool $truncated = false): array { + // a truncated entity cut off inside its headers has no usable body + if ($truncated && !str_contains($content, "\r\n\r\n") && !str_contains($content, "\n\n")) { + return []; + } + [, $body] = self::splitMimeEntity($content); if ($part->isMultipart()) { @@ -655,10 +669,16 @@ final class FetchMessageParser } /** + * Split a multipart body into its entities. + * + * A body without a closing boundary (partial fetch or malformed message) keeps + * its last, unterminated entity and reports it through $truncated. + * * @return list */ - private static function splitMultipartBody(string $content, string $boundary): array + private static function splitMultipartBody(string $content, string $boundary, ?bool &$truncated = null): array { + $truncated = false; $pattern = '/(?:^|\r\n|\n)--' . preg_quote($boundary, '/') . '(--)?[ \t]*(?:\r\n|\n|$)/'; if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) { return []; @@ -677,12 +697,17 @@ final class FetchMessageParser && $matches[1][$index][0] === '--'; if ($isClosing) { - break; + return $segments; } $segmentStart = $offset + strlen($match); } + if ($segmentStart !== null && $segmentStart < strlen($content)) { + $segments[] = substr($content, $segmentStart); + $truncated = true; + } + return $segments; } @@ -706,11 +731,22 @@ final class FetchMessageParser return ['', $content]; } + /** + * Decode base64 content, ignoring an incomplete trailing quantum left by a partial fetch. + */ + private static function decodeBase64(string $content): string + { + $content = preg_replace('/[^A-Za-z0-9+\/=]/', '', $content) ?? ''; + $content = substr($content, 0, strlen($content) - (strlen($content) % 4)); + + return base64_decode($content, true) ?: ''; + } + private static function decodeSectionContent(string $content, ?string $encoding, string $charset): string { $decoded = match (strtolower($encoding ?? '7bit')) { 'quoted-printable' => quoted_printable_decode($content), - 'base64' => base64_decode($content, true) ?: '', + 'base64' => self::decodeBase64($content), default => $content, }; diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index a96ae00..2b547df 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -61,6 +61,7 @@ class RemoteMailService private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription']; private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']; + private const LIST_BODY_TEXT_LIMIT = 1048576; // 1 MB private ?ImapClient $imapClient = null; private ?SmtpClient $smtpClient = null; @@ -304,7 +305,8 @@ class RemoteMailService */ public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator { - $options = FetchOptions::message()->withBodyText(); + // text parts normally precede attachments, so capping TEXT avoids transferring attachment bytes + $options = FetchOptions::message()->withBodyText(self::LIST_BODY_TEXT_LIMIT); // fast path: fetch all messages without filtering, sorting or pagination if ($filter === null && $sort === null && $range === null) { diff --git a/tests/php/Unit/FetchMessageParserTest.php b/tests/php/Unit/FetchMessageParserTest.php new file mode 100644 index 0000000..c7320ca --- /dev/null +++ b/tests/php/Unit/FetchMessageParserTest.php @@ -0,0 +1,91 @@ +assertStringContainsString('BODY.PEEK[TEXT]<0.1024>', FetchOptions::message()->withBodyText(1024)->toCommand()); + $this->assertStringContainsString('BODY.PEEK[TEXT]', FetchOptions::message()->withBodyText()->toCommand()); + $this->assertStringNotContainsString('<0.', FetchOptions::message()->withBodyText()->toCommand()); + } + + public function testCompleteMultipartKeepsTextSectionsOnly(): void + { + $body = self::ALTERNATIVE_PREFIX . "

Hello

\r\n--alt--\r\n" + . "--mix\r\nContent-Type: application/pdf\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0=\r\n--mix--\r\n"; + + $message = FetchMessageParser::parse($this->fetchResponse(self::MIXED_STRUCTURE, 'BODY[TEXT]', $body)); + + $this->assertSame(['1.1' => 'Hello', '1.2' => '

Hello

'], $message->bodySections()); + } + + public function testPartialResponseWithOriginIsParsed(): void + { + $body = self::ALTERNATIVE_PREFIX . "

Hello

\r\n--alt--\r\n--mix--\r\n"; + + $message = FetchMessageParser::parse($this->fetchResponse(self::MIXED_STRUCTURE, 'BODY[TEXT]<0>', $body)); + + $this->assertSame(7, $message->uid()); + $this->assertSame(['1.1' => 'Hello', '1.2' => '

Hello

'], $message->bodySections()); + } + + public function testBodyCutInsideNestedTextPartKeepsPrecedingAndPartialText(): void + { + $body = self::ALTERNATIVE_PREFIX . '

Hello wor'; + + $message = FetchMessageParser::parse($this->fetchResponse(self::MIXED_STRUCTURE, 'BODY[TEXT]<0>', $body)); + + $this->assertSame(['1.1' => 'Hello', '1.2' => '

Hello wor'], $message->bodySections()); + } + + public function testBodyCutInsideAttachmentHeadersDropsAttachment(): void + { + $body = self::ALTERNATIVE_PREFIX . "

Hello

\r\n--alt--\r\n--mix\r\nContent-Type: applic"; + + $message = FetchMessageParser::parse($this->fetchResponse(self::MIXED_STRUCTURE, 'BODY[TEXT]<0>', $body)); + + $this->assertSame(['1.1' => 'Hello', '1.2' => '

Hello

'], $message->bodySections()); + } + + public function testBodyCutInsideBase64TextPartDecodesCompleteQuanta(): void + { + $structure = '(("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "BASE64" 20 1)' + . '("APPLICATION" "PDF" NIL NIL NIL "BASE64" 100 NIL NIL NIL) "MIXED" ("BOUNDARY" "mix") NIL NIL)'; + // "Hello world" = SGVsbG8gd29ybGQ= ; cut after "SGVsbG8gd29y" + 2 stray chars + $body = "--mix\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\nSGVsbG8gd29yYm"; + + $message = FetchMessageParser::parse($this->fetchResponse($structure, 'BODY[TEXT]<0>', $body)); + + $this->assertSame(['1' => 'Hello wor'], $message->bodySections()); + } + + private function fetchResponse(string $bodyStructure, string $section, string $body): string + { + return sprintf( + "* 1 FETCH (UID 7 FLAGS () BODYSTRUCTURE %s %s {%d}\r\n%s)", + $bodyStructure, + $section, + strlen($body), + $body, + ); + } +}