generated from Nodarx/template
feat: add fetch message size limit
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -84,8 +84,15 @@ final class FetchOptions
|
|||||||
return $this->with('BODYSTRUCTURE');
|
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]');
|
return $this->with('BODY.PEEK[TEXT]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,14 @@ final class FetchMessageParser
|
|||||||
$depth--;
|
$depth--;
|
||||||
if ($depth === 0) {
|
if ($depth === 0) {
|
||||||
$offset++;
|
$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 = [];
|
$sections = [];
|
||||||
$segments = self::splitMultipartBody($content, $boundary);
|
$segments = self::splitMultipartBody($content, $boundary, $truncated);
|
||||||
|
$lastIndex = count($segments) - 1;
|
||||||
foreach ($part->parts() as $index => $childPart) {
|
foreach ($part->parts() as $index => $childPart) {
|
||||||
if (!isset($segments[$index])) {
|
if (!isset($segments[$index])) {
|
||||||
break;
|
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;
|
$sections[$section] = $childContent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,8 +632,13 @@ final class FetchMessageParser
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @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);
|
[, $body] = self::splitMimeEntity($content);
|
||||||
|
|
||||||
if ($part->isMultipart()) {
|
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>
|
* @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|$)/';
|
$pattern = '/(?:^|\r\n|\n)--' . preg_quote($boundary, '/') . '(--)?[ \t]*(?:\r\n|\n|$)/';
|
||||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) {
|
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) {
|
||||||
return [];
|
return [];
|
||||||
@@ -677,12 +697,17 @@ final class FetchMessageParser
|
|||||||
&& $matches[1][$index][0] === '--';
|
&& $matches[1][$index][0] === '--';
|
||||||
|
|
||||||
if ($isClosing) {
|
if ($isClosing) {
|
||||||
break;
|
return $segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$segmentStart = $offset + strlen($match);
|
$segmentStart = $offset + strlen($match);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($segmentStart !== null && $segmentStart < strlen($content)) {
|
||||||
|
$segments[] = substr($content, $segmentStart);
|
||||||
|
$truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
return $segments;
|
return $segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,11 +731,22 @@ final class FetchMessageParser
|
|||||||
return ['', $content];
|
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
|
private static function decodeSectionContent(string $content, ?string $encoding, string $charset): string
|
||||||
{
|
{
|
||||||
$decoded = match (strtolower($encoding ?? '7bit')) {
|
$decoded = match (strtolower($encoding ?? '7bit')) {
|
||||||
'quoted-printable' => quoted_printable_decode($content),
|
'quoted-printable' => quoted_printable_decode($content),
|
||||||
'base64' => base64_decode($content, true) ?: '',
|
'base64' => self::decodeBase64($content),
|
||||||
default => $content,
|
default => $content,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class RemoteMailService
|
|||||||
|
|
||||||
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
|
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
|
||||||
private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY'];
|
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 ?ImapClient $imapClient = null;
|
||||||
private ?SmtpClient $smtpClient = 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
|
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
|
// fast path: fetch all messages without filtering, sorting or pagination
|
||||||
if ($filter === null && $sort === null && $range === null) {
|
if ($filter === null && $sort === null && $range === null) {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\ProviderImap\Tests\Unit;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FetchMessageParserTest extends TestCase
|
||||||
|
{
|
||||||
|
/** multipart/mixed ( multipart/alternative ( text/plain, text/html ), application/pdf ) */
|
||||||
|
private const MIXED_STRUCTURE = '((("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" 5 1)'
|
||||||
|
. '("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "7BIT" 12 1) "ALTERNATIVE" ("BOUNDARY" "alt") NIL NIL)'
|
||||||
|
. '("APPLICATION" "PDF" ("NAME" "a.pdf") NIL NIL "BASE64" 100 NIL ("ATTACHMENT" ("FILENAME" "a.pdf")) NIL)'
|
||||||
|
. ' "MIXED" ("BOUNDARY" "mix") NIL NIL)';
|
||||||
|
|
||||||
|
private const ALTERNATIVE_PREFIX = "--mix\r\n"
|
||||||
|
. "Content-Type: multipart/alternative; boundary=alt\r\n\r\n"
|
||||||
|
. "--alt\r\nContent-Type: text/plain\r\n\r\nHello\r\n"
|
||||||
|
. "--alt\r\nContent-Type: text/html\r\n\r\n";
|
||||||
|
|
||||||
|
public function testBodyTextWithLimitRequestsPartialFetch(): void
|
||||||
|
{
|
||||||
|
$this->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 . "<p>Hello</p>\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' => '<p>Hello</p>'], $message->bodySections());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartialResponseWithOriginIsParsed(): void
|
||||||
|
{
|
||||||
|
$body = self::ALTERNATIVE_PREFIX . "<p>Hello</p>\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' => '<p>Hello</p>'], $message->bodySections());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBodyCutInsideNestedTextPartKeepsPrecedingAndPartialText(): void
|
||||||
|
{
|
||||||
|
$body = self::ALTERNATIVE_PREFIX . '<p>Hello wor';
|
||||||
|
|
||||||
|
$message = FetchMessageParser::parse($this->fetchResponse(self::MIXED_STRUCTURE, 'BODY[TEXT]<0>', $body));
|
||||||
|
|
||||||
|
$this->assertSame(['1.1' => 'Hello', '1.2' => '<p>Hello wor'], $message->bodySections());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBodyCutInsideAttachmentHeadersDropsAttachment(): void
|
||||||
|
{
|
||||||
|
$body = self::ALTERNATIVE_PREFIX . "<p>Hello</p>\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' => '<p>Hello</p>'], $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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user