diff --git a/doc/UPGRADING.md b/doc/UPGRADING.md index 2c941e9f..88a1ee0e 100644 --- a/doc/UPGRADING.md +++ b/doc/UPGRADING.md @@ -120,6 +120,77 @@ it (SQL, Redis, file, ...) is now the PSR-16 implementation's concern, not this library's. If you want a SQL-indexed IMAP cache, supply a SQL-backed `CacheInterface`. +### Appending messages + +`append()` is still a list of per-message arrays with +`data`, `flags` and `internaldate`. + +However the byte handling differs from `lib/`. + +**Line endings are no longer normalized.** The legacy `Socket` driver +rewrote bare `LF` and `CR` to `CRLF` throughout an ordinary APPEND via an +EOL filter. The modern `ImapClient` sends your bytes verbatim. If you +relied on the legacy driver to canonicalize line endings you need to normalize the +message yourself before calling `append()`. + +```php +// AFTER (3.x): supply a message whose textual headers and MIME +// boundaries already use CRLF. +$client->append('Drafts', [['data' => $rfc822WithCrlf]]); +``` + +**Streams are accepted for `data`.** A `data` entry may be a readable, +seekable stream resource as well as a string. Bytes from the stream's +current position through EOF are sent in bounded chunks, so a large +message is never fully buffered in PHP memory. The caller keeps ownership. +The position is restored after sending. A non-seekable stream is rejected +before any command goes out. Spool it first. + +```php +// AFTER (3.x): append straight from a file handle. +$fh = fopen('/path/message.eml', 'rb'); +try { + $client->append('Archive', [['data' => $fh]]); +} finally { + fclose($fh); +} +``` + +**Byte-preserving binary APPEND is opt-in per message.** Set +`binary: true` to force `literal8` (`~{n}`) and guarantee the server +stores the body byte for byte. + +This is required for `Content-Transfer-Encoding: binary` payloads. +We check for the server's `BINARY` capability first. + +```php +// AFTER (3.x): preserve an octet-stream body exactly. +$client->append('Objects', [['data' => $mimeMessage, 'binary' => true]]); +``` + +**CATENATE part shape changed.** The legacy `['t' => 'text', 'v' => ...]` +/ `['t' => 'url', 'v' => ...]` tuples become `['text' => ...]` / +`['url' => ...]` under a `catenate` key. + +A `url` part still needs the server's `CATENATE` capability. + +With `binary: true` set, the TEXT parts are assembled locally into one `literal8` +instead of a server side `CATENATE`. A `url` part cannot be combined with `binary`. + +```php +// BEFORE (2.x) +$client->append('Drafts', [['data' => [ + ['t' => 'text', 'v' => $headers], + ['t' => 'url', 'v' => 'imap://...'], +]]]); + +// AFTER (3.x) +$client->append('Drafts', [['catenate' => [ + ['text' => $headers], + ['url' => 'imap://...'], +]]]); +``` + ### Deliberately dropped - The `Serializable` interface throughout: value objects use diff --git a/src/ImapClient.php b/src/ImapClient.php index 11da2570..4287d105 100644 --- a/src/ImapClient.php +++ b/src/ImapClient.php @@ -263,7 +263,7 @@ public function openMailbox(string $mailbox, OpenMode $mode): void * {@see enableQresync()}). * * @param ?ImapIdSet $knownUids The UIDs the client already has (the - * QRESYNC "known-uids" set), or null to + * QRESYNC "known-uids" set) or null to * omit it. * * @throws CapabilityNotSupportedException If QRESYNC is not enabled. @@ -906,7 +906,7 @@ private function searchArguments(array $built, array $results, bool $sequence): // RFC 3501 §6.4.4: CHARSET is optional and only carried when the // query holds text. RFC 6855 §3: once the connection is in UTF-8 - // mode (UTF8=ACCEPT, or IMAP4rev2 which implies it) the client + // mode (UTF8=ACCEPT or IMAP4rev2 which implies it) the client // MUST NOT send a CHARSET, since UTF-8 is implied. if ($built['charset'] !== null && !$this->utf8Enabled()) { $arguments[] = new ImapWireAtom('CHARSET'); @@ -997,13 +997,13 @@ public function store(string $mailbox, array $options): MessageIdSet } /** - * Permanently remove messages flagged `\Deleted` (RFC 3501 §6.4.3), + * Permanently remove messages flagged `\Deleted` (RFC 3501 §6.4.3) * or a UID subset of them with `UID EXPUNGE` (UIDPLUS, RFC 4315). * * With `delete: true` the requested `ids` are flagged `\Deleted` * first. With `list: true` the expunged messages are collected and * returned: sequence numbers from `* n EXPUNGE` on a plain - * connection, or UIDs from `* VANISHED` once QRESYNC is enabled + * connection or UIDs from `* VANISHED` once QRESYNC is enabled * (RFC 7162 §3.2.10). Cache-driven expunge bookkeeping is a * separate concern (see {@see ImapCacheStore}). * @@ -1059,7 +1059,7 @@ public function expunge(string $mailbox, array $options = []): MessageIdSet * @param array{ids?: MessageIdSet, create?: bool} $options * * @return MessageIdSet The destination UIDs from a `[COPYUID ...]` - * response (UIDPLUS, RFC 4315), or empty when the + * response (UIDPLUS, RFC 4315) or empty when the * server does not report them. * * @throws ServerResponseException If the server rejects the COPY. @@ -1079,7 +1079,7 @@ public function copy(string $source, string $dest, array $options = []): Message * @param array{ids?: MessageIdSet, create?: bool} $options * * @return MessageIdSet The destination UIDs from a `[COPYUID ...]` - * response, or empty when unreported. + * response or empty when unreported. * * @throws ServerResponseException If the server rejects the command. */ @@ -1113,16 +1113,22 @@ public function move(string $source, string $dest, array $options = []): Message * an ordinary literal (Dovecot stores bare LF as CRLF), so use it when * the message body must be stored byte for byte, for example with * `Content-Transfer-Encoding: binary`. + * With a `catenate` parts list, `binary: true` assembles TEXT parts locally into one `literal8` + * rather than a server-side `CATENATE` (RFC 4469 forbids literal8 for + * CATENATE parts). + * A `url` part cannot be combined with `binary`. * * @param list, flags?: list, internaldate?: DateTimeInterface, binary?: bool}> $data * @param array{create?: bool} $options * * @return MessageIdSet The new UIDs from `[APPENDUID ...]` responses - * (UIDPLUS, RFC 4315), or empty when unreported. + * (UIDPLUS, RFC 4315) or empty when unreported. * * @throws CapabilityNotSupportedException If a `url` CATENATE part is * used without server CATENATE, - * or binary content without BINARY. + * binary content without BINARY + * or a `url` CATENATE part is + * combined with `binary`. * @throws ServerResponseException If the server rejects the APPEND. * @throws WireEncodingException If stream input is invalid or cannot be read. */ @@ -1134,11 +1140,41 @@ public function append(string $mailbox, array $data, array $options = []): Messa // MULTIAPPEND is unavailable. Capture each stream's original position // once so retries and repeated references replay the same literal. foreach ($data as $index => $message) { + $binary = !empty($message['binary']); + if (isset($message['catenate']) && is_array($message['catenate'])) { + if (!$binary) { + continue; + } + // RFC 4469 permits an ordinary literal for a CATENATE TEXT + // part. A byte-preserving message cannot be + // assembled server-side. Concatenate the TEXT parts locally + // and send the whole thing as one literal8 instead. A URL + // part is resolved server side and has no literal8 form. + if (!$this->getCapability()->query('BINARY')) { + throw new CapabilityNotSupportedException('Binary APPEND requires the BINARY extension.'); + } + foreach ($message['catenate'] as $part) { + if (isset($part['url'])) { + throw new CapabilityNotSupportedException( + 'RFC 4469: A binary APPEND cannot contain a CATENATE URL part.' + ); + } + } + $value = fopen('php://temp', 'w+b'); + foreach ($message['catenate'] as $part) { + if (isset($part['text'])) { + fwrite($value, $part['text']); + } + } + rewind($value); + unset($data[$index]['catenate']); + $data[$index]['data'] = new ImapWireStream($value, true); + continue; } + $value = $message['data'] ?? ''; - $binary = !empty($message['binary']); if ($binary && !$this->getCapability()->query('BINARY')) { throw new CapabilityNotSupportedException('Binary APPEND requires the BINARY extension.'); } @@ -1209,7 +1245,7 @@ private function sendAppend(string $mailbox, array $messages, bool $create): Mes /** * The wire arguments for one appended message: optional flags, * optional internaldate, then the message content (a plain literal, a - * `UTF8 (...)` wrapper under UTF8=ACCEPT, or a `CATENATE (...)` list). + * `UTF8 (...)` wrapper under UTF8=ACCEPT or a `CATENATE (...)` list). * * @param array $message * diff --git a/test/Unit/Src/ImapClientAppendExtensionsTest.php b/test/Unit/Src/ImapClientAppendExtensionsTest.php index fd8cdab1..c3f0aadd 100644 --- a/test/Unit/Src/ImapClientAppendExtensionsTest.php +++ b/test/Unit/Src/ImapClientAppendExtensionsTest.php @@ -143,4 +143,81 @@ public function testCatenateUrlWithoutCapabilityThrows(): void $client->append('Drafts', [['catenate' => [['url' => '/Sent;UID=20']]]]); } + + public function testBinaryCatenateAssemblesLocallyEvenWithCatenateCapability(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1 BINARY CATENATE UIDPLUS] Server ready.'), + InMemoryImapSocket::line('+ go'), + InMemoryImapSocket::line('A1 OK [APPENDUID 1 5] APPEND completed.'), + ); + $client = $this->client($socket); + + $client->append('Drafts', [[ + 'binary' => true, + 'catenate' => [ + ['text' => "header\r\n\r\n"], + ['text' => "\0\n\r\xff"], + ], + ]]); + + // RFC 4469 forbids literal8 for CATENATE parts. A binary message is + // assembled locally into one literal8. We don't send a CATENATE keyword on the wire. + $body = "header\r\n\r\n\0\n\r\xff"; + self::assertSame( + 'A1 APPEND Drafts ~{' . strlen($body) . '}', + $socket->written[0], + ); + self::assertStringContainsString('~{', implode('', $socket->rawWritten)); + self::assertStringNotContainsString('CATENATE', implode('', $socket->rawWritten)); + self::assertStringContainsString($body, implode('', $socket->rawWritten)); + } + + public function testBinaryCatenateAssemblesLocallyWithoutCatenateCapability(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1 BINARY UIDPLUS] Server ready.'), + InMemoryImapSocket::line('+ go'), + InMemoryImapSocket::line('A1 OK [APPENDUID 1 5] APPEND completed.'), + ); + $client = $this->client($socket); + + $client->append('Drafts', [[ + 'binary' => true, + 'catenate' => [ + ['text' => "header\r\n\r\n"], + ['text' => "\0\n\r\xff"], + ], + ]]); + + $body = "header\r\n\r\n\0\n\r\xff"; + self::assertStringNotContainsString('CATENATE', implode('', $socket->rawWritten)); + self::assertStringContainsString($body, implode('', $socket->rawWritten)); + } + + public function testBinaryCatenateWithUrlPartThrows(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 BINARY CATENATE] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->append('Drafts', [[ + 'binary' => true, + 'catenate' => [['text' => 'Header: v'], ['url' => '/Sent;UID=20']], + ]]); + } + + public function testBinaryCatenateWithoutBinaryCapabilityThrows(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 CATENATE] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->append('Drafts', [[ + 'binary' => true, + 'catenate' => [['text' => 'Header: v']], + ]]); + } }