diff --git a/lib/Client/Command/AppendCommand.php b/lib/Client/Command/AppendCommand.php new file mode 100644 index 0000000..3da27e9 --- /dev/null +++ b/lib/Client/Command/AppendCommand.php @@ -0,0 +1,102 @@ +}`, the + * server replies with a continuation request ("+"), and only then is the + * message streamed back via {@see ResponseStream::respond()}. Returns the + * assigned UID when the server reports APPENDUID (RFC 4315 / UIDPLUS), + * otherwise null. + * + * @implements CommandInterface + */ +final class AppendCommand implements CommandInterface +{ + private readonly string $literal; + + /** + * @param list $flags optional initial flags, e.g. ['\\Seen'] + */ + public function __construct( + private readonly string $mailbox, + string $message, + private readonly array $flags = [], + ) { + // IMAP literals are octet-counted; normalise to CRLF line endings. + $this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message); + } + + public function name(): string + { + return 'APPEND'; + } + + public function allowedStates(): array + { + return [SessionState::Authenticated, SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + $flagSegment = $this->flags === [] ? '' : '(' . implode(' ', $this->flags) . ') '; + + return new RequestFrame(sprintf( + 'APPEND %s %s{%d}', + $this->quote($this->mailbox), + $flagSegment, + strlen($this->literal), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): ?int + { + unset($context); + + foreach ($responses as $response) { + if ($response instanceof ContinuationResponse) { + // Continuation granted: stream the literal, then CRLF to end the command. + $responses->respond($this->literal . "\r\n"); + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('APPEND failed: ' . $response->text()); + } + + return $this->parseAppendUid($response->text()); + } + } + + throw new ImapException('APPEND did not receive a tagged completion response.'); + } + + private function quote(string $mailbox): string + { + return '"' . addcslashes($mailbox, "\\\"") . '"'; + } + + private function parseAppendUid(string $text): ?int + { + if (preg_match('/\[APPENDUID\s+\d+\s+(\d+)\]/i', $text, $matches) === 1) { + return (int) $matches[1]; + } + + return null; + } +} diff --git a/lib/Client/Protocol/CommandExecutor.php b/lib/Client/Protocol/CommandExecutor.php index a84cf69..582a77a 100644 --- a/lib/Client/Protocol/CommandExecutor.php +++ b/lib/Client/Protocol/CommandExecutor.php @@ -42,9 +42,14 @@ final class CommandExecutor $frame = $command->encode($tag, $context); $this->writer->write($tag, $frame); - return $command->handle(new ResponseStream(function () use ($tag, $context): Generator { - yield from $this->processPerform($tag, $context); - }), $context); + return $command->handle(new ResponseStream( + function () use ($tag, $context): Generator { + yield from $this->processPerform($tag, $context); + }, + function (string $payload): void { + $this->writer->writeRaw($payload); + }, + ), $context); } /** diff --git a/lib/Client/Protocol/ProtocolWriter.php b/lib/Client/Protocol/ProtocolWriter.php index 0ea5da6..a114067 100644 --- a/lib/Client/Protocol/ProtocolWriter.php +++ b/lib/Client/Protocol/ProtocolWriter.php @@ -27,6 +27,21 @@ final class ProtocolWriter $this->connection->write($wire); } + /** + * Write raw bytes to the connection without tagging or logging the payload. + * + * Used to send literal data (e.g. an APPEND message body) after the server + * has issued a command continuation request. + */ + public function writeRaw(string $payload): void + { + $this->logger?->debug('IMAP literal sent (bytes={bytes})', [ + 'bytes' => strlen($payload), + ]); + + $this->connection->write($payload); + } + private function sanitizeWire(string $wire): string { $trimmed = rtrim($wire, "\r\n"); diff --git a/lib/Client/Protocol/ResponseStream.php b/lib/Client/Protocol/ResponseStream.php index 8ef96d4..79b54b1 100644 --- a/lib/Client/Protocol/ResponseStream.php +++ b/lib/Client/Protocol/ResponseStream.php @@ -6,6 +6,7 @@ namespace KTXM\ProviderImap\Client\Protocol; use Generator; use IteratorAggregate; +use KTXM\ProviderImap\Client\ImapException; use Traversable; final class ResponseStream implements IteratorAggregate @@ -13,16 +14,39 @@ final class ResponseStream implements IteratorAggregate /** @var \Closure():Generator */ private readonly \Closure $generatorFactory; + /** @var (\Closure(string):void)|null */ + private readonly ?\Closure $continuationWriter; + /** - * @param \Closure():Generator $generatorFactory + * @param \Closure():Generator $generatorFactory + * @param (\Closure(string):void)|null $continuationWriter writes raw bytes + * back to the server in response to a command continuation request */ - public function __construct(\Closure $generatorFactory) + public function __construct(\Closure $generatorFactory, ?\Closure $continuationWriter = null) { $this->generatorFactory = $generatorFactory; + $this->continuationWriter = $continuationWriter; } public function getIterator(): Traversable { return ($this->generatorFactory)(); } -} \ No newline at end of file + + /** + * Send raw bytes to the server after a command continuation request (a "+" + * response) — e.g. the literal payload of an APPEND. + * + * Must only be called while iterating this stream, in response to a + * {@see \KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse}; + * the next pulled response then reflects the server's reaction to the data. + */ + public function respond(string $payload): void + { + if ($this->continuationWriter === null) { + throw new ImapException('This response stream does not support continuation replies.'); + } + + ($this->continuationWriter)($payload); + } +} diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index d5c6c5c..7a809af 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -12,6 +12,7 @@ namespace KTXM\ProviderImap\Service\Remote; use DateTimeImmutable; use Generator; use KTXM\ProviderImap\Client\Client; +use KTXM\ProviderImap\Client\Command\AppendCommand; use KTXM\ProviderImap\Client\Command\FetchManyCommand; use KTXM\ProviderImap\Client\Command\FetchOneCommand; use KTXM\ProviderImap\Client\Command\ExpungeCommand; @@ -410,9 +411,9 @@ class RemoteMailService * * @param string[] $flags optional initial flags, e.g. ['\\Seen'] */ - public function entityCreate(string $collection, string $rawMessage, array $flags = []): int + public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int { - return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null); + return $this->client->perform(new AppendCommand($collection, $rawMessage, $flags)); } /**