feat: add fetch message size limit

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-26 08:43:32 -04:00
parent 681a66862e
commit 025812fe4e
4 changed files with 145 additions and 9 deletions
@@ -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]');
}
@@ -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<string, string>
*/
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<string>
*/
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,
};
+3 -1
View File
@@ -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) {