Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions doc/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 46 additions & 10 deletions src/ImapClient.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

declare(strict_types=1);
Expand Down Expand Up @@ -263,7 +263,7 @@
* {@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.
Expand Down Expand Up @@ -906,7 +906,7 @@

// 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');
Expand Down Expand Up @@ -997,13 +997,13 @@
}

/**
* 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}).
*
Expand Down Expand Up @@ -1059,7 +1059,7 @@
* @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.
Expand All @@ -1079,7 +1079,7 @@
* @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.
*/
Expand Down Expand Up @@ -1113,16 +1113,22 @@
* 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<array{data?: string|resource, catenate?: list<array{text?: string, url?: string}>, flags?: list<SystemFlag|string>, 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.
*/
Expand All @@ -1134,11 +1140,41 @@
// 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.');
}
Expand Down Expand Up @@ -1209,7 +1245,7 @@
/**
* 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<string, mixed> $message
*
Expand Down
77 changes: 77 additions & 0 deletions test/Unit/Src/ImapClientAppendExtensionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
]]);
}
}
Loading