generated from Nodarx/template
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ace5d000bb |
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"psr/log": "^1.0|^2.0|^3.0",
|
"psr/log": "^1.0|^2.0|^3.0",
|
||||||
"doctrine/lexer": "^3.0",
|
"doctrine/lexer": "^3.0",
|
||||||
"symfony/mime": "^7.0",
|
"symfony/mime": "^8.0",
|
||||||
"egulias/email-validator": "^4.0"
|
"egulias/email-validator": "^4.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderImap\Client;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\Command\CapabilityCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||||
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CapabilityCommand;
|
use KTXM\ProviderImap\Client\Command\LoginCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
|
use KTXM\ProviderImap\Client\Command\StartTlsCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\LoginCommand;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\StartTlsCommand;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CommandExecutor;
|
use KTXM\ProviderImap\Client\Protocol\CommandExecutor;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ProtocolReader;
|
use KTXM\ProviderImap\Client\Protocol\ProtocolReader;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ProtocolWriter;
|
use KTXM\ProviderImap\Client\Protocol\ProtocolWriter;
|
||||||
@@ -90,7 +87,7 @@ final class Client implements ClientInterface
|
|||||||
return $this->executor->perform($command, $this->session);
|
return $this->executor->perform($command, $this->session);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function download(MessageTarget $target, string $section, int $chunkSize = 8192): \Generator
|
public function download(FetchTarget $target, string $section, int $chunkSize = 8192): \Generator
|
||||||
{
|
{
|
||||||
if ($this->session === null || $this->executor === null) {
|
if ($this->session === null || $this->executor === null) {
|
||||||
throw new ImapException('IMAP client is not connected.');
|
throw new ImapException('IMAP client is not connected.');
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderImap\Client;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
|
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
|
|
||||||
interface ClientInterface
|
interface ClientInterface
|
||||||
{
|
{
|
||||||
@@ -17,10 +17,6 @@ interface ClientInterface
|
|||||||
public function capabilities(): array;
|
public function capabilities(): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsuccessful command completion throws ImapException. For streamed
|
|
||||||
* results, completion is checked as the returned generator is consumed.
|
|
||||||
*
|
|
||||||
* @throws ImapException
|
|
||||||
* @template TResult
|
* @template TResult
|
||||||
* @param CommandInterface<TResult> $command
|
* @param CommandInterface<TResult> $command
|
||||||
* @return TResult
|
* @return TResult
|
||||||
@@ -32,5 +28,5 @@ interface ClientInterface
|
|||||||
*
|
*
|
||||||
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
||||||
*/
|
*/
|
||||||
public function download(MessageTarget $target, string $section, int $chunkSize = 8192): \Generator;
|
public function download(FetchTarget $target, string $section, int $chunkSize = 8192): \Generator;
|
||||||
}
|
}
|
||||||
+15
-20
@@ -2,18 +2,15 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\ResponseCodeParser;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* APPEND a raw RFC822 message to a mailbox (RFC 3501 §6.3.11).
|
* APPEND a raw RFC822 message to a mailbox (RFC 3501 §6.3.11).
|
||||||
@@ -28,7 +25,6 @@ use KTXM\ProviderImap\Client\Protocol\SessionState;
|
|||||||
*/
|
*/
|
||||||
final class AppendCommand implements CommandInterface
|
final class AppendCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
private readonly ResponseCodeParser $responseCodeParser;
|
|
||||||
private readonly string $literal;
|
private readonly string $literal;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,8 +35,6 @@ final class AppendCommand implements CommandInterface
|
|||||||
string $message,
|
string $message,
|
||||||
private readonly array $flags = [],
|
private readonly array $flags = [],
|
||||||
) {
|
) {
|
||||||
$this->responseCodeParser = new ResponseCodeParser();
|
|
||||||
|
|
||||||
// IMAP literals are octet-counted; normalise to CRLF line endings.
|
// IMAP literals are octet-counted; normalise to CRLF line endings.
|
||||||
$this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message);
|
$this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message);
|
||||||
}
|
}
|
||||||
@@ -63,7 +57,7 @@ final class AppendCommand implements CommandInterface
|
|||||||
|
|
||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'APPEND %s %s{%d}',
|
'APPEND %s %s{%d}',
|
||||||
StringEncoder::quote($this->mailbox),
|
$this->quote($this->mailbox),
|
||||||
$flagSegment,
|
$flagSegment,
|
||||||
strlen($this->literal),
|
strlen($this->literal),
|
||||||
));
|
));
|
||||||
@@ -81,7 +75,9 @@ final class AppendCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('APPEND failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return $this->parseAppendUid($response->text());
|
return $this->parseAppendUid($response->text());
|
||||||
}
|
}
|
||||||
@@ -90,18 +86,17 @@ final class AppendCommand implements CommandInterface
|
|||||||
throw new ImapException('APPEND did not receive a tagged completion response.');
|
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
|
private function parseAppendUid(string $text): ?int
|
||||||
{
|
{
|
||||||
$responseCode = $this->responseCodeParser->parse($text);
|
if (preg_match('/\[APPENDUID\s+\d+\s+(\d+)\]/i', $text, $matches) === 1) {
|
||||||
if ($responseCode === null || $responseCode['name'] !== 'APPENDUID') {
|
return (int) $matches[1];
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$arguments = $responseCode['arguments'];
|
return null;
|
||||||
if (count($arguments) !== 2 || !ctype_digit($arguments[0]) || !ctype_digit($arguments[1])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $arguments[1];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-6
@@ -2,17 +2,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CapabilityResult;
|
||||||
use KTXM\ProviderImap\Client\Result\CapabilityResult;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CapabilityResult>
|
* @implements CommandInterface<CapabilityResult>
|
||||||
@@ -50,7 +49,9 @@ final class CapabilityCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('CAPABILITY failed: ' . $response->text());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+3
-3
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @template TResult
|
* @template TResult
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
|
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<MessageTransferResult>
|
* @implements CommandInterface<MessageTransferResult>
|
||||||
@@ -19,7 +19,7 @@ final class CopyCommand implements CommandInterface
|
|||||||
private readonly MessageTransferCommand $command;
|
private readonly MessageTransferCommand $command;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
MessageTarget|string|SequenceSet|null $target = null,
|
FetchTarget|string|SequenceSet|null $target = null,
|
||||||
string $destinationMailbox = '',
|
string $destinationMailbox = '',
|
||||||
) {
|
) {
|
||||||
$this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox);
|
$this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox);
|
||||||
+16
-11
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class CreateCommand implements CommandInterface
|
final class CreateCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -40,21 +38,28 @@ final class CreateCommand implements CommandInterface
|
|||||||
{
|
{
|
||||||
unset($tag, $context);
|
unset($tag, $context);
|
||||||
|
|
||||||
return new RequestFrame(sprintf('CREATE %s', StringEncoder::quote($this->mailbox)));
|
return new RequestFrame(sprintf('CREATE %s', $this->quote($this->mailbox)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
unset($context);
|
unset($context);
|
||||||
|
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('CREATE failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ImapException('CREATE did not receive a tagged completion response.');
|
throw new ImapException('CREATE did not receive a tagged completion response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+16
-11
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class DeleteCommand implements CommandInterface
|
final class DeleteCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -40,10 +38,10 @@ final class DeleteCommand implements CommandInterface
|
|||||||
{
|
{
|
||||||
unset($tag, $context);
|
unset($tag, $context);
|
||||||
|
|
||||||
return new RequestFrame(sprintf('DELETE %s', StringEncoder::quote($this->mailbox)));
|
return new RequestFrame(sprintf('DELETE %s', $this->quote($this->mailbox)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
if ($context->selectedMailbox() === $this->mailbox) {
|
if ($context->selectedMailbox() === $this->mailbox) {
|
||||||
$context->setSelectedMailbox(null);
|
$context->setSelectedMailbox(null);
|
||||||
@@ -52,12 +50,19 @@ final class DeleteCommand implements CommandInterface
|
|||||||
|
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('DELETE failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ImapException('DELETE did not receive a tagged completion response.');
|
throw new ImapException('DELETE did not receive a tagged completion response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+15
-12
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<list<int>>
|
* @implements CommandInterface<list<int>>
|
||||||
@@ -23,7 +22,7 @@ final class ExpungeCommand implements CommandInterface
|
|||||||
{
|
{
|
||||||
private readonly ?SequenceSet $sequenceSet;
|
private readonly ?SequenceSet $sequenceSet;
|
||||||
|
|
||||||
public function __construct(MessageTarget|string|SequenceSet|null $target = null)
|
public function __construct(FetchTarget|string|SequenceSet|null $target = null)
|
||||||
{
|
{
|
||||||
if ($target === null) {
|
if ($target === null) {
|
||||||
$this->sequenceSet = null;
|
$this->sequenceSet = null;
|
||||||
@@ -32,9 +31,9 @@ final class ExpungeCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$resolvedTarget = match (true) {
|
$resolvedTarget = match (true) {
|
||||||
$target instanceof MessageTarget => $target,
|
$target instanceof FetchTarget => $target,
|
||||||
$target instanceof SequenceSet => MessageTarget::sequence($target),
|
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||||
is_string($target) => MessageTarget::sequence($target),
|
is_string($target) => FetchTarget::sequence($target),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +89,11 @@ final class ExpungeCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->sequenceSet === null ? 'EXPUNGE' : 'UID EXPUNGE', $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException($this->sequenceSet === null
|
||||||
|
? 'EXPUNGE failed: ' . $response->text()
|
||||||
|
: 'UID EXPUNGE failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return $expunged;
|
return $expunged;
|
||||||
}
|
}
|
||||||
+14
-17
@@ -2,37 +2,34 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\FetchResultReader;
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
use KTXM\ProviderImap\Client\FetchOptions;
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Message;
|
use KTXM\ProviderImap\Client\Message;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<Generator<int, Message>>
|
* @implements CommandInterface<Generator<int, Message>>
|
||||||
*/
|
*/
|
||||||
final class FetchManyCommand implements CommandInterface
|
final class FetchManyCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
private readonly MessageTarget $target;
|
private readonly FetchTarget $target;
|
||||||
private readonly FetchOptions $options;
|
private readonly FetchOptions $options;
|
||||||
|
|
||||||
public function __construct(MessageTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null)
|
public function __construct(FetchTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null)
|
||||||
{
|
{
|
||||||
$this->target = match (true) {
|
$this->target = match (true) {
|
||||||
$target instanceof MessageTarget => $target,
|
$target instanceof FetchTarget => $target,
|
||||||
$target instanceof SequenceSet => MessageTarget::sequence($target),
|
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||||
is_string($target) => MessageTarget::sequence($target),
|
is_string($target) => FetchTarget::sequence($target),
|
||||||
default => MessageTarget::all(),
|
default => FetchTarget::all(),
|
||||||
};
|
};
|
||||||
$this->options = $options ?? FetchOptions::default();
|
$this->options = $options ?? FetchOptions::default();
|
||||||
}
|
}
|
||||||
@@ -53,7 +50,7 @@ final class FetchManyCommand implements CommandInterface
|
|||||||
|
|
||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'%s %s (%s)',
|
'%s %s (%s)',
|
||||||
$this->target->identifierMode() === IdentifierMode::Uid ? 'UID FETCH' : 'FETCH',
|
$this->target->toCommand(),
|
||||||
$this->target->sequenceSet()->toCommand(),
|
$this->target->sequenceSet()->toCommand(),
|
||||||
$this->options->toCommand(),
|
$this->options->toCommand(),
|
||||||
));
|
));
|
||||||
@@ -65,6 +62,6 @@ final class FetchManyCommand implements CommandInterface
|
|||||||
throw new ImapException('FETCH requires a selected mailbox.');
|
throw new ImapException('FETCH requires a selected mailbox.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new FetchResultReader())->readMany($responses);
|
return (new FetchResponseParser())->parseMany($responses);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-14
@@ -2,33 +2,30 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
|
use KTXM\ProviderImap\Client\FetchOptions;
|
||||||
use KTXM\ProviderImap\Client\Protocol\FetchResultReader;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Message;
|
use KTXM\ProviderImap\Client\Message;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<Message>
|
* @implements CommandInterface<Message>
|
||||||
*/
|
*/
|
||||||
final class FetchOneCommand implements CommandInterface
|
final class FetchOneCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
private readonly MessageTarget $target;
|
private readonly FetchTarget $target;
|
||||||
private readonly FetchOptions $options;
|
private readonly FetchOptions $options;
|
||||||
|
|
||||||
public function __construct(MessageTarget|int|string $target, ?FetchOptions $options = null)
|
public function __construct(FetchTarget|int|string $target, ?FetchOptions $options = null)
|
||||||
{
|
{
|
||||||
$this->target = $target instanceof MessageTarget
|
$this->target = $target instanceof FetchTarget
|
||||||
? $target
|
? $target
|
||||||
: MessageTarget::sequence($target);
|
: FetchTarget::sequence($target);
|
||||||
$this->options = $options ?? FetchOptions::default();
|
$this->options = $options ?? FetchOptions::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +45,7 @@ final class FetchOneCommand implements CommandInterface
|
|||||||
|
|
||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'%s %s (%s)',
|
'%s %s (%s)',
|
||||||
$this->target->identifierMode() === IdentifierMode::Uid ? 'UID FETCH' : 'FETCH',
|
$this->target->toCommand(),
|
||||||
$this->target->sequenceSet()->toCommand(),
|
$this->target->sequenceSet()->toCommand(),
|
||||||
$this->options->toCommand(),
|
$this->options->toCommand(),
|
||||||
));
|
));
|
||||||
@@ -60,6 +57,6 @@ final class FetchOneCommand implements CommandInterface
|
|||||||
throw new ImapException('FETCH requires a selected mailbox.');
|
throw new ImapException('FETCH requires a selected mailbox.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new FetchResultReader())->readOne($responses);
|
return (new FetchResponseParser())->parseOne($responses);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
-14
@@ -2,25 +2,23 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Message;
|
use KTXM\ProviderImap\Client\Message;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser;
|
use KTXM\ProviderImap\Client\MessageParser;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
|
|
||||||
/**
|
final class FetchResponseParser
|
||||||
* Reads FETCH messages from a response stream and validates command completion.
|
|
||||||
*/
|
|
||||||
final class FetchResultReader
|
|
||||||
{
|
{
|
||||||
public function readOne(ResponseStream $responses): Message
|
public function parseOne(ResponseStream $responses): Message
|
||||||
{
|
{
|
||||||
$message = null;
|
$message = null;
|
||||||
|
|
||||||
foreach ($this->readMany($responses) as $summary) {
|
foreach ($this->parseMany($responses) as $summary) {
|
||||||
if ($message !== null) {
|
if ($message !== null) {
|
||||||
throw new ImapException('FETCH returned multiple messages for a single-message request.');
|
throw new ImapException('FETCH returned multiple messages for a single-message request.');
|
||||||
}
|
}
|
||||||
@@ -36,20 +34,20 @@ final class FetchResultReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consume the generator fully to reach and validate the tagged completion.
|
|
||||||
*
|
|
||||||
* @return Generator<int, Message>
|
* @return Generator<int, Message>
|
||||||
*/
|
*/
|
||||||
public function readMany(ResponseStream $responses): Generator
|
public function parseMany(ResponseStream $responses): Generator
|
||||||
{
|
{
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof UntaggedResponse && FetchMessageParser::isFetchMessage($response->raw())) {
|
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->raw())) {
|
||||||
yield FetchMessageParser::parse($response->raw());
|
yield MessageParser::parse($response->raw());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess('FETCH', $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('FETCH failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
|
use Generator;
|
||||||
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
|
use KTXM\ProviderImap\Client\ListReturnOptions;
|
||||||
|
use KTXM\ProviderImap\Client\ListSelectionOptions;
|
||||||
|
use KTXM\ProviderImap\Client\Mailbox;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements CommandInterface<Generator<int, Mailbox>>
|
||||||
|
*/
|
||||||
|
final class ListCommand implements CommandInterface
|
||||||
|
{
|
||||||
|
private readonly ListSelectionOptions $selectionOptions;
|
||||||
|
private readonly ListReturnOptions $returnOptions;
|
||||||
|
private readonly StatusResponseParser $statusResponseParser;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $reference = '',
|
||||||
|
private readonly string $pattern = '*',
|
||||||
|
?ListSelectionOptions $selectionOptions = null,
|
||||||
|
?ListReturnOptions $returnOptions = null,
|
||||||
|
) {
|
||||||
|
$this->selectionOptions = $selectionOptions ?? ListSelectionOptions::none();
|
||||||
|
$this->returnOptions = $returnOptions ?? ListReturnOptions::none();
|
||||||
|
$this->statusResponseParser = new StatusResponseParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'LIST';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowedStates(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
SessionState::Authenticated,
|
||||||
|
SessionState::Selected,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||||
|
{
|
||||||
|
unset($tag, $context);
|
||||||
|
|
||||||
|
$command = 'LIST';
|
||||||
|
|
||||||
|
$selectionOptions = $this->selectionOptions->toCommand();
|
||||||
|
if ($selectionOptions !== null) {
|
||||||
|
$command .= ' ' . $selectionOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$command .= sprintf(
|
||||||
|
' %s %s',
|
||||||
|
$this->quote($this->reference),
|
||||||
|
$this->quote($this->pattern),
|
||||||
|
);
|
||||||
|
|
||||||
|
$returnOptions = $this->returnOptions->toCommand();
|
||||||
|
if ($returnOptions !== null) {
|
||||||
|
$command .= ' RETURN ' . $returnOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RequestFrame($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ResponseStream $responses, SessionContext $context): Generator
|
||||||
|
{
|
||||||
|
unset($context);
|
||||||
|
|
||||||
|
if (!$this->returnOptions->hasStatus()) {
|
||||||
|
foreach ($responses as $response) {
|
||||||
|
if ($response instanceof UntaggedResponse && $response->label() === 'LIST') {
|
||||||
|
yield $this->parseMailbox($response->payload());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof TaggedResponse) {
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('LIST failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ImapException('LIST did not receive a tagged completion response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailboxes = [];
|
||||||
|
$statuses = [];
|
||||||
|
|
||||||
|
foreach ($responses as $response) {
|
||||||
|
if ($response instanceof UntaggedResponse && $response->label() === 'LIST') {
|
||||||
|
$mailbox = $this->parseMailbox($response->payload());
|
||||||
|
$mailboxes[$mailbox->name()] = $this->applyStatus(
|
||||||
|
$mailbox,
|
||||||
|
$statuses[$mailbox->name()] ?? [],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') {
|
||||||
|
[$mailboxName, $status] = $this->statusResponseParser->parse($response->payload());
|
||||||
|
$statuses[$mailboxName] = $status;
|
||||||
|
|
||||||
|
if (isset($mailboxes[$mailboxName])) {
|
||||||
|
$mailboxes[$mailboxName] = $this->applyStatus($mailboxes[$mailboxName], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof TaggedResponse) {
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('LIST failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($mailboxes as $mailbox) {
|
||||||
|
yield $mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ImapException('LIST did not receive a tagged completion response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseMailbox(string $payload): Mailbox
|
||||||
|
{
|
||||||
|
$payload = trim($payload);
|
||||||
|
$offset = 0;
|
||||||
|
|
||||||
|
$attributesToken = $this->readToken($payload, $offset);
|
||||||
|
$delimiterToken = $this->readToken($payload, $offset);
|
||||||
|
$nameToken = $this->readToken($payload, $offset);
|
||||||
|
|
||||||
|
if ($attributesToken === null || $delimiterToken === null || $nameToken === null) {
|
||||||
|
throw new ImapException('Unable to parse LIST response payload: ' . $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributeString = trim($attributesToken, '() ');
|
||||||
|
$attributes = $attributeString === '' || strtoupper($attributeString) === 'NIL'
|
||||||
|
? []
|
||||||
|
: array_map('strtoupper', preg_split('/\s+/', $attributeString) ?: []);
|
||||||
|
|
||||||
|
$delimiter = $this->decodeAtom($delimiterToken);
|
||||||
|
$name = $this->decodeMailboxName($nameToken);
|
||||||
|
|
||||||
|
return new Mailbox($name, $delimiter, $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $status
|
||||||
|
*/
|
||||||
|
private function applyStatus(Mailbox $mailbox, array $status): Mailbox
|
||||||
|
{
|
||||||
|
return new Mailbox(
|
||||||
|
$mailbox->name(),
|
||||||
|
$mailbox->delimiter(),
|
||||||
|
$mailbox->attributes(),
|
||||||
|
$status['MESSAGES'] ?? $mailbox->messages(),
|
||||||
|
$status['UNSEEN'] ?? $mailbox->unread(),
|
||||||
|
$mailbox->state(),
|
||||||
|
$mailbox->recent(),
|
||||||
|
$mailbox->flags(),
|
||||||
|
$mailbox->readOnly(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readToken(string $payload, int &$offset): ?string
|
||||||
|
{
|
||||||
|
$length = strlen($payload);
|
||||||
|
|
||||||
|
while ($offset < $length && ctype_space($payload[$offset])) {
|
||||||
|
$offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($offset >= $length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload[$offset] === '(') {
|
||||||
|
$end = strpos($payload, ')', $offset);
|
||||||
|
|
||||||
|
if ($end === false) {
|
||||||
|
throw new ImapException('Unterminated LIST attribute block: ' . $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = substr($payload, $offset, $end - $offset + 1);
|
||||||
|
$offset = $end + 1;
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload[$offset] === '"') {
|
||||||
|
$start = $offset;
|
||||||
|
$offset++;
|
||||||
|
|
||||||
|
while ($offset < $length) {
|
||||||
|
if ($payload[$offset] === '\\') {
|
||||||
|
$offset += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload[$offset] === '"') {
|
||||||
|
$offset++;
|
||||||
|
return substr($payload, $start, $offset - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ImapException('Unterminated quoted LIST token: ' . $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $offset;
|
||||||
|
while ($offset < $length && !ctype_space($payload[$offset])) {
|
||||||
|
$offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($payload, $start, $offset - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeAtom(string $value): ?string
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if (strtoupper($value) === 'NIL') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
|
||||||
|
return stripcslashes(substr($value, 1, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeMailboxName(string $value): string
|
||||||
|
{
|
||||||
|
$name = $this->decodeAtom($value);
|
||||||
|
|
||||||
|
// LIST may advertise the root mailbox as an empty quoted string.
|
||||||
|
return $name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-12
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class LoginCommand implements CommandInterface
|
final class LoginCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -40,23 +38,30 @@ final class LoginCommand implements CommandInterface
|
|||||||
|
|
||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'LOGIN %s %s',
|
'LOGIN %s %s',
|
||||||
StringEncoder::quote($this->username),
|
$this->quote($this->username),
|
||||||
StringEncoder::quote($this->password),
|
$this->quote($this->password),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('LOGIN failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
$context->setState(SessionState::Authenticated);
|
$context->setState(SessionState::Authenticated);
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ImapException('LOGIN did not receive a tagged completion response.');
|
throw new ImapException('LOGIN did not receive a tagged completion response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+10
-9
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class LogoutCommand implements CommandInterface
|
final class LogoutCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -39,17 +38,19 @@ final class LogoutCommand implements CommandInterface
|
|||||||
return new RequestFrame('LOGOUT');
|
return new RequestFrame('LOGOUT');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('LOGOUT failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
$context->setSelectedMailbox(null);
|
$context->setSelectedMailbox(null);
|
||||||
$context->setState(SessionState::Logout);
|
$context->setState(SessionState::Logout);
|
||||||
$context->connection()->disconnect();
|
$context->connection()->disconnect();
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+47
-24
@@ -2,45 +2,39 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\ResponseCodeParser;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<MessageTransferResult>
|
* @implements CommandInterface<MessageTransferResult>
|
||||||
*/
|
*/
|
||||||
final class MessageTransferCommand implements CommandInterface
|
final class MessageTransferCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
private readonly ResponseCodeParser $responseCodeParser;
|
|
||||||
private readonly string $operation;
|
private readonly string $operation;
|
||||||
private readonly SequenceSet $sequenceSet;
|
private readonly SequenceSet $sequenceSet;
|
||||||
private readonly IdentifierMode $identifierMode;
|
private readonly IdentifierMode $identifierMode;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $operation,
|
string $operation,
|
||||||
MessageTarget|string|SequenceSet|null $target = null,
|
FetchTarget|string|SequenceSet|null $target = null,
|
||||||
private readonly string $destinationMailbox = '',
|
private readonly string $destinationMailbox = '',
|
||||||
) {
|
) {
|
||||||
$this->responseCodeParser = new ResponseCodeParser();
|
|
||||||
|
|
||||||
$resolvedTarget = match (true) {
|
$resolvedTarget = match (true) {
|
||||||
$target instanceof MessageTarget => $target,
|
$target instanceof FetchTarget => $target,
|
||||||
$target instanceof SequenceSet => MessageTarget::sequence($target),
|
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||||
is_string($target) => MessageTarget::sequence($target),
|
is_string($target) => FetchTarget::sequence($target),
|
||||||
default => MessageTarget::all(),
|
default => FetchTarget::all(),
|
||||||
};
|
};
|
||||||
|
|
||||||
$this->operation = strtoupper(trim($operation));
|
$this->operation = strtoupper(trim($operation));
|
||||||
@@ -72,7 +66,7 @@ final class MessageTransferCommand implements CommandInterface
|
|||||||
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
|
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
|
||||||
$this->operation,
|
$this->operation,
|
||||||
$this->sequenceSet->toCommand(),
|
$this->sequenceSet->toCommand(),
|
||||||
StringEncoder::quote($this->destinationMailbox),
|
$this->quote($this->destinationMailbox),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +108,7 @@ final class MessageTransferCommand implements CommandInterface
|
|||||||
$highestModSeq,
|
$highestModSeq,
|
||||||
);
|
);
|
||||||
|
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
$result = new MessageTransferResult(
|
||||||
|
|
||||||
return new MessageTransferResult(
|
|
||||||
$response->status(),
|
$response->status(),
|
||||||
$response->text(),
|
$response->text(),
|
||||||
$responseCodes,
|
$responseCodes,
|
||||||
@@ -126,6 +118,12 @@ final class MessageTransferCommand implements CommandInterface
|
|||||||
$expunged,
|
$expunged,
|
||||||
$vanished,
|
$vanished,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException($this->operation . ' failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +183,7 @@ final class MessageTransferCommand implements CommandInterface
|
|||||||
bool &$tryCreate,
|
bool &$tryCreate,
|
||||||
?string &$highestModSeq,
|
?string &$highestModSeq,
|
||||||
): void {
|
): void {
|
||||||
$responseCode = $this->responseCodeParser->parse($text);
|
$responseCode = $this->parseResponseCode($text);
|
||||||
if ($responseCode === null) {
|
if ($responseCode === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,4 +215,29 @@ final class MessageTransferCommand implements CommandInterface
|
|||||||
'destinationUids' => $responseCode['arguments'][2],
|
'destinationUids' => $responseCode['arguments'][2],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* @return ?array{name:string, arguments:list<string>, text:string}
|
||||||
|
*/
|
||||||
|
private function parseResponseCode(string $text): ?array
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
if (preg_match('/^\[([A-Z0-9.-]+)(?:\s+([^\]]+))?\](?:\s*(.*))?$/i', $text, $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$arguments = trim($matches[2] ?? '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => strtoupper($matches[1]),
|
||||||
|
'arguments' => $arguments === '' ? [] : (preg_split('/\s+/', $arguments) ?: []),
|
||||||
|
'text' => trim($matches[3] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
|
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<MessageTransferResult>
|
* @implements CommandInterface<MessageTransferResult>
|
||||||
@@ -19,7 +19,7 @@ final class MoveCommand implements CommandInterface
|
|||||||
private readonly MessageTransferCommand $command;
|
private readonly MessageTransferCommand $command;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
MessageTarget|string|SequenceSet|null $target = null,
|
FetchTarget|string|SequenceSet|null $target = null,
|
||||||
string $destinationMailbox = '',
|
string $destinationMailbox = '',
|
||||||
) {
|
) {
|
||||||
$this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox);
|
$this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox);
|
||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class NoopCommand implements CommandInterface
|
final class NoopCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -39,15 +38,17 @@ final class NoopCommand implements CommandInterface
|
|||||||
return new RequestFrame('NOOP');
|
return new RequestFrame('NOOP');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
unset($context);
|
unset($context);
|
||||||
|
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('NOOP failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+17
-12
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class RenameCommand implements CommandInterface
|
final class RenameCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -43,25 +41,32 @@ final class RenameCommand implements CommandInterface
|
|||||||
|
|
||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'RENAME %s %s',
|
'RENAME %s %s',
|
||||||
StringEncoder::quote($this->fromMailbox),
|
$this->quote($this->fromMailbox),
|
||||||
StringEncoder::quote($this->toMailbox),
|
$this->quote($this->toMailbox),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('RENAME failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
if ($context->selectedMailbox() === $this->fromMailbox) {
|
if ($context->selectedMailbox() === $this->fromMailbox) {
|
||||||
$context->setSelectedMailbox($this->toMailbox);
|
$context->setSelectedMailbox($this->toMailbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ImapException('RENAME did not receive a tagged completion response.');
|
throw new ImapException('RENAME did not receive a tagged completion response.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Result;
|
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||||
|
|
||||||
final class CapabilityResult
|
final class CapabilityResult
|
||||||
{
|
{
|
||||||
+7
-5
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Result;
|
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||||
|
|
||||||
/**
|
final class CommandStatusResult
|
||||||
* Successful command output; unsuccessful completion is reported by exception.
|
|
||||||
*/
|
|
||||||
final class CommandCompletion
|
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $status,
|
private readonly string $status,
|
||||||
@@ -23,4 +20,9 @@ final class CommandCompletion
|
|||||||
{
|
{
|
||||||
return $this->text;
|
return $this->text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isOk(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'OK';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+6
-4
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Result;
|
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||||
|
|
||||||
/**
|
|
||||||
* Successful command output; unsuccessful completion is reported by exception.
|
|
||||||
*/
|
|
||||||
final class MessageTransferResult
|
final class MessageTransferResult
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +33,11 @@ final class MessageTransferResult
|
|||||||
return $this->text;
|
return $this->text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isOk(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{source:string, name:string, arguments:list<string>, text:string}>
|
* @return list<array{source:string, name:string, arguments:list<string>, text:string}>
|
||||||
*/
|
*/
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Result;
|
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
|
|
||||||
final class SearchResult
|
final class SearchResult
|
||||||
{
|
{
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Result;
|
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
|
|
||||||
final class SortResult
|
final class SortResult
|
||||||
{
|
{
|
||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Result;
|
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||||
|
|
||||||
final class MailboxStatusResult
|
final class StatusResult
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, int> $items
|
* @param array<string, int> $items
|
||||||
@@ -47,7 +47,7 @@ final class MailboxStatusResult
|
|||||||
return max(0, $this->messages() - $this->unseen());
|
return max(0, $this->messages() - $this->unseen());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function uidValidity(): int
|
public function state(): int
|
||||||
{
|
{
|
||||||
return $this->value('UIDVALIDITY');
|
return $this->value('UIDVALIDITY');
|
||||||
}
|
}
|
||||||
+9
-8
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\SearchResult;
|
||||||
use KTXM\ProviderImap\Client\Result\SearchResult;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\SearchCriteriaBuilder;
|
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<SearchResult>
|
* @implements CommandInterface<SearchResult>
|
||||||
@@ -75,7 +74,9 @@ final class SearchCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('SEARCH failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new SearchResult($matches, $this->identifierMode);
|
return new SearchResult($matches, $this->identifierMode);
|
||||||
}
|
}
|
||||||
+12
-26
@@ -2,18 +2,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
use KTXM\ProviderImap\Client\Mailbox;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<Mailbox>
|
* @implements CommandInterface<Mailbox>
|
||||||
@@ -45,7 +43,7 @@ final class SelectCommand implements CommandInterface
|
|||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'%s %s',
|
'%s %s',
|
||||||
$this->name(),
|
$this->name(),
|
||||||
StringEncoder::quote($this->mailbox),
|
$this->quote($this->mailbox),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +51,6 @@ final class SelectCommand implements CommandInterface
|
|||||||
{
|
{
|
||||||
$exists = 0;
|
$exists = 0;
|
||||||
$recent = 0;
|
$recent = 0;
|
||||||
$uidValidity = null;
|
|
||||||
$uidNext = null;
|
|
||||||
$highestModSeq = null;
|
|
||||||
$flags = [];
|
$flags = [];
|
||||||
$readOnly = $this->readOnly;
|
$readOnly = $this->readOnly;
|
||||||
|
|
||||||
@@ -73,16 +68,6 @@ final class SelectCommand implements CommandInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// response codes, e.g. "* OK [UIDVALIDITY 3857529045] UIDs valid"
|
|
||||||
if (preg_match('/^\*\s+OK\s+\[(UIDVALIDITY|UIDNEXT|HIGHESTMODSEQ)\s+(\d+)\]/i', $raw, $matches)) {
|
|
||||||
match (strtoupper($matches[1])) {
|
|
||||||
'UIDVALIDITY' => $uidValidity = (int) $matches[2],
|
|
||||||
'UIDNEXT' => $uidNext = (int) $matches[2],
|
|
||||||
'HIGHESTMODSEQ' => $highestModSeq = (int) $matches[2],
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response->label() === 'FLAGS' && preg_match('/\(([^)]*)\)/', $response->payload(), $matches)) {
|
if ($response->label() === 'FLAGS' && preg_match('/\(([^)]*)\)/', $response->payload(), $matches)) {
|
||||||
$flags = $this->parseFlags($matches[1]);
|
$flags = $this->parseFlags($matches[1]);
|
||||||
continue;
|
continue;
|
||||||
@@ -90,11 +75,9 @@ final class SelectCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
// a failed SELECT leaves no mailbox selected (RFC 3501 6.3.1)
|
if (!$response->isOk()) {
|
||||||
if ($response->status() !== 'OK') {
|
throw new ImapException($this->name() . ' failed: ' . $response->text());
|
||||||
$context->setSelectedMailbox(null);
|
|
||||||
}
|
}
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
|
||||||
|
|
||||||
if (str_contains(strtoupper($response->text()), 'READ-ONLY')) {
|
if (str_contains(strtoupper($response->text()), 'READ-ONLY')) {
|
||||||
$readOnly = true;
|
$readOnly = true;
|
||||||
@@ -109,12 +92,10 @@ final class SelectCommand implements CommandInterface
|
|||||||
[],
|
[],
|
||||||
$exists,
|
$exists,
|
||||||
0,
|
0,
|
||||||
$uidValidity,
|
null,
|
||||||
$recent,
|
$recent,
|
||||||
$flags,
|
$flags,
|
||||||
$readOnly,
|
$readOnly,
|
||||||
$uidNext,
|
|
||||||
$highestModSeq,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,4 +116,9 @@ final class SelectCommand implements CommandInterface
|
|||||||
|
|
||||||
return preg_split('/\s+/', $flags) ?: [];
|
return preg_split('/\s+/', $flags) ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\SortResult;
|
||||||
use KTXM\ProviderImap\Client\Result\SortResult;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\SearchCriteriaBuilder;
|
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<SortResult>
|
* @implements CommandInterface<SortResult>
|
||||||
@@ -83,7 +82,9 @@ final class SortCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('SORT failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new SortResult($matches, $this->identifierMode);
|
return new SortResult($matches, $this->identifierMode);
|
||||||
}
|
}
|
||||||
+10
-9
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class StartTlsCommand implements CommandInterface
|
final class StartTlsCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -35,16 +34,18 @@ final class StartTlsCommand implements CommandInterface
|
|||||||
return new RequestFrame('STARTTLS');
|
return new RequestFrame('STARTTLS');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('STARTTLS failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
$context->connection()->upgradeToTls();
|
$context->connection()->upgradeToTls();
|
||||||
$context->replaceCapabilities();
|
$context->replaceCapabilities();
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+15
-12
@@ -2,22 +2,19 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\StatusResponseParser;
|
|
||||||
use KTXM\ProviderImap\Client\Result\MailboxStatusResult;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<MailboxStatusResult>
|
* @implements CommandInterface<StatusResult>
|
||||||
*/
|
*/
|
||||||
final class StatusCommand implements CommandInterface
|
final class StatusCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -53,12 +50,12 @@ final class StatusCommand implements CommandInterface
|
|||||||
|
|
||||||
return new RequestFrame(sprintf(
|
return new RequestFrame(sprintf(
|
||||||
'STATUS %s (%s)',
|
'STATUS %s (%s)',
|
||||||
StringEncoder::quote($this->mailbox),
|
$this->quote($this->mailbox),
|
||||||
implode(' ', $this->normalizeItems($this->items)),
|
implode(' ', $this->normalizeItems($this->items)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): MailboxStatusResult
|
public function handle(ResponseStream $responses, SessionContext $context): StatusResult
|
||||||
{
|
{
|
||||||
unset($context);
|
unset($context);
|
||||||
|
|
||||||
@@ -72,9 +69,11 @@ final class StatusCommand implements CommandInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('STATUS failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new MailboxStatusResult($mailbox, $items);
|
return new StatusResult($mailbox, $items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +111,8 @@ final class StatusCommand implements CommandInterface
|
|||||||
|
|
||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
private function quote(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
|
|
||||||
+18
-17
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements CommandInterface<CommandCompletion>
|
* @implements CommandInterface<CommandStatusResult>
|
||||||
*/
|
*/
|
||||||
final class StoreCommand implements CommandInterface
|
final class StoreCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
@@ -28,16 +27,16 @@ final class StoreCommand implements CommandInterface
|
|||||||
* @param list<string> $flags
|
* @param list<string> $flags
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
MessageTarget|string|SequenceSet|null $target = null,
|
FetchTarget|string|SequenceSet|null $target = null,
|
||||||
private array $flags = [],
|
private array $flags = [],
|
||||||
private string $action = '',
|
private string $action = '',
|
||||||
private bool $silent = true,
|
private bool $silent = true,
|
||||||
) {
|
) {
|
||||||
$resolvedTarget = match (true) {
|
$resolvedTarget = match (true) {
|
||||||
$target instanceof MessageTarget => $target,
|
$target instanceof FetchTarget => $target,
|
||||||
$target instanceof SequenceSet => MessageTarget::sequence($target),
|
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||||
is_string($target) => MessageTarget::sequence($target),
|
is_string($target) => FetchTarget::sequence($target),
|
||||||
default => MessageTarget::all(),
|
default => FetchTarget::all(),
|
||||||
};
|
};
|
||||||
|
|
||||||
$normalizedAction = trim($this->action);
|
$normalizedAction = trim($this->action);
|
||||||
@@ -83,7 +82,7 @@ final class StoreCommand implements CommandInterface
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
|
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||||
{
|
{
|
||||||
if ($context->selectedMailbox() === null) {
|
if ($context->selectedMailbox() === null) {
|
||||||
throw new ImapException('STORE requires a selected mailbox.');
|
throw new ImapException('STORE requires a selected mailbox.');
|
||||||
@@ -91,9 +90,11 @@ final class StoreCommand implements CommandInterface
|
|||||||
|
|
||||||
foreach ($responses as $response) {
|
foreach ($responses as $response) {
|
||||||
if ($response instanceof TaggedResponse) {
|
if ($response instanceof TaggedResponse) {
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('STORE failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandCompletion($response->status(), $response->text());
|
return new CommandStatusResult($response->status(), $response->text());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A server-reported unsuccessful tagged command completion.
|
|
||||||
*/
|
|
||||||
final class CommandFailedException extends ImapException
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param ?array{name:string, arguments:list<string>, text:string} $responseCode
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $command,
|
|
||||||
private readonly string $status,
|
|
||||||
private readonly string $text,
|
|
||||||
private readonly ?array $responseCode = null,
|
|
||||||
) {
|
|
||||||
parent::__construct($command . ' failed: ' . $text);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function command(): string
|
|
||||||
{
|
|
||||||
return $this->command;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function status(): string
|
|
||||||
{
|
|
||||||
return $this->status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function text(): string
|
|
||||||
{
|
|
||||||
return $this->text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The code from the tagged completion, if present.
|
|
||||||
*
|
|
||||||
* @return ?array{name:string, arguments:list<string>, text:string}
|
|
||||||
*/
|
|
||||||
public function responseCode(): ?array
|
|
||||||
{
|
|
||||||
return $this->responseCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-13
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
final class FetchOptions
|
final class FetchOptions
|
||||||
{
|
{
|
||||||
@@ -57,7 +57,7 @@ final class FetchOptions
|
|||||||
|
|
||||||
public function withHeaders(): self
|
public function withHeaders(): self
|
||||||
{
|
{
|
||||||
return $this->with('BODY.PEEK[HEADER]');
|
return $this->with('BODY[HEADER]');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withHeader(string ...$fields): self
|
public function withHeader(string ...$fields): self
|
||||||
@@ -84,21 +84,14 @@ 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('BODY[TEXT]');
|
||||||
return $this->with(sprintf('BODY.PEEK[TEXT]<0.%d>', $limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->with('BODY.PEEK[TEXT]');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withBody(): self
|
public function withBody(): self
|
||||||
{
|
{
|
||||||
return $this->with('BODY.PEEK[]');
|
return $this->with('BODY[]');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function withBodySection(string $section): self
|
public function withBodySection(string $section): self
|
||||||
@@ -109,7 +102,7 @@ final class FetchOptions
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->with(sprintf('BODY.PEEK[%s]', $section));
|
return $this->with(sprintf('BODY[%s]', $section));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function with(string $item): self
|
public function with(string $item): self
|
||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
final class FetchTarget
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
|
||||||
|
|
||||||
final class MessageTarget
|
|
||||||
{
|
{
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private readonly SequenceSet $sequenceSet,
|
private readonly SequenceSet $sequenceSet,
|
||||||
@@ -39,6 +36,11 @@ final class MessageTarget
|
|||||||
return $this->identifierMode;
|
return $this->identifierMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toCommand(): string
|
||||||
|
{
|
||||||
|
return $this->identifierMode->toCommand();
|
||||||
|
}
|
||||||
|
|
||||||
private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet
|
private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
|
enum IdentifierMode
|
||||||
|
{
|
||||||
|
case Sequence;
|
||||||
|
case Uid;
|
||||||
|
|
||||||
|
public function toCommand(): string
|
||||||
|
{
|
||||||
|
return $this === self::Uid ? 'UID FETCH' : 'FETCH';
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-3
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
|
||||||
|
|
||||||
final class ListReturnOptions
|
final class ListReturnOptions
|
||||||
{
|
{
|
||||||
+1
-3
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
|
||||||
|
|
||||||
final class ListSelectionOptions
|
final class ListSelectionOptions
|
||||||
{
|
{
|
||||||
+8
-24
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderImap\Client;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Result\MailboxStatusResult;
|
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
|
||||||
|
|
||||||
final class Mailbox
|
final class Mailbox
|
||||||
{
|
{
|
||||||
@@ -18,30 +18,24 @@ final class Mailbox
|
|||||||
private readonly array $attributes,
|
private readonly array $attributes,
|
||||||
private readonly int $messages = 0,
|
private readonly int $messages = 0,
|
||||||
private readonly int $unread = 0,
|
private readonly int $unread = 0,
|
||||||
private readonly ?int $uidValidity = null,
|
private readonly ?int $state = null,
|
||||||
private readonly int $recent = 0,
|
private readonly int $recent = 0,
|
||||||
private readonly array $flags = [],
|
private readonly array $flags = [],
|
||||||
private readonly bool $readOnly = true,
|
private readonly bool $readOnly = true,
|
||||||
private readonly ?int $uidNext = null,
|
|
||||||
private readonly ?int $highestModSeq = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function fromStatus(MailboxStatusResult $status): self
|
public function fromStatus(StatusResult $status): self
|
||||||
{
|
{
|
||||||
$items = $status->items();
|
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
$this->name,
|
$this->name,
|
||||||
$this->delimiter,
|
$this->delimiter,
|
||||||
$this->attributes,
|
$this->attributes,
|
||||||
$items['MESSAGES'] ?? $this->messages,
|
$status->messages() ?? $this->messages,
|
||||||
$items['UNSEEN'] ?? $this->unread,
|
$status->unseen() ?? $this->unread,
|
||||||
$items['UIDVALIDITY'] ?? $this->uidValidity,
|
$status->state() ?? $this->state,
|
||||||
$this->recent,
|
$this->recent,
|
||||||
$this->flags,
|
$this->flags,
|
||||||
$this->readOnly,
|
$this->readOnly,
|
||||||
$items['UIDNEXT'] ?? $this->uidNext,
|
|
||||||
$items['HIGHESTMODSEQ'] ?? $this->highestModSeq,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,19 +57,9 @@ final class Mailbox
|
|||||||
return $this->attributes;
|
return $this->attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function uidValidity(): ?int
|
public function state(): ?int
|
||||||
{
|
{
|
||||||
return $this->uidValidity;
|
return $this->state;
|
||||||
}
|
|
||||||
|
|
||||||
public function uidNext(): ?int
|
|
||||||
{
|
|
||||||
return $this->uidNext;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function highestModSeq(): ?int
|
|
||||||
{
|
|
||||||
return $this->highestModSeq;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function messages(): int
|
public function messages(): int
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ final class Message
|
|||||||
* @param list<MessageAddress> $cc
|
* @param list<MessageAddress> $cc
|
||||||
* @param list<MessageAddress> $bcc
|
* @param list<MessageAddress> $bcc
|
||||||
* @param array<string, string> $bodySections
|
* @param array<string, string> $bodySections
|
||||||
* @param list<string> $references message ids from the References header, without angle brackets
|
|
||||||
* @param list<string> $truncatedSections part ids of text sections cut off by a partial fetch
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly int $sequence,
|
private readonly int $sequence,
|
||||||
@@ -37,8 +35,6 @@ final class Message
|
|||||||
private readonly array $bcc,
|
private readonly array $bcc,
|
||||||
private readonly ?MessagePart $bodyStructure,
|
private readonly ?MessagePart $bodyStructure,
|
||||||
private readonly array $bodySections,
|
private readonly array $bodySections,
|
||||||
private readonly array $references = [],
|
|
||||||
private readonly array $truncatedSections = [],
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function sequence(): int
|
public function sequence(): int
|
||||||
@@ -94,14 +90,6 @@ final class Message
|
|||||||
return $this->inReplyTo;
|
return $this->inReplyTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function references(): array
|
|
||||||
{
|
|
||||||
return $this->references;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<MessageAddress>
|
* @return list<MessageAddress>
|
||||||
*/
|
*/
|
||||||
@@ -168,16 +156,6 @@ final class Message
|
|||||||
return $this->bodySections;
|
return $this->bodySections;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Part ids of text sections that were cut off by a partial fetch.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function truncatedSections(): array
|
|
||||||
{
|
|
||||||
return $this->truncatedSections;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function bodyRaw(): ?string
|
public function bodyRaw(): ?string
|
||||||
{
|
{
|
||||||
return $this->bodySections[''] ?? null;
|
return $this->bodySections[''] ?? null;
|
||||||
@@ -207,8 +185,6 @@ final class Message
|
|||||||
$this->bcc,
|
$this->bcc,
|
||||||
$bodyStructure,
|
$bodyStructure,
|
||||||
$bodySections,
|
$bodySections,
|
||||||
$this->references,
|
|
||||||
$this->truncatedSections,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,11 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
|
||||||
use KTXM\ProviderImap\Client\Message;
|
|
||||||
use KTXM\ProviderImap\Client\MessageAddress;
|
|
||||||
use KTXM\ProviderImap\Client\MessagePart;
|
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
|
|
||||||
/**
|
final class MessageParser
|
||||||
* Decodes an individual untagged FETCH response into a message.
|
|
||||||
*/
|
|
||||||
final class FetchMessageParser
|
|
||||||
{
|
{
|
||||||
public static function isFetchMessage(string $raw): bool
|
public static function isFetchMessage(string $raw): bool
|
||||||
{
|
{
|
||||||
@@ -33,8 +26,7 @@ final class FetchMessageParser
|
|||||||
$uid = self::toInt($attributes['UID'] ?? null, 'FETCH response is missing UID: ' . $raw);
|
$uid = self::toInt($attributes['UID'] ?? null, 'FETCH response is missing UID: ' . $raw);
|
||||||
$envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null;
|
$envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null;
|
||||||
$bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null;
|
$bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null;
|
||||||
$truncatedSections = [];
|
$bodySections = self::parseBodySections($attributes, $bodyStructure);
|
||||||
$bodySections = self::parseBodySections($attributes, $bodyStructure, $truncatedSections);
|
|
||||||
$headers = self::parseFetchedHeaders($attributes);
|
$headers = self::parseFetchedHeaders($attributes);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
@@ -47,7 +39,7 @@ final class FetchMessageParser
|
|||||||
self::decodeMimeHeader(self::envelopeString($envelope, 1)),
|
self::decodeMimeHeader(self::envelopeString($envelope, 1)),
|
||||||
self::envelopeString($envelope, 0),
|
self::envelopeString($envelope, 0),
|
||||||
self::trimAngles(self::envelopeString($envelope, 9)),
|
self::trimAngles(self::envelopeString($envelope, 9)),
|
||||||
self::trimAngles(self::envelopeString($envelope, 8)),
|
self::envelopeString($envelope, 8),
|
||||||
self::parseAddressList($envelope[2] ?? null),
|
self::parseAddressList($envelope[2] ?? null),
|
||||||
self::parseAddressList($envelope[3] ?? null),
|
self::parseAddressList($envelope[3] ?? null),
|
||||||
self::parseAddressList($envelope[4] ?? null),
|
self::parseAddressList($envelope[4] ?? null),
|
||||||
@@ -56,8 +48,6 @@ final class FetchMessageParser
|
|||||||
self::parseAddressList($envelope[7] ?? null),
|
self::parseAddressList($envelope[7] ?? null),
|
||||||
$bodyStructure,
|
$bodyStructure,
|
||||||
$bodySections,
|
$bodySections,
|
||||||
self::extractReferences($headers),
|
|
||||||
$truncatedSections,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +95,7 @@ final class FetchMessageParser
|
|||||||
$depth--;
|
$depth--;
|
||||||
if ($depth === 0) {
|
if ($depth === 0) {
|
||||||
$offset++;
|
$offset++;
|
||||||
$name = substr($payload, $start, $offset - $start);
|
return 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,24 +337,6 @@ final class FetchMessageParser
|
|||||||
return $parsed;
|
return $parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the message ids listed in the References header, without angle brackets.
|
|
||||||
*
|
|
||||||
* @param array<string, list<string>> $headers
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function extractReferences(array $headers): array
|
|
||||||
{
|
|
||||||
$references = [];
|
|
||||||
foreach ($headers['references'] ?? [] as $value) {
|
|
||||||
if (preg_match_all('/<([^<>\s]+)>/', $value, $matches) > 0) {
|
|
||||||
array_push($references, ...$matches[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($references));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, list<string>> $headers
|
* @param array<string, list<string>> $headers
|
||||||
*/
|
*/
|
||||||
@@ -545,7 +510,7 @@ final class FetchMessageParser
|
|||||||
* @param array<string, mixed> $attributes
|
* @param array<string, mixed> $attributes
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function parseBodySections(array $attributes, ?MessagePart $bodyStructure = null, array &$truncated = []): array
|
private static function parseBodySections(array $attributes, ?MessagePart $bodyStructure = null): array
|
||||||
{
|
{
|
||||||
$sections = [];
|
$sections = [];
|
||||||
|
|
||||||
@@ -572,7 +537,7 @@ final class FetchMessageParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($bodyStructure->isMultipart()) {
|
if ($bodyStructure->isMultipart()) {
|
||||||
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure, $truncated);
|
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure);
|
||||||
unset($sections['TEXT']);
|
unset($sections['TEXT']);
|
||||||
|
|
||||||
foreach ($derivedSections as $section => $content) {
|
foreach ($derivedSections as $section => $content) {
|
||||||
@@ -583,11 +548,6 @@ final class FetchMessageParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($bodyStructure->mimeType(), 'text/')) {
|
if (str_starts_with($bodyStructure->mimeType(), 'text/')) {
|
||||||
// a single-part body has no closing boundary; a partial fetch shows as fewer octets than declared
|
|
||||||
$declaredSize = $bodyStructure->size();
|
|
||||||
if ($declaredSize !== null && strlen($sections['TEXT']) < $declaredSize) {
|
|
||||||
$truncated[] = $bodyStructure->partId();
|
|
||||||
}
|
|
||||||
$sections[$bodyStructure->partId()] ??= $sections['TEXT'];
|
$sections[$bodyStructure->partId()] ??= $sections['TEXT'];
|
||||||
unset($sections['TEXT']);
|
unset($sections['TEXT']);
|
||||||
}
|
}
|
||||||
@@ -623,7 +583,7 @@ final class FetchMessageParser
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function sectionsFromBodyText(string $content, MessagePart $part, array &$truncatedParts = []): array
|
private static function sectionsFromBodyText(string $content, MessagePart $part): array
|
||||||
{
|
{
|
||||||
if ($part->isMultipart()) {
|
if ($part->isMultipart()) {
|
||||||
$boundary = $part->parameters()['boundary'] ?? '';
|
$boundary = $part->parameters()['boundary'] ?? '';
|
||||||
@@ -632,15 +592,13 @@ final class FetchMessageParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sections = [];
|
$sections = [];
|
||||||
$segments = self::splitMultipartBody($content, $boundary, $truncated);
|
$segments = self::splitMultipartBody($content, $boundary);
|
||||||
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
$segmentTruncated = $truncated && $index === $lastIndex;
|
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) {
|
||||||
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart, $segmentTruncated, $truncatedParts) as $section => $childContent) {
|
|
||||||
$sections[$section] = $childContent;
|
$sections[$section] = $childContent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,27 +616,18 @@ final class FetchMessageParser
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function sectionsFromMimeEntity(string $content, MessagePart $part, bool $truncated = false, array &$truncatedParts = []): array
|
private static function sectionsFromMimeEntity(string $content, MessagePart $part): 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()) {
|
||||||
return self::sectionsFromBodyText($body, $part, $truncatedParts);
|
return self::sectionsFromBodyText($body, $part);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!str_starts_with($part->mimeType(), 'text/')) {
|
if (!str_starts_with($part->mimeType(), 'text/')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($truncated) {
|
|
||||||
$truncatedParts[] = $part->partId();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$part->partId() => $body];
|
return [$part->partId() => $body];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,16 +648,10 @@ 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, ?bool &$truncated = null): array
|
private static function splitMultipartBody(string $content, string $boundary): 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 [];
|
||||||
@@ -727,17 +670,12 @@ final class FetchMessageParser
|
|||||||
&& $matches[1][$index][0] === '--';
|
&& $matches[1][$index][0] === '--';
|
||||||
|
|
||||||
if ($isClosing) {
|
if ($isClosing) {
|
||||||
return $segments;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$segmentStart = $offset + strlen($match);
|
$segmentStart = $offset + strlen($match);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($segmentStart !== null && $segmentStart < strlen($content)) {
|
|
||||||
$segments[] = substr($content, $segmentStart);
|
|
||||||
$truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $segments;
|
return $segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,22 +699,11 @@ 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' => self::decodeBase64($content),
|
'base64' => base64_decode($content, true) ?: '',
|
||||||
default => $content,
|
default => $content,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\CompletionChecker;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\ListResponseParser;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\StatusResponseParser;
|
|
||||||
use Generator;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListReturnOptions;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListSelectionOptions;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Client\Result\MailboxStatusResult;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @implements CommandInterface<Generator<int, Mailbox>>
|
|
||||||
*/
|
|
||||||
final class ListCommand implements CommandInterface
|
|
||||||
{
|
|
||||||
private readonly ListSelectionOptions $selectionOptions;
|
|
||||||
private readonly ListReturnOptions $returnOptions;
|
|
||||||
private readonly ListResponseParser $listResponseParser;
|
|
||||||
private readonly StatusResponseParser $statusResponseParser;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $reference = '',
|
|
||||||
private readonly string $pattern = '*',
|
|
||||||
?ListSelectionOptions $selectionOptions = null,
|
|
||||||
?ListReturnOptions $returnOptions = null,
|
|
||||||
) {
|
|
||||||
$this->selectionOptions = $selectionOptions ?? ListSelectionOptions::none();
|
|
||||||
$this->returnOptions = $returnOptions ?? ListReturnOptions::none();
|
|
||||||
$this->listResponseParser = new ListResponseParser();
|
|
||||||
$this->statusResponseParser = new StatusResponseParser();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
|
||||||
{
|
|
||||||
return 'LIST';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function allowedStates(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
SessionState::Authenticated,
|
|
||||||
SessionState::Selected,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
|
||||||
{
|
|
||||||
unset($tag, $context);
|
|
||||||
|
|
||||||
$command = 'LIST';
|
|
||||||
|
|
||||||
$selectionOptions = $this->selectionOptions->toCommand();
|
|
||||||
if ($selectionOptions !== null) {
|
|
||||||
$command .= ' ' . $selectionOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
$command .= sprintf(
|
|
||||||
' %s %s',
|
|
||||||
StringEncoder::quote($this->reference),
|
|
||||||
StringEncoder::quote($this->pattern),
|
|
||||||
);
|
|
||||||
|
|
||||||
$returnOptions = $this->returnOptions->toCommand();
|
|
||||||
if ($returnOptions !== null) {
|
|
||||||
$command .= ' RETURN ' . $returnOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RequestFrame($command);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(ResponseStream $responses, SessionContext $context): Generator
|
|
||||||
{
|
|
||||||
unset($context);
|
|
||||||
|
|
||||||
if (!$this->returnOptions->hasStatus()) {
|
|
||||||
foreach ($responses as $response) {
|
|
||||||
if ($response instanceof UntaggedResponse && $response->label() === 'LIST') {
|
|
||||||
yield $this->listResponseParser->parse($response->payload());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ImapException('LIST did not receive a tagged completion response.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$mailboxes = [];
|
|
||||||
$statuses = [];
|
|
||||||
|
|
||||||
foreach ($responses as $response) {
|
|
||||||
if ($response instanceof UntaggedResponse && $response->label() === 'LIST') {
|
|
||||||
$mailbox = $this->listResponseParser->parse($response->payload());
|
|
||||||
$mailboxes[$mailbox->name()] = $this->applyStatus(
|
|
||||||
$mailbox,
|
|
||||||
$statuses[$mailbox->name()] ?? [],
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') {
|
|
||||||
[$mailboxName, $status] = $this->statusResponseParser->parse($response->payload());
|
|
||||||
$statuses[$mailboxName] = $status;
|
|
||||||
|
|
||||||
if (isset($mailboxes[$mailboxName])) {
|
|
||||||
$mailboxes[$mailboxName] = $this->applyStatus($mailboxes[$mailboxName], $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse) {
|
|
||||||
CompletionChecker::assertSuccess($this->name(), $response);
|
|
||||||
|
|
||||||
foreach ($mailboxes as $mailbox) {
|
|
||||||
yield $mailbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ImapException('LIST did not receive a tagged completion response.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, int> $status
|
|
||||||
*/
|
|
||||||
private function applyStatus(Mailbox $mailbox, array $status): Mailbox
|
|
||||||
{
|
|
||||||
return $mailbox->fromStatus(new MailboxStatusResult($mailbox->name(), $status));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
namespace KTXM\ProviderImap\Client\Protocol;
|
||||||
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
|
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
final class CommandExecutor
|
final class CommandExecutor
|
||||||
@@ -62,13 +62,13 @@ final class CommandExecutor
|
|||||||
*
|
*
|
||||||
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
||||||
*/
|
*/
|
||||||
public function download(MessageTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
|
public function download(FetchTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
|
||||||
{
|
{
|
||||||
$this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)');
|
$this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)');
|
||||||
|
|
||||||
$tag = $this->tags->next();
|
$tag = $this->tags->next();
|
||||||
$this->writer->write($tag, new RequestFrame(sprintf(
|
$this->writer->write($tag, new RequestFrame(sprintf(
|
||||||
'UID FETCH %s (UID BODY.PEEK[%s])',
|
'UID FETCH %s (UID BODY[%s])',
|
||||||
$target->sequenceSet()->toCommand(),
|
$target->sequenceSet()->toCommand(),
|
||||||
$section,
|
$section,
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\CommandFailedException;
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\ResponseCodeParser;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
|
||||||
|
|
||||||
final class CompletionChecker
|
|
||||||
{
|
|
||||||
public static function assertSuccess(string $command, TaggedResponse $response): void
|
|
||||||
{
|
|
||||||
if ($response->isOk()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($response->status(), ['NO', 'BAD'], true)) {
|
|
||||||
throw new ImapException($command . ' received an invalid completion status: ' . $response->status());
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new CommandFailedException(
|
|
||||||
$command,
|
|
||||||
$response->status(),
|
|
||||||
$response->text(),
|
|
||||||
(new ResponseCodeParser())->parse($response->text()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
|
||||||
|
|
||||||
enum IdentifierMode
|
|
||||||
{
|
|
||||||
case Sequence;
|
|
||||||
case Uid;
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes an individual LIST response payload into a mailbox.
|
|
||||||
*/
|
|
||||||
final class ListResponseParser
|
|
||||||
{
|
|
||||||
public function parse(string $payload): Mailbox
|
|
||||||
{
|
|
||||||
$payload = trim($payload);
|
|
||||||
$offset = 0;
|
|
||||||
|
|
||||||
$attributesToken = $this->readToken($payload, $offset);
|
|
||||||
$delimiterToken = $this->readToken($payload, $offset);
|
|
||||||
$nameToken = $this->readToken($payload, $offset);
|
|
||||||
|
|
||||||
if ($attributesToken === null || $delimiterToken === null || $nameToken === null) {
|
|
||||||
throw new ImapException('Unable to parse LIST response payload: ' . $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attributeString = trim($attributesToken, '() ');
|
|
||||||
$attributes = $attributeString === '' || strtoupper($attributeString) === 'NIL'
|
|
||||||
? []
|
|
||||||
: array_map('strtoupper', preg_split('/\s+/', $attributeString) ?: []);
|
|
||||||
|
|
||||||
$delimiter = $this->decodeAtom($delimiterToken);
|
|
||||||
$name = $this->decodeMailboxName($nameToken);
|
|
||||||
|
|
||||||
return new Mailbox($name, $delimiter, $attributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readToken(string $payload, int &$offset): ?string
|
|
||||||
{
|
|
||||||
$length = strlen($payload);
|
|
||||||
|
|
||||||
while ($offset < $length && ctype_space($payload[$offset])) {
|
|
||||||
$offset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($offset >= $length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload[$offset] === '(') {
|
|
||||||
$end = strpos($payload, ')', $offset);
|
|
||||||
|
|
||||||
if ($end === false) {
|
|
||||||
throw new ImapException('Unterminated LIST attribute block: ' . $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = substr($payload, $offset, $end - $offset + 1);
|
|
||||||
$offset = $end + 1;
|
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload[$offset] === '"') {
|
|
||||||
$start = $offset;
|
|
||||||
$offset++;
|
|
||||||
|
|
||||||
while ($offset < $length) {
|
|
||||||
if ($payload[$offset] === '\\') {
|
|
||||||
$offset += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload[$offset] === '"') {
|
|
||||||
$offset++;
|
|
||||||
return substr($payload, $start, $offset - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
$offset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ImapException('Unterminated quoted LIST token: ' . $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
$start = $offset;
|
|
||||||
while ($offset < $length && !ctype_space($payload[$offset])) {
|
|
||||||
$offset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return substr($payload, $start, $offset - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decodeAtom(string $value): ?string
|
|
||||||
{
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if (strtoupper($value) === 'NIL') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
|
|
||||||
return stripcslashes(substr($value, 1, -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decodeMailboxName(string $value): string
|
|
||||||
{
|
|
||||||
$name = $this->decodeAtom($value);
|
|
||||||
|
|
||||||
// LIST may advertise the root mailbox as an empty quoted string.
|
|
||||||
return $name ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the optional leading bracketed code in response text.
|
|
||||||
* Command-specific argument interpretation belongs to the caller.
|
|
||||||
*/
|
|
||||||
final class ResponseCodeParser
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return ?array{name:string, arguments:list<string>, text:string}
|
|
||||||
*/
|
|
||||||
public function parse(string $text): ?array
|
|
||||||
{
|
|
||||||
$text = trim($text);
|
|
||||||
|
|
||||||
if (preg_match('/^\[([A-Z0-9.-]+)(?:\s+([^\]]+))?\](?:\s*(.*))?$/i', $text, $matches) !== 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$arguments = trim($matches[2] ?? '');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => strtoupper($matches[1]),
|
|
||||||
'arguments' => $arguments === '' ? [] : (preg_split('/\s+/', $arguments) ?: []),
|
|
||||||
'text' => trim($matches[3] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -129,12 +129,9 @@ final class ProtocolReader
|
|||||||
if (str_starts_with($trimmed, $tag . ' ')) {
|
if (str_starts_with($trimmed, $tag . ' ')) {
|
||||||
$parts = preg_split('/\s+/', $trimmed, 3) ?: [];
|
$parts = preg_split('/\s+/', $trimmed, 3) ?: [];
|
||||||
$status = strtoupper($parts[1] ?? '');
|
$status = strtoupper($parts[1] ?? '');
|
||||||
CompletionChecker::assertSuccess('FETCH', new TaggedResponse(
|
if ($status === 'NO' || $status === 'BAD') {
|
||||||
$tag,
|
throw new ImapException(sprintf('FETCH command failed: %s', $trimmed));
|
||||||
$status,
|
}
|
||||||
$parts[2] ?? '',
|
|
||||||
$trimmed,
|
|
||||||
));
|
|
||||||
return null; // Tagged OK without ever finding a literal → UID not found
|
return null; // Tagged OK without ever finding a literal → UID not found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +145,9 @@ final class ProtocolReader
|
|||||||
$response = $this->readResponse();
|
$response = $this->readResponse();
|
||||||
|
|
||||||
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
|
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
|
||||||
CompletionChecker::assertSuccess('FETCH', $response);
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException(sprintf('FETCH failed: %s', $response->text()));
|
||||||
|
}
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
|
||||||
|
|
||||||
final class StringEncoder
|
|
||||||
{
|
|
||||||
public static function quote(string $value): string
|
|
||||||
{
|
|
||||||
return '"' . addcslashes($value, "\\\"") . '"';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-7
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
|
||||||
|
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -191,8 +188,8 @@ final class SearchCriteriaBuilder
|
|||||||
{
|
{
|
||||||
return $this->pushExpression(sprintf(
|
return $this->pushExpression(sprintf(
|
||||||
'HEADER %s %s',
|
'HEADER %s %s',
|
||||||
StringEncoder::quote($name),
|
$this->formatString($name),
|
||||||
StringEncoder::quote($value),
|
$this->formatString($value),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +285,7 @@ final class SearchCriteriaBuilder
|
|||||||
return $this->pushExpression(sprintf(
|
return $this->pushExpression(sprintf(
|
||||||
'%s %s',
|
'%s %s',
|
||||||
$key,
|
$key,
|
||||||
$quote ? StringEncoder::quote($value) : $value,
|
$quote ? $this->formatString($value) : $value,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,4 +369,9 @@ final class SearchCriteriaBuilder
|
|||||||
default => SequenceSet::parse($value)->toCommand(),
|
default => SequenceSet::parse($value)->toCommand(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function formatString(string $value): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($value, "\\\"") . '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\ConnectionConfig;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\GreetingResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\GreetingResponse;
|
||||||
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
|
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Client\Protocol;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
enum SessionState: string
|
enum SessionState: string
|
||||||
{
|
{
|
||||||
@@ -10,10 +10,10 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap\Console;
|
namespace KTXM\ProviderImap\Console;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\Provider;
|
use KTXM\ProviderImap\Providers\Provider;
|
||||||
use KTXM\ProviderImap\Providers\LiveService;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||||
use KTXC\Context\TenantContext;
|
use KTXC\SessionTenant;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -41,7 +41,7 @@ class ConnectCommand extends Command
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Provider $provider,
|
private readonly Provider $provider,
|
||||||
private readonly TenantContext $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ class ConnectCommand extends Command
|
|||||||
'secret' => $password,
|
'secret' => $password,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new LiveService();
|
$service = new Service();
|
||||||
$service->setLocation($location);
|
$service->setLocation($location);
|
||||||
$service->setIdentity($identity);
|
$service->setIdentity($identity);
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ class ConnectCommand extends Command
|
|||||||
|
|
||||||
// ── Persist ──────────────────────────────────────────────────────────
|
// ── Persist ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
$this->sessionTenant->configureById($tenantId);
|
||||||
|
|
||||||
$label = $io->ask('Service label', $username);
|
$label = $io->ask('Service label', $username);
|
||||||
if ($label) {
|
if ($label) {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap\Console;
|
namespace KTXM\ProviderImap\Console;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\Provider;
|
use KTXM\ProviderImap\Providers\Provider;
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
use KTXC\Context\TenantContext;
|
use KTXC\SessionTenant;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -37,7 +37,7 @@ class DisconnectCommand extends Command
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Provider $provider,
|
private readonly Provider $provider,
|
||||||
private readonly TenantContext $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ class DisconnectCommand extends Command
|
|||||||
$userId = (string) ($input->getOption('user') ?? '');
|
$userId = (string) ($input->getOption('user') ?? '');
|
||||||
|
|
||||||
if ($tenantId !== '' && $userId !== '') {
|
if ($tenantId !== '' && $userId !== '') {
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
$this->sessionTenant->configureById($tenantId);
|
||||||
$services = $this->provider->serviceList($tenantId, $userId);
|
$services = $this->provider->serviceList($tenantId, $userId);
|
||||||
|
|
||||||
if (empty($services)) {
|
if (empty($services)) {
|
||||||
@@ -96,7 +96,7 @@ class DisconnectCommand extends Command
|
|||||||
|
|
||||||
$choices = [];
|
$choices = [];
|
||||||
foreach ($services as $id => $service) {
|
foreach ($services as $id => $service) {
|
||||||
$label = $service instanceof ServiceBase ? ($service->getLabel() ?? $id) : $id;
|
$label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id;
|
||||||
$choices[$id] = "{$label} [{$id}]";
|
$choices[$id] = "{$label} [{$id}]";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ class DisconnectCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch service for display ────────────────────────────────────────
|
// ── Fetch service for display ────────────────────────────────────────
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
$this->sessionTenant->configureById($tenantId);
|
||||||
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
||||||
|
|
||||||
if ($service === null) {
|
if ($service === null) {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap\Console;
|
namespace KTXM\ProviderImap\Console;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\Provider;
|
use KTXM\ProviderImap\Providers\Provider;
|
||||||
use KTXM\ProviderImap\Providers\LiveService;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||||
use KTXM\ProviderImap\Service\Discovery;
|
use KTXM\ProviderImap\Service\Discovery;
|
||||||
use KTXC\Context\TenantContext;
|
use KTXC\SessionTenant;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -43,7 +43,7 @@ class DiscoverCommand extends Command
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Provider $provider,
|
private readonly Provider $provider,
|
||||||
private readonly Discovery $discovery,
|
private readonly Discovery $discovery,
|
||||||
private readonly TenantContext $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ class DiscoverCommand extends Command
|
|||||||
|
|
||||||
// ── Test before saving ───────────────────────────────────────────────
|
// ── Test before saving ───────────────────────────────────────────────
|
||||||
|
|
||||||
$service = new LiveService();
|
$service = new Service();
|
||||||
$service->setLocation($location);
|
$service->setLocation($location);
|
||||||
$service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([
|
$service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([
|
||||||
'identity' => $username,
|
'identity' => $username,
|
||||||
@@ -210,7 +210,7 @@ class DiscoverCommand extends Command
|
|||||||
|
|
||||||
// ── Persist ──────────────────────────────────────────────────────────
|
// ── Persist ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
$this->sessionTenant->configureById($tenantId);
|
||||||
|
|
||||||
$label = $io->ask('Service label', $address);
|
$label = $io->ask('Service label', $address);
|
||||||
if ($label) {
|
if ($label) {
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Console;
|
|
||||||
|
|
||||||
use KTXC\Context\TenantContext;
|
|
||||||
use KTXM\ProviderImap\Providers\Provider;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageDeltaService;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonize the cache of a stored IMAP service with its server.
|
|
||||||
*
|
|
||||||
* Read-only towards the server (EXAMINE, BODY.PEEK); safe to run repeatedly.
|
|
||||||
*/
|
|
||||||
#[AsCommand(
|
|
||||||
name: 'provider_imap_mail:cache:harmonize',
|
|
||||||
description: 'Harmonize the message cache of an IMAP service with its server',
|
|
||||||
)]
|
|
||||||
class HarmonizeCommand extends Command
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly Provider $provider,
|
|
||||||
private readonly TenantContext $tenantContext,
|
|
||||||
private readonly HarmonizationService $harmonizer,
|
|
||||||
private readonly MailboxStore $mailboxStore,
|
|
||||||
private readonly MessageStore $messageStore,
|
|
||||||
) {
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function configure(): void
|
|
||||||
{
|
|
||||||
$this
|
|
||||||
->addArgument('service-id', InputArgument::REQUIRED, 'Stored service ID')
|
|
||||||
->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID')
|
|
||||||
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID')
|
|
||||||
->addOption('mailbox', 'm', InputOption::VALUE_REQUIRED, 'Mailbox to harmonize (default: INBOX)')
|
|
||||||
->addOption('all', 'a', InputOption::VALUE_NONE, 'Harmonize every selectable mailbox')
|
|
||||||
->setHelp(<<<'HELP'
|
|
||||||
The <info>provider_imap_mail:cache:harmonize</info> command brings the cache of a
|
|
||||||
stored IMAP service in line with its server.
|
|
||||||
|
|
||||||
It will:
|
|
||||||
1. Create the cache indexes (idempotent)
|
|
||||||
2. Harmonize the mailbox list
|
|
||||||
3. Harmonize the messages of one mailbox, or of every selectable mailbox with --all
|
|
||||||
|
|
||||||
Server access is read-only: nothing is marked as read.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
Harmonize INBOX:
|
|
||||||
<info>provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1</info>
|
|
||||||
|
|
||||||
Harmonize a specific mailbox:
|
|
||||||
<info>provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 --mailbox=Sent</info>
|
|
||||||
|
|
||||||
Harmonize every mailbox:
|
|
||||||
<info>provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 --all</info>
|
|
||||||
HELP);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
||||||
{
|
|
||||||
$io = new SymfonyStyle($input, $output);
|
|
||||||
|
|
||||||
$tenantId = (string) ($input->getOption('tenant') ?? '');
|
|
||||||
$userId = (string) ($input->getOption('user') ?? '');
|
|
||||||
$serviceId = (string) $input->getArgument('service-id');
|
|
||||||
$mailbox = trim((string) ($input->getOption('mailbox') ?? 'INBOX'));
|
|
||||||
$all = (bool) $input->getOption('all');
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
if ($tenantId === '') {
|
|
||||||
$errors[] = 'Tenant ID is required (--tenant).';
|
|
||||||
}
|
|
||||||
if ($userId === '') {
|
|
||||||
$errors[] = 'User ID is required (--user).';
|
|
||||||
}
|
|
||||||
if ($all && $input->getOption('mailbox') !== null) {
|
|
||||||
$errors[] = 'Use either --mailbox or --all, not both.';
|
|
||||||
}
|
|
||||||
if ($errors !== []) {
|
|
||||||
$io->error($errors);
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
|
||||||
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
|
||||||
if (!$service instanceof ServiceBase) {
|
|
||||||
$io->error(sprintf("Service '%s' not found.", $serviceId));
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->mailboxStore->ensureIndexes();
|
|
||||||
$this->messageStore->ensureIndexes();
|
|
||||||
|
|
||||||
$harmonizer = $this->harmonizer->for($service);
|
|
||||||
$startedAt = microtime(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($all) {
|
|
||||||
$result = $harmonizer->harmonizeAll();
|
|
||||||
$mailboxes = $result['mailboxes'];
|
|
||||||
$messages = $result['messages'];
|
|
||||||
} else {
|
|
||||||
$mailboxes = $harmonizer->harmonizeMailboxes();
|
|
||||||
$messages = [$mailbox => $harmonizer->harmonizeMessages($mailbox)];
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$io->error('Harmonization failed: ' . $e->getMessage());
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->section('Mailboxes');
|
|
||||||
$io->definitionList(
|
|
||||||
['Added' => self::names($mailboxes['added'])],
|
|
||||||
['Updated' => count($mailboxes['updated'])],
|
|
||||||
['Removed' => self::names($mailboxes['removed'])],
|
|
||||||
);
|
|
||||||
|
|
||||||
$io->section('Messages');
|
|
||||||
$rows = [];
|
|
||||||
$failed = 0;
|
|
||||||
foreach ($messages as $name => $outcome) {
|
|
||||||
if (is_string($outcome)) {
|
|
||||||
$failed++;
|
|
||||||
$rows[] = [$name, '<error>failed</error>', '', '', '', '', '', $outcome];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$state = $this->mailboxStore->state($serviceId, (string) $name);
|
|
||||||
$rows[] = [
|
|
||||||
$name,
|
|
||||||
$outcome['status'],
|
|
||||||
$outcome['added'],
|
|
||||||
$outcome['updated'],
|
|
||||||
$outcome['removed'],
|
|
||||||
$outcome['complete'] ? 'yes' : 'no',
|
|
||||||
$state['uidValidity'] !== null
|
|
||||||
? MessageDeltaService::signature((int) $state['uidValidity'], (int) $state['changeSeq'])
|
|
||||||
: '',
|
|
||||||
$outcome['reset'] ? 'UIDVALIDITY changed, cache reset' : '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$io->table(['Mailbox', 'Status', 'Added', 'Updated', 'Removed', 'Complete', 'Signature', 'Note'], $rows);
|
|
||||||
|
|
||||||
$io->writeln(sprintf(
|
|
||||||
'Cache: <info>storage/%s/provider_imap/%s</info>, MongoDB <info>provider_imap_mail_mailboxes</info> / <info>provider_imap_mail_messages</info>',
|
|
||||||
$tenantId,
|
|
||||||
$serviceId,
|
|
||||||
));
|
|
||||||
$io->writeln(sprintf('Completed in <info>%.2fs</info>', microtime(true) - $startedAt));
|
|
||||||
|
|
||||||
if ($failed > 0) {
|
|
||||||
$io->warning(sprintf('%d mailbox(es) failed.', $failed));
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $names
|
|
||||||
*/
|
|
||||||
private static function names(array $names): string
|
|
||||||
{
|
|
||||||
return $names === [] ? '-' : implode(', ', $names);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-14
@@ -9,15 +9,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderImap\Console;
|
namespace KTXM\ProviderImap\Console;
|
||||||
|
|
||||||
use KTXC\Context\TenantContext;
|
use KTXC\SessionTenant;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\SelectCommand;
|
use KTXM\ProviderImap\Client\Command\SelectCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
use KTXM\ProviderImap\Client\FetchOptions;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Message;
|
use KTXM\ProviderImap\Client\Message;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Providers\Provider;
|
use KTXM\ProviderImap\Providers\Provider;
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -41,7 +41,7 @@ class TestCommand extends Command
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Provider $provider,
|
private readonly Provider $provider,
|
||||||
private readonly TenantContext $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ class TestCommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
$this->sessionTenant->configureById($tenantId);
|
||||||
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
||||||
|
|
||||||
if ($service === null) {
|
if ($service === null) {
|
||||||
@@ -119,7 +119,7 @@ class TestCommand extends Command
|
|||||||
$startedAt = microtime(true);
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$mailService = new LiveMailService($service);
|
$mailService = new RemoteMailService($service);
|
||||||
$mailboxes = $mailService->collectionList();
|
$mailboxes = $mailService->collectionList();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$io->error('IMAP diagnostic failed: ' . $e->getMessage());
|
$io->error('IMAP diagnostic failed: ' . $e->getMessage());
|
||||||
@@ -183,7 +183,7 @@ class TestCommand extends Command
|
|||||||
$io->section(sprintf('Recent Messages: %s', $mailboxName));
|
$io->section(sprintf('Recent Messages: %s', $mailboxName));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$mailboxService = new LiveMailService($service);
|
$mailboxService = new RemoteMailService($service);
|
||||||
$selectedMailbox = $mailboxService->imapClient()->perform(new SelectCommand($mailboxName, true));
|
$selectedMailbox = $mailboxService->imapClient()->perform(new SelectCommand($mailboxName, true));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$io->error('Mailbox inspection failed: ' . $e->getMessage());
|
$io->error('Mailbox inspection failed: ' . $e->getMessage());
|
||||||
@@ -209,7 +209,7 @@ class TestCommand extends Command
|
|||||||
try {
|
try {
|
||||||
foreach ($mailboxService->messageList(
|
foreach ($mailboxService->messageList(
|
||||||
$mailboxName,
|
$mailboxName,
|
||||||
MessageTarget::sequence(SequenceSet::range($startSequence, $totalMessages)),
|
FetchTarget::sequence(SequenceSet::range($startSequence, $totalMessages)),
|
||||||
FetchOptions::message(),
|
FetchOptions::message(),
|
||||||
) as $message) {
|
) as $message) {
|
||||||
$messageRows[] = [
|
$messageRows[] = [
|
||||||
@@ -269,7 +269,7 @@ class TestCommand extends Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tenantContext->resolveIdentifier($tenantId);
|
$this->sessionTenant->configureById($tenantId);
|
||||||
$services = $this->provider->serviceList($tenantId, $userId);
|
$services = $this->provider->serviceList($tenantId, $userId);
|
||||||
|
|
||||||
if ($services === []) {
|
if ($services === []) {
|
||||||
@@ -299,7 +299,7 @@ class TestCommand extends Command
|
|||||||
return $limit >= 0 ? $limit : $default;
|
return $limit >= 0 ? $limit : $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatTarget(ServiceBase $service): string
|
private function formatTarget(Service $service): string
|
||||||
{
|
{
|
||||||
$location = $service->getLocation();
|
$location = $service->getLocation();
|
||||||
if ($location === null) {
|
if ($location === null) {
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Listeners;
|
|
||||||
|
|
||||||
use KTXC\User\Event\UserCreatedEvent;
|
|
||||||
use KTXC\User\Event\UserDeletingEvent;
|
|
||||||
use KTXM\ProviderImap\Stores\ServiceStore;
|
|
||||||
|
|
||||||
final class UserEventListener
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ServiceStore $serviceStore,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function onUserCreated(UserCreatedEvent $event): void
|
|
||||||
{
|
|
||||||
// TODO: implement provisioning configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
public function onUserDeleting(UserDeletingEvent $event): void
|
|
||||||
{
|
|
||||||
$this->serviceStore->deleteByUser($event->tenantIdentifier(), $event->userIdentifier());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
-51
@@ -10,37 +10,25 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap;
|
namespace KTXM\ProviderImap;
|
||||||
|
|
||||||
use KTXC\Resource\ProviderManager;
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXC\User\Event\UserCreatedEvent;
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
use KTXC\User\Event\UserDeletingEvent;
|
use KTXF\Module\ModuleConsoleInterface;
|
||||||
use KTXF\Event\DeliveryMode;
|
|
||||||
use KTXF\Event\EventListenerRegistrarInterface;
|
|
||||||
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
|
||||||
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
|
|
||||||
use KTXF\Module\Configuration\ModuleContextInterface;
|
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
use KTXF\Resource\Provider\ProviderInterface;
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
use KTXM\ProviderImap\Console\ConnectCommand;
|
use KTXM\ProviderImap\Console\ConnectCommand;
|
||||||
use KTXM\ProviderImap\Console\DiscoverCommand;
|
use KTXM\ProviderImap\Console\DiscoverCommand;
|
||||||
use KTXM\ProviderImap\Console\DisconnectCommand;
|
use KTXM\ProviderImap\Console\DisconnectCommand;
|
||||||
use KTXM\ProviderImap\Console\HarmonizeCommand;
|
|
||||||
use KTXM\ProviderImap\Console\TestCommand;
|
use KTXM\ProviderImap\Console\TestCommand;
|
||||||
use KTXM\ProviderImap\Listeners\UserEventListener;
|
|
||||||
use KTXM\ProviderImap\Providers\Provider as MailProvider;
|
use KTXM\ProviderImap\Providers\Provider as MailProvider;
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Mail Provider Module
|
* IMAP Mail Provider Module
|
||||||
*
|
*
|
||||||
* Registers the IMAP mail provider with the Ktrix provider manager.
|
* Registers the IMAP mail provider with the Ktrix provider manager.
|
||||||
*/
|
*/
|
||||||
class Module extends ModuleInstanceAbstract
|
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProviderManager $providerManager,
|
private readonly ProviderManager $providerManager,
|
||||||
private readonly EventListenerRegistrarInterface $events,
|
|
||||||
private readonly MailboxStore $mailboxStore,
|
|
||||||
private readonly MessageStore $messageStore,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(): string
|
public function handle(): string
|
||||||
@@ -79,50 +67,31 @@ class Module extends ModuleInstanceAbstract
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function install(): void
|
|
||||||
{
|
|
||||||
$this->ensureIndexes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function upgrade(): void
|
|
||||||
{
|
|
||||||
$this->ensureIndexes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Register listeners
|
|
||||||
$this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred);
|
|
||||||
$this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred);
|
|
||||||
// Register providers
|
|
||||||
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'imap', MailProvider::class);
|
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'imap', MailProvider::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configure(ModuleContextInterface $context): void
|
public function registerCI(): array
|
||||||
{
|
{
|
||||||
if ($context instanceof BrowserModuleContextInterface) {
|
return [
|
||||||
$context->registerModule($this, 'ProviderImap', 'static/module.mjs');
|
DiscoverCommand::class,
|
||||||
}
|
ConnectCommand::class,
|
||||||
|
DisconnectCommand::class,
|
||||||
if ($context instanceof ConsoleModuleContextInterface) {
|
TestCommand::class,
|
||||||
foreach ([
|
];
|
||||||
DiscoverCommand::class,
|
|
||||||
ConnectCommand::class,
|
|
||||||
DisconnectCommand::class,
|
|
||||||
TestCommand::class,
|
|
||||||
HarmonizeCommand::class,
|
|
||||||
] as $command) {
|
|
||||||
$context->registerCommand($command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function registerBI(): array
|
||||||
* Create the cache collection indexes (idempotent).
|
|
||||||
*/
|
|
||||||
private function ensureIndexes(): void
|
|
||||||
{
|
{
|
||||||
$this->mailboxStore->ensureIndexes();
|
return [
|
||||||
$this->messageStore->ensureIndexes();
|
'handle' => $this->handle(),
|
||||||
|
'namespace' => 'ProviderImap',
|
||||||
|
'version' => $this->version(),
|
||||||
|
'label' => $this->label(),
|
||||||
|
'author' => $this->author(),
|
||||||
|
'description' => $this->description(),
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Providers;
|
|
||||||
|
|
||||||
use Generator;
|
|
||||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
|
||||||
use KTXF\Mail\Collection\CollectionPropertiesBaseInterface;
|
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
|
||||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
|
||||||
use KTXF\Resource\BinaryResource;
|
|
||||||
use KTXF\Resource\Delta\Delta;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
|
||||||
use KTXF\Resource\Range\IRange;
|
|
||||||
use KTXF\Resource\Range\IRangeTally;
|
|
||||||
use KTXF\Resource\Range\RangeAnchorType;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageDeltaService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageQueryBuilder;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
use UnexpectedValueException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IMAP mail service that serves reads from the cache.
|
|
||||||
*
|
|
||||||
* Holds a LiveService for the same account: writes and uncached operations go
|
|
||||||
* through it (its rules stay in one place), reads come from the cache and
|
|
||||||
* harmonize with the server when stale. Extends ServiceBase rather than
|
|
||||||
* LiveService, so every mail method is written out here.
|
|
||||||
*/
|
|
||||||
class CachedService extends ServiceBase
|
|
||||||
{
|
|
||||||
/** Seconds after which a mailbox (or the mailbox list) is harmonized again on access */
|
|
||||||
public const FRESHNESS_WINDOW = 60;
|
|
||||||
|
|
||||||
/** Messages hydrated from the cache per meta store query */
|
|
||||||
private const HYDRATE_BATCH_SIZE = 100;
|
|
||||||
|
|
||||||
private ?LiveMailService $liveMail = null;
|
|
||||||
private ?LiveService $live = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly MailboxStore $mailboxStore,
|
|
||||||
private readonly MessageStore $messageStore,
|
|
||||||
private readonly MessageFileStore $fileStore,
|
|
||||||
private readonly MessageIngestor $ingestor,
|
|
||||||
private readonly MessageQueryBuilder $queries,
|
|
||||||
private readonly HarmonizationService $harmonizer,
|
|
||||||
private readonly MessageDeltaService $deltas,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ── Collections (cache) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unfiltered lists come from the cache; filtered lists (e.g. role lookups) go to the server,
|
|
||||||
* so filter behaviour stays identical to live mode.
|
|
||||||
*/
|
|
||||||
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
|
|
||||||
{
|
|
||||||
if ($location !== null || $filter !== null) {
|
|
||||||
return $this->live()->collectionList($location, $filter, $sort);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->harmonizeMailboxesIfStale();
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
foreach ($this->mailboxStore->list($this->serviceId()) as $name => $document) {
|
|
||||||
$list[(string) $name] = $this->collectionFromCache($document);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionExtant(string|int ...$identifiers): array
|
|
||||||
{
|
|
||||||
$this->harmonizeMailboxesIfStale();
|
|
||||||
$cached = $this->mailboxStore->list($this->serviceId());
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
foreach ($identifiers as $identifier) {
|
|
||||||
$list[(string) $identifier] = isset($cached[(string) $identifier]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionFetch(string|int $identifier): ?CollectionResource
|
|
||||||
{
|
|
||||||
$this->harmonizeMailboxesIfStale();
|
|
||||||
$document = $this->mailboxStore->fetch($this->serviceId(), (string) $identifier);
|
|
||||||
|
|
||||||
return $document === null ? null : $this->collectionFromCache($document);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Collections (server; cache maintenance follows in step 6) ────────────
|
|
||||||
|
|
||||||
public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface
|
|
||||||
{
|
|
||||||
return $this->live()->collectionCreate($target, $properties, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface
|
|
||||||
{
|
|
||||||
return $this->live()->collectionUpdate($target, $properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true
|
|
||||||
{
|
|
||||||
return $this->live()->collectionDelete($target, $force);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface
|
|
||||||
{
|
|
||||||
return $this->live()->collectionMove($target, $source);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entities: reads ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
|
|
||||||
{
|
|
||||||
return iterator_to_array($this->entityListStream($collection, $filter, $sort, $range, $properties), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List messages of a mailbox.
|
|
||||||
*
|
|
||||||
* - mailbox not harmonized yet: streamed from the server, each message cached as it passes
|
|
||||||
* - harmonized: UIDs from the meta store (filter / sort translated, range applied like the
|
|
||||||
* live service), content from message.json; a filter on body / full text has the server
|
|
||||||
* find the UIDs (IMAP SEARCH) and only the content comes from the cache
|
|
||||||
*/
|
|
||||||
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator
|
|
||||||
{
|
|
||||||
$mailbox = (string) $collection;
|
|
||||||
$state = $this->mailboxStore->state($this->serviceId(), $mailbox);
|
|
||||||
|
|
||||||
if (!$state['harmonizationComplete'] || $state['uidValidity'] === null) {
|
|
||||||
yield from $this->listFromServer($mailbox, $filter, $sort, $range);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$uidValidity = (int) $state['uidValidity'];
|
|
||||||
|
|
||||||
if ($this->queries->needsServer($filter)) {
|
|
||||||
$uids = $this->liveMail()->entityFind($mailbox, $filter, $sort, $range);
|
|
||||||
} else {
|
|
||||||
$order = $this->queries->sort($sort);
|
|
||||||
$uids = self::applyRange(
|
|
||||||
$this->messageStore->query($this->serviceId(), $mailbox, $uidValidity, $this->queries->filter($filter), $order['sort'], $order['collation']),
|
|
||||||
$range,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->hydrate($mailbox, $uidValidity, $uids) as $entity) {
|
|
||||||
yield $entity->urn() => $entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array
|
|
||||||
{
|
|
||||||
return iterator_to_array($this->entityFetchStream(...$identifiers), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch messages from the cache; messages that are not cached are fetched from the
|
|
||||||
* server and cached, mailboxes never harmonized are read from the server.
|
|
||||||
*/
|
|
||||||
public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator
|
|
||||||
{
|
|
||||||
$byMailbox = [];
|
|
||||||
foreach ($identifiers as $identifier) {
|
|
||||||
if ($identifier->provider() !== $this->provider() || (string) $identifier->service() !== $this->serviceId()) {
|
|
||||||
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier);
|
|
||||||
}
|
|
||||||
$byMailbox[(string) $identifier->collection()][] = (int) $identifier->entity();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($byMailbox as $mailbox => $uids) {
|
|
||||||
$uidValidity = $this->mailboxStore->state($this->serviceId(), (string) $mailbox)['uidValidity'];
|
|
||||||
$entities = $uidValidity === null
|
|
||||||
? $this->liveMail()->entityFetch((string) $mailbox, ...$uids)
|
|
||||||
: $this->hydrate((string) $mailbox, (int) $uidValidity, $uids);
|
|
||||||
|
|
||||||
foreach ($entities as $entity) {
|
|
||||||
yield $entity->urn() => $entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes since a signature, harmonizing the mailbox first when stale.
|
|
||||||
*
|
|
||||||
* Harmonization never waits on another run's lock, and a server that cannot be
|
|
||||||
* reached does not fail the request: the delta is answered from what is cached.
|
|
||||||
*/
|
|
||||||
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
|
|
||||||
{
|
|
||||||
$mailbox = (string) $collection;
|
|
||||||
|
|
||||||
if ($this->isStale($this->mailboxStore->state($this->serviceId(), $mailbox)['harmonizedAt'])) {
|
|
||||||
try {
|
|
||||||
$this->harmonizer()->harmonizeMessages($mailbox);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// answer from the cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->deltas->delta($this->serviceId(), $mailbox, $signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityExtant(string|int $collection, string|int ...$identifiers): array
|
|
||||||
{
|
|
||||||
return $this->live()->entityExtant($collection, ...$identifiers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource
|
|
||||||
{
|
|
||||||
return $this->live()->entityDownload($target, $part);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entities: writes (server; cache maintenance follows in step 6) ───────
|
|
||||||
|
|
||||||
public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult
|
|
||||||
{
|
|
||||||
return $this->live()->entitySubmit($sender, $source, $message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
|
|
||||||
{
|
|
||||||
return $this->live()->entityCreate($target, $properties, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource
|
|
||||||
{
|
|
||||||
return $this->live()->entityModify($target, $properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array
|
|
||||||
{
|
|
||||||
return $this->live()->entityPatch($properties, ...$targets);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityDelete(EntityIdentifier ...$targets): array
|
|
||||||
{
|
|
||||||
return $this->live()->entityDelete(...$targets);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
|
||||||
{
|
|
||||||
return $this->live()->entityMove($target, ...$sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
|
||||||
{
|
|
||||||
return $this->live()->entityCopy($target, ...$sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internals ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream a list from the server, caching each message as it passes (cold mailbox).
|
|
||||||
*/
|
|
||||||
private function listFromServer(string $mailbox, ?IFilter $filter, ?ISort $sort, ?IRange $range): Generator
|
|
||||||
{
|
|
||||||
$uidValidity = $this->cacheGeneration($mailbox);
|
|
||||||
|
|
||||||
foreach ($this->liveMail()->entityList($mailbox, $filter, $sort, $range) as $entity) {
|
|
||||||
if ($uidValidity !== null) {
|
|
||||||
$this->ingestQuietly($uidValidity, $entity);
|
|
||||||
}
|
|
||||||
yield $entity->urn() => $entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entities for UIDs in the given order: cached content + meta flags, with messages
|
|
||||||
* missing from the cache (or written with another schema version) fetched from the
|
|
||||||
* server and cached.
|
|
||||||
*
|
|
||||||
* @param int[] $uids
|
|
||||||
* @return Generator<int, EntityResource>
|
|
||||||
*/
|
|
||||||
private function hydrate(string $mailbox, int $uidValidity, array $uids): Generator
|
|
||||||
{
|
|
||||||
foreach (array_chunk($uids, self::HYDRATE_BATCH_SIZE) as $batch) {
|
|
||||||
$metas = $this->messageStore->fetchMany($this->serviceId(), $mailbox, $uidValidity, ...$batch);
|
|
||||||
|
|
||||||
$entities = [];
|
|
||||||
$missing = [];
|
|
||||||
foreach ($batch as $uid) {
|
|
||||||
$entity = isset($metas[$uid]) ? $this->entityFromCache($mailbox, $uidValidity, $metas[$uid]) : null;
|
|
||||||
if ($entity === null) {
|
|
||||||
$missing[] = $uid;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($entity->getProperties()->getIncompleteSections() !== []) {
|
|
||||||
$this->completeSections($mailbox, $uidValidity, $entity);
|
|
||||||
}
|
|
||||||
$entities[$uid] = $entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($missing !== []) {
|
|
||||||
foreach ($this->liveMail()->entityFetch($mailbox, ...$missing) as $uid => $entity) {
|
|
||||||
$this->ingestQuietly($uidValidity, $entity);
|
|
||||||
$entities[$uid] = $entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($batch as $uid) {
|
|
||||||
if (isset($entities[$uid])) {
|
|
||||||
yield $uid => $entities[$uid];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the full content of sections cut off at ingest and store the completed message.
|
|
||||||
*
|
|
||||||
* Content is immutable, so the meta document and change sequence are untouched. A
|
|
||||||
* failure leaves the cut off text in place; it is tried again on the next read.
|
|
||||||
*/
|
|
||||||
private function completeSections(string $mailbox, int $uidValidity, EntityResource $entity): void
|
|
||||||
{
|
|
||||||
$properties = $entity->getProperties();
|
|
||||||
$uid = (int) $entity->identifier();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$sections = $this->liveMail()->messageSections($mailbox, $uid, ...$properties->getIncompleteSections());
|
|
||||||
if ($sections === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
foreach ($sections as $partId => $content) {
|
|
||||||
$properties->completeSection((string) $partId, $content);
|
|
||||||
}
|
|
||||||
$this->fileStore->write($this->tenantId(), $this->serviceId(), $mailbox, $uidValidity, $uid, $entity->toCacheContent());
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// keep the cut off text; retried on the next read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An entity from its meta document and message.json; null when the content is missing or outdated.
|
|
||||||
*/
|
|
||||||
private function entityFromCache(string $mailbox, int $uidValidity, array $meta): ?EntityResource
|
|
||||||
{
|
|
||||||
$content = $this->fileStore->read($this->tenantId(), $this->serviceId(), $mailbox, $uidValidity, (int) $meta['uid']);
|
|
||||||
if ($content === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $this->entityFresh()->fromCacheContent($content)->fromCacheMeta($meta);
|
|
||||||
} catch (UnexpectedValueException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UIDVALIDITY to cache a not yet harmonized mailbox under; null when it cannot be cached.
|
|
||||||
*/
|
|
||||||
private function cacheGeneration(string $mailbox): ?int
|
|
||||||
{
|
|
||||||
// the mailbox document holds the change sequence, so it has to exist before ingesting
|
|
||||||
if ($this->mailboxStore->fetch($this->serviceId(), $mailbox) === null) {
|
|
||||||
try {
|
|
||||||
$this->harmonizer()->harmonizeMailboxes();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($this->mailboxStore->fetch($this->serviceId(), $mailbox) === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$state = $this->mailboxStore->state($this->serviceId(), $mailbox);
|
|
||||||
if ($state['uidValidity'] !== null) {
|
|
||||||
return (int) $state['uidValidity'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->liveMail()->mailboxFetch($mailbox)?->uidValidity();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache a message; a failing cache write must not fail the read that triggered it.
|
|
||||||
*/
|
|
||||||
private function ingestQuietly(int $uidValidity, EntityResource $entity): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->ingestor->ingest($this->tenantId(), $this->serviceId(), $uidValidity, $entity);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// the next harmonization caches it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a list range to sorted UIDs, as the live service does: absolute skips `position`
|
|
||||||
* messages, relative starts at the UID given as `position`.
|
|
||||||
*
|
|
||||||
* @param int[] $uids
|
|
||||||
* @return int[]
|
|
||||||
*/
|
|
||||||
private static function applyRange(array $uids, ?IRange $range): array
|
|
||||||
{
|
|
||||||
if (!$range instanceof IRangeTally) {
|
|
||||||
return array_values($uids);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tally = max(0, $range->getTally());
|
|
||||||
if ($tally === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($range->getAnchor() === RangeAnchorType::ABSOLUTE) {
|
|
||||||
$start = max(0, (int) $range->getPosition());
|
|
||||||
} else {
|
|
||||||
$index = array_search((int) $range->getPosition(), $uids, true);
|
|
||||||
$start = $index === false ? 0 : $index;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_slice($uids, $start, $tally));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a collection from its cached document; its signature is the delta signature of the mailbox.
|
|
||||||
*/
|
|
||||||
private function collectionFromCache(array $document): CollectionResource
|
|
||||||
{
|
|
||||||
if (isset($document['uidValidity'])) {
|
|
||||||
$document['signature'] = MessageDeltaService::signature((int) $document['uidValidity'], (int) ($document['changeSeq'] ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->collectionFresh()->fromCacheMeta($document);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function harmonizeMailboxesIfStale(): void
|
|
||||||
{
|
|
||||||
if ($this->isStale($this->mailboxStore->listedAt($this->serviceId()))) {
|
|
||||||
$this->harmonizer()->harmonizeMailboxes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isStale(?int $timestamp): bool
|
|
||||||
{
|
|
||||||
return $timestamp === null || time() - $timestamp >= self::FRESHNESS_WINDOW;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serviceId(): string
|
|
||||||
{
|
|
||||||
return (string) $this->identifier();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantId(): string
|
|
||||||
{
|
|
||||||
return (string) $this->tenantIdentifier();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One IMAP connection per service object, shared by the LiveService and harmonization.
|
|
||||||
*/
|
|
||||||
protected function liveMail(): LiveMailService
|
|
||||||
{
|
|
||||||
return $this->liveMail ??= new LiveMailService($this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function live(): LiveService
|
|
||||||
{
|
|
||||||
return $this->live ??= (new LiveService($this->liveMail()))->fromStore($this->toStore());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function harmonizer(): HarmonizationService
|
|
||||||
{
|
|
||||||
return $this->harmonizer->for($this, $this->liveMail());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,8 @@ use KTXF\Mail\Collection\CollectionRoles;
|
|||||||
/**
|
/**
|
||||||
* IMAP Mail Collection Properties
|
* IMAP Mail Collection Properties
|
||||||
*
|
*
|
||||||
* Backed by the same internal $data shape as the JMAP provider so that
|
* Backed by the same internal $data shape as the JMAP provider so that cache
|
||||||
* cache documents are interchangeable with fromCacheMeta() / toCacheMeta().
|
* documents are interchangeable with fromStore() / toStore().
|
||||||
*/
|
*/
|
||||||
class CollectionProperties extends CollectionPropertiesMutableAbstract
|
class CollectionProperties extends CollectionPropertiesMutableAbstract
|
||||||
{
|
{
|
||||||
@@ -50,14 +50,14 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cache (meta store) ───────────────────────────────────────────────────
|
// ── Store (MongoDB cache) ────────────────────────────────────────────────
|
||||||
|
|
||||||
public function toCacheMeta(): array
|
public function toStore(): array
|
||||||
{
|
{
|
||||||
return $this->data;
|
return $this->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fromCacheMeta(array $data): static
|
public function fromStore(array $data): static
|
||||||
{
|
{
|
||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
return $this;
|
return $this;
|
||||||
|
|||||||
@@ -54,38 +54,29 @@ class CollectionResource extends CollectionMutableAbstract
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cache (meta store) ───────────────────────────────────────────────────
|
// ── Store (MongoDB cache) ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialise to a meta store document.
|
* Serialise to a MongoDB document.
|
||||||
*
|
*
|
||||||
* The store adds the key fields it owns (sid, tid).
|
* The caller must inject the service UUID as `sid` before persisting.
|
||||||
*/
|
*/
|
||||||
public function toCacheMeta(): array
|
public function toStore(): array
|
||||||
{
|
{
|
||||||
return [
|
return array_merge(
|
||||||
'name' => (string) $this->data[static::PROPERTY_IDENTIFIER],
|
$this->data,
|
||||||
'parent' => $this->data[static::PROPERTY_COLLECTION] ?? null,
|
[
|
||||||
'signature' => $this->data[static::PROPERTY_SIGNATURE] ?? null,
|
'name' => $this->data['identifier'],
|
||||||
'properties' => $this->getProperties()->toCacheMeta(),
|
'properties' => $this->getProperties()->toStore(),
|
||||||
];
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function fromStore(array $data): static
|
||||||
* Restore from a meta store document.
|
|
||||||
*
|
|
||||||
* Only the resource's own fields are read; store keys and harmonization state
|
|
||||||
* on the same document are ignored.
|
|
||||||
*/
|
|
||||||
public function fromCacheMeta(array $data): static
|
|
||||||
{
|
{
|
||||||
$this->data[static::PROPERTY_IDENTIFIER] = (string) $data['name'];
|
$this->data = $data;
|
||||||
$this->data[static::PROPERTY_COLLECTION] = $data['parent'] ?? null;
|
|
||||||
if (isset($data['signature'])) {
|
|
||||||
$this->data[static::PROPERTY_SIGNATURE] = $data['signature'];
|
|
||||||
}
|
|
||||||
if (isset($data['properties'])) {
|
if (isset($data['properties'])) {
|
||||||
$this->getProperties()->fromCacheMeta((array) $data['properties']);
|
$this->getProperties()->fromStore($data['properties']);
|
||||||
}
|
}
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,12 @@ namespace KTXM\ProviderImap\Providers;
|
|||||||
|
|
||||||
use KTXM\ProviderImap\Client\Message;
|
use KTXM\ProviderImap\Client\Message;
|
||||||
use KTXF\Mail\Entity\EntityMutableAbstract;
|
use KTXF\Mail\Entity\EntityMutableAbstract;
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mail Entity Resource Implementation
|
* Mail Entity Resource Implementation
|
||||||
*/
|
*/
|
||||||
class EntityResource extends EntityMutableAbstract {
|
class EntityResource extends EntityMutableAbstract {
|
||||||
|
|
||||||
/** Version of the meta store / content store formats; bump on any change to either */
|
|
||||||
public const CACHE_SCHEMA_VERSION = 1;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $provider = 'imap',
|
string $provider = 'imap',
|
||||||
string|int|null $service = null,
|
string|int|null $service = null,
|
||||||
@@ -46,92 +42,6 @@ class EntityResource extends EntityMutableAbstract {
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate an entity returned by a create or replacement operation.
|
|
||||||
*/
|
|
||||||
public function fromMutation(
|
|
||||||
string $mailbox,
|
|
||||||
string|int $identifier,
|
|
||||||
MessagePropertiesMutableInterface $properties,
|
|
||||||
): static {
|
|
||||||
$this->data['collection'] = $mailbox;
|
|
||||||
$this->data['identifier'] = $identifier;
|
|
||||||
|
|
||||||
if ($properties instanceof MessageProperties) {
|
|
||||||
$this->setProperties($properties);
|
|
||||||
} else {
|
|
||||||
$this->getProperties()->jsonDeserialize($properties->jsonSerialize());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cache (meta store / content store) ───────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialise to a meta store document.
|
|
||||||
*
|
|
||||||
* The store adds the key fields it owns (sid, tid, uidValidity).
|
|
||||||
*/
|
|
||||||
public function toCacheMeta(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'mailbox' => (string) $this->data['collection'],
|
|
||||||
'uid' => (int) $this->data['identifier'],
|
|
||||||
'created' => $this->data['created'] ?? null,
|
|
||||||
'schemaVersion' => self::CACHE_SCHEMA_VERSION,
|
|
||||||
...$this->getProperties()->toCacheMeta(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore identity and mutable fields (flags) from a meta store document.
|
|
||||||
*/
|
|
||||||
public function fromCacheMeta(array $document): static
|
|
||||||
{
|
|
||||||
$this->data['collection'] = (string) $document['mailbox'];
|
|
||||||
$this->data['identifier'] = (int) $document['uid'];
|
|
||||||
|
|
||||||
$this->getProperties()->fromCacheMeta($document);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialise the immutable message content for the content store (message.json).
|
|
||||||
*/
|
|
||||||
public function toCacheContent(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'schemaVersion' => self::CACHE_SCHEMA_VERSION,
|
|
||||||
'created' => $this->data['created'] ?? null,
|
|
||||||
'incomplete' => $this->getProperties()->getIncompleteSections(),
|
|
||||||
'properties' => $this->getProperties()->toCacheContent(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore the immutable message content from the content store (message.json).
|
|
||||||
*
|
|
||||||
* @throws \UnexpectedValueException when the content was written with a different schema version
|
|
||||||
*/
|
|
||||||
public function fromCacheContent(array $content): static
|
|
||||||
{
|
|
||||||
if (($content['schemaVersion'] ?? null) !== self::CACHE_SCHEMA_VERSION) {
|
|
||||||
throw new \UnexpectedValueException('Cached message content has an unsupported schema version');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($content['created'])) {
|
|
||||||
$this->data['created'] = $content['created'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->getProperties()
|
|
||||||
->fromCacheContent($content['properties'] ?? [])
|
|
||||||
->setIncompleteSections(...array_map('strval', $content['incomplete'] ?? []));
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,617 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Providers;
|
|
||||||
|
|
||||||
use Generator;
|
|
||||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
|
||||||
use KTXF\Mail\Collection\CollectionPropertiesBaseInterface;
|
|
||||||
use KTXF\Mail\Object\Address;
|
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceMutableInterface;
|
|
||||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
|
||||||
use KTXF\Resource\BinaryResource;
|
|
||||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
|
||||||
use KTXF\Resource\Delta\Delta;
|
|
||||||
use KTXF\Resource\Filter\Filter;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
|
||||||
use KTXF\Resource\Range\IRange;
|
|
||||||
use KTXF\Resource\Range\Range;
|
|
||||||
use KTXF\Resource\Range\RangeTally;
|
|
||||||
use KTXF\Resource\Range\RangeType;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use KTXF\Resource\Sort\Sort;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
|
||||||
use KTXM\ProviderImap\Mime\MessageBuilder;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXF\Mail\Collection\CollectionRoles;
|
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IMAP mail service that talks to the server directly.
|
|
||||||
*
|
|
||||||
* Holds the mail API rules (validation, delete modes, submission, move / copy
|
|
||||||
* result mapping); CachedService delegates writes here.
|
|
||||||
*/
|
|
||||||
class LiveService extends ServiceBase
|
|
||||||
{
|
|
||||||
private LiveMailService $mailService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param LiveMailService|null $mailService share an existing IMAP connection (e.g. CachedService); created on demand otherwise
|
|
||||||
*/
|
|
||||||
public function __construct(?LiveMailService $mailService = null)
|
|
||||||
{
|
|
||||||
if ($mailService !== null) {
|
|
||||||
$this->mailService = $mailService;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
return $this->mailService->collectionList($location === null ? null : (string) $location, $filter, $sort);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionExtant(string|int ...$identifiers): array
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
|
|
||||||
foreach ($identifiers as $identifier) {
|
|
||||||
$key = (string) $identifier;
|
|
||||||
$list[$key] = $this->mailService->collectionFetch($key) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionFetch(string|int $identifier): ?CollectionResource
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
return $this->mailService->collectionFetch((string) $identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
if (!$properties->getLabel()) {
|
|
||||||
throw new \InvalidArgumentException('Collection label is required property');
|
|
||||||
}
|
|
||||||
$label = $properties->getLabel();
|
|
||||||
|
|
||||||
// Resolve the full name: if a parent location is given, prepend it
|
|
||||||
if ($target !== null) {
|
|
||||||
$path = $target->collection();
|
|
||||||
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
|
|
||||||
$delimiter = $this->mailService->collectionDelimiter();
|
|
||||||
$label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->mailService->collectionCreate($label, $delimiter ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
if (!$properties->getLabel()) {
|
|
||||||
throw new \InvalidArgumentException('Collection label is a required property');
|
|
||||||
}
|
|
||||||
$label = $properties->getLabel();
|
|
||||||
|
|
||||||
// In IMAP, "update" = rename to the new label
|
|
||||||
$oldPath = (string) $target->collection();
|
|
||||||
$newName = $properties->getLabel();
|
|
||||||
return $this->mailService->collectionRename($oldPath, $newName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
|
|
||||||
|
|
||||||
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
|
|
||||||
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
|
|
||||||
}
|
|
||||||
|
|
||||||
$deleteTarget = $deleteMode === 'soft' ? $this->resolveDeleteDestination() : null;
|
|
||||||
|
|
||||||
// we need to determine if the folder being deleted is already in the trash
|
|
||||||
if ($deleteTarget !== null && str_starts_with((string) $target->collection(), $deleteTarget)) {
|
|
||||||
// if so, we should hard delete instead of moving to avoid duplicates in the trash
|
|
||||||
$deleteMode = 'hard';
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = match ($deleteMode) {
|
|
||||||
'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target),
|
|
||||||
'hard' => $this->mailService->collectionDestroy((string) $target->collection()),
|
|
||||||
};
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$sourceMailbox = $this->mailService->collectionFetch((string) $source->collection());
|
|
||||||
$targetMailbox = $this->mailService->collectionFetch((string) $target->collection());
|
|
||||||
if ($sourceMailbox === null) {
|
|
||||||
throw new \RuntimeException('Source collection not found for move operation');
|
|
||||||
}
|
|
||||||
if ($targetMailbox === null) {
|
|
||||||
throw new \RuntimeException('Target collection not found for move operation');
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourceDelimiter = $sourceMailbox->getProperties()->getDelimiter() ?: '/';
|
|
||||||
$targetDelimiter = $targetMailbox->getProperties()->getDelimiter() ?: '/';
|
|
||||||
|
|
||||||
$extantPath = (string) $sourceMailbox->identifier();
|
|
||||||
$extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter));
|
|
||||||
|
|
||||||
$freshPath = rtrim((string) $targetMailbox->identifier(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs);
|
|
||||||
|
|
||||||
return $this->mailService->collectionRename($extantPath, $freshPath, $targetDelimiter);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
|
|
||||||
{
|
|
||||||
return iterator_to_array($this->entityListStream((string) $collection, $filter, $sort, $range), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $resource) {
|
|
||||||
yield $resource->urn() => $resource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array
|
|
||||||
{
|
|
||||||
return iterator_to_array($this->entityFetchStream(...$identifiers), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$identifiers = $this->groupEntitiesByCollection(...$identifiers);
|
|
||||||
|
|
||||||
foreach ($identifiers as $collection => $entities) {
|
|
||||||
$uids = array_keys($entities);
|
|
||||||
foreach ($this->mailService->entityFetch((string) $collection, ...$uids) as $resource) {
|
|
||||||
yield $resource->urn() => $resource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource {
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$collection = $target->collection();
|
|
||||||
$uid = (int) $target->entity();
|
|
||||||
$partId = isset($part['partId']) ? (string) $part['partId'] : null;
|
|
||||||
|
|
||||||
return $this->mailService->entityDownload($collection, $uid, $partId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
|
|
||||||
{
|
|
||||||
return new Delta(signature: $signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityExtant(string|int $collection, string|int ...$identifiers): array
|
|
||||||
{
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
// only positive integers are valid UIDs; anything else cannot exist
|
|
||||||
$uids = array_values(array_filter(
|
|
||||||
array_map(static fn (string|int $id): int => (int) $id, $identifiers),
|
|
||||||
static fn (int $uid): bool => $uid > 0,
|
|
||||||
));
|
|
||||||
|
|
||||||
$existing = array_flip($this->mailService->entityExtant((string) $collection, ...$uids));
|
|
||||||
|
|
||||||
$extant = [];
|
|
||||||
foreach ($identifiers as $id) {
|
|
||||||
$extant[$id] = isset($existing[(int) $id]) && (string) (int) $id === (string) $id;
|
|
||||||
}
|
|
||||||
return $extant;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult
|
|
||||||
{
|
|
||||||
if ($message === null) {
|
|
||||||
return new EntitySubmitResult(
|
|
||||||
EntitySubmitResult::DISPOSITION_ERROR,
|
|
||||||
errorCode: 'invalid_message',
|
|
||||||
errorMessage: 'No message properties were provided for submission.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outbound submission via SMTP — independent of the IMAP connection.
|
|
||||||
try {
|
|
||||||
$raw = (new MessageBuilder())->build($message);
|
|
||||||
$recipients = MessageBuilder::recipients($message);
|
|
||||||
if ($recipients === []) {
|
|
||||||
throw new \RuntimeException('Message has no recipients.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->initialize();
|
|
||||||
$smtp = $this->mailService->smtpClient();
|
|
||||||
try {
|
|
||||||
$queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw);
|
|
||||||
} finally {
|
|
||||||
$smtp->quit();
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return new EntitySubmitResult(
|
|
||||||
EntitySubmitResult::DISPOSITION_ERROR,
|
|
||||||
errorCode: 'submission_failed',
|
|
||||||
errorMessage: $e->getMessage(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort: store a copy in the Sent collection. A failure here must
|
|
||||||
// not turn a successful delivery into an error.
|
|
||||||
$sentEntity = null;
|
|
||||||
try {
|
|
||||||
$this->initialize();
|
|
||||||
$sentCollection = $this->resolveSentCollection();
|
|
||||||
if ($sentCollection !== null) {
|
|
||||||
$uid = $this->mailService->entityCreate($sentCollection, $raw, ['\\Seen']);
|
|
||||||
if ($uid !== null && $uid > 0) {
|
|
||||||
$sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// ignore — the message was already delivered
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort: IMAP has no native "submit existing draft" operation, so the
|
|
||||||
// message above was always sent fresh. If it originated from a synced draft,
|
|
||||||
// remove the now-superseded draft UID. A failure here must not turn a
|
|
||||||
// successful delivery into an error; the draft is simply left for a later
|
|
||||||
// discard or retry.
|
|
||||||
if ($source !== null
|
|
||||||
&& $source->provider() === $this->provider()
|
|
||||||
&& $source->service() === $this->identifier()) {
|
|
||||||
try {
|
|
||||||
$this->initialize();
|
|
||||||
$this->mailService->entityDestroy($source->collection(), (int) $source->entity());
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// ignore — the message was already delivered
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new EntitySubmitResult(
|
|
||||||
disposition: EntitySubmitResult::DISPOSITION_SENT,
|
|
||||||
transportId: $queueId !== '' ? $queueId : null,
|
|
||||||
sentEntity: $sentEntity,
|
|
||||||
sourceDraft: $source instanceof EntityIdentifier ? $source : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
|
|
||||||
{
|
|
||||||
if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) {
|
|
||||||
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . (string)$target);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties, $options);
|
|
||||||
$created = $this->mailService->entityCreate(
|
|
||||||
(string)$target->collection(),
|
|
||||||
$nativeMessage,
|
|
||||||
$nativeFlags,
|
|
||||||
);
|
|
||||||
if ($created === null || $created <= 0) {
|
|
||||||
throw new \RuntimeException('IMAP APPEND did not return a valid UID');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->entityFresh()->fromMutation((string)$target->collection(), $created, $properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource
|
|
||||||
{
|
|
||||||
if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) {
|
|
||||||
throw new \InvalidArgumentException('Target entity does not belong to this service: ' . (string)$target);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties);
|
|
||||||
$modified = $this->mailService->entityReplace(
|
|
||||||
(string)$target->collection(),
|
|
||||||
(int)$target->entity(),
|
|
||||||
$nativeMessage,
|
|
||||||
$nativeFlags,
|
|
||||||
);
|
|
||||||
if ($modified === null || $modified <= 0) {
|
|
||||||
throw new \RuntimeException('IMAP replacement did not return a valid UID');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->entityFresh()->fromMutation((string)$target->collection(), $modified, $properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array
|
|
||||||
{
|
|
||||||
// validate identifiers and group by collection
|
|
||||||
$targets = $this->groupEntitiesByCollection(...$targets);
|
|
||||||
|
|
||||||
// move entities on remote store and construct result map
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
|
|
||||||
foreach ($targets as $targetCollection => $targetIdentifiers) {
|
|
||||||
$uids = array_keys($targetIdentifiers);
|
|
||||||
$flagsAdd = [];
|
|
||||||
$flagsRemove = [];
|
|
||||||
|
|
||||||
foreach ($properties->getFlags() as $flag => $value) {
|
|
||||||
if ($value === true) {
|
|
||||||
$flagsAdd[] = $flag;
|
|
||||||
} elseif ($value === false) {
|
|
||||||
$flagsRemove[] = $flag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$mutations = $this->mailService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids);
|
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityDelete(EntityIdentifier ...$targets): array
|
|
||||||
{
|
|
||||||
// validate identifiers and group by collection
|
|
||||||
$targets = $this->groupEntitiesByCollection(...$targets);
|
|
||||||
|
|
||||||
// determine delete mode and target collection (e.g. Trash) if applicable
|
|
||||||
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
|
|
||||||
|
|
||||||
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
|
|
||||||
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
|
|
||||||
}
|
|
||||||
|
|
||||||
// connect to remote store
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$deleteTargetNative = null;
|
|
||||||
$deleteTargetIdentifier = null;
|
|
||||||
if ($deleteMode === 'soft') {
|
|
||||||
$deleteTargetNative = $this->resolveDeleteDestination();
|
|
||||||
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if all targets are already in the delete target collection, we should hard delete instead of moving to avoid duplicates in the trash
|
|
||||||
if (array_keys($targets) === [$deleteTargetNative]) {
|
|
||||||
$deleteMode = 'hard';
|
|
||||||
}
|
|
||||||
|
|
||||||
// entities need to be moved or deleted by collection
|
|
||||||
$list = [];
|
|
||||||
foreach ($targets as $sourceCollection => $sourceEntities) {
|
|
||||||
if ($deleteMode === 'soft' && $sourceCollection === $deleteTargetNative) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$uids = array_keys($sourceEntities);
|
|
||||||
|
|
||||||
$mutations = match ($deleteMode) {
|
|
||||||
'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
|
|
||||||
'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids),
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$mutatedUid = !isset($mutations[$uid]) || $mutations[$uid] === true ? null : $mutations[$uid];
|
|
||||||
$list[(string)$sourceEntities[$uid]] = [
|
|
||||||
'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted',
|
|
||||||
'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null,
|
|
||||||
'mutation' => $deleteMode === 'soft' && $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
|
||||||
{
|
|
||||||
// validate target belongs to this service
|
|
||||||
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
|
|
||||||
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate identifiers and group by collection
|
|
||||||
$sources = $this->groupEntitiesByCollection(...$sources);
|
|
||||||
|
|
||||||
// move entities on remote store and construct result map
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
|
|
||||||
foreach ($sources as $sourceCollection => $sourceEntities) {
|
|
||||||
$uids = array_keys($sourceEntities);
|
|
||||||
|
|
||||||
$mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids);
|
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$mutatedUid = $mutations[$uid] ?? null;
|
|
||||||
$list[(string)$sourceEntities[$uid]] = [
|
|
||||||
'disposition' => 'moved',
|
|
||||||
'destination' => $target,
|
|
||||||
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
|
||||||
];
|
|
||||||
unset($sourceEntities[$uid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
|
||||||
{
|
|
||||||
// validate target belongs to this service
|
|
||||||
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
|
|
||||||
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate identifiers and group by collection
|
|
||||||
$sources = $this->groupEntitiesByCollection(...$sources);
|
|
||||||
|
|
||||||
// copy entities on remote store and construct result map
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
|
|
||||||
foreach ($sources as $sourceCollection => $sourceEntities) {
|
|
||||||
$uids = array_keys($sourceEntities);
|
|
||||||
|
|
||||||
$mutations = $this->mailService->entityCopy($target->collection(), $sourceCollection, ...$uids);
|
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$mutatedUid = $mutations[$uid] ?? null;
|
|
||||||
$list[(string)$sourceEntities[$uid]] = [
|
|
||||||
'disposition' => $mutatedUid !== null ? 'copied' : 'error',
|
|
||||||
'destination' => $target,
|
|
||||||
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the native name of the first collection flagged with the given role.
|
|
||||||
*/
|
|
||||||
private function resolveRoleCollection(CollectionRoles $role): ?string
|
|
||||||
{
|
|
||||||
$filter = $this->collectionListFilter();
|
|
||||||
$filter->condition('role', $role->value);
|
|
||||||
|
|
||||||
$collections = $this->mailService->collectionList(null, $filter, null);
|
|
||||||
|
|
||||||
return $collections === [] ? null : (string) array_key_first($collections);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the native name of the collection flagged with the Sent role.
|
|
||||||
*/
|
|
||||||
private function resolveSentCollection(): ?string
|
|
||||||
{
|
|
||||||
return $this->resolveRoleCollection(CollectionRoles::Sent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the native name of the collection soft-deleted messages and collections are moved to.
|
|
||||||
*/
|
|
||||||
private function resolveDeleteDestination(): string
|
|
||||||
{
|
|
||||||
$destination = trim((string) ($this->auxiliary['deleteDestination'] ?? ''));
|
|
||||||
|
|
||||||
if ($destination === '') {
|
|
||||||
return $this->resolveRoleCollection(CollectionRoles::Trash)
|
|
||||||
?? throw new \RuntimeException('No Trash collection configured or found for deletion');
|
|
||||||
}
|
|
||||||
|
|
||||||
$role = CollectionRoles::tryFrom(strtolower($destination));
|
|
||||||
if ($role !== null && $role !== CollectionRoles::None) {
|
|
||||||
return $this->resolveRoleCollection($role) ?? $destination;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $destination;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array
|
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
foreach ($identifiers as $identifier) {
|
|
||||||
if ($identifier->provider() !== $this->provider() || $identifier->service() !== $this->identifier()) {
|
|
||||||
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier);
|
|
||||||
}
|
|
||||||
$list[$identifier->collection()][$identifier->entity()] = $identifier;
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert canonical message flags to IMAP system flags.
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
private function messageFlags(MessagePropertiesMutableInterface $properties): array
|
|
||||||
{
|
|
||||||
$flags = [];
|
|
||||||
foreach ($properties->getFlags() as $flag => $enabled) {
|
|
||||||
if ($enabled !== true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$flags[] = match (strtolower((string)$flag)) {
|
|
||||||
'seen' => '\\Seen',
|
|
||||||
'flagged' => '\\Flagged',
|
|
||||||
'answered' => '\\Answered',
|
|
||||||
'draft' => '\\Draft',
|
|
||||||
'deleted' => '\\Deleted',
|
|
||||||
default => (string)$flag,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($flags));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert message properties into the native IMAP append payload.
|
|
||||||
*
|
|
||||||
* @return array{0: string, 1: string[]}
|
|
||||||
*/
|
|
||||||
private function messagePayload(MessagePropertiesMutableInterface $properties, array $options = []): array
|
|
||||||
{
|
|
||||||
$flags = $this->messageFlags($properties);
|
|
||||||
if (isset($options['flags']) && is_array($options['flags'])) {
|
|
||||||
$flags = array_values(array_unique([...$flags, ...$options['flags']]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [(new MessageBuilder())->build($properties), $flags];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function initialize(): void
|
|
||||||
{
|
|
||||||
if (!isset($this->mailService)) {
|
|
||||||
$this->mailService = new LiveMailService($this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,20 +20,12 @@ use KTXF\Mail\Object\MessagePropertiesMutableAbstract;
|
|||||||
*/
|
*/
|
||||||
class MessageProperties extends MessagePropertiesMutableAbstract {
|
class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||||
|
|
||||||
/**
|
|
||||||
* Part ids of text sections that were cut off when fetched (cache only, not part of the API shape)
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private array $incompleteSections = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert IMAP data to mail message properties object.
|
* Convert IMAP data to mail message properties object.
|
||||||
*/
|
*/
|
||||||
public function fromImap(Message $message): static
|
public function fromImap(Message $message): static
|
||||||
{
|
{
|
||||||
$this->data[static::PROPERTY_SIZE] = $message->size();
|
$this->data[static::PROPERTY_SIZE] = $message->size();
|
||||||
$this->incompleteSections = $message->truncatedSections();
|
|
||||||
|
|
||||||
if ($message->messageId() !== null) {
|
if ($message->messageId() !== null) {
|
||||||
$this->data[static::PROPERTY_URID] = $message->messageId();
|
$this->data[static::PROPERTY_URID] = $message->messageId();
|
||||||
@@ -43,9 +35,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
|||||||
$this->data[static::PROPERTY_IN_REPLY_TO] = $message->inReplyTo();
|
$this->data[static::PROPERTY_IN_REPLY_TO] = $message->inReplyTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($message->references() !== []) {
|
//if ($message->references() !== []) {
|
||||||
$this->data[static::PROPERTY_REFERENCES] = $message->references();
|
// $this->data[static::PROPERTY_REFERENCES] = $message->references();
|
||||||
}
|
//}
|
||||||
|
|
||||||
$receivedAt = $message->receivedAt() ?? $message->internalDate();
|
$receivedAt = $message->receivedAt() ?? $message->internalDate();
|
||||||
if ($receivedAt !== null) {
|
if ($receivedAt !== null) {
|
||||||
@@ -100,181 +92,23 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->data[static::PROPERTY_FLAGS] = array_fill_keys(self::normalizeFlags($message->flags()), true);
|
$this->data[static::PROPERTY_FLAGS] = [];
|
||||||
|
foreach ($message->flags() as $flag) {
|
||||||
return $this;
|
$flag = ltrim($flag, '\\');
|
||||||
}
|
$normalized = match (strtolower($flag)) {
|
||||||
|
'seen' => 'seen',
|
||||||
/**
|
'flagged' => 'flagged',
|
||||||
* Normalise IMAP flags (e.g. "\\Seen", "$Forwarded") to flag names (e.g. "seen", "$forwarded").
|
'answered' => 'answered',
|
||||||
*
|
'draft' => 'draft',
|
||||||
* @param string[] $flags
|
'deleted' => 'deleted',
|
||||||
* @return list<string>
|
default => strtolower($flag),
|
||||||
*/
|
};
|
||||||
public static function normalizeFlags(array $flags): array
|
$this->data[static::PROPERTY_FLAGS][$normalized] = true;
|
||||||
{
|
|
||||||
$normalized = [];
|
|
||||||
foreach ($flags as $flag) {
|
|
||||||
$normalized[] = strtolower(ltrim($flag, '\\'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($normalized));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cache (meta store / content store) ───────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Part ids of text sections that were cut off when fetched and must be completed on open.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function getIncompleteSections(): array
|
|
||||||
{
|
|
||||||
return $this->incompleteSections;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setIncompleteSections(string ...$partIds): static
|
|
||||||
{
|
|
||||||
$this->incompleteSections = array_values(array_unique($partIds));
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the (cut off) content of a body section with its full content and clear its incomplete mark.
|
|
||||||
*
|
|
||||||
* @return bool whether the section exists in the body
|
|
||||||
*/
|
|
||||||
public function completeSection(string $partId, string $content): bool
|
|
||||||
{
|
|
||||||
if (!isset($this->data[static::PROPERTY_BODY]) || !is_array($this->data[static::PROPERTY_BODY])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$found = self::replaceSectionContent($this->data[static::PROPERTY_BODY], $partId, $content);
|
|
||||||
if ($found) {
|
|
||||||
$this->incompleteSections = array_values(array_diff($this->incompleteSections, [$partId]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialise the fields the meta store needs for list, filter and sort.
|
|
||||||
*
|
|
||||||
* Dates are normalised to UTC so they sort correctly as strings; the original
|
|
||||||
* values (with their offsets) are kept in the content store.
|
|
||||||
*/
|
|
||||||
public function toCacheMeta(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'received' => self::cacheDate($this->data[static::PROPERTY_RECEIVED] ?? null),
|
|
||||||
'sent' => self::cacheDate($this->data[static::PROPERTY_SENT] ?? null),
|
|
||||||
'size' => $this->data[static::PROPERTY_SIZE] ?? 0,
|
|
||||||
'subject' => $this->data[static::PROPERTY_SUBJECT] ?? '',
|
|
||||||
'from' => $this->data[static::PROPERTY_FROM] ?? null,
|
|
||||||
'to' => $this->data[static::PROPERTY_TO] ?? [],
|
|
||||||
'cc' => $this->data[static::PROPERTY_CC] ?? [],
|
|
||||||
'bcc' => $this->data[static::PROPERTY_BCC] ?? [],
|
|
||||||
'urid' => $this->data[static::PROPERTY_URID] ?? null,
|
|
||||||
'inReplyTo' => $this->data[static::PROPERTY_IN_REPLY_TO] ?? null,
|
|
||||||
'references' => $this->data[static::PROPERTY_REFERENCES] ?? [],
|
|
||||||
// list of set flags, not a map: keywords such as $Forwarded are not valid field names
|
|
||||||
'flags' => array_keys(array_filter($this->data[static::PROPERTY_FLAGS] ?? [])),
|
|
||||||
'hasAttachments' => ($this->data[static::PROPERTY_ATTACHMENTS] ?? []) !== [],
|
|
||||||
'preview' => $this->cachePreview(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore the mutable fields from a meta store document.
|
|
||||||
*
|
|
||||||
* Only flags are taken from the meta store; everything else comes from the
|
|
||||||
* content store, which holds the original values.
|
|
||||||
*/
|
|
||||||
public function fromCacheMeta(array $document): static
|
|
||||||
{
|
|
||||||
$this->data[static::PROPERTY_FLAGS] = array_fill_keys(
|
|
||||||
array_map('strval', (array) ($document['flags'] ?? [])),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialise the immutable message content for the content store.
|
|
||||||
*/
|
|
||||||
public function toCacheContent(): array
|
|
||||||
{
|
|
||||||
$content = $this->data;
|
|
||||||
unset($content[static::PROPERTY_FLAGS]);
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore the immutable message content from the content store, keeping any flags already set.
|
|
||||||
*/
|
|
||||||
public function fromCacheContent(array $content): static
|
|
||||||
{
|
|
||||||
unset($content[static::PROPERTY_FLAGS]);
|
|
||||||
$flags = $this->data[static::PROPERTY_FLAGS] ?? null;
|
|
||||||
|
|
||||||
$this->data = $content;
|
|
||||||
if ($flags !== null) {
|
|
||||||
$this->data[static::PROPERTY_FLAGS] = $flags;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function replaceSectionContent(array &$part, string $partId, string $content): bool
|
|
||||||
{
|
|
||||||
if ((string) ($part['partId'] ?? '') === $partId) {
|
|
||||||
$part['content'] = $content;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($part['subParts'] ?? [] as $index => $child) {
|
|
||||||
if (is_array($child) && self::replaceSectionContent($child, $partId, $content)) {
|
|
||||||
$part['subParts'][$index] = $child;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function cachePreview(int $length = 200): string
|
|
||||||
{
|
|
||||||
$text = $this->getBodyTextPlain();
|
|
||||||
if ($text === null) {
|
|
||||||
$html = $this->getBodyTextHtml();
|
|
||||||
$text = $html !== null
|
|
||||||
? html_entity_decode(strip_tags((string) preg_replace('/<(style|script)\b[^>]*>.*?<\/\1>/is', ' ', $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$text = trim((string) preg_replace('/\s+/u', ' ', $text));
|
|
||||||
|
|
||||||
return mb_substr($text, 0, $length);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function cacheDate(?string $value): ?string
|
|
||||||
{
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (new DateTimeImmutable($value))
|
|
||||||
->setTimezone(new \DateTimeZone('UTC'))
|
|
||||||
->format('Y-m-d\TH:i:s\Z');
|
|
||||||
} catch (\Exception) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively collect attachment parts from body structure
|
* Recursively collect attachment parts from body structure
|
||||||
*/
|
*/
|
||||||
|
|||||||
+12
-12
@@ -18,7 +18,7 @@ use KTXF\Mail\Service\ServiceMutableInterface;
|
|||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
||||||
use KTXM\ProviderImap\Service\Discovery;
|
use KTXM\ProviderImap\Service\Discovery;
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
use KTXM\ProviderImap\Stores\ServiceStore;
|
use KTXM\ProviderImap\Stores\ServiceStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,7 +106,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?ServiceBase
|
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service
|
||||||
{
|
{
|
||||||
$serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier);
|
$serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier);
|
||||||
if ($serviceData === null) {
|
if ($serviceData === null) {
|
||||||
@@ -116,9 +116,9 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
return $serviceInstance;
|
return $serviceInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBase
|
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
|
||||||
{
|
{
|
||||||
/** @var ServiceBase[] $services */
|
/** @var Service[] $services */
|
||||||
$services = $this->serviceList($tenantId, $userId);
|
$services = $this->serviceList($tenantId, $userId);
|
||||||
foreach ($services as $service) {
|
foreach ($services as $service) {
|
||||||
if ($service->hasAddress($address)) {
|
if ($service->hasAddress($address)) {
|
||||||
@@ -133,14 +133,14 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
return $this->serviceStore->extant($tenantId, $userId, $identifiers);
|
return $this->serviceStore->extant($tenantId, $userId, $identifiers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function serviceFresh(): ServiceBase
|
public function serviceFresh(): Service
|
||||||
{
|
{
|
||||||
return new LiveService();
|
return new Service();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
|
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
|
||||||
{
|
{
|
||||||
if (!($service instanceof ServiceBase)) {
|
if (!($service instanceof Service)) {
|
||||||
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
|
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
|
|
||||||
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
|
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
|
||||||
{
|
{
|
||||||
if (!($service instanceof ServiceBase)) {
|
if (!($service instanceof Service)) {
|
||||||
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
|
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
|
|
||||||
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
|
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
|
||||||
{
|
{
|
||||||
if (!($service instanceof ServiceBase)) {
|
if (!($service instanceof Service)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,15 +185,15 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
$startTime = microtime(true);
|
$startTime = microtime(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!($service instanceof ServiceBase)) {
|
if (!($service instanceof Service)) {
|
||||||
throw new \InvalidArgumentException('Service must be an instance of IMAP Service');
|
throw new \InvalidArgumentException('Service must be an instance of IMAP Service');
|
||||||
}
|
}
|
||||||
// augment the service with any provided test options (e.g. override location or credentials)
|
// augment the service with any provided test options (e.g. override location or credentials)
|
||||||
$service->fromStore(['sid' => 'test']);
|
$service->fromStore(['sid' => 'test']);
|
||||||
|
|
||||||
// Attempt to authenticate and list mailboxes as a connectivity check
|
// Attempt to authenticate and list mailboxes as a connectivity check
|
||||||
$live = new LiveMailService($service);
|
$remote = new RemoteMailService($service);
|
||||||
$mailboxes = $live->collectionList();
|
$mailboxes = iterator_to_array($remote->collectionList());
|
||||||
|
|
||||||
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,894 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Providers;
|
||||||
|
|
||||||
|
use Generator;
|
||||||
|
use KTXF\Mail\Collection\CollectionBaseInterface;
|
||||||
|
use KTXF\Mail\Collection\CollectionPropertiesBaseInterface;
|
||||||
|
use KTXF\Mail\Object\Address;
|
||||||
|
use KTXF\Mail\Object\AddressInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceMutableInterface;
|
||||||
|
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
||||||
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
|
use KTXF\Resource\Delta\Delta;
|
||||||
|
use KTXF\Resource\Filter\Filter;
|
||||||
|
use KTXF\Resource\Filter\IFilter;
|
||||||
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
|
use KTXF\Resource\Range\IRange;
|
||||||
|
use KTXF\Resource\Range\Range;
|
||||||
|
use KTXF\Resource\Range\RangeTally;
|
||||||
|
use KTXF\Resource\Range\RangeType;
|
||||||
|
use KTXF\Resource\Sort\ISort;
|
||||||
|
use KTXF\Resource\Sort\Sort;
|
||||||
|
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||||
|
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||||
|
use KTXM\ProviderImap\Mime\MessageBuilder;
|
||||||
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
|
use KTXM\ProviderImap\Providers\CollectionResource;
|
||||||
|
use KTXF\Mail\Collection\CollectionRoles;
|
||||||
|
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
||||||
|
use KTXM\ProviderImap\Providers\EntityResource;
|
||||||
|
use KTXM\ProviderImap\Client\Mailbox;
|
||||||
|
|
||||||
|
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface
|
||||||
|
{
|
||||||
|
private const PROVIDER_IDENTIFIER = 'imap';
|
||||||
|
|
||||||
|
private ?string $serviceTenantId = null;
|
||||||
|
private ?string $serviceUserId = null;
|
||||||
|
private ?string $serviceIdentifier = null;
|
||||||
|
private ?string $serviceLabel = null;
|
||||||
|
private bool $serviceEnabled = false;
|
||||||
|
private array $primaryAddress = [];
|
||||||
|
private array $secondaryAddresses = [];
|
||||||
|
private ?ServiceLocation $location = null;
|
||||||
|
private ?ServiceIdentityBasic $identity = null;
|
||||||
|
private array $auxiliary = [];
|
||||||
|
|
||||||
|
private array $serviceAbilities = [
|
||||||
|
self::CAPABILITY_COLLECTION_LIST => true,
|
||||||
|
self::CAPABILITY_COLLECTION_LIST_FILTER => [
|
||||||
|
self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256',
|
||||||
|
self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1',
|
||||||
|
self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1',
|
||||||
|
],
|
||||||
|
self::CAPABILITY_COLLECTION_LIST_SORT => [
|
||||||
|
self::CAPABILITY_COLLECTION_SORT_LABEL,
|
||||||
|
self::CAPABILITY_COLLECTION_SORT_RANK,
|
||||||
|
],
|
||||||
|
self::CAPABILITY_COLLECTION_FETCH => true,
|
||||||
|
self::CAPABILITY_COLLECTION_EXTANT => true,
|
||||||
|
self::CAPABILITY_COLLECTION_CREATE => true,
|
||||||
|
self::CAPABILITY_COLLECTION_UPDATE => true,
|
||||||
|
self::CAPABILITY_COLLECTION_DELETE => true,
|
||||||
|
self::CAPABILITY_COLLECTION_MOVE => true,
|
||||||
|
self::CAPABILITY_ENTITY_LIST => true,
|
||||||
|
self::CAPABILITY_ENTITY_LIST_FILTER => [
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 's:32:1:1',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16',
|
||||||
|
self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32',
|
||||||
|
],
|
||||||
|
self::CAPABILITY_ENTITY_LIST_SORT => [
|
||||||
|
self::CAPABILITY_ENTITY_SORT_FROM,
|
||||||
|
self::CAPABILITY_ENTITY_SORT_TO,
|
||||||
|
self::CAPABILITY_ENTITY_SORT_SUBJECT,
|
||||||
|
self::CAPABILITY_ENTITY_SORT_DATE_RECEIVED,
|
||||||
|
self::CAPABILITY_ENTITY_SORT_DATE_SENT,
|
||||||
|
self::CAPABILITY_ENTITY_SORT_SIZE,
|
||||||
|
],
|
||||||
|
self::CAPABILITY_ENTITY_LIST_RANGE => [
|
||||||
|
'tally' => ['absolute', 'relative']
|
||||||
|
],
|
||||||
|
self::CAPABILITY_ENTITY_FETCH => true,
|
||||||
|
self::CAPABILITY_ENTITY_EXTANT => true,
|
||||||
|
self::CAPABILITY_ENTITY_CREATE => false,
|
||||||
|
self::CAPABILITY_ENTITY_MODIFY => false,
|
||||||
|
self::CAPABILITY_ENTITY_PATCH => true,
|
||||||
|
self::CAPABILITY_ENTITY_DELETE => true,
|
||||||
|
self::CAPABILITY_ENTITY_MOVE => true,
|
||||||
|
self::CAPABILITY_ENTITY_COPY => false,
|
||||||
|
'EntityTransmit' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
private RemoteMailService $remoteService;
|
||||||
|
|
||||||
|
public function __construct() {}
|
||||||
|
|
||||||
|
private function initialize(): void
|
||||||
|
{
|
||||||
|
if (!isset($this->remoteService)) {
|
||||||
|
$this->remoteService = new RemoteMailService($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toStore(): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
'tid' => $this->serviceTenantId,
|
||||||
|
'uid' => $this->serviceUserId,
|
||||||
|
'sid' => $this->serviceIdentifier,
|
||||||
|
'enabled' => $this->serviceEnabled,
|
||||||
|
'label' => $this->serviceLabel,
|
||||||
|
'primaryAddress' => $this->primaryAddress,
|
||||||
|
'secondaryAddresses'=> $this->secondaryAddresses,
|
||||||
|
'location' => $this->location?->toStore(),
|
||||||
|
'identity' => $this->identity?->toStore(),
|
||||||
|
'auxiliary' => $this->auxiliary,
|
||||||
|
], fn($v) => $v !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromStore(array $data): static
|
||||||
|
{
|
||||||
|
$this->serviceTenantId = $data['tid'] ?? null;
|
||||||
|
$this->serviceUserId = $data['uid'] ?? null;
|
||||||
|
$this->serviceIdentifier = $data['sid'] ?? null;
|
||||||
|
$this->serviceLabel = $data['label'] ?? '';
|
||||||
|
$this->serviceEnabled = $data['enabled'] ?? false;
|
||||||
|
|
||||||
|
if (isset($data['primaryAddress'])) {
|
||||||
|
$this->primaryAddress = $data['primaryAddress'];
|
||||||
|
}
|
||||||
|
if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) {
|
||||||
|
$this->secondaryAddresses = $data['secondaryAddresses'];
|
||||||
|
}
|
||||||
|
if (isset($data['location'])) {
|
||||||
|
$this->location = (new ServiceLocation())->fromStore($data['location']);
|
||||||
|
}
|
||||||
|
if (isset($data['identity'])) {
|
||||||
|
$this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']);
|
||||||
|
}
|
||||||
|
if (isset($data['auxiliary']) && is_array($data['auxiliary'])) {
|
||||||
|
$this->auxiliary = $data['auxiliary'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return array_filter([
|
||||||
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||||
|
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
||||||
|
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
||||||
|
self::PROPERTY_LABEL => $this->serviceLabel,
|
||||||
|
self::PROPERTY_ENABLED => $this->serviceEnabled,
|
||||||
|
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
||||||
|
self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
|
||||||
|
self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
|
||||||
|
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
||||||
|
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
||||||
|
self::PROPERTY_AUXILIARY => $this->auxiliary,
|
||||||
|
], fn($v) => $v !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data, bool $delta = false): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data[self::PROPERTY_ENABLED])) {
|
||||||
|
$this->setEnabled($data[self::PROPERTY_ENABLED]);
|
||||||
|
}
|
||||||
|
if (isset($data[self::PROPERTY_LABEL])) {
|
||||||
|
$this->setLabel($data[self::PROPERTY_LABEL]);
|
||||||
|
}
|
||||||
|
if (isset($data[self::PROPERTY_LOCATION])) {
|
||||||
|
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
|
||||||
|
}
|
||||||
|
if (isset($data[self::PROPERTY_IDENTITY])) {
|
||||||
|
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
|
||||||
|
}
|
||||||
|
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::PROPERTY_PRIMARY_ADDRESS])) {
|
||||||
|
$this->setPrimaryAddress(new Address($data[self::PROPERTY_PRIMARY_ADDRESS]));
|
||||||
|
}
|
||||||
|
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
|
||||||
|
$this->setSecondaryAddresses(array_map(
|
||||||
|
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
|
||||||
|
$data[self::PROPERTY_SECONDARY_ADDRESSES]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
|
||||||
|
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capable(string $value): bool
|
||||||
|
{
|
||||||
|
return isset($this->serviceAbilities[$value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capabilities(): array
|
||||||
|
{
|
||||||
|
return $this->serviceAbilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provider(): string
|
||||||
|
{
|
||||||
|
return self::PROVIDER_IDENTIFIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identifier(): string|int
|
||||||
|
{
|
||||||
|
return $this->serviceIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->serviceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->serviceLabel = $label;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->serviceEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(bool $enabled): static
|
||||||
|
{
|
||||||
|
$this->serviceEnabled = $enabled;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrimaryAddress(): AddressInterface
|
||||||
|
{
|
||||||
|
return Address::fromArray($this->primaryAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPrimaryAddress(AddressInterface $value): static
|
||||||
|
{
|
||||||
|
$this->primaryAddress = $value->toArray();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSecondaryAddresses(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr])
|
||||||
|
, $this->secondaryAddresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSecondaryAddresses(array $addresses): static
|
||||||
|
{
|
||||||
|
$this->secondaryAddresses = array_map(
|
||||||
|
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]),
|
||||||
|
$addresses
|
||||||
|
);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasAddress(string $address): bool
|
||||||
|
{
|
||||||
|
$address = strtolower(trim($address));
|
||||||
|
|
||||||
|
if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach ($this->secondaryAddresses as $secondary) {
|
||||||
|
$secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary;
|
||||||
|
if (strtolower($secondaryAddr) === $address) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocation(): ServiceLocation
|
||||||
|
{
|
||||||
|
return $this->location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLocation(ResourceServiceLocationInterface $location): static
|
||||||
|
{
|
||||||
|
$this->location = $location;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function freshLocation(?string $type = null, array $data = []): ServiceLocation
|
||||||
|
{
|
||||||
|
$loc = new ServiceLocation();
|
||||||
|
$loc->jsonDeserialize($data);
|
||||||
|
return $loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIdentity(): ServiceIdentityBasic
|
||||||
|
{
|
||||||
|
return $this->identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIdentity(ResourceServiceIdentityInterface $identity): static
|
||||||
|
{
|
||||||
|
$this->identity = $identity;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic
|
||||||
|
{
|
||||||
|
$id = new ServiceIdentityBasic();
|
||||||
|
$id->jsonDeserialize($data);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDebug(): bool
|
||||||
|
{
|
||||||
|
return ($this->auxiliary['debug'] ?? false) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDebug(bool $debug): static
|
||||||
|
{
|
||||||
|
$this->auxiliary['debug'] = $debug;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuxiliary(): array
|
||||||
|
{
|
||||||
|
return $this->auxiliary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAuxiliary(array $auxiliary): static
|
||||||
|
{
|
||||||
|
$this->auxiliary = $auxiliary;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($this->remoteService->collectionList($location, $filter, $sort) as $mailbox) {
|
||||||
|
$resource = $this->collectionFresh();
|
||||||
|
$resource->fromImap($mailbox);
|
||||||
|
$list[$mailbox->name()] = $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionListFilter(): Filter
|
||||||
|
{
|
||||||
|
return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionListSort(): Sort
|
||||||
|
{
|
||||||
|
return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionExtant(string|int ...$identifiers): array
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
$key = (string) $identifier;
|
||||||
|
$result = $this->remoteService->collectionFetch($key);
|
||||||
|
|
||||||
|
$list[$key] = $result !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionFetch(string|int $identifier): ?CollectionResource
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$mailbox = $this->remoteService->collectionFetch((string) $identifier);
|
||||||
|
if ($mailbox === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$collection = $this->collectionFresh();
|
||||||
|
$collection->fromImap($mailbox);
|
||||||
|
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionFresh(): CollectionResource
|
||||||
|
{
|
||||||
|
return new CollectionResource($this->provider(), $this->identifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
if (!$properties->getLabel()) {
|
||||||
|
throw new \InvalidArgumentException('Collection label is required property');
|
||||||
|
}
|
||||||
|
$label = $properties->getLabel();
|
||||||
|
|
||||||
|
// Resolve the full name: if a parent location is given, prepend it
|
||||||
|
if ($target !== null) {
|
||||||
|
$path = $target->collection();
|
||||||
|
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
|
||||||
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, null, null, ''));
|
||||||
|
$rootMailbox = $mailboxes === [] ? null : reset($mailboxes);
|
||||||
|
$delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/');
|
||||||
|
$label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailbox = $this->remoteService->collectionCreate($label);
|
||||||
|
|
||||||
|
$collection = $this->collectionFresh();
|
||||||
|
$collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]);
|
||||||
|
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
if (!$properties->getLabel()) {
|
||||||
|
throw new \InvalidArgumentException('Collection label is a required property');
|
||||||
|
}
|
||||||
|
$label = $properties->getLabel();
|
||||||
|
|
||||||
|
// In IMAP, "update" = rename to the new label
|
||||||
|
$oldPath = (string) $target->collection();
|
||||||
|
$newName = $properties->getLabel();
|
||||||
|
$mailbox = $this->remoteService->collectionRename($oldPath, $newName);
|
||||||
|
|
||||||
|
$collection = $this->collectionFresh();
|
||||||
|
$collection->fromImap($mailbox);
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
|
||||||
|
$deleteTarget = $this->auxiliary['deleteTarget'] ?? null;
|
||||||
|
|
||||||
|
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
|
||||||
|
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to target collection (e.g. Trash) instead of deleting
|
||||||
|
if ($deleteMode === 'soft' && $deleteTarget !== null) {
|
||||||
|
return $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deleteMode === 'soft' && $deleteTarget === null) {
|
||||||
|
$filter = $this->collectionListFilter();
|
||||||
|
$filter->condition('role', CollectionRoles::Trash->value);
|
||||||
|
|
||||||
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||||
|
if (empty($mailboxes)) {
|
||||||
|
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleteTarget = key($mailboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need to determine if the folder being deleted is already in the trash
|
||||||
|
if (str_starts_with((string) $target->collection(), (string) $deleteTarget)) {
|
||||||
|
// if so, we should hard delete instead of moving to avoid duplicates in the trash
|
||||||
|
$deleteMode = 'hard';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = match ($deleteMode) {
|
||||||
|
'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target),
|
||||||
|
'hard' => $this->remoteService->collectionDestroy((string) $target->collection()),
|
||||||
|
};
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$sourceMailbox = $this->remoteService->collectionFetch((string) $source->collection());
|
||||||
|
$targetMailbox = $this->remoteService->collectionFetch((string) $target->collection());
|
||||||
|
if ($sourceMailbox === null) {
|
||||||
|
throw new \RuntimeException('Source collection not found for move operation');
|
||||||
|
}
|
||||||
|
if ($targetMailbox === null) {
|
||||||
|
throw new \RuntimeException('Target collection not found for move operation');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceDelimiter = $sourceMailbox->delimiter() ?? '/';
|
||||||
|
$targetDelimiter = $targetMailbox->delimiter() ?? '/';
|
||||||
|
|
||||||
|
$extantPath = $sourceMailbox->name();
|
||||||
|
$extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter));
|
||||||
|
|
||||||
|
$freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs);
|
||||||
|
|
||||||
|
$mutatedMailbox = $this->remoteService->collectionRename($extantPath, $freshPath);
|
||||||
|
|
||||||
|
$collection = $this->collectionFresh();
|
||||||
|
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
|
||||||
|
{
|
||||||
|
return iterator_to_array($this->entityListStream((string) $collection, $filter, $sort, $range), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
foreach ($this->remoteService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) {
|
||||||
|
$resource = $this->entityFresh();
|
||||||
|
$resource->fromImap($message, $collection);
|
||||||
|
yield $resource->urn() => $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityListFilter(): Filter
|
||||||
|
{
|
||||||
|
return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityListSort(): Sort
|
||||||
|
{
|
||||||
|
return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityListRange(RangeType $type): IRange
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
RangeType::TALLY => new RangeTally(),
|
||||||
|
default => new Range(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array
|
||||||
|
{
|
||||||
|
return iterator_to_array($this->entityFetchStream(...$identifiers), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$identifiers = $this->groupEntitiesByCollection(...$identifiers);
|
||||||
|
|
||||||
|
foreach ($identifiers as $collection => $entities) {
|
||||||
|
$uids = array_keys($entities);
|
||||||
|
foreach ($this->remoteService->entityFetch((string) $collection, null, ...$uids) as $uid => $message) {
|
||||||
|
$resource = $this->entityFresh();
|
||||||
|
$resource->fromImap($message, $collection);
|
||||||
|
yield $resource->urn() => $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource {
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$collection = $target->collection();
|
||||||
|
$uid = (int) $target->entity();
|
||||||
|
$partId = isset($part['partId']) ? (string) $part['partId'] : null;
|
||||||
|
|
||||||
|
return $this->remoteService->entityDownload($collection, $uid, $partId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
|
||||||
|
{
|
||||||
|
return new Delta(signature: $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityExtant(string|int $collection, string|int ...$identifiers): array
|
||||||
|
{
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$allUids = $this->remoteService->entityList((string) $collection);
|
||||||
|
$uidSet = array_flip($allUids); // int[] → [uid => index]
|
||||||
|
$extant = [];
|
||||||
|
foreach ($identifiers as $id) {
|
||||||
|
$extant[$id] = isset($uidSet[(int) $id]);
|
||||||
|
}
|
||||||
|
return $extant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityFresh(): EntityResource
|
||||||
|
{
|
||||||
|
return new EntityResource($this->provider(), $this->identifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult
|
||||||
|
{
|
||||||
|
if ($message === null) {
|
||||||
|
return new EntitySubmitResult(
|
||||||
|
EntitySubmitResult::DISPOSITION_ERROR,
|
||||||
|
errorCode: 'invalid_message',
|
||||||
|
errorMessage: 'No message properties were provided for submission.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbound submission via SMTP — independent of the IMAP connection.
|
||||||
|
try {
|
||||||
|
$raw = (new MessageBuilder())->build($message);
|
||||||
|
$recipients = MessageBuilder::recipients($message);
|
||||||
|
if ($recipients === []) {
|
||||||
|
throw new \RuntimeException('Message has no recipients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initialize();
|
||||||
|
$smtp = $this->remoteService->smtpClient();
|
||||||
|
try {
|
||||||
|
$queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw);
|
||||||
|
} finally {
|
||||||
|
$smtp->quit();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new EntitySubmitResult(
|
||||||
|
EntitySubmitResult::DISPOSITION_ERROR,
|
||||||
|
errorCode: 'submission_failed',
|
||||||
|
errorMessage: $e->getMessage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: store a copy in the Sent collection. A failure here must
|
||||||
|
// not turn a successful delivery into an error.
|
||||||
|
$sentEntity = null;
|
||||||
|
try {
|
||||||
|
$this->initialize();
|
||||||
|
$sentCollection = $this->resolveSentCollection();
|
||||||
|
if ($sentCollection !== null) {
|
||||||
|
$uid = $this->remoteService->entityCreate($sentCollection, $raw, ['\\Seen']);
|
||||||
|
if ($uid !== null && $uid > 0) {
|
||||||
|
$sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// ignore — the message was already delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EntitySubmitResult(
|
||||||
|
disposition: EntitySubmitResult::DISPOSITION_SENT,
|
||||||
|
transportId: $queueId !== '' ? $queueId : null,
|
||||||
|
sentEntity: $sentEntity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the native name of the collection flagged with the Sent role.
|
||||||
|
*/
|
||||||
|
private function resolveSentCollection(): ?string
|
||||||
|
{
|
||||||
|
$filter = $this->collectionListFilter();
|
||||||
|
$filter->condition('role', CollectionRoles::Sent->value);
|
||||||
|
|
||||||
|
/** @var Mailbox[] $mailboxes */
|
||||||
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||||
|
if ($mailboxes === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$mailbox = reset($mailboxes);
|
||||||
|
return $mailbox === false ? null : $mailbox->name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Entity creation is not supported in this service');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Entity modification is not supported in this service');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array
|
||||||
|
{
|
||||||
|
// validate identifiers and group by collection
|
||||||
|
$targets = $this->groupEntitiesByCollection(...$targets);
|
||||||
|
|
||||||
|
// move entities on remote store and construct result map
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($targets as $targetCollection => $targetIdentifiers) {
|
||||||
|
$uids = array_keys($targetIdentifiers);
|
||||||
|
$flagsAdd = [];
|
||||||
|
$flagsRemove = [];
|
||||||
|
|
||||||
|
foreach ($properties->getFlags() as $flag => $value) {
|
||||||
|
if ($value === true) {
|
||||||
|
$flagsAdd[] = $flag;
|
||||||
|
} elseif ($value === false) {
|
||||||
|
$flagsRemove[] = $flag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations = $this->remoteService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids);
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityDelete(EntityIdentifier ...$targets): array
|
||||||
|
{
|
||||||
|
// validate identifiers and group by collection
|
||||||
|
$targets = $this->groupEntitiesByCollection(...$targets);
|
||||||
|
|
||||||
|
// determine delete mode and target collection (e.g. Trash) if applicable
|
||||||
|
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
|
||||||
|
$deleteTarget = $this->auxiliary['deleteTarget'] ?? null;
|
||||||
|
|
||||||
|
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
|
||||||
|
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect to remote store
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
// attempt to find a target collection for soft deletion if none was specified
|
||||||
|
if ($deleteMode === 'soft' && $deleteTarget === null) {
|
||||||
|
$filter = $this->collectionListFilter();
|
||||||
|
$filter->condition('role', CollectionRoles::Trash->value);
|
||||||
|
|
||||||
|
/** @var Mailbox[] $mailboxes */
|
||||||
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||||
|
if (empty($mailboxes)) {
|
||||||
|
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetMailbox = reset($mailboxes);
|
||||||
|
if ($targetMailbox === false) {
|
||||||
|
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleteTargetNative = $targetMailbox->name();
|
||||||
|
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
|
||||||
|
} else {
|
||||||
|
$deleteTargetNative = $deleteTarget;
|
||||||
|
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if all targets are already in the delete target collection, we should hard delete instead of moving to avoid duplicates in the trash
|
||||||
|
if (array_keys($targets) === [$deleteTargetNative]) {
|
||||||
|
$deleteMode = 'hard';
|
||||||
|
}
|
||||||
|
|
||||||
|
// entities need to be moved or deleted by collection
|
||||||
|
$list = [];
|
||||||
|
foreach ($targets as $sourceCollection => $sourceEntities) {
|
||||||
|
if ($deleteMode === 'soft' && $sourceCollection === $deleteTargetNative) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uids = array_keys($sourceEntities);
|
||||||
|
|
||||||
|
$mutations = match ($deleteMode) {
|
||||||
|
'soft' => $this->remoteService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
|
||||||
|
'hard' => $this->remoteService->entityDestroy($sourceCollection, ...$uids),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$mutatedUid = !isset($mutations[$uid]) || $mutations[$uid] === true ? null : $mutations[$uid];
|
||||||
|
$list[(string)$sourceEntities[$uid]] = [
|
||||||
|
'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted',
|
||||||
|
'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null,
|
||||||
|
'mutation' => $deleteMode === 'soft' && $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
||||||
|
{
|
||||||
|
// validate target belongs to this service
|
||||||
|
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate identifiers and group by collection
|
||||||
|
$sources = $this->groupEntitiesByCollection(...$sources);
|
||||||
|
|
||||||
|
// move entities on remote store and construct result map
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($sources as $sourceCollection => $sourceEntities) {
|
||||||
|
$uids = array_keys($sourceEntities);
|
||||||
|
|
||||||
|
$mutations = $this->remoteService->entityMove($target->collection(), $sourceCollection, ...$uids);
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$mutatedUid = $mutations[$uid] ?? null;
|
||||||
|
$list[(string)$sourceEntities[$uid]] = [
|
||||||
|
'disposition' => 'moved',
|
||||||
|
'destination' => $target,
|
||||||
|
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
||||||
|
];
|
||||||
|
unset($sourceEntities[$uid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
||||||
|
{
|
||||||
|
// validate target belongs to this service
|
||||||
|
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate identifiers and group by collection
|
||||||
|
$sources = $this->groupEntitiesByCollection(...$sources);
|
||||||
|
|
||||||
|
// copy entities on remote store and construct result map
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($sources as $sourceCollection => $sourceEntities) {
|
||||||
|
$uids = array_keys($sourceEntities);
|
||||||
|
|
||||||
|
$mutations = $this->remoteService->entityCopy($target->collection(), $sourceCollection, ...$uids);
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$mutatedUid = $mutations[$uid] ?? null;
|
||||||
|
$list[(string)$sourceEntities[$uid]] = [
|
||||||
|
'disposition' => $mutatedUid !== null ? 'copied' : 'error',
|
||||||
|
'destination' => $target,
|
||||||
|
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
foreach ($identifiers as $identifier) {
|
||||||
|
if ($identifier->provider() !== $this->provider() || $identifier->service() !== $this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier);
|
||||||
|
}
|
||||||
|
$list[$identifier->collection()][$identifier->entity()] = $identifier;
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Providers;
|
|
||||||
|
|
||||||
use Generator;
|
|
||||||
use KTXF\Mail\Collection\CollectionBaseInterface;
|
|
||||||
use KTXF\Mail\Collection\CollectionPropertiesBaseInterface;
|
|
||||||
use KTXF\Mail\Object\Address;
|
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
|
||||||
use KTXF\Mail\Service\ServiceMutableInterface;
|
|
||||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
|
||||||
use KTXF\Resource\BinaryResource;
|
|
||||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
|
||||||
use KTXF\Resource\Delta\Delta;
|
|
||||||
use KTXF\Resource\Filter\Filter;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
|
||||||
use KTXF\Resource\Range\IRange;
|
|
||||||
use KTXF\Resource\Range\Range;
|
|
||||||
use KTXF\Resource\Range\RangeTally;
|
|
||||||
use KTXF\Resource\Range\RangeType;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use KTXF\Resource\Sort\Sort;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
|
||||||
use KTXM\ProviderImap\Mime\MessageBuilder;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXF\Mail\Collection\CollectionRoles;
|
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IMAP mail service: account configuration and serialization.
|
|
||||||
*
|
|
||||||
* The mail API (collection / entity methods, declared by the implemented
|
|
||||||
* interfaces) is left to the concrete classes: LiveService talks to the IMAP
|
|
||||||
* server, CachedService serves reads from the cache. The Provider picks the
|
|
||||||
* class by mode when loading a service.
|
|
||||||
*/
|
|
||||||
abstract class ServiceBase implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface
|
|
||||||
{
|
|
||||||
protected const PROVIDER_IDENTIFIER = 'imap';
|
|
||||||
|
|
||||||
protected ?string $serviceTenantId = null;
|
|
||||||
protected ?string $serviceUserId = null;
|
|
||||||
protected ?string $serviceIdentifier = null;
|
|
||||||
protected ?string $serviceLabel = null;
|
|
||||||
protected bool $serviceEnabled = false;
|
|
||||||
protected array $primaryAddress = [];
|
|
||||||
protected array $secondaryAddresses = [];
|
|
||||||
protected ?ServiceLocation $location = null;
|
|
||||||
protected ?ServiceIdentityBasic $identity = null;
|
|
||||||
protected array $auxiliary = [];
|
|
||||||
|
|
||||||
protected array $serviceAbilities = [
|
|
||||||
self::CAPABILITY_COLLECTION_LIST => true,
|
|
||||||
self::CAPABILITY_COLLECTION_LIST_FILTER => [
|
|
||||||
self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256',
|
|
||||||
self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1',
|
|
||||||
self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1',
|
|
||||||
],
|
|
||||||
self::CAPABILITY_COLLECTION_LIST_SORT => [
|
|
||||||
self::CAPABILITY_COLLECTION_SORT_LABEL,
|
|
||||||
self::CAPABILITY_COLLECTION_SORT_RANK,
|
|
||||||
],
|
|
||||||
self::CAPABILITY_COLLECTION_FETCH => true,
|
|
||||||
self::CAPABILITY_COLLECTION_EXTANT => true,
|
|
||||||
self::CAPABILITY_COLLECTION_CREATE => true,
|
|
||||||
self::CAPABILITY_COLLECTION_UPDATE => true,
|
|
||||||
self::CAPABILITY_COLLECTION_DELETE => true,
|
|
||||||
self::CAPABILITY_COLLECTION_MOVE => true,
|
|
||||||
self::CAPABILITY_ENTITY_LIST => true,
|
|
||||||
self::CAPABILITY_ENTITY_LIST_FILTER => [
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 's:32:1:1',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16',
|
|
||||||
self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32',
|
|
||||||
],
|
|
||||||
self::CAPABILITY_ENTITY_LIST_SORT => [
|
|
||||||
self::CAPABILITY_ENTITY_SORT_FROM,
|
|
||||||
self::CAPABILITY_ENTITY_SORT_TO,
|
|
||||||
self::CAPABILITY_ENTITY_SORT_SUBJECT,
|
|
||||||
self::CAPABILITY_ENTITY_SORT_DATE_RECEIVED,
|
|
||||||
self::CAPABILITY_ENTITY_SORT_DATE_SENT,
|
|
||||||
self::CAPABILITY_ENTITY_SORT_SIZE,
|
|
||||||
],
|
|
||||||
self::CAPABILITY_ENTITY_LIST_RANGE => [
|
|
||||||
'tally' => ['absolute', 'relative']
|
|
||||||
],
|
|
||||||
self::CAPABILITY_ENTITY_FETCH => true,
|
|
||||||
self::CAPABILITY_ENTITY_EXTANT => true,
|
|
||||||
self::CAPABILITY_ENTITY_CREATE => true,
|
|
||||||
self::CAPABILITY_ENTITY_MODIFY => true,
|
|
||||||
self::CAPABILITY_ENTITY_PATCH => true,
|
|
||||||
self::CAPABILITY_ENTITY_DELETE => true,
|
|
||||||
self::CAPABILITY_ENTITY_MOVE => true,
|
|
||||||
self::CAPABILITY_ENTITY_COPY => false,
|
|
||||||
'EntityTransmit' => true,
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct() {}
|
|
||||||
|
|
||||||
public function toStore(): array
|
|
||||||
{
|
|
||||||
return array_filter([
|
|
||||||
'tid' => $this->serviceTenantId,
|
|
||||||
'uid' => $this->serviceUserId,
|
|
||||||
'sid' => $this->serviceIdentifier,
|
|
||||||
'enabled' => $this->serviceEnabled,
|
|
||||||
'label' => $this->serviceLabel,
|
|
||||||
'primaryAddress' => $this->primaryAddress,
|
|
||||||
'secondaryAddresses'=> $this->secondaryAddresses,
|
|
||||||
'location' => $this->location?->toStore(),
|
|
||||||
'identity' => $this->identity?->toStore(),
|
|
||||||
'auxiliary' => $this->auxiliary,
|
|
||||||
], fn($v) => $v !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fromStore(array $data): static
|
|
||||||
{
|
|
||||||
$this->serviceTenantId = $data['tid'] ?? null;
|
|
||||||
$this->serviceUserId = $data['uid'] ?? null;
|
|
||||||
$this->serviceIdentifier = $data['sid'] ?? null;
|
|
||||||
$this->serviceLabel = $data['label'] ?? '';
|
|
||||||
$this->serviceEnabled = $data['enabled'] ?? false;
|
|
||||||
|
|
||||||
if (isset($data['primaryAddress'])) {
|
|
||||||
$this->primaryAddress = $data['primaryAddress'];
|
|
||||||
}
|
|
||||||
if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) {
|
|
||||||
$this->secondaryAddresses = $data['secondaryAddresses'];
|
|
||||||
}
|
|
||||||
if (isset($data['location'])) {
|
|
||||||
$this->location = (new ServiceLocation())->fromStore($data['location']);
|
|
||||||
}
|
|
||||||
if (isset($data['identity'])) {
|
|
||||||
$this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']);
|
|
||||||
}
|
|
||||||
if (isset($data['auxiliary']) && is_array($data['auxiliary'])) {
|
|
||||||
$this->auxiliary = $data['auxiliary'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jsonSerialize(): array
|
|
||||||
{
|
|
||||||
return array_filter([
|
|
||||||
self::PROPERTY_TYPE => self::JSON_TYPE,
|
|
||||||
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
|
||||||
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
|
||||||
self::PROPERTY_LABEL => $this->serviceLabel,
|
|
||||||
self::PROPERTY_ENABLED => $this->serviceEnabled,
|
|
||||||
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
|
||||||
self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
|
|
||||||
self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
|
|
||||||
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
|
||||||
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
|
||||||
self::PROPERTY_AUXILIARY => $this->auxiliary,
|
|
||||||
], fn($v) => $v !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function jsonDeserialize(array|string $data, bool $delta = false): static
|
|
||||||
{
|
|
||||||
if (is_string($data)) {
|
|
||||||
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($data[self::PROPERTY_ENABLED])) {
|
|
||||||
$this->setEnabled($data[self::PROPERTY_ENABLED]);
|
|
||||||
}
|
|
||||||
if (isset($data[self::PROPERTY_LABEL])) {
|
|
||||||
$this->setLabel($data[self::PROPERTY_LABEL]);
|
|
||||||
}
|
|
||||||
if (isset($data[self::PROPERTY_LOCATION])) {
|
|
||||||
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
|
|
||||||
}
|
|
||||||
if (isset($data[self::PROPERTY_IDENTITY])) {
|
|
||||||
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
|
|
||||||
}
|
|
||||||
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) {
|
|
||||||
$value = $data[self::PROPERTY_PRIMARY_ADDRESS];
|
|
||||||
$this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value));
|
|
||||||
}
|
|
||||||
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
|
|
||||||
$this->setSecondaryAddresses(array_map(
|
|
||||||
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
|
|
||||||
$data[self::PROPERTY_SECONDARY_ADDRESSES]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
|
|
||||||
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function capable(string $value): bool
|
|
||||||
{
|
|
||||||
return isset($this->serviceAbilities[$value]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function capabilities(): array
|
|
||||||
{
|
|
||||||
return $this->serviceAbilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function provider(): string
|
|
||||||
{
|
|
||||||
return self::PROVIDER_IDENTIFIER;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function identifier(): string|int
|
|
||||||
{
|
|
||||||
return $this->serviceIdentifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantIdentifier(): ?string
|
|
||||||
{
|
|
||||||
return $this->serviceTenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLabel(): ?string
|
|
||||||
{
|
|
||||||
return $this->serviceLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setLabel(string $label): static
|
|
||||||
{
|
|
||||||
$this->serviceLabel = $label;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEnabled(): bool
|
|
||||||
{
|
|
||||||
return $this->serviceEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEnabled(bool $enabled): static
|
|
||||||
{
|
|
||||||
$this->serviceEnabled = $enabled;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPrimaryAddress(): AddressInterface
|
|
||||||
{
|
|
||||||
return Address::fromArray($this->primaryAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPrimaryAddress(AddressInterface $value): static
|
|
||||||
{
|
|
||||||
$this->primaryAddress = $value->toArray();
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSecondaryAddresses(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr])
|
|
||||||
, $this->secondaryAddresses);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setSecondaryAddresses(array $addresses): static
|
|
||||||
{
|
|
||||||
$this->secondaryAddresses = array_map(
|
|
||||||
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]),
|
|
||||||
$addresses
|
|
||||||
);
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasAddress(string $address): bool
|
|
||||||
{
|
|
||||||
$address = strtolower(trim($address));
|
|
||||||
|
|
||||||
if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
foreach ($this->secondaryAddresses as $secondary) {
|
|
||||||
$secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary;
|
|
||||||
if (strtolower($secondaryAddr) === $address) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLocation(): ServiceLocation
|
|
||||||
{
|
|
||||||
return $this->location;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setLocation(ResourceServiceLocationInterface $location): static
|
|
||||||
{
|
|
||||||
$this->location = $location;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function freshLocation(?string $type = null, array $data = []): ServiceLocation
|
|
||||||
{
|
|
||||||
$loc = new ServiceLocation();
|
|
||||||
$loc->jsonDeserialize($data);
|
|
||||||
return $loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIdentity(): ServiceIdentityBasic
|
|
||||||
{
|
|
||||||
return $this->identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setIdentity(ResourceServiceIdentityInterface $identity): static
|
|
||||||
{
|
|
||||||
$this->identity = $identity;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic
|
|
||||||
{
|
|
||||||
$id = new ServiceIdentityBasic();
|
|
||||||
$id->jsonDeserialize($data);
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDebug(): bool
|
|
||||||
{
|
|
||||||
return ($this->auxiliary['debug'] ?? false) === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDebug(bool $debug): static
|
|
||||||
{
|
|
||||||
$this->auxiliary['debug'] = $debug;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAuxiliary(): array
|
|
||||||
{
|
|
||||||
return $this->auxiliary;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setAuxiliary(array $auxiliary): static
|
|
||||||
{
|
|
||||||
$this->auxiliary = $auxiliary;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionListFilter(): Filter
|
|
||||||
{
|
|
||||||
return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionListSort(): Sort
|
|
||||||
{
|
|
||||||
return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collectionFresh(): CollectionResource
|
|
||||||
{
|
|
||||||
return new CollectionResource($this->provider(), $this->identifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityListFilter(): Filter
|
|
||||||
{
|
|
||||||
return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityListSort(): Sort
|
|
||||||
{
|
|
||||||
return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityListRange(RangeType $type): IRange
|
|
||||||
{
|
|
||||||
return match ($type) {
|
|
||||||
RangeType::TALLY => new RangeTally(),
|
|
||||||
default => new Range(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityFresh(): EntityResource
|
|
||||||
{
|
|
||||||
return new EntityResource($this->provider(), $this->identifier());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Service\Cache;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Providers\CollectionResource;
|
||||||
|
use KTXM\ProviderImap\Providers\EntityResource;
|
||||||
|
use KTXM\ProviderImap\Stores\MessageStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache Mail Service
|
||||||
|
*
|
||||||
|
* Provides read-only access to locally cached IMAP data stored in MongoDB by
|
||||||
|
* CacheService. Call CacheService::syncMailboxes() / syncMessages() to keep
|
||||||
|
* the cache up to date before reading from here.
|
||||||
|
*/
|
||||||
|
class CacheMailService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageStore $messageStore,
|
||||||
|
private readonly string $provider,
|
||||||
|
private readonly string|int $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Collection (mailbox) reads ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all cached mailboxes for this service.
|
||||||
|
*
|
||||||
|
* @return CollectionResource[] keyed by mailbox name
|
||||||
|
*/
|
||||||
|
public function collectionList(): array
|
||||||
|
{
|
||||||
|
$docs = $this->messageStore->listMailboxes((string) $this->service);
|
||||||
|
$result = [];
|
||||||
|
foreach ($docs as $doc) {
|
||||||
|
$resource = new CollectionResource($this->provider, $this->service);
|
||||||
|
$resource->fromStore($doc);
|
||||||
|
$result[$resource->identifier()] = $resource;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single cached mailbox by name.
|
||||||
|
*/
|
||||||
|
public function collectionFetch(string $name): ?CollectionResource
|
||||||
|
{
|
||||||
|
$doc = $this->messageStore->fetchMailbox((string) $this->service, $name);
|
||||||
|
if ($doc === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$resource = new CollectionResource($this->provider, $this->service);
|
||||||
|
$resource->fromStore($doc);
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entity (message) reads ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all cached UIDs for a mailbox.
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public function entityList(string $collection): array
|
||||||
|
{
|
||||||
|
return $this->messageStore->listUids((string) $this->service, $collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one or more cached messages by UID.
|
||||||
|
*
|
||||||
|
* @param int ...$uids
|
||||||
|
* @return EntityResource[] keyed by UID
|
||||||
|
*/
|
||||||
|
public function entityFetch(string $collection, int ...$uids): array
|
||||||
|
{
|
||||||
|
if (empty($uids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$docs = $this->messageStore->fetchMessages(
|
||||||
|
(string) $this->service,
|
||||||
|
$collection,
|
||||||
|
array_values($uids),
|
||||||
|
);
|
||||||
|
$result = [];
|
||||||
|
foreach ($docs as $uid => $doc) {
|
||||||
|
$resource = new EntityResource($this->provider, $this->service);
|
||||||
|
$resource->fromStore($doc);
|
||||||
|
$result[$uid] = $resource;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single cached message.
|
||||||
|
*/
|
||||||
|
public function entityFetchOne(string $collection, int $uid): ?EntityResource
|
||||||
|
{
|
||||||
|
$doc = $this->messageStore->fetchMessage((string) $this->service, $collection, $uid);
|
||||||
|
if ($doc === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$resource = new EntityResource($this->provider, $this->service);
|
||||||
|
$resource->fromStore($doc);
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Service\Cache;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Providers\CollectionResource;
|
||||||
|
use KTXM\ProviderImap\Providers\EntityResource;
|
||||||
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
|
use KTXM\ProviderImap\Stores\MessageStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache Service — IMAP Sync Orchestrator
|
||||||
|
*
|
||||||
|
* Keeps the local MongoDB cache in sync with a remote IMAP server.
|
||||||
|
*
|
||||||
|
* Strategy (UID-based):
|
||||||
|
* 1. Fetch the complete remote UID set.
|
||||||
|
* 2. Compare against cached UIDs.
|
||||||
|
* 3. Fetch and store new UIDs; remove stale UIDs from the cache.
|
||||||
|
*
|
||||||
|
* This is always a full-range UID comparison. A CONDSTORE/HIGHESTMODSEQ
|
||||||
|
* strategy can be layered on later for servers that support RFC 7162.
|
||||||
|
*/
|
||||||
|
class CacheService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RemoteMailService $remoteMailService,
|
||||||
|
private readonly MessageStore $messageStore,
|
||||||
|
private readonly string $provider,
|
||||||
|
private readonly string|int $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Mailbox sync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise the mailbox list for this service.
|
||||||
|
*
|
||||||
|
* Inserts/updates every selectable mailbox returned by the remote server
|
||||||
|
* and removes cached mailboxes that no longer exist remotely.
|
||||||
|
*
|
||||||
|
* @return array{added: string[], removed: string[]}
|
||||||
|
*/
|
||||||
|
public function syncMailboxes(): array
|
||||||
|
{
|
||||||
|
$remoteCollections = $this->remoteMailService->collectionList();
|
||||||
|
|
||||||
|
$added = [];
|
||||||
|
$removed = [];
|
||||||
|
|
||||||
|
// Upsert every remote mailbox
|
||||||
|
foreach ($remoteCollections as $name => $resource) {
|
||||||
|
$doc = array_merge(
|
||||||
|
$resource->toStore(),
|
||||||
|
['sid' => (string) $this->service],
|
||||||
|
);
|
||||||
|
$this->messageStore->upsertMailbox($doc);
|
||||||
|
$added[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove cached mailboxes that no longer exist on the server
|
||||||
|
$cached = $this->messageStore->listMailboxes((string) $this->service);
|
||||||
|
foreach ($cached as $cachedDoc) {
|
||||||
|
$name = $cachedDoc['name'] ?? ($cachedDoc['identifier'] ?? null);
|
||||||
|
if ($name === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($remoteCollections[$name])) {
|
||||||
|
$this->messageStore->deleteMailbox((string) $this->service, $name);
|
||||||
|
$removed[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['added' => $added, 'removed' => $removed];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message sync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronise all messages in one mailbox.
|
||||||
|
*
|
||||||
|
* Fetches new UIDs from the remote server, stores them in MongoDB, and
|
||||||
|
* removes UIDs from the cache that no longer exist on the server.
|
||||||
|
*
|
||||||
|
* @return array{added: int[], removed: int[]}
|
||||||
|
*/
|
||||||
|
public function syncMessages(string $mailbox): array
|
||||||
|
{
|
||||||
|
$remoteUids = $this->remoteMailService->entityList($mailbox);
|
||||||
|
$cachedUids = $this->messageStore->listUids((string) $this->service, $mailbox);
|
||||||
|
|
||||||
|
$remoteSet = array_fill_keys($remoteUids, true);
|
||||||
|
$cachedSet = array_fill_keys($cachedUids, true);
|
||||||
|
|
||||||
|
$newUids = array_keys(array_diff_key($remoteSet, $cachedSet));
|
||||||
|
$removedUids = array_keys(array_diff_key($cachedSet, $remoteSet));
|
||||||
|
|
||||||
|
// Stream-fetch new messages one at a time and store each
|
||||||
|
foreach ($this->remoteMailService->entitySyncStream($mailbox, $newUids) as $uid => $resource) {
|
||||||
|
/** @var EntityResource $resource */
|
||||||
|
$doc = array_merge(
|
||||||
|
$resource->toStore(),
|
||||||
|
[
|
||||||
|
'sid' => (string) $this->service,
|
||||||
|
'mailbox' => $mailbox,
|
||||||
|
'uid' => $uid,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$this->messageStore->upsertMessage($doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge stale UIDs from cache
|
||||||
|
if (!empty($removedUids)) {
|
||||||
|
$this->messageStore->deleteMessages((string) $this->service, $mailbox, $removedUids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'added' => $newUids,
|
||||||
|
'removed' => $removedUids,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a full sync: mailboxes first, then all messages in each selectable mailbox.
|
||||||
|
*
|
||||||
|
* @return array{mailboxes: array, messages: array<string, array>}
|
||||||
|
*/
|
||||||
|
public function syncAll(): array
|
||||||
|
{
|
||||||
|
$mailboxResult = $this->syncMailboxes();
|
||||||
|
$messagesResults = [];
|
||||||
|
|
||||||
|
foreach ($this->remoteMailService->collectionList() as $name => $collection) {
|
||||||
|
try {
|
||||||
|
$messagesResults[$name] = $this->syncMessages($name);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$messagesResults[$name] = ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mailboxes' => $mailboxResult,
|
||||||
|
'messages' => $messagesResults,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Partial helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync only the flags of already-cached messages in a mailbox.
|
||||||
|
*
|
||||||
|
* This is cheaper than a full message sync: it fetches FLAGS only from
|
||||||
|
* the remote server and updates cached documents in place.
|
||||||
|
*/
|
||||||
|
public function syncFlags(string $mailbox): void
|
||||||
|
{
|
||||||
|
$cachedUids = $this->messageStore->listUids((string) $this->service, $mailbox);
|
||||||
|
|
||||||
|
if (empty($cachedUids)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only FLAGS + UID from remote
|
||||||
|
$items = ['FLAGS', 'UID'];
|
||||||
|
|
||||||
|
foreach ($this->remoteMailService->entitySyncStream($mailbox, $cachedUids, $items) as $uid => $resource) {
|
||||||
|
/** @var EntityResource $resource */
|
||||||
|
$existing = $this->messageStore->fetchMessage((string) $this->service, $mailbox, $uid);
|
||||||
|
if ($existing === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge updated flags into the cached document
|
||||||
|
$existingProperties = $existing['properties'] ?? [];
|
||||||
|
$updatedProperties = $resource->toStore()['properties'] ?? [];
|
||||||
|
|
||||||
|
// Only overwrite flag-related fields
|
||||||
|
foreach (['seen', 'flagged', 'answered', 'draft', 'deleted', 'junk'] as $field) {
|
||||||
|
if (isset($updatedProperties[$field])) {
|
||||||
|
$existingProperties[$field] = $updatedProperties[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing['properties'] = $existingProperties;
|
||||||
|
$this->messageStore->upsertMessage($existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Service\Cache;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
use KTXM\ProviderImap\Providers\MessageProperties;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
use LogicException;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brings the cache of one service in line with its IMAP server.
|
|
||||||
*
|
|
||||||
* Created by the container without a service; call for() to bind one. Only ever
|
|
||||||
* talks to LiveMailService, never to the cache it maintains.
|
|
||||||
*/
|
|
||||||
class HarmonizationService
|
|
||||||
{
|
|
||||||
/** Messages fetched per UID FETCH when ingesting; each is cached as it streams in */
|
|
||||||
public const INGEST_BATCH_SIZE = 200;
|
|
||||||
|
|
||||||
/** Lifetime of a mailbox harmonization lock in seconds; long enough for a large first run */
|
|
||||||
public const LOCK_TTL = 900;
|
|
||||||
|
|
||||||
private ?ServiceBase $service = null;
|
|
||||||
private ?LiveMailService $live = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly MailboxStore $mailboxStore,
|
|
||||||
private readonly MessageStore $messageStore,
|
|
||||||
private readonly MessageFileStore $fileStore,
|
|
||||||
private readonly MessageIngestor $ingestor,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind a service; returns a new instance so the shared one stays unbound.
|
|
||||||
*/
|
|
||||||
public function for(ServiceBase $service, ?LiveMailService $live = null): static
|
|
||||||
{
|
|
||||||
$bound = clone $this;
|
|
||||||
$bound->service = $service;
|
|
||||||
$bound->live = $live ?? new LiveMailService($service);
|
|
||||||
|
|
||||||
return $bound;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonize the mailbox list of the service with the server.
|
|
||||||
*
|
|
||||||
* New mailboxes are added (not yet harmonized), existing ones updated, and
|
|
||||||
* mailboxes gone from the server purged with their messages. A rename shows
|
|
||||||
* up as one mailbox gone and one new.
|
|
||||||
*
|
|
||||||
* @return array{added: string[], updated: string[], removed: string[]}
|
|
||||||
*/
|
|
||||||
public function harmonizeMailboxes(): array
|
|
||||||
{
|
|
||||||
[$service, $live] = $this->bound();
|
|
||||||
$tenantId = (string) $service->tenantIdentifier();
|
|
||||||
$serviceId = (string) $service->identifier();
|
|
||||||
|
|
||||||
$remote = $live->collectionList();
|
|
||||||
$cached = $this->mailboxStore->list($serviceId);
|
|
||||||
|
|
||||||
$result = ['added' => [], 'updated' => [], 'removed' => []];
|
|
||||||
|
|
||||||
foreach ($remote as $name => $collection) {
|
|
||||||
$this->mailboxStore->upsert($tenantId, $serviceId, $collection);
|
|
||||||
$result[isset($cached[$name]) ? 'updated' : 'added'][] = $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// every IMAP account has an INBOX; a list without one is incomplete, so nothing is purged
|
|
||||||
if (!self::containsInbox(array_keys($remote))) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_keys(array_diff_key($cached, $remote)) as $name) {
|
|
||||||
$this->purgeMailbox($tenantId, $serviceId, (string) $name);
|
|
||||||
$result['removed'][] = (string) $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonize the mailbox list, then the messages of every selectable mailbox.
|
|
||||||
*
|
|
||||||
* A failing mailbox does not stop the others; its error is reported instead.
|
|
||||||
*
|
|
||||||
* @return array{mailboxes: array{added: string[], updated: string[], removed: string[]}, messages: array<string, array|string>}
|
|
||||||
*/
|
|
||||||
public function harmonizeAll(): array
|
|
||||||
{
|
|
||||||
[$service] = $this->bound();
|
|
||||||
$mailboxes = $this->harmonizeMailboxes();
|
|
||||||
|
|
||||||
$messages = [];
|
|
||||||
foreach ($this->mailboxStore->list((string) $service->identifier()) as $name => $document) {
|
|
||||||
if (!self::isSelectable($document)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$messages[$name] = $this->harmonizeMessages((string) $name);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$messages[$name] = $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['mailboxes' => $mailboxes, 'messages' => $messages];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonize the messages of one mailbox with the server.
|
|
||||||
*
|
|
||||||
* Ingests messages that are not cached yet (newest first), updates changed
|
|
||||||
* flags and removes messages expunged on the server. A changed UIDVALIDITY
|
|
||||||
* discards the mailbox's cache first. Never waits: when another run holds the
|
|
||||||
* mailbox lock, nothing is done.
|
|
||||||
*
|
|
||||||
* @return array{status: 'harmonized'|'skipped', reset: bool, added: int, updated: int, removed: int, complete: bool}
|
|
||||||
*/
|
|
||||||
public function harmonizeMessages(string $mailbox): array
|
|
||||||
{
|
|
||||||
[$service, $live] = $this->bound();
|
|
||||||
$tenantId = (string) $service->tenantIdentifier();
|
|
||||||
$serviceId = (string) $service->identifier();
|
|
||||||
|
|
||||||
$result = ['status' => 'skipped', 'reset' => false, 'added' => 0, 'updated' => 0, 'removed' => 0, 'complete' => false];
|
|
||||||
|
|
||||||
// the lock lives on the mailbox document, so it has to exist first
|
|
||||||
if ($this->mailboxStore->fetch($serviceId, $mailbox) === null) {
|
|
||||||
$this->harmonizeMailboxes();
|
|
||||||
if ($this->mailboxStore->fetch($serviceId, $mailbox) === null) {
|
|
||||||
throw new RuntimeException("Mailbox not found on server: {$mailbox}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$owner = bin2hex(random_bytes(8));
|
|
||||||
if (!$this->mailboxStore->acquireLock($serviceId, $mailbox, $owner, self::LOCK_TTL)) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$state = $this->mailboxStore->state($serviceId, $mailbox);
|
|
||||||
$selected = $live->mailboxFetch($mailbox)
|
|
||||||
?? throw new RuntimeException("Mailbox not found on server: {$mailbox}");
|
|
||||||
$uidValidity = $selected->uidValidity()
|
|
||||||
?? throw new RuntimeException("Server did not report UIDVALIDITY for mailbox: {$mailbox}");
|
|
||||||
|
|
||||||
// a new UIDVALIDITY invalidates every cached UID of the mailbox
|
|
||||||
if ($state['uidValidity'] !== null && (int) $state['uidValidity'] !== $uidValidity) {
|
|
||||||
$this->messageStore->deleteByMailbox($serviceId, $mailbox);
|
|
||||||
$this->fileStore->deleteByMailbox($tenantId, $serviceId, $mailbox);
|
|
||||||
$result['reset'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cachedFlags = $this->messageStore->flags($serviceId, $mailbox, $uidValidity);
|
|
||||||
|
|
||||||
// stream every UID and its flags from the server: cached messages get their flags
|
|
||||||
// compared (and are dropped from $cachedFlags), unknown UIDs are collected as new.
|
|
||||||
// No other IMAP command may run until the stream is consumed.
|
|
||||||
$new = [];
|
|
||||||
foreach ($live->entityFlags($mailbox) as $uid => $flags) {
|
|
||||||
if (!isset($cachedFlags[$uid])) {
|
|
||||||
$new[] = $uid;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$flags = MessageProperties::normalizeFlags($flags);
|
|
||||||
if (!self::sameFlags($flags, $cachedFlags[$uid])) {
|
|
||||||
$seq = $this->mailboxStore->reserveSequence($serviceId, $mailbox);
|
|
||||||
$this->messageStore->updateFlags($serviceId, $mailbox, $uidValidity, $uid, $flags, $seq);
|
|
||||||
$result['updated']++;
|
|
||||||
}
|
|
||||||
unset($cachedFlags[$uid]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// new: newest first so recent mail is available soonest
|
|
||||||
rsort($new);
|
|
||||||
$missing = [];
|
|
||||||
foreach (array_chunk($new, self::INGEST_BATCH_SIZE) as $batch) {
|
|
||||||
$ingested = $this->ingestBatch($tenantId, $serviceId, $mailbox, $uidValidity, $batch);
|
|
||||||
$result['added'] += count($ingested);
|
|
||||||
array_push($missing, ...array_diff($batch, $ingested));
|
|
||||||
}
|
|
||||||
|
|
||||||
// expunged: cached UIDs the server did not list; tombstones first, then files
|
|
||||||
$expunged = array_keys($cachedFlags);
|
|
||||||
if ($expunged !== []) {
|
|
||||||
$seq = $this->mailboxStore->reserveSequence($serviceId, $mailbox);
|
|
||||||
$this->messageStore->tombstone($serviceId, $mailbox, $uidValidity, $seq, ...$expunged);
|
|
||||||
$this->fileStore->delete($tenantId, $serviceId, $mailbox, $uidValidity, ...$expunged);
|
|
||||||
$result['removed'] = count($expunged);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UIDs that could not be fetched (e.g. expunged meanwhile) are retried next run
|
|
||||||
$result['complete'] = $missing === [];
|
|
||||||
$result['status'] = 'harmonized';
|
|
||||||
|
|
||||||
$this->mailboxStore->updateState($serviceId, $mailbox, [
|
|
||||||
'uidValidity' => $uidValidity,
|
|
||||||
'uidNext' => $selected->uidNext(),
|
|
||||||
'highestModSeq' => $selected->highestModSeq(),
|
|
||||||
'harmonizedAt' => time(),
|
|
||||||
'harmonizationComplete' => $result['complete'],
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
$this->mailboxStore->releaseLock($serviceId, $mailbox, $owner);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a batch of messages and cache each one as it streams in.
|
|
||||||
*
|
|
||||||
* @param int[] $uids
|
|
||||||
* @return int[] UIDs that were cached
|
|
||||||
*/
|
|
||||||
private function ingestBatch(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, array $uids): array
|
|
||||||
{
|
|
||||||
[$service, $live] = $this->bound();
|
|
||||||
$options = FetchOptions::message()->withBodyText(MessageIngestor::BODY_TEXT_LIMIT);
|
|
||||||
|
|
||||||
$ingested = [];
|
|
||||||
foreach ($live->messageFetch($mailbox, $options, ...$uids) as $message) {
|
|
||||||
$entity = (new EntityResource($service->provider(), $serviceId))->fromImap($message, $mailbox);
|
|
||||||
array_push($ingested, ...$this->ingestor->ingest($tenantId, $serviceId, $uidValidity, $entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ingested;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $left
|
|
||||||
* @param string[] $right
|
|
||||||
*/
|
|
||||||
private static function sameFlags(array $left, array $right): bool
|
|
||||||
{
|
|
||||||
sort($left);
|
|
||||||
sort($right);
|
|
||||||
|
|
||||||
return $left === $right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a mailbox and everything cached for it: meta documents first, then files, then the mailbox.
|
|
||||||
*/
|
|
||||||
private function purgeMailbox(string $tenantId, string $serviceId, string $name): void
|
|
||||||
{
|
|
||||||
$this->messageStore->deleteByMailbox($serviceId, $name);
|
|
||||||
$this->fileStore->deleteByMailbox($tenantId, $serviceId, $name);
|
|
||||||
$this->mailboxStore->delete($serviceId, $name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Service, 1: LiveMailService}
|
|
||||||
*/
|
|
||||||
private function bound(): array
|
|
||||||
{
|
|
||||||
if ($this->service === null || $this->live === null) {
|
|
||||||
throw new LogicException('HarmonizationService is not bound to a service; call for() first');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$this->service, $this->live];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a cached mailbox document describes a mailbox that can be selected.
|
|
||||||
*/
|
|
||||||
private static function isSelectable(array $document): bool
|
|
||||||
{
|
|
||||||
foreach ((array) ($document['properties']['attributes'] ?? []) as $attribute) {
|
|
||||||
if (in_array(strtolower((string) $attribute), ['\\noselect', '\\nonexistent'], true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $names
|
|
||||||
*/
|
|
||||||
private static function containsInbox(array $names): bool
|
|
||||||
{
|
|
||||||
foreach ($names as $name) {
|
|
||||||
if (strcasecmp($name, 'INBOX') === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Service\Cache;
|
|
||||||
|
|
||||||
use KTXF\Resource\Delta\Delta;
|
|
||||||
use KTXF\Resource\Delta\DeltaCollection;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answers "what changed since this signature?" for a cached mailbox.
|
|
||||||
*
|
|
||||||
* A signature is `<uidValidity>:<changeSeq>`. Read-only: harmonizing before
|
|
||||||
* answering is the caller's decision.
|
|
||||||
*/
|
|
||||||
class MessageDeltaService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly MailboxStore $mailboxStore,
|
|
||||||
private readonly MessageStore $messageStore,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes of a mailbox since a signature.
|
|
||||||
*
|
|
||||||
* - empty signature: the current signature and no changes, so the client gets a
|
|
||||||
* starting point; while the initial harmonization is incomplete the signature is
|
|
||||||
* `<uidValidity>:0`, so additions of the run in progress are not skipped
|
|
||||||
* - signature of another UIDVALIDITY, older than the purged tombstones, or not
|
|
||||||
* understood: a reset, answered as changes since 0 (every message is an addition)
|
|
||||||
* - otherwise: additions, modifications and deletions after the signature
|
|
||||||
*
|
|
||||||
* A mailbox that is not cached (or never harmonized) yields an empty delta.
|
|
||||||
*/
|
|
||||||
public function delta(string $serviceId, string $mailbox, string $signature): Delta
|
|
||||||
{
|
|
||||||
$state = $this->mailboxStore->state($serviceId, $mailbox);
|
|
||||||
if ($state['uidValidity'] === null) {
|
|
||||||
return new Delta();
|
|
||||||
}
|
|
||||||
|
|
||||||
$uidValidity = (int) $state['uidValidity'];
|
|
||||||
$current = self::signature($uidValidity, (int) $state['changeSeq']);
|
|
||||||
|
|
||||||
if ($signature === '') {
|
|
||||||
return new Delta(signature: $state['harmonizationComplete'] ? $current : self::signature($uidValidity, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
$since = self::since($signature, $uidValidity, (int) $state['purgedSeq'], (int) $state['changeSeq']) ?? 0;
|
|
||||||
$changes = $this->messageStore->changes($serviceId, $mailbox, $uidValidity, $since);
|
|
||||||
|
|
||||||
return new Delta(
|
|
||||||
new DeltaCollection(array_map('strval', $changes['additions'])),
|
|
||||||
new DeltaCollection(array_map('strval', $changes['modifications'])),
|
|
||||||
new DeltaCollection(array_map('strval', $changes['deletions'])),
|
|
||||||
$current,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function signature(int $uidValidity, int $changeSeq): string
|
|
||||||
{
|
|
||||||
return $uidValidity . ':' . $changeSeq;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The change sequence a signature refers to, or null when it needs a reset.
|
|
||||||
*/
|
|
||||||
private static function since(string $signature, int $uidValidity, int $purgedSeq, int $changeSeq): ?int
|
|
||||||
{
|
|
||||||
if (preg_match('/^(\d+):(\d+)$/', $signature, $matches) !== 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$since = (int) $matches[2];
|
|
||||||
if ((int) $matches[1] !== $uidValidity || $since < $purgedSeq || $since > $changeSeq) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $since;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Service\Cache;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes fetched messages into the cache (content store + meta store).
|
|
||||||
*
|
|
||||||
* Shared by harmonization and by the cold list path, which caches the page it streams.
|
|
||||||
*/
|
|
||||||
class MessageIngestor
|
|
||||||
{
|
|
||||||
/** Maximum octets of BODY[TEXT] fetched per message for the cache; text past it is completed on open */
|
|
||||||
public const BODY_TEXT_LIMIT = 262144; // 256 KB
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly MessageFileStore $fileStore,
|
|
||||||
private readonly MessageStore $messageStore,
|
|
||||||
private readonly MailboxStore $mailboxStore,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache messages of one mailbox generation.
|
|
||||||
*
|
|
||||||
* The content is written before the meta document, so an interruption leaves at
|
|
||||||
* most an orphaned file, never a meta document without content. Each message is
|
|
||||||
* stamped with its own change sequence, reserved per mailbox in one step.
|
|
||||||
*
|
|
||||||
* @return int[] UIDs that were cached
|
|
||||||
*/
|
|
||||||
public function ingest(string $tenantId, string $serviceId, int $uidValidity, EntityResource ...$entities): array
|
|
||||||
{
|
|
||||||
$byMailbox = [];
|
|
||||||
foreach ($entities as $entity) {
|
|
||||||
$byMailbox[(string) $entity->collection()][] = $entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ingested = [];
|
|
||||||
foreach ($byMailbox as $mailbox => $group) {
|
|
||||||
$seq = $this->mailboxStore->reserveSequence($serviceId, (string) $mailbox, count($group));
|
|
||||||
|
|
||||||
foreach ($group as $entity) {
|
|
||||||
$uid = (int) $entity->identifier();
|
|
||||||
|
|
||||||
$this->fileStore->write($tenantId, $serviceId, (string) $mailbox, $uidValidity, $uid, $entity->toCacheContent());
|
|
||||||
$this->messageStore->upsert($tenantId, $serviceId, $uidValidity, $entity, $seq++);
|
|
||||||
|
|
||||||
$ingested[] = $uid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ingested;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Service\Cache;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use KTXF\Resource\Filter\FilterComparisonOperator;
|
|
||||||
use KTXF\Resource\Filter\FilterConjunctionOperator;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use MongoDB\BSON\Regex;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates entity list filters and sorts into meta store queries.
|
|
||||||
*
|
|
||||||
* Mirrors LiveMailService's IMAP SEARCH / SORT translation so a cached list matches a
|
|
||||||
* live one: conditions are combined left to right with their conjunction, strings
|
|
||||||
* match as case-insensitive substrings, dates compare whole (UTC) days on `received`,
|
|
||||||
* sizes compare `size`. Conditions the cache cannot answer (body text, full text)
|
|
||||||
* make the whole filter a server filter.
|
|
||||||
*/
|
|
||||||
class MessageQueryBuilder
|
|
||||||
{
|
|
||||||
/** Attributes that need the message body, which the meta store does not hold */
|
|
||||||
private const SERVER_ATTRIBUTES = ['body', '*', 'all'];
|
|
||||||
|
|
||||||
/** Address fields: matched on address and display label */
|
|
||||||
private const ADDRESS_ATTRIBUTES = ['from', 'to', 'cc', 'bcc'];
|
|
||||||
|
|
||||||
/** Sorts on text fields use a case-insensitive collation */
|
|
||||||
private const TEXT_SORTS = ['subject', 'from', 'to'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the filter has to be evaluated by the server (IMAP SEARCH).
|
|
||||||
*/
|
|
||||||
public function needsServer(?IFilter $filter): bool
|
|
||||||
{
|
|
||||||
foreach ($filter?->conditions() ?? [] as $condition) {
|
|
||||||
if (in_array($condition['attribute'] ?? '', self::SERVER_ATTRIBUTES, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Meta store filter for a list filter; [] matches everything.
|
|
||||||
*
|
|
||||||
* Only valid when needsServer() is false.
|
|
||||||
*/
|
|
||||||
public function filter(?IFilter $filter): array
|
|
||||||
{
|
|
||||||
$expression = null;
|
|
||||||
|
|
||||||
foreach ($filter?->conditions() ?? [] as $condition) {
|
|
||||||
$operand = $this->operand($condition);
|
|
||||||
if ($operand === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($expression === null) {
|
|
||||||
$expression = $operand;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$expression = (($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR)
|
|
||||||
? ['$or' => [$expression, $operand]]
|
|
||||||
: ['$and' => [$expression, $operand]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $expression ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Meta store sort for a list sort; always ends with uid so pages are stable.
|
|
||||||
*
|
|
||||||
* @return array{sort: array<string, int>, collation: ?array} collation is set when a text field is sorted
|
|
||||||
*/
|
|
||||||
public function sort(?ISort $sort): array
|
|
||||||
{
|
|
||||||
$order = [];
|
|
||||||
$text = false;
|
|
||||||
|
|
||||||
foreach ($sort?->conditions() ?? [] as $condition) {
|
|
||||||
$attribute = $condition['attribute'] ?? '';
|
|
||||||
$field = match ($attribute) {
|
|
||||||
'from' => 'from.address',
|
|
||||||
'to' => 'to.0.address',
|
|
||||||
'subject' => 'subject',
|
|
||||||
'received' => 'received',
|
|
||||||
'sent' => 'sent',
|
|
||||||
'size' => 'size',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
if ($field === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$order[$field] = ($condition['direction'] ?? true) ? 1 : -1;
|
|
||||||
$text = $text || in_array($attribute, self::TEXT_SORTS, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$order['uid'] ??= 1;
|
|
||||||
|
|
||||||
return ['sort' => $order, 'collation' => $text ? ['locale' => 'en', 'strength' => 2] : null];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition
|
|
||||||
*/
|
|
||||||
private function operand(array $condition): ?array
|
|
||||||
{
|
|
||||||
$attribute = $condition['attribute'] ?? '';
|
|
||||||
$value = $condition['value'] ?? null;
|
|
||||||
$comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ;
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$attribute === 'subject', in_array($attribute, self::ADDRESS_ATTRIBUTES, true) => $this->stringOperand($attribute, $value, $comparator),
|
|
||||||
$attribute === 'before', $attribute === 'after' => $this->dateOperand($attribute, $value, $comparator),
|
|
||||||
$attribute === 'min', $attribute === 'max' => $this->sizeOperand($attribute, $value, $comparator),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function stringOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array
|
|
||||||
{
|
|
||||||
$values = is_array($value) ? array_values($value) : [$value];
|
|
||||||
$values = array_values(array_filter(array_map(
|
|
||||||
static fn (mixed $item): string => trim((string) $item),
|
|
||||||
$values,
|
|
||||||
), static fn (string $item): bool => $item !== ''));
|
|
||||||
|
|
||||||
if ($values === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$matches = array_map(fn (string $item): array => $this->stringMatch($attribute, $item), $values);
|
|
||||||
|
|
||||||
return match ($comparator) {
|
|
||||||
FilterComparisonOperator::EQ, FilterComparisonOperator::LIKE, FilterComparisonOperator::IN
|
|
||||||
=> count($matches) === 1 ? $matches[0] : ['$or' => $matches],
|
|
||||||
FilterComparisonOperator::NEQ, FilterComparisonOperator::NLIKE, FilterComparisonOperator::NIN
|
|
||||||
=> ['$nor' => $matches],
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function stringMatch(string $attribute, string $value): array
|
|
||||||
{
|
|
||||||
$regex = new Regex(preg_quote($value, '/'), 'i');
|
|
||||||
|
|
||||||
if ($attribute === 'subject') {
|
|
||||||
return ['subject' => $regex];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['$or' => [[$attribute . '.address' => $regex], [$attribute . '.label' => $regex]]];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dateOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array
|
|
||||||
{
|
|
||||||
$day = $this->day($value);
|
|
||||||
if ($day === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$start = $day->format('Y-m-d\TH:i:s\Z');
|
|
||||||
$end = $day->modify('+1 day')->format('Y-m-d\TH:i:s\Z');
|
|
||||||
$on = ['received' => ['$gte' => $start, '$lt' => $end]];
|
|
||||||
|
|
||||||
return match ($comparator) {
|
|
||||||
FilterComparisonOperator::EQ => $on,
|
|
||||||
FilterComparisonOperator::NEQ => ['$nor' => [$on]],
|
|
||||||
FilterComparisonOperator::LT, FilterComparisonOperator::LTE => $attribute === 'before' ? ['received' => ['$lt' => $start]] : null,
|
|
||||||
FilterComparisonOperator::GT, FilterComparisonOperator::GTE => $attribute === 'after' ? ['received' => ['$gte' => $start]] : null,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sizeOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array
|
|
||||||
{
|
|
||||||
if (!is_int($value) && !is_numeric($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$size = max(0, (int) $value);
|
|
||||||
|
|
||||||
return match ($attribute) {
|
|
||||||
'min' => match ($comparator) {
|
|
||||||
FilterComparisonOperator::EQ, FilterComparisonOperator::GTE => ['size' => ['$gte' => $size]],
|
|
||||||
FilterComparisonOperator::GT => ['size' => ['$gt' => $size]],
|
|
||||||
FilterComparisonOperator::LT => ['size' => ['$lt' => $size]],
|
|
||||||
FilterComparisonOperator::LTE => ['size' => ['$lte' => $size]],
|
|
||||||
FilterComparisonOperator::NEQ => ['size' => ['$ne' => $size]],
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
'max' => match ($comparator) {
|
|
||||||
FilterComparisonOperator::EQ, FilterComparisonOperator::LTE => ['size' => ['$lte' => $size]],
|
|
||||||
FilterComparisonOperator::LT => ['size' => ['$lt' => $size]],
|
|
||||||
FilterComparisonOperator::GT => ['size' => ['$gt' => $size]],
|
|
||||||
FilterComparisonOperator::GTE => ['size' => ['$gte' => $size]],
|
|
||||||
FilterComparisonOperator::NEQ => ['size' => ['$ne' => $size]],
|
|
||||||
default => null,
|
|
||||||
},
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start of the (UTC) day a filter value falls on.
|
|
||||||
*/
|
|
||||||
private function day(mixed $value): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$date = $value instanceof DateTimeImmutable ? $value : new DateTimeImmutable(trim((string) $value));
|
|
||||||
} catch (\Exception) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DateTimeImmutable($date->format('Y-m-d'), new DateTimeZone('UTC'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,40 +7,39 @@ declare(strict_types=1);
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Service\Live;
|
namespace KTXM\ProviderImap\Service\Remote;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Generator;
|
use Generator;
|
||||||
|
use KTXC\Server;
|
||||||
use KTXC\Logger\PlainFileLogger;
|
use KTXC\Logger\PlainFileLogger;
|
||||||
use KTXM\ProviderImap\Client\Client as ImapClient;
|
use KTXM\ProviderImap\Client\Client as ImapClient;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\AppendCommand;
|
use KTXM\ProviderImap\Client\Command\AppendCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\FetchManyCommand;
|
use KTXM\ProviderImap\Client\Command\FetchManyCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\FetchOneCommand;
|
use KTXM\ProviderImap\Client\Command\FetchOneCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\ExpungeCommand;
|
use KTXM\ProviderImap\Client\Command\ExpungeCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\ListCommand;
|
use KTXM\ProviderImap\Client\Command\ListCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\SearchCommand;
|
use KTXM\ProviderImap\Client\Command\SearchCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\SelectCommand;
|
use KTXM\ProviderImap\Client\Command\SelectCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\SortCommand;
|
use KTXM\ProviderImap\Client\Command\SortCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\StatusCommand;
|
use KTXM\ProviderImap\Client\Command\StatusCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\StoreCommand;
|
use KTXM\ProviderImap\Client\Command\StoreCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CopyCommand;
|
use KTXM\ProviderImap\Client\Command\CopyCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CreateCommand;
|
use KTXM\ProviderImap\Client\Command\CreateCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\RenameCommand;
|
use KTXM\ProviderImap\Client\Command\RenameCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\DeleteCommand;
|
use KTXM\ProviderImap\Client\Command\DeleteCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
use KTXM\ProviderImap\Client\FetchOptions;
|
||||||
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListReturnOptions;
|
use KTXM\ProviderImap\Client\ListReturnOptions;
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
use KTXM\ProviderImap\Client\Mailbox;
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
use KTXM\ProviderImap\Client\Message;
|
use KTXM\ProviderImap\Client\Message;
|
||||||
use KTXM\ProviderImap\Client\MessageAddress;
|
use KTXM\ProviderImap\Client\MessageAddress;
|
||||||
use KTXM\ProviderImap\Client\MessagePart;
|
use KTXM\ProviderImap\Client\MessagePart;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\MoveCommand;
|
use KTXM\ProviderImap\Client\Command\MoveCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\SearchCriteriaBuilder;
|
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
||||||
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXF\Mail\Collection\CollectionRoles;
|
use KTXF\Mail\Collection\CollectionRoles;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
use KTXF\Resource\Filter\FilterComparisonOperator;
|
use KTXF\Resource\Filter\FilterComparisonOperator;
|
||||||
@@ -51,27 +50,24 @@ use KTXF\Resource\Range\RangeAnchorType;
|
|||||||
use KTXF\Resource\Range\RangeTally;
|
use KTXF\Resource\Range\RangeTally;
|
||||||
use KTXF\Resource\Sort\ISort;
|
use KTXF\Resource\Sort\ISort;
|
||||||
use KTXF\Resource\BinaryResource;
|
use KTXF\Resource\BinaryResource;
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
use KTXM\ProviderImap\Smtp\Client as SmtpClient;
|
use KTXM\ProviderImap\Smtp\Client as SmtpClient;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Live Mail Service
|
* IMAP Remote Mail Service
|
||||||
*/
|
*/
|
||||||
class LiveMailService
|
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;
|
||||||
/** Result of the last SELECT on the current connection, reused while still selected */
|
|
||||||
private ?Mailbox $selected = null;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ServiceBase $service,
|
private readonly Service $service,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,54 +132,17 @@ class LiveMailService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$logDir = dirname(__DIR__, 5) . '/var/logs/provider_imap';
|
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
|
||||||
|
|
||||||
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
|
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the collections of the account, keyed by mailbox name.
|
* list of collections in remote storage
|
||||||
*
|
*
|
||||||
* @return array<string, CollectionResource>
|
* @since Release 1.0.0
|
||||||
*/
|
*/
|
||||||
public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array
|
public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
foreach ($this->mailboxList($location, $filter, $sort) as $name => $mailbox) {
|
|
||||||
$list[(string) $name] = $this->collectionResource($mailbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single collection by its mailbox name; null when it does not exist.
|
|
||||||
*/
|
|
||||||
public function collectionFetch(string $identifier): ?CollectionResource
|
|
||||||
{
|
|
||||||
$mailbox = $this->mailboxFetch($identifier);
|
|
||||||
|
|
||||||
return $mailbox === null ? null : $this->collectionResource($mailbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hierarchy delimiter of the account, from `LIST "" ""` (no mailboxes are listed); "/" when unknown.
|
|
||||||
*/
|
|
||||||
public function collectionDelimiter(): string
|
|
||||||
{
|
|
||||||
foreach ($this->imapClient()->perform(new ListCommand('', '')) as $mailbox) {
|
|
||||||
return $mailbox->delimiter() ?: '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the IMAP mailboxes of the account (with status), keyed by name.
|
|
||||||
*
|
|
||||||
* @return Generator<string, Mailbox>
|
|
||||||
*/
|
|
||||||
public function mailboxList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
|
|
||||||
{
|
{
|
||||||
// Prepare location filter
|
// Prepare location filter
|
||||||
if (!empty($location)) {
|
if (!empty($location)) {
|
||||||
@@ -235,67 +194,66 @@ class LiveMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a single IMAP mailbox (with status) by its full name.
|
* Fetch a single mailbox by its full name.
|
||||||
*
|
*
|
||||||
* Returns null when no mailbox matching $identifier is found.
|
* Returns null when no mailbox matching $identifier is found.
|
||||||
*/
|
*/
|
||||||
public function mailboxFetch(string $identifier): ?Mailbox
|
public function collectionFetch(string $identifier): ?Mailbox
|
||||||
{
|
{
|
||||||
// LIST-STATUS (RFC 5819) returns the status with the LIST response in one round trip
|
|
||||||
$listStatus = $this->imapClient()->hasCapability('LIST-STATUS');
|
|
||||||
$command = $listStatus
|
|
||||||
? new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))
|
|
||||||
: new ListCommand('', $identifier);
|
|
||||||
|
|
||||||
// retrieve mailbox from remote
|
// retrieve mailbox from remote
|
||||||
$mailbox = iterator_to_array($this->imapClient()->perform($command));
|
$mailbox = iterator_to_array($this->imapClient()->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
|
||||||
if (empty($mailbox)) {
|
if (empty($mailbox)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$mailbox = reset($mailbox);
|
$mailbox = reset($mailbox);
|
||||||
|
// enrich with STATUS
|
||||||
|
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||||
|
$mailbox = $mailbox->fromStatus($status);
|
||||||
|
|
||||||
|
return $mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
// enrich with STATUS when LIST could not provide it
|
/**
|
||||||
if (!$listStatus && $mailbox->isSelectable()) {
|
* Create a new IMAP mailbox and return it.
|
||||||
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
*
|
||||||
$mailbox = $mailbox->fromStatus($status);
|
* If the server-side LIST cannot confirm the new mailbox (e.g., immediate
|
||||||
|
* consistency), a lightweight stub resource is returned instead.
|
||||||
|
*/
|
||||||
|
public function collectionCreate(string $name): Mailbox
|
||||||
|
{
|
||||||
|
$result = $this->imapClient()->perform(new CreateCommand($name));
|
||||||
|
|
||||||
|
if (!$result->isOk()) {
|
||||||
|
throw new ImapException('Failed to create mailbox: ' . $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to refetch the new mailbox from the server
|
||||||
|
$mailbox = $this->collectionFetch($name);
|
||||||
|
if ($mailbox === null) {
|
||||||
|
throw new ImapException('Failed to create mailbox: ' . $name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $mailbox;
|
return $mailbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new IMAP mailbox and return it as a collection resource.
|
|
||||||
*
|
|
||||||
* If the server-side LIST cannot confirm the new mailbox (e.g., immediate
|
|
||||||
* consistency), a lightweight stub resource is returned instead.
|
|
||||||
*/
|
|
||||||
public function collectionCreate(string $name, ?string $delimiter = null): CollectionResource
|
|
||||||
{
|
|
||||||
$this->imapClient()->perform(new CreateCommand($name));
|
|
||||||
|
|
||||||
// Attempt to refetch the new mailbox from the server
|
|
||||||
$mailbox = $this->mailboxFetch($name);
|
|
||||||
if ($mailbox === null) {
|
|
||||||
throw new ImapException('Failed to create mailbox: ' . $name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->collectionResource($mailbox, $delimiter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a mailbox and return the updated resource.
|
* Rename a mailbox and return the updated resource.
|
||||||
*/
|
*/
|
||||||
public function collectionRename(string $oldName, string $newName, ?string $delimiter = null): CollectionResource
|
public function collectionRename(string $oldName, string $newName): Mailbox
|
||||||
{
|
{
|
||||||
$this->imapClient()->perform(new RenameCommand($oldName, $newName));
|
$result = $this->imapClient()->perform(new RenameCommand($oldName, $newName));
|
||||||
|
|
||||||
$mailbox = $this->mailboxFetch($newName);
|
if (!$result->isOk()) {
|
||||||
|
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailbox = $this->collectionFetch($newName);
|
||||||
|
|
||||||
if ($mailbox === null) {
|
if ($mailbox === null) {
|
||||||
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->collectionResource($mailbox, $delimiter);
|
return $mailbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,7 +261,11 @@ class LiveMailService
|
|||||||
*/
|
*/
|
||||||
public function collectionDestroy(string $name): bool
|
public function collectionDestroy(string $name): bool
|
||||||
{
|
{
|
||||||
$this->imapClient()->perform(new DeleteCommand($name));
|
$result = $this->imapClient()->perform(new DeleteCommand($name));
|
||||||
|
|
||||||
|
if (!$result->isOk()) {
|
||||||
|
throw new ImapException('Failed to delete mailbox: ' . $name);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -320,7 +282,7 @@ class LiveMailService
|
|||||||
$nativeFilter = $this->buildEntitySearchCriteria($filter);
|
$nativeFilter = $this->buildEntitySearchCriteria($filter);
|
||||||
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
|
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
|
||||||
|
|
||||||
$this->select($collection);
|
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
$rfc5258 = $this->imapClient()->hasCapability('SORT');
|
$rfc5258 = $this->imapClient()->hasCapability('SORT');
|
||||||
|
|
||||||
$uids = [];
|
$uids = [];
|
||||||
@@ -349,95 +311,24 @@ class LiveMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream the flags of every message in a mailbox.
|
* Retrieve a list of messages in a mailbox matching the given filter, sorted and paginated as requested.
|
||||||
*
|
*
|
||||||
* The stream also lists every UID that exists in the mailbox.
|
* @return Message[] list of messages matching the filter, sorted and paginated as requested
|
||||||
*
|
|
||||||
* @return Generator<int, list<string>> IMAP flags keyed by UID
|
|
||||||
*/
|
|
||||||
public function entityFlags(string $collection): Generator
|
|
||||||
{
|
|
||||||
// fresh SELECT: the message count decides whether "1:*" may be sent
|
|
||||||
$mailbox = $this->select($collection, refresh: true);
|
|
||||||
|
|
||||||
// "1:*" on an empty mailbox is rejected by some servers
|
|
||||||
if ($mailbox->messages() === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->imapClient()->perform(new FetchManyCommand(
|
|
||||||
MessageTarget::uid('1:*'),
|
|
||||||
FetchOptions::of('FLAGS'),
|
|
||||||
)) as $message) {
|
|
||||||
yield $message->uid() => $message->flags();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine which of the given UIDs exist in a mailbox.
|
|
||||||
*
|
|
||||||
* @return int[] the subset of the given UIDs that exist
|
|
||||||
*/
|
|
||||||
public function entityExtant(string $collection, int ...$uids): array
|
|
||||||
{
|
|
||||||
if ($uids === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->select($collection);
|
|
||||||
|
|
||||||
return $this->imapClient()->perform(new SearchCommand(
|
|
||||||
SearchCriteriaBuilder::create()->uid(SequenceSet::items(...$uids)),
|
|
||||||
IdentifierMode::Uid,
|
|
||||||
))->matches();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the entities of a mailbox matching the given filter, sorted and paginated as requested.
|
|
||||||
*
|
|
||||||
* @return Generator<int, EntityResource> keyed by UID
|
|
||||||
*/
|
*/
|
||||||
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
|
||||||
{
|
{
|
||||||
foreach ($this->messageList($collection, $filter, $sort, $range) as $message) {
|
$options = FetchOptions::message()->withBodyText();
|
||||||
$entity = $this->entityResource($message, $collection);
|
|
||||||
yield (int) $entity->identifier() => $entity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch entities by UID (full TEXT).
|
|
||||||
*
|
|
||||||
* @return Generator<int, EntityResource> keyed by UID
|
|
||||||
*/
|
|
||||||
public function entityFetch(string $collection, int ...$uids): Generator
|
|
||||||
{
|
|
||||||
foreach ($this->messageFetch($collection, null, ...$uids) as $uid => $message) {
|
|
||||||
yield $uid => $this->entityResource($message, $collection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List IMAP messages of a mailbox matching the given filter, sorted and paginated as requested.
|
|
||||||
*
|
|
||||||
* @return Generator<Message>
|
|
||||||
*/
|
|
||||||
public function messageList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator
|
|
||||||
{
|
|
||||||
// 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) {
|
||||||
$mailbox = $this->select($collection, refresh: true);
|
$mailbox = $this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
|
|
||||||
// "1:*" on an empty mailbox is rejected by some servers
|
if ($mailbox === null) {
|
||||||
if ($mailbox->messages() === 0) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
yield from $this->imapClient()->perform(new FetchManyCommand(
|
yield from $this->imapClient()->perform(new FetchManyCommand(
|
||||||
MessageTarget::all(),
|
FetchTarget::all(),
|
||||||
$options,
|
$options,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -451,26 +342,26 @@ class LiveMailService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
yield from $this->messageFetch($collection, $options, ...$uids);
|
yield from $this->entityFetch($collection, $options, ...$uids);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch one or more messages by UID as IMAP messages.
|
* Fetch one or more messages by UID and return EntityResource objects.
|
||||||
*
|
*
|
||||||
* @param int ...$uids
|
* @param int ...$uids
|
||||||
* @return Generator<int, Message> keyed by UID
|
* @return Message[] keyed by UID
|
||||||
*/
|
*/
|
||||||
public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
|
public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
|
||||||
{
|
{
|
||||||
if (empty($uids)) {
|
if (empty($uids)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$options ??= FetchOptions::message()->withBodyText();
|
$options ??= FetchOptions::message()->withBodyText();
|
||||||
$this->select($collection);
|
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
|
|
||||||
$request = new FetchManyCommand(
|
$request = new FetchManyCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$options,
|
$options,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -480,31 +371,6 @@ class LiveMailService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the full, decoded content of body sections of one message.
|
|
||||||
*
|
|
||||||
* @param string[] $partIds e.g. ['1.2']
|
|
||||||
* @return array<string, string> content keyed by part id; sections the server did not return are absent
|
|
||||||
*/
|
|
||||||
public function messageSections(string $collection, int $uid, string ...$partIds): array
|
|
||||||
{
|
|
||||||
if ($partIds === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// BODYSTRUCTURE lets the parser decode each section by its transfer encoding and charset
|
|
||||||
$options = FetchOptions::of('BODYSTRUCTURE');
|
|
||||||
foreach ($partIds as $partId) {
|
|
||||||
$options = $options->withBodySection($partId);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->messageFetch($collection, $options, $uid) as $message) {
|
|
||||||
return array_intersect_key($message->bodySections(), array_flip($partIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream the raw bytes of a message or a specific MIME part without buffering.
|
* Stream the raw bytes of a message or a specific MIME part without buffering.
|
||||||
*
|
*
|
||||||
@@ -517,7 +383,7 @@ class LiveMailService
|
|||||||
*/
|
*/
|
||||||
public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource
|
public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource
|
||||||
{
|
{
|
||||||
$this->select($collection);
|
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
|
|
||||||
$encoding = null;
|
$encoding = null;
|
||||||
|
|
||||||
@@ -527,7 +393,7 @@ class LiveMailService
|
|||||||
} else {
|
} else {
|
||||||
// Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred)
|
// Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred)
|
||||||
$message = $this->imapClient()->perform(new FetchOneCommand(
|
$message = $this->imapClient()->perform(new FetchOneCommand(
|
||||||
MessageTarget::uid(SequenceSet::items($uid)),
|
FetchTarget::uid(SequenceSet::items($uid)),
|
||||||
FetchOptions::of('BODYSTRUCTURE'),
|
FetchOptions::of('BODYSTRUCTURE'),
|
||||||
));
|
));
|
||||||
$bodyStructure = $message->bodyStructure();
|
$bodyStructure = $message->bodyStructure();
|
||||||
@@ -542,7 +408,7 @@ class LiveMailService
|
|||||||
|
|
||||||
// Start download stream
|
// Start download stream
|
||||||
$stream = $this->decodeStream(
|
$stream = $this->decodeStream(
|
||||||
$this->imapClient()->download(MessageTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
|
$this->imapClient()->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
|
||||||
$encoding
|
$encoding
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -625,44 +491,6 @@ class LiveMailService
|
|||||||
return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags));
|
return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a replacement message, then permanently remove the superseded UID.
|
|
||||||
* The replacement is removed on cleanup failure so the old UID remains
|
|
||||||
* authoritative for a later retry.
|
|
||||||
*
|
|
||||||
* @param string[] $flags
|
|
||||||
*/
|
|
||||||
public function entityReplace(
|
|
||||||
string $collection,
|
|
||||||
int $identifier,
|
|
||||||
string $rawMessage,
|
|
||||||
array $flags = [],
|
|
||||||
): ?int {
|
|
||||||
if ($identifier <= 0) {
|
|
||||||
throw new RuntimeException('A valid IMAP UID is required for replacement');
|
|
||||||
}
|
|
||||||
|
|
||||||
$replacement = $this->entityCreate($collection, $rawMessage, $flags);
|
|
||||||
if ($replacement === null || $replacement <= 0) {
|
|
||||||
throw new RuntimeException('IMAP replacement append did not return a valid UID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$deleted = $this->entityDestroy($collection, $identifier);
|
|
||||||
if (($deleted[$identifier] ?? false) !== true) {
|
|
||||||
throw new RuntimeException('Failed to delete the superseded IMAP entity');
|
|
||||||
}
|
|
||||||
} catch (\Throwable $error) {
|
|
||||||
try {
|
|
||||||
$this->entityDestroy($collection, $replacement);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
}
|
|
||||||
throw $error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $replacement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modify message flags for one or more messages.
|
* Modify message flags for one or more messages.
|
||||||
*
|
*
|
||||||
@@ -676,9 +504,9 @@ class LiveMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->select($collection, readOnly: false);
|
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||||
$this->imapClient()->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$flags,
|
$flags,
|
||||||
$action,
|
$action,
|
||||||
));
|
));
|
||||||
@@ -693,9 +521,9 @@ class LiveMailService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$target = MessageTarget::uid(SequenceSet::items(...array_values($uids)));
|
$target = FetchTarget::uid(SequenceSet::items(...array_values($uids)));
|
||||||
|
|
||||||
$this->select($collection, readOnly: false);
|
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||||
$this->imapClient()->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
$this->imapClient()->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
||||||
$this->imapClient()->perform(new ExpungeCommand($target));
|
$this->imapClient()->perform(new ExpungeCommand($target));
|
||||||
|
|
||||||
@@ -709,14 +537,14 @@ class LiveMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->select($collection, readOnly: false);
|
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||||
|
|
||||||
$flagsToAdd = $this->normalizeFlags($flagsToAdd);
|
$flagsToAdd = $this->normalizeFlags($flagsToAdd);
|
||||||
$flagsToRemove = $this->normalizeFlags($flagsToRemove);
|
$flagsToRemove = $this->normalizeFlags($flagsToRemove);
|
||||||
|
|
||||||
if (!empty($flagsToAdd)) {
|
if (!empty($flagsToAdd)) {
|
||||||
$this->imapClient()->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$flagsToAdd,
|
$flagsToAdd,
|
||||||
'+',
|
'+',
|
||||||
));
|
));
|
||||||
@@ -724,7 +552,7 @@ class LiveMailService
|
|||||||
|
|
||||||
if (!empty($flagsToRemove)) {
|
if (!empty($flagsToRemove)) {
|
||||||
$this->imapClient()->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$flagsToRemove,
|
$flagsToRemove,
|
||||||
'-',
|
'-',
|
||||||
));
|
));
|
||||||
@@ -741,25 +569,31 @@ class LiveMailService
|
|||||||
|
|
||||||
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
|
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
|
||||||
if ($rfc6851) {
|
if ($rfc6851) {
|
||||||
$this->select($sourceCollection, readOnly: false);
|
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||||
$response = $this->imapClient()->perform(new MoveCommand(
|
$response = $this->imapClient()->perform(new MoveCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$targetCollection,
|
$targetCollection,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
$this->select($sourceCollection, readOnly: false);
|
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||||
$response = $this->imapClient()->perform(new CopyCommand(
|
$response = $this->imapClient()->perform(new CopyCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$targetCollection,
|
$targetCollection,
|
||||||
));
|
));
|
||||||
$this->imapClient()->perform(new StoreCommand(
|
if ($response->isOk()) {
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
['\\Deleted'],
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
'+',
|
['\\Deleted'],
|
||||||
));
|
'+',
|
||||||
$this->imapClient()->perform(new ExpungeCommand(
|
));
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
$this->imapClient()->perform(new ExpungeCommand(
|
||||||
));
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('Failed to move messages: ' . implode(', ', $response->responseCodes()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
|
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
|
||||||
@@ -783,12 +617,16 @@ class LiveMailService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->select($sourceCollection, readOnly: false);
|
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||||
$response = $this->imapClient()->perform(new CopyCommand(
|
$response = $this->imapClient()->perform(new CopyCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$targetCollection,
|
$targetCollection,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('Failed to copy messages: ' . implode(', ', $response->responseCodes()));
|
||||||
|
}
|
||||||
|
|
||||||
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
|
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
|
||||||
$map = $response->copyUidMap();
|
$map = $response->copyUidMap();
|
||||||
if ($map === []) {
|
if ($map === []) {
|
||||||
@@ -1006,7 +844,7 @@ class LiveMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$messages = iterator_to_array($this->imapClient()->perform(new FetchManyCommand(
|
$messages = iterator_to_array($this->imapClient()->perform(new FetchManyCommand(
|
||||||
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$options,
|
$options,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
@@ -1269,40 +1107,4 @@ class LiveMailService
|
|||||||
}
|
}
|
||||||
return $normalized;
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function collectionResource(Mailbox $mailbox, ?string $delimiter = null): CollectionResource
|
|
||||||
{
|
|
||||||
return (new CollectionResource($this->service->provider(), $this->service->identifier()))
|
|
||||||
->fromImap($mailbox, ['delimiter' => $delimiter]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function entityResource(Message $message, string $collection): EntityResource
|
|
||||||
{
|
|
||||||
return (new EntityResource($this->service->provider(), $this->service->identifier()))
|
|
||||||
->fromImap($message, $collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a mailbox, reusing the current selection when possible.
|
|
||||||
*
|
|
||||||
* The current selection is reused when the same mailbox is still selected on the
|
|
||||||
* session and its access mode suffices (read-write covers read-only). A refresh
|
|
||||||
* forces a new SELECT, e.g. when an up-to-date message count is needed.
|
|
||||||
*/
|
|
||||||
private function select(string $collection, bool $readOnly = true, bool $refresh = false): Mailbox
|
|
||||||
{
|
|
||||||
if (!$refresh
|
|
||||||
&& $this->selected !== null
|
|
||||||
&& $this->selected->name() === $collection
|
|
||||||
&& $this->imapClient()->session()->selectedMailbox() === $collection
|
|
||||||
&& ($readOnly || !$this->selected->readOnly())
|
|
||||||
) {
|
|
||||||
return $this->selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->selected = null;
|
|
||||||
$this->selected = $this->imapClient()->perform(new SelectCommand($collection, $readOnly));
|
|
||||||
|
|
||||||
return $this->selected;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Stores;
|
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use MongoDB\Operation\FindOneAndUpdate;
|
|
||||||
use RuntimeException;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IMAP Mailbox Meta Store
|
|
||||||
*
|
|
||||||
* One MongoDB document per cached mailbox in `provider_imap_mail_mailboxes`,
|
|
||||||
* keyed by (sid, name). The document holds the mailbox itself and its
|
|
||||||
* harmonization state.
|
|
||||||
*/
|
|
||||||
class MailboxStore
|
|
||||||
{
|
|
||||||
protected const COLLECTION_NAME = 'provider_imap_mail_mailboxes';
|
|
||||||
|
|
||||||
/** Harmonization state fields and their defaults for a mailbox that was never harmonized */
|
|
||||||
public const STATE_DEFAULTS = [
|
|
||||||
'uidValidity' => null,
|
|
||||||
'uidNext' => null,
|
|
||||||
'highestModSeq' => null,
|
|
||||||
'changeSeq' => 0,
|
|
||||||
'purgedSeq' => 0,
|
|
||||||
'harmonizedAt' => null,
|
|
||||||
'harmonizationComplete' => false,
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
protected readonly DataStore $dataStore,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the collection indexes.
|
|
||||||
*
|
|
||||||
* MongoDB createIndex is idempotent when the name and specification match.
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public function ensureIndexes(): array
|
|
||||||
{
|
|
||||||
$collection = $this->dataStore->selectCollection(self::COLLECTION_NAME);
|
|
||||||
|
|
||||||
return [
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'name' => 1],
|
|
||||||
['name' => 'mailboxes_key', 'unique' => true]
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['tid' => 1, 'sid' => 1],
|
|
||||||
['name' => 'mailboxes_by_tenant_service']
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert or update the mailbox fields of a document.
|
|
||||||
*
|
|
||||||
* Harmonization state on an existing document is left untouched; a new document
|
|
||||||
* starts with the default (never harmonized) state.
|
|
||||||
*/
|
|
||||||
public function upsert(string $tenantId, string $serviceId, CollectionResource $collection): void
|
|
||||||
{
|
|
||||||
$document = $collection->toCacheMeta();
|
|
||||||
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
|
||||||
['sid' => $serviceId, 'name' => $document['name']],
|
|
||||||
[
|
|
||||||
'$set' => ['tid' => $tenantId, 'sid' => $serviceId, ...$document, 'listedAt' => time()],
|
|
||||||
'$setOnInsert' => self::STATE_DEFAULTS,
|
|
||||||
],
|
|
||||||
['upsert' => true],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the mailbox list of a service was last harmonized (newest listedAt); null when never.
|
|
||||||
*/
|
|
||||||
public function listedAt(string $serviceId): ?int
|
|
||||||
{
|
|
||||||
$document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
|
|
||||||
['sid' => $serviceId],
|
|
||||||
['projection' => ['listedAt' => 1], 'sort' => ['listedAt' => -1]],
|
|
||||||
);
|
|
||||||
|
|
||||||
return isset($document['listedAt']) ? (int) $document['listedAt'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve the document of a mailbox.
|
|
||||||
*/
|
|
||||||
public function fetch(string $serviceId, string $name): ?array
|
|
||||||
{
|
|
||||||
return $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'name' => $name,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the documents of all mailboxes of a service.
|
|
||||||
*
|
|
||||||
* @return array<string, array> keyed by mailbox name
|
|
||||||
*/
|
|
||||||
public function list(string $serviceId): array
|
|
||||||
{
|
|
||||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['sid' => $serviceId]);
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
foreach ($cursor as $document) {
|
|
||||||
$list[(string) $document['name']] = $document;
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Harmonization state ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonization state of a mailbox; defaults when the mailbox is not cached.
|
|
||||||
*
|
|
||||||
* @return array{uidValidity: ?int, uidNext: ?int, highestModSeq: ?int, changeSeq: int, purgedSeq: int, harmonizedAt: ?int, harmonizationComplete: bool}
|
|
||||||
*/
|
|
||||||
public function state(string $serviceId, string $name): array
|
|
||||||
{
|
|
||||||
$document = $this->fetch($serviceId, $name) ?? [];
|
|
||||||
|
|
||||||
return array_replace(self::STATE_DEFAULTS, array_intersect_key($document, self::STATE_DEFAULTS));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update harmonization state fields of a mailbox; keys outside the state are ignored.
|
|
||||||
*/
|
|
||||||
public function updateState(string $serviceId, string $name, array $state): void
|
|
||||||
{
|
|
||||||
$state = array_intersect_key($state, self::STATE_DEFAULTS);
|
|
||||||
if ($state === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
|
||||||
['sid' => $serviceId, 'name' => $name],
|
|
||||||
['$set' => $state],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reserve a range of change sequence numbers for a mailbox.
|
|
||||||
*
|
|
||||||
* Atomically advances the mailbox's changeSeq by $count and returns the first
|
|
||||||
* number of the reserved range (range = first .. first + count - 1).
|
|
||||||
*
|
|
||||||
* @throws RuntimeException when the mailbox is not cached
|
|
||||||
*/
|
|
||||||
public function reserveSequence(string $serviceId, string $name, int $count = 1): int
|
|
||||||
{
|
|
||||||
$count = max(1, $count);
|
|
||||||
|
|
||||||
$document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->getMongoCollection()->findOneAndUpdate(
|
|
||||||
['sid' => $serviceId, 'name' => $name],
|
|
||||||
['$inc' => ['changeSeq' => $count]],
|
|
||||||
[
|
|
||||||
'projection' => ['changeSeq' => 1],
|
|
||||||
'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($document === null) {
|
|
||||||
throw new RuntimeException("Mailbox is not cached: {$name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) ((array) $document)['changeSeq'] - $count + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take the harmonization lock of a mailbox when it is free or expired.
|
|
||||||
*
|
|
||||||
* Never waits: returns false when another owner holds a valid lock or the
|
|
||||||
* mailbox is not cached.
|
|
||||||
*/
|
|
||||||
public function acquireLock(string $serviceId, string $name, string $owner, int $ttl): bool
|
|
||||||
{
|
|
||||||
$now = time();
|
|
||||||
|
|
||||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
|
||||||
[
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'name' => $name,
|
|
||||||
'$or' => [
|
|
||||||
['lock' => null],
|
|
||||||
['lock.expiresAt' => ['$lte' => $now]],
|
|
||||||
['lock.owner' => $owner],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
['$set' => ['lock' => ['owner' => $owner, 'expiresAt' => $now + $ttl]]],
|
|
||||||
);
|
|
||||||
|
|
||||||
return $result->getMatchedCount() === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release the harmonization lock of a mailbox, if still held by the owner.
|
|
||||||
*/
|
|
||||||
public function releaseLock(string $serviceId, string $name, string $owner): void
|
|
||||||
{
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
|
||||||
['sid' => $serviceId, 'name' => $name, 'lock.owner' => $owner],
|
|
||||||
['$unset' => ['lock' => '']],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Deletion ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the document of a mailbox.
|
|
||||||
*/
|
|
||||||
public function delete(string $serviceId, string $name): void
|
|
||||||
{
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'name' => $name,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the documents of all mailboxes of a service.
|
|
||||||
*/
|
|
||||||
public function deleteByService(string $serviceId): void
|
|
||||||
{
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany(['sid' => $serviceId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Stores;
|
|
||||||
|
|
||||||
use DI\Attribute\Inject;
|
|
||||||
use FilesystemIterator;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use JsonException;
|
|
||||||
use RecursiveDirectoryIterator;
|
|
||||||
use RecursiveIteratorIterator;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IMAP Message Content Store
|
|
||||||
*
|
|
||||||
* Holds the immutable content of cached messages (message.json) on the filesystem:
|
|
||||||
*
|
|
||||||
* {rootDir}/storage/{tid}/provider_imap/{sid}/{sha1(mailbox)}/{uidValidity}/{uid}/message.json
|
|
||||||
*
|
|
||||||
* Mailbox names are hashed because they contain delimiters and modified UTF-7.
|
|
||||||
* Files are written atomically (temporary file + rename).
|
|
||||||
*/
|
|
||||||
class MessageFileStore
|
|
||||||
{
|
|
||||||
private const CONTENT_FILE = 'message.json';
|
|
||||||
private const FOLDER_PERMISSIONS = 0750;
|
|
||||||
private const FILE_PERMISSIONS = 0640;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
#[Inject('rootDir')] private readonly string $rootDir,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the content of a message, replacing any existing content.
|
|
||||||
*/
|
|
||||||
public function write(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid, array $content): void
|
|
||||||
{
|
|
||||||
$folder = $this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid);
|
|
||||||
if (!is_dir($folder) && !@mkdir($folder, self::FOLDER_PERMISSIONS, true) && !is_dir($folder)) {
|
|
||||||
throw new RuntimeException("Failed to create message folder: {$folder}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$json = json_encode($content, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
|
||||||
} catch (JsonException $e) {
|
|
||||||
throw new RuntimeException('Failed to encode message content: ' . $e->getMessage(), 0, $e);
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $folder . DIRECTORY_SEPARATOR . self::CONTENT_FILE;
|
|
||||||
$temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
|
|
||||||
|
|
||||||
if (@file_put_contents($temporary, $json, LOCK_EX) !== strlen($json)) {
|
|
||||||
@unlink($temporary);
|
|
||||||
throw new RuntimeException("Failed to write message content: {$file}");
|
|
||||||
}
|
|
||||||
@chmod($temporary, self::FILE_PERMISSIONS);
|
|
||||||
|
|
||||||
if (!@rename($temporary, $file)) {
|
|
||||||
@unlink($temporary);
|
|
||||||
throw new RuntimeException("Failed to replace message content: {$file}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the content of a message.
|
|
||||||
*
|
|
||||||
* @return array|null null when the message is not cached or its content is unreadable
|
|
||||||
*/
|
|
||||||
public function read(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): ?array
|
|
||||||
{
|
|
||||||
$file = $this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid) . DIRECTORY_SEPARATOR . self::CONTENT_FILE;
|
|
||||||
if (!is_file($file)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$json = @file_get_contents($file);
|
|
||||||
if ($json === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$content = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
} catch (JsonException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_array($content) ? $content : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the cached files of messages.
|
|
||||||
*/
|
|
||||||
public function delete(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int ...$uids): void
|
|
||||||
{
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$this->removeTree($this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the cached files of a mailbox (all UIDVALIDITY generations).
|
|
||||||
*/
|
|
||||||
public function deleteByMailbox(string $tenantId, string $serviceId, string $mailbox): void
|
|
||||||
{
|
|
||||||
$this->removeTree($this->mailboxPath($tenantId, $serviceId, $mailbox));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the cached files of a service.
|
|
||||||
*/
|
|
||||||
public function deleteByService(string $tenantId, string $serviceId): void
|
|
||||||
{
|
|
||||||
$this->removeTree($this->servicePath($tenantId, $serviceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function servicePath(string $tenantId, string $serviceId): string
|
|
||||||
{
|
|
||||||
return implode(DIRECTORY_SEPARATOR, [
|
|
||||||
rtrim($this->rootDir, DIRECTORY_SEPARATOR),
|
|
||||||
'storage',
|
|
||||||
self::pathSegment($tenantId, 'tenant'),
|
|
||||||
'provider_imap',
|
|
||||||
self::pathSegment($serviceId, 'service'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function mailboxPath(string $tenantId, string $serviceId, string $mailbox): string
|
|
||||||
{
|
|
||||||
return $this->servicePath($tenantId, $serviceId) . DIRECTORY_SEPARATOR . sha1($mailbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function messagePath(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): string
|
|
||||||
{
|
|
||||||
if ($uidValidity < 0 || $uid <= 0) {
|
|
||||||
throw new InvalidArgumentException('Invalid UIDVALIDITY or UID');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->mailboxPath($tenantId, $serviceId, $mailbox)
|
|
||||||
. DIRECTORY_SEPARATOR . $uidValidity
|
|
||||||
. DIRECTORY_SEPARATOR . $uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guard identifiers used as path segments against traversal.
|
|
||||||
*/
|
|
||||||
private static function pathSegment(string $value, string $label): string
|
|
||||||
{
|
|
||||||
if (preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) {
|
|
||||||
throw new InvalidArgumentException("Invalid {$label} identifier for cache path");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function removeTree(string $path): void
|
|
||||||
{
|
|
||||||
if (!file_exists($path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_dir($path) || is_link($path)) {
|
|
||||||
@unlink($path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$items = new RecursiveIteratorIterator(
|
|
||||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
|
||||||
RecursiveIteratorIterator::CHILD_FIRST,
|
|
||||||
);
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
|
||||||
}
|
|
||||||
@rmdir($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+139
-249
@@ -10,325 +10,215 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap\Stores;
|
namespace KTXM\ProviderImap\Stores;
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
use KTXC\Db\DataStore;
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Message Meta Store
|
* MongoDB cache for IMAP messages and mailboxes.
|
||||||
*
|
*
|
||||||
* One MongoDB document per cached message in `provider_imap_mail_messages`,
|
* Collections:
|
||||||
* keyed by (sid, mailbox, uidValidity, uid). Holds only what list, filter and
|
* provider_imap_mail_messages — one document per (sid, mailbox, uid)
|
||||||
* sort need; message content lives in the content store (message.json).
|
* provider_imap_mail_mailboxes — one document per (sid, name)
|
||||||
*
|
*
|
||||||
* Changes are tracked on the documents themselves (as IMAP CONDSTORE does):
|
* Documents are stored **pre-formatted** in the same internal shape used by
|
||||||
* `addedSeq` and `modSeq` come from the mailbox's change sequence, and an
|
* EntityResource / MessageProperties and CollectionResource / CollectionProperties
|
||||||
* expunged message stays behind as a tombstone with `removedSeq` until it is
|
* so the read path does zero IMAP parsing.
|
||||||
* purged. Every read except changes() skips tombstones.
|
|
||||||
*/
|
*/
|
||||||
class MessageStore
|
class MessageStore
|
||||||
{
|
{
|
||||||
protected const COLLECTION_NAME = 'provider_imap_mail_messages';
|
protected const MESSAGES_COLLECTION = 'provider_imap_mail_messages';
|
||||||
|
protected const MAILBOXES_COLLECTION = 'provider_imap_mail_mailboxes';
|
||||||
/** Filter that excludes tombstones */
|
|
||||||
private const LIVE = ['removedSeq' => null];
|
|
||||||
|
|
||||||
/** Fields dropped when a message becomes a tombstone */
|
|
||||||
private const TOMBSTONE_UNSET = [
|
|
||||||
'created' => '', 'received' => '', 'sent' => '', 'size' => '',
|
|
||||||
'subject' => '', 'from' => '', 'to' => '', 'cc' => '', 'bcc' => '',
|
|
||||||
'urid' => '', 'inReplyTo' => '', 'references' => '',
|
|
||||||
'flags' => '', 'hasAttachments' => '', 'preview' => '', 'blobs' => '',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected readonly DataStore $dataStore,
|
protected readonly DataStore $store,
|
||||||
) {}
|
) {
|
||||||
|
$this->ensureIndexes();
|
||||||
/**
|
|
||||||
* Create the collection indexes.
|
|
||||||
*
|
|
||||||
* MongoDB createIndex is idempotent when the name and specification match.
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public function ensureIndexes(): array
|
|
||||||
{
|
|
||||||
$collection = $this->dataStore->selectCollection(self::COLLECTION_NAME);
|
|
||||||
|
|
||||||
return [
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'uidValidity' => 1, 'uid' => 1],
|
|
||||||
['name' => 'messages_key', 'unique' => true]
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'received' => -1],
|
|
||||||
['name' => 'messages_by_received']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'sent' => -1],
|
|
||||||
['name' => 'messages_by_sent']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'flags' => 1],
|
|
||||||
['name' => 'messages_by_flags']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'urid' => 1],
|
|
||||||
['name' => 'messages_by_urid']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['tid' => 1, 'sid' => 1],
|
|
||||||
['name' => 'messages_by_tenant_service']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'uidValidity' => 1, 'addedSeq' => 1],
|
|
||||||
['name' => 'messages_by_added_seq']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'uidValidity' => 1, 'modSeq' => 1],
|
|
||||||
['name' => 'messages_by_mod_seq']
|
|
||||||
),
|
|
||||||
$collection->createIndex(
|
|
||||||
['sid' => 1, 'mailbox' => 1, 'uidValidity' => 1, 'removedSeq' => 1],
|
|
||||||
['name' => 'messages_by_removed_seq', 'partialFilterExpression' => ['removedSeq' => ['$exists' => true]]]
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Index creation ───────────────────────────────────────────────────────
|
||||||
* Insert or replace the meta document of a message.
|
|
||||||
*
|
|
||||||
* `$seq` becomes the document's modSeq, and its addedSeq when the document is new.
|
|
||||||
* Fields owned by other writers (e.g. blobs) are left untouched.
|
|
||||||
*/
|
|
||||||
public function upsert(string $tenantId, string $serviceId, int $uidValidity, EntityResource $entity, int $seq): void
|
|
||||||
{
|
|
||||||
$document = $entity->toCacheMeta();
|
|
||||||
$key = [
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'mailbox' => $document['mailbox'],
|
|
||||||
'uidValidity' => $uidValidity,
|
|
||||||
'uid' => $document['uid'],
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
private function ensureIndexes(): void
|
||||||
$key,
|
{
|
||||||
[
|
$messages = $this->store->selectCollection(self::MESSAGES_COLLECTION);
|
||||||
'$set' => ['tid' => $tenantId, ...$key, ...$document, 'modSeq' => $seq],
|
$mailboxes = $this->store->selectCollection(self::MAILBOXES_COLLECTION);
|
||||||
'$setOnInsert' => ['addedSeq' => $seq],
|
|
||||||
],
|
$messages->createIndex(['sid' => 1, 'mailbox' => 1, 'uid' => 1], ['unique' => true]);
|
||||||
|
$mailboxes->createIndex(['sid' => 1, 'name' => 1], ['unique' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or replace a cached message document.
|
||||||
|
*
|
||||||
|
* @param array $data Result of EntityResource::toStore() — must include
|
||||||
|
* sid, mailbox, uid at the top level.
|
||||||
|
*/
|
||||||
|
public function upsertMessage(array $data): void
|
||||||
|
{
|
||||||
|
$filter = [
|
||||||
|
'sid' => $data['sid'],
|
||||||
|
'mailbox' => $data['mailbox'],
|
||||||
|
'uid' => (int)$data['uid'],
|
||||||
|
];
|
||||||
|
$data['syncedAt'] = (new \DateTime())->format(\DateTimeInterface::ATOM);
|
||||||
|
|
||||||
|
$this->store->selectCollection(self::MESSAGES_COLLECTION)->updateOne(
|
||||||
|
$filter,
|
||||||
|
['$set' => $data],
|
||||||
['upsert' => true],
|
['upsert' => true],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the meta document of a message.
|
* Retrieve a single cached message by service ID, mailbox and UID.
|
||||||
*/
|
*/
|
||||||
public function fetch(string $serviceId, string $mailbox, int $uidValidity, int $uid): ?array
|
public function fetchMessage(string $serviceId, string $mailbox, int $uid): ?array
|
||||||
{
|
{
|
||||||
return $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([
|
$doc = $this->store->selectCollection(self::MESSAGES_COLLECTION)->findOne([
|
||||||
'sid' => $serviceId,
|
'sid' => $serviceId,
|
||||||
'mailbox' => $mailbox,
|
'mailbox' => $mailbox,
|
||||||
'uidValidity' => $uidValidity,
|
'uid' => $uid,
|
||||||
'uid' => $uid,
|
|
||||||
...self::LIVE,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return $doc ? (array)$doc : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve the meta documents of several messages.
|
* Return all cached UIDs for a mailbox/service combination.
|
||||||
*
|
|
||||||
* @return array<int, array> keyed by UID; UIDs that are not cached are absent
|
|
||||||
*/
|
|
||||||
public function fetchMany(string $serviceId, string $mailbox, int $uidValidity, int ...$uids): array
|
|
||||||
{
|
|
||||||
if ($uids === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find([
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'mailbox' => $mailbox,
|
|
||||||
'uidValidity' => $uidValidity,
|
|
||||||
'uid' => ['$in' => array_values($uids)],
|
|
||||||
...self::LIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
foreach ($cursor as $document) {
|
|
||||||
$list[(int) $document['uid']] = $document;
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the UIDs cached for a mailbox.
|
|
||||||
*
|
*
|
||||||
* @return int[]
|
* @return int[]
|
||||||
*/
|
*/
|
||||||
public function uids(string $serviceId, string $mailbox, int $uidValidity): array
|
public function listUids(string $serviceId, string $mailbox): array
|
||||||
{
|
{
|
||||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(
|
$cursor = $this->store->selectCollection(self::MESSAGES_COLLECTION)->find(
|
||||||
['sid' => $serviceId, 'mailbox' => $mailbox, 'uidValidity' => $uidValidity, ...self::LIVE],
|
['sid' => $serviceId, 'mailbox' => $mailbox],
|
||||||
['projection' => ['uid' => 1]],
|
['projection' => ['uid' => 1]],
|
||||||
);
|
);
|
||||||
|
|
||||||
$uids = [];
|
$uids = [];
|
||||||
foreach ($cursor as $document) {
|
foreach ($cursor as $doc) {
|
||||||
$uids[] = (int) $document['uid'];
|
$uids[] = (int)$doc['uid'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $uids;
|
return $uids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UIDs of the cached messages matching a filter, in sort order (tombstones excluded).
|
* Fetch multiple cached messages by UID.
|
||||||
*
|
*
|
||||||
* @param array $filter meta store filter (see MessageQueryBuilder::filter())
|
* @param int[] $uids
|
||||||
* @param array<string, int> $sort meta store sort (see MessageQueryBuilder::sort())
|
* @return array[]
|
||||||
* @param array|null $collation collation for text sorts
|
|
||||||
* @return int[]
|
|
||||||
*/
|
*/
|
||||||
public function query(string $serviceId, string $mailbox, int $uidValidity, array $filter = [], array $sort = ['uid' => 1], ?array $collation = null): array
|
public function fetchMessages(string $serviceId, string $mailbox, array $uids): array
|
||||||
{
|
{
|
||||||
$match = ['sid' => $serviceId, 'mailbox' => $mailbox, 'uidValidity' => $uidValidity, ...self::LIVE];
|
$cursor = $this->store->selectCollection(self::MESSAGES_COLLECTION)->find([
|
||||||
if ($filter !== []) {
|
'sid' => $serviceId,
|
||||||
$match = ['$and' => [$match, $filter]];
|
'mailbox' => $mailbox,
|
||||||
|
'uid' => ['$in' => $uids],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($cursor as $doc) {
|
||||||
|
$doc = (array)$doc;
|
||||||
|
$result[$doc['uid']] = $doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
$options = ['projection' => ['uid' => 1], 'sort' => $sort];
|
return $result;
|
||||||
if ($collation !== null) {
|
|
||||||
$options['collation'] = $collation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$uids = [];
|
|
||||||
foreach ($this->dataStore->selectCollection(self::COLLECTION_NAME)->find($match, $options) as $document) {
|
|
||||||
$uids[] = (int) $document['uid'];
|
|
||||||
}
|
|
||||||
return $uids;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the flags of every message cached for a mailbox.
|
* Delete a cached message entry by UID.
|
||||||
*
|
|
||||||
* @return array<int, list<string>> set flags keyed by UID
|
|
||||||
*/
|
*/
|
||||||
public function flags(string $serviceId, string $mailbox, int $uidValidity): array
|
public function deleteMessage(string $serviceId, string $mailbox, int $uid): void
|
||||||
{
|
{
|
||||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(
|
$this->store->selectCollection(self::MESSAGES_COLLECTION)->deleteOne([
|
||||||
['sid' => $serviceId, 'mailbox' => $mailbox, 'uidValidity' => $uidValidity, ...self::LIVE],
|
'sid' => $serviceId,
|
||||||
['projection' => ['uid' => 1, 'flags' => 1]],
|
'mailbox' => $mailbox,
|
||||||
);
|
'uid' => $uid,
|
||||||
|
]);
|
||||||
$flags = [];
|
|
||||||
foreach ($cursor as $document) {
|
|
||||||
$flags[(int) $document['uid']] = array_values(array_map('strval', (array) ($document['flags'] ?? [])));
|
|
||||||
}
|
|
||||||
return $flags;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the flags of a message and stamp it with the change sequence.
|
* Delete multiple cached message entries.
|
||||||
*
|
*
|
||||||
* @param string[] $flags set flags, e.g. ['seen', 'flagged']
|
* @param int[] $uids
|
||||||
*/
|
*/
|
||||||
public function updateFlags(string $serviceId, string $mailbox, int $uidValidity, int $uid, array $flags, int $seq): void
|
public function deleteMessages(string $serviceId, string $mailbox, array $uids): void
|
||||||
{
|
{
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
if (empty($uids)) {
|
||||||
['sid' => $serviceId, 'mailbox' => $mailbox, 'uidValidity' => $uidValidity, 'uid' => $uid, ...self::LIVE],
|
|
||||||
['$set' => ['flags' => array_values($flags), 'modSeq' => $seq]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn messages into tombstones: their list fields are dropped and removedSeq is set.
|
|
||||||
*/
|
|
||||||
public function tombstone(string $serviceId, string $mailbox, int $uidValidity, int $seq, int ...$uids): void
|
|
||||||
{
|
|
||||||
if ($uids === []) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateMany(
|
$this->store->selectCollection(self::MESSAGES_COLLECTION)->deleteMany([
|
||||||
[
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'mailbox' => $mailbox,
|
|
||||||
'uidValidity' => $uidValidity,
|
|
||||||
'uid' => ['$in' => array_values($uids)],
|
|
||||||
...self::LIVE,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'$set' => ['removedSeq' => $seq, 'modSeq' => $seq],
|
|
||||||
'$unset' => self::TOMBSTONE_UNSET,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UIDs added, modified and removed after a change sequence.
|
|
||||||
*
|
|
||||||
* A message added after $since is an addition even if it was modified later;
|
|
||||||
* a message both added and removed after $since is not reported at all.
|
|
||||||
*
|
|
||||||
* @return array{additions: int[], modifications: int[], deletions: int[]}
|
|
||||||
*/
|
|
||||||
public function changes(string $serviceId, string $mailbox, int $uidValidity, int $since): array
|
|
||||||
{
|
|
||||||
$key = ['sid' => $serviceId, 'mailbox' => $mailbox, 'uidValidity' => $uidValidity];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'additions' => $this->findUids([...$key, ...self::LIVE, 'addedSeq' => ['$gt' => $since]]),
|
|
||||||
'modifications' => $this->findUids([...$key, ...self::LIVE, 'addedSeq' => ['$lte' => $since], 'modSeq' => ['$gt' => $since]]),
|
|
||||||
'deletions' => $this->findUids([...$key, 'addedSeq' => ['$lte' => $since], 'removedSeq' => ['$gt' => $since]]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete tombstones removed at or before a change sequence.
|
|
||||||
*
|
|
||||||
* The caller records $upTo as the mailbox's purgedSeq: older signatures can no
|
|
||||||
* longer be answered with deletions and get a reset instead.
|
|
||||||
*/
|
|
||||||
public function purgeTombstones(string $serviceId, string $mailbox, int $upTo): void
|
|
||||||
{
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany([
|
|
||||||
'sid' => $serviceId,
|
|
||||||
'mailbox' => $mailbox,
|
|
||||||
'removedSeq' => ['$lte' => $upTo],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all meta documents of a mailbox (any UIDVALIDITY), tombstones included.
|
|
||||||
*/
|
|
||||||
public function deleteByMailbox(string $serviceId, string $mailbox): void
|
|
||||||
{
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany([
|
|
||||||
'sid' => $serviceId,
|
'sid' => $serviceId,
|
||||||
'mailbox' => $mailbox,
|
'mailbox' => $mailbox,
|
||||||
|
'uid' => ['$in' => $uids],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mailboxes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all meta documents of a service.
|
* Insert or replace a cached mailbox document.
|
||||||
|
*
|
||||||
|
* @param array $data Result of CollectionResource::toStore() — must include
|
||||||
|
* sid and name at the top level.
|
||||||
*/
|
*/
|
||||||
public function deleteByService(string $serviceId): void
|
public function upsertMailbox(array $data): void
|
||||||
{
|
{
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany(['sid' => $serviceId]);
|
$filter = [
|
||||||
|
'sid' => $data['sid'],
|
||||||
|
'name' => $data['name'],
|
||||||
|
];
|
||||||
|
$data['syncedAt'] = (new \DateTime())->format(\DateTimeInterface::ATOM);
|
||||||
|
|
||||||
|
$this->store->selectCollection(self::MAILBOXES_COLLECTION)->updateOne(
|
||||||
|
$filter,
|
||||||
|
['$set' => $data],
|
||||||
|
['upsert' => true],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return int[]
|
* Retrieve a cached mailbox by service ID and name.
|
||||||
*/
|
*/
|
||||||
private function findUids(array $filter): array
|
public function fetchMailbox(string $serviceId, string $name): ?array
|
||||||
{
|
{
|
||||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(
|
$doc = $this->store->selectCollection(self::MAILBOXES_COLLECTION)->findOne([
|
||||||
$filter,
|
'sid' => $serviceId,
|
||||||
['projection' => ['uid' => 1], 'sort' => ['uid' => 1]],
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $doc ? (array)$doc : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all cached mailboxes for a service.
|
||||||
|
*
|
||||||
|
* @return array[]
|
||||||
|
*/
|
||||||
|
public function listMailboxes(string $serviceId): array
|
||||||
|
{
|
||||||
|
$cursor = $this->store->selectCollection(self::MAILBOXES_COLLECTION)->find(
|
||||||
|
['sid' => $serviceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
$uids = [];
|
$result = [];
|
||||||
foreach ($cursor as $document) {
|
foreach ($cursor as $doc) {
|
||||||
$uids[] = (int) $document['uid'];
|
$doc = (array)$doc;
|
||||||
|
$result[$doc['name']] = $doc;
|
||||||
}
|
}
|
||||||
return $uids;
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a cached mailbox entry by service ID and name.
|
||||||
|
*/
|
||||||
|
public function deleteMailbox(string $serviceId, string $name): void
|
||||||
|
{
|
||||||
|
$this->store->selectCollection(self::MAILBOXES_COLLECTION)->deleteOne([
|
||||||
|
'sid' => $serviceId,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace KTXM\ProviderImap\Stores;
|
|||||||
use KTXC\Db\DataStore;
|
use KTXC\Db\DataStore;
|
||||||
use KTXF\Security\Crypto;
|
use KTXF\Security\Crypto;
|
||||||
use KTXF\Utile\UUID;
|
use KTXF\Utile\UUID;
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Service Store
|
* IMAP Service Store
|
||||||
@@ -114,7 +114,7 @@ class ServiceStore
|
|||||||
/**
|
/**
|
||||||
* Create a new service
|
* Create a new service
|
||||||
*/
|
*/
|
||||||
public function create(string $tenantId, string $userId, ServiceBase $service): array
|
public function create(string $tenantId, string $userId, Service $service): array
|
||||||
{
|
{
|
||||||
$document = $service->toStore();
|
$document = $service->toStore();
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ class ServiceStore
|
|||||||
/**
|
/**
|
||||||
* Modify an existing service
|
* Modify an existing service
|
||||||
*/
|
*/
|
||||||
public function modify(string $tenantId, string $userId, ServiceBase $service): array
|
public function modify(string $tenantId, string $userId, Service $service): array
|
||||||
{
|
{
|
||||||
$serviceId = $service->identifier();
|
$serviceId = $service->identifier();
|
||||||
if (empty($serviceId)) {
|
if (empty($serviceId)) {
|
||||||
@@ -177,15 +177,4 @@ class ServiceStore
|
|||||||
return $result->getDeletedCount() > 0;
|
return $result->getDeletedCount() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete every service owned by a user.
|
|
||||||
*/
|
|
||||||
public function deleteByUser(string $tenantId, string $userId): void
|
|
||||||
{
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany([
|
|
||||||
'tid' => $tenantId,
|
|
||||||
'uid' => $userId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+68
-119
@@ -70,13 +70,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0-rc.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^8.0.0",
|
"@babel/parser": "^8.0.0-rc.5",
|
||||||
"@babel/types": "^8.0.0",
|
"@babel/types": "^8.0.0-rc.5",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"@types/jsesc": "^2.5.0",
|
"@types/jsesc": "^2.5.0",
|
||||||
@@ -87,30 +85,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0-rc.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.18.0 || >=24.11.0"
|
"node": "^22.18.0 || >=24.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.0-rc.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
|
|
||||||
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.18.0 || >=24.11.0"
|
"node": "^22.18.0 || >=24.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator/node_modules/@babel/parser": {
|
"node_modules/@babel/generator/node_modules/@babel/parser": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.0-rc.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz",
|
|
||||||
"integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^8.0.4"
|
"@babel/types": "^8.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -120,13 +112,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator/node_modules/@babel/types": {
|
"node_modules/@babel/generator/node_modules/@babel/types": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.0-rc.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
|
|
||||||
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^8.0.0",
|
"@babel/helper-string-parser": "^8.0.0-rc.5",
|
||||||
"@babel/helper-validator-identifier": "^8.0.4"
|
"@babel/helper-validator-identifier": "^8.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.18.0 || >=24.11.0"
|
"node": "^22.18.0 || >=24.11.0"
|
||||||
@@ -542,6 +532,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -558,6 +551,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -574,6 +570,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -590,6 +589,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -604,6 +606,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -618,6 +623,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -911,9 +919,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue-macros/common": {
|
"node_modules/@vue-macros/common": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz",
|
|
||||||
"integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-sfc": "^3.5.22",
|
"@vue/compiler-sfc": "^3.5.22",
|
||||||
@@ -980,36 +986,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz",
|
|
||||||
"integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-kit": "^8.1.5"
|
"@vue/devtools-kit": "^8.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api/node_modules/@vue/devtools-kit": {
|
"node_modules/@vue/devtools-api/node_modules/@vue/devtools-kit": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
|
|
||||||
"integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-shared": "^8.1.5",
|
"@vue/devtools-shared": "^8.1.2",
|
||||||
"birpc": "^2.6.1",
|
"birpc": "^2.6.1",
|
||||||
"hookable": "^5.5.3",
|
"hookable": "^5.5.3",
|
||||||
"perfect-debounce": "^2.0.0"
|
"perfect-debounce": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api/node_modules/@vue/devtools-shared": {
|
"node_modules/@vue/devtools-api/node_modules/@vue/devtools-shared": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
|
|
||||||
"integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api/node_modules/perfect-debounce": {
|
"node_modules/@vue/devtools-api/node_modules/perfect-debounce": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-kit": {
|
"node_modules/@vue/devtools-kit": {
|
||||||
@@ -1033,9 +1031,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/language-core": {
|
"node_modules/@vue/language-core": {
|
||||||
"version": "3.3.8",
|
"version": "3.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.8.tgz",
|
|
||||||
"integrity": "sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1833,6 +1829,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1853,6 +1852,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1871,6 +1873,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1889,6 +1894,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1943,9 +1951,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/local-pkg": {
|
"node_modules/local-pkg": {
|
||||||
"version": "1.2.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mlly": "^1.7.4",
|
"mlly": "^1.7.4",
|
||||||
@@ -2099,12 +2105,6 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nostics": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2648,67 +2648,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unplugin": {
|
"node_modules/unplugin": {
|
||||||
"version": "3.3.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz",
|
|
||||||
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.3",
|
||||||
"webpack-virtual-modules": "^0.6.2"
|
"webpack-virtual-modules": "^0.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@farmfe/core": "*",
|
|
||||||
"@rspack/core": "*",
|
|
||||||
"bun-types-no-globals": "*",
|
|
||||||
"esbuild": "*",
|
|
||||||
"rolldown": "*",
|
|
||||||
"rollup": "*",
|
|
||||||
"unloader": "*",
|
|
||||||
"vite": "*",
|
|
||||||
"webpack": "*"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@farmfe/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@rspack/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"bun-types-no-globals": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"esbuild": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"rolldown": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"unloader": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vite": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"webpack": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unplugin-utils": {
|
"node_modules/unplugin-utils": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
|
|
||||||
"integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"picomatch": "^4.0.4"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
@@ -2911,28 +2867,25 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "5.2.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/generator": "^8.0.0",
|
"@babel/generator": "^8.0.0-rc.4",
|
||||||
"@vue-macros/common": "^3.1.3",
|
"@vue-macros/common": "^3.1.1",
|
||||||
"@vue/devtools-api": "^8.1.5",
|
"@vue/devtools-api": "^8.1.2",
|
||||||
"ast-walker-scope": "^0.9.0",
|
"ast-walker-scope": "^0.9.0",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"local-pkg": "^1.2.1",
|
"local-pkg": "^1.1.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"mlly": "^1.8.2",
|
"mlly": "^1.8.2",
|
||||||
"muggle-string": "^0.4.1",
|
"muggle-string": "^0.4.1",
|
||||||
"nostics": "^1.1.4",
|
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"picomatch": "^4.0.5",
|
"picomatch": "^4.0.3",
|
||||||
"scule": "^1.3.0",
|
"scule": "^1.3.0",
|
||||||
"tinyglobby": "^0.2.17",
|
"tinyglobby": "^0.2.16",
|
||||||
"unplugin": "^3.3.0",
|
"unplugin": "^3.0.0",
|
||||||
"unplugin-utils": "^0.3.2",
|
"unplugin-utils": "^0.3.1",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -2940,10 +2893,10 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@pinia/colada": ">=0.21.2",
|
"@pinia/colada": ">=0.21.2",
|
||||||
"@vue/compiler-sfc": "^3.5.34 || ^4.0.0",
|
"@vue/compiler-sfc": "^3.5.34",
|
||||||
"pinia": "^3.0.4 || ^4.0.2",
|
"pinia": "^3.0.4",
|
||||||
"vite": "^7.3.0 || ^8.0.0",
|
"vite": "^7.0.0 || ^8.0.0",
|
||||||
"vue": "^3.5.34 || ^4.0.0"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@pinia/colada": {
|
"@pinia/colada": {
|
||||||
@@ -2961,14 +2914,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.3.8",
|
"version": "3.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz",
|
|
||||||
"integrity": "sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/typescript": "2.4.28",
|
"@volar/typescript": "2.4.28",
|
||||||
"@vue/language-core": "3.3.8"
|
"@vue/language-core": "3.3.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vue-tsc": "bin/vue-tsc.js"
|
"vue-tsc": "bin/vue-tsc.js"
|
||||||
@@ -2978,9 +2929,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vuetify": {
|
"node_modules/vuetify": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
|
|
||||||
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
prepend-inner-icon="mdi-account"
|
prepend-inner-icon="mdi-account"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
autocomplete="off"
|
autocomplete="username"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
@@ -130,7 +130,7 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
prepend-inner-icon="mdi-lock"
|
prepend-inner-icon="mdi-lock"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
autocomplete="new-password"
|
autocomplete="current-password"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Support;
|
|
||||||
|
|
||||||
use Generator;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Range\IRange;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LiveMailService without a server: mailboxes, UIDs and flags are set by the test.
|
|
||||||
*/
|
|
||||||
final class FakeLiveMailService extends LiveMailService
|
|
||||||
{
|
|
||||||
public ?int $uidValidity = null;
|
|
||||||
public ?int $uidNext = null;
|
|
||||||
/** @var array<int, list<string>> */
|
|
||||||
public array $flags = [];
|
|
||||||
/** @var int[] */
|
|
||||||
public array $unfetchable = [];
|
|
||||||
public array $fetchBatches = [];
|
|
||||||
public int $selects = 0;
|
|
||||||
public ?int $bodyTextLimit = null;
|
|
||||||
/** @var Mailbox[] */
|
|
||||||
public array $mailboxList = [];
|
|
||||||
/** @var string[] */
|
|
||||||
public array $failing = [];
|
|
||||||
|
|
||||||
/** @var list<array{0: string, 1: array}> */
|
|
||||||
public array $calls = [];
|
|
||||||
|
|
||||||
public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array
|
|
||||||
{
|
|
||||||
$this->calls[] = ['collectionList', [$location, $filter]];
|
|
||||||
$list = [];
|
|
||||||
foreach ($this->mailboxList as $mailbox) {
|
|
||||||
$list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox);
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mailboxFetch(string $identifier): ?Mailbox
|
|
||||||
{
|
|
||||||
if (in_array($identifier, $this->failing, true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$this->selects++;
|
|
||||||
return new Mailbox($identifier, '/', [], count($this->flags), 0, $this->uidValidity, 0, [], true, $this->uidNext);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityFlags(string $collection): Generator
|
|
||||||
{
|
|
||||||
yield from $this->flags;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
|
|
||||||
{
|
|
||||||
$this->fetchBatches[] = $uids;
|
|
||||||
if (preg_match('/BODY\.PEEK\[TEXT\]<0\.(\d+)>/', (string) $options?->toCommand(), $matches) === 1) {
|
|
||||||
$this->bodyTextLimit = (int) $matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
if (in_array($uid, $this->unfetchable, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$flags = implode(' ', $this->flags[$uid] ?? []);
|
|
||||||
yield $uid => FetchMessageParser::parse("* {$uid} FETCH (UID {$uid} FLAGS ({$flags}))");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every message of the mailbox in UID order; filter / sort / range are not evaluated.
|
|
||||||
*/
|
|
||||||
public function messageList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator
|
|
||||||
{
|
|
||||||
$this->calls[] = ['messageList', [$collection]];
|
|
||||||
$uids = array_keys($this->flags);
|
|
||||||
sort($uids);
|
|
||||||
yield from $this->messageFetch($collection, null, ...$uids);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var int[] UIDs entityFind() returns */
|
|
||||||
public array $found = [];
|
|
||||||
|
|
||||||
public function entityFind(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array
|
|
||||||
{
|
|
||||||
$this->calls[] = ['entityFind', [$collection, $filter]];
|
|
||||||
return $this->found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<int, array<string, string>> full section content by UID */
|
|
||||||
public array $sections = [];
|
|
||||||
|
|
||||||
public function messageSections(string $collection, int $uid, string ...$partIds): array
|
|
||||||
{
|
|
||||||
$this->calls[] = ['messageSections', [$collection, $uid, $partIds]];
|
|
||||||
return array_intersect_key($this->sections[$uid] ?? [], array_flip($partIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityPatch(string $collection, array $flagsToAdd = [], array $flagsToRemove = [], int ...$uids): void
|
|
||||||
{
|
|
||||||
$this->calls[] = ['entityPatch', [$collection, $flagsToAdd, $flagsToRemove, $uids]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Support;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory MailboxStore: documents, state, change sequence and a single lock.
|
|
||||||
*/
|
|
||||||
final class FakeMailboxStore extends MailboxStore
|
|
||||||
{
|
|
||||||
/** @var array<string, array> */
|
|
||||||
public array $documents = [];
|
|
||||||
public ?string $lockOwner = null;
|
|
||||||
/** Fixed time for listedAt; null = now */
|
|
||||||
public ?int $clock = null;
|
|
||||||
|
|
||||||
public function upsert(string $tenantId, string $serviceId, CollectionResource $collection): void
|
|
||||||
{
|
|
||||||
$name = (string) $collection->identifier();
|
|
||||||
$this->documents[$name] = [
|
|
||||||
...($this->documents[$name] ?? self::STATE_DEFAULTS),
|
|
||||||
...$collection->toCacheMeta(),
|
|
||||||
'listedAt' => $this->clock ?? time(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetch(string $serviceId, string $name): ?array
|
|
||||||
{
|
|
||||||
return $this->documents[$name] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function list(string $serviceId): array
|
|
||||||
{
|
|
||||||
return $this->documents;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateState(string $serviceId, string $name, array $state): void
|
|
||||||
{
|
|
||||||
$this->documents[$name] = array_replace($this->documents[$name], array_intersect_key($state, self::STATE_DEFAULTS));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acquireLock(string $serviceId, string $name, string $owner, int $ttl): bool
|
|
||||||
{
|
|
||||||
if ($this->lockOwner !== null && $this->lockOwner !== $owner) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$this->lockOwner = $owner;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function releaseLock(string $serviceId, string $name, string $owner): void
|
|
||||||
{
|
|
||||||
if ($this->lockOwner === $owner) {
|
|
||||||
$this->lockOwner = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reserveSequence(string $serviceId, string $name, int $count = 1): int
|
|
||||||
{
|
|
||||||
$this->documents[$name]['changeSeq'] += $count;
|
|
||||||
return $this->documents[$name]['changeSeq'] - $count + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function state(string $serviceId, string $name): array
|
|
||||||
{
|
|
||||||
return array_replace(self::STATE_DEFAULTS, array_intersect_key($this->documents[$name] ?? [], self::STATE_DEFAULTS));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listedAt(string $serviceId): ?int
|
|
||||||
{
|
|
||||||
$times = array_filter(array_column($this->documents, 'listedAt'));
|
|
||||||
return $times === [] ? null : max($times);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(string $serviceId, string $name): void
|
|
||||||
{
|
|
||||||
unset($this->documents[$name]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Support;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory MessageFileStore.
|
|
||||||
*/
|
|
||||||
final class FakeMessageFileStore extends MessageFileStore
|
|
||||||
{
|
|
||||||
/** @var array<int, array<int, array>> content by UIDVALIDITY and UID */
|
|
||||||
private array $files = [];
|
|
||||||
|
|
||||||
public function write(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid, array $content): void
|
|
||||||
{
|
|
||||||
$this->files[$uidValidity][$uid] = $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function read(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): ?array
|
|
||||||
{
|
|
||||||
return $this->files[$uidValidity][$uid] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int ...$uids): void
|
|
||||||
{
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
unset($this->files[$uidValidity][$uid]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteByMailbox(string $tenantId, string $serviceId, string $mailbox): void
|
|
||||||
{
|
|
||||||
$this->files = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Direct access for tests */
|
|
||||||
public function content(int $uidValidity, int $uid): ?array
|
|
||||||
{
|
|
||||||
return $this->files[$uidValidity][$uid] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return int[] */
|
|
||||||
public function uids(int $uidValidity): array
|
|
||||||
{
|
|
||||||
$uids = array_keys($this->files[$uidValidity] ?? []);
|
|
||||||
sort($uids);
|
|
||||||
return $uids;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Support;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory MessageStore modelling addedSeq / modSeq / tombstones like the Mongo queries.
|
|
||||||
*/
|
|
||||||
final class FakeMessageStore extends MessageStore
|
|
||||||
{
|
|
||||||
/** @var array<int, array<int, array{flags: list<string>, addedSeq: int, modSeq: int, removedSeq: ?int}>> by UIDVALIDITY and UID */
|
|
||||||
private array $documents = [];
|
|
||||||
|
|
||||||
public function upsert(string $tenantId, string $serviceId, int $uidValidity, EntityResource $entity, int $seq): void
|
|
||||||
{
|
|
||||||
$meta = $entity->toCacheMeta();
|
|
||||||
$existing = $this->documents[$uidValidity][$meta['uid']] ?? null;
|
|
||||||
$this->documents[$uidValidity][$meta['uid']] = [
|
|
||||||
'meta' => $meta,
|
|
||||||
'flags' => $meta['flags'],
|
|
||||||
'addedSeq' => $existing['addedSeq'] ?? $seq,
|
|
||||||
'modSeq' => $seq,
|
|
||||||
'removedSeq' => null,
|
|
||||||
];
|
|
||||||
ksort($this->documents[$uidValidity]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last query() arguments, for assertions */
|
|
||||||
public ?array $lastQuery = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live UIDs in ascending order; the filter / sort translation is covered by MessageQueryBuilderTest.
|
|
||||||
*/
|
|
||||||
public function query(string $serviceId, string $mailbox, int $uidValidity, array $filter = [], array $sort = ['uid' => 1], ?array $collation = null): array
|
|
||||||
{
|
|
||||||
$this->lastQuery = [$filter, $sort, $collation];
|
|
||||||
return array_keys($this->flags($serviceId, $mailbox, $uidValidity));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetchMany(string $serviceId, string $mailbox, int $uidValidity, int ...$uids): array
|
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$document = $this->documents[$uidValidity][$uid] ?? null;
|
|
||||||
if ($document !== null && $document['removedSeq'] === null) {
|
|
||||||
$list[$uid] = [...$document['meta'], 'uid' => $uid, 'flags' => $document['flags']];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function flags(string $serviceId, string $mailbox, int $uidValidity): array
|
|
||||||
{
|
|
||||||
$flags = [];
|
|
||||||
foreach ($this->documents[$uidValidity] ?? [] as $uid => $document) {
|
|
||||||
if ($document['removedSeq'] === null) {
|
|
||||||
$flags[$uid] = $document['flags'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $flags;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateFlags(string $serviceId, string $mailbox, int $uidValidity, int $uid, array $flags, int $seq): void
|
|
||||||
{
|
|
||||||
$this->documents[$uidValidity][$uid]['flags'] = array_values($flags);
|
|
||||||
$this->documents[$uidValidity][$uid]['modSeq'] = $seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tombstone(string $serviceId, string $mailbox, int $uidValidity, int $seq, int ...$uids): void
|
|
||||||
{
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$this->documents[$uidValidity][$uid]['removedSeq'] = $seq;
|
|
||||||
$this->documents[$uidValidity][$uid]['modSeq'] = $seq;
|
|
||||||
$this->documents[$uidValidity][$uid]['flags'] = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function changes(string $serviceId, string $mailbox, int $uidValidity, int $since): array
|
|
||||||
{
|
|
||||||
$changes = ['additions' => [], 'modifications' => [], 'deletions' => []];
|
|
||||||
foreach ($this->documents[$uidValidity] ?? [] as $uid => $document) {
|
|
||||||
if ($document['removedSeq'] !== null) {
|
|
||||||
if ($document['addedSeq'] <= $since && $document['removedSeq'] > $since) {
|
|
||||||
$changes['deletions'][] = $uid;
|
|
||||||
}
|
|
||||||
} elseif ($document['addedSeq'] > $since) {
|
|
||||||
$changes['additions'][] = $uid;
|
|
||||||
} elseif ($document['modSeq'] > $since) {
|
|
||||||
$changes['modifications'][] = $uid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteByMailbox(string $serviceId, string $mailbox): void
|
|
||||||
{
|
|
||||||
$this->documents = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXC\Db\Collection;
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Client\Message;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use UnexpectedValueException;
|
|
||||||
|
|
||||||
final class CacheSerializationTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testReferencesHeaderIsParsed(): void
|
|
||||||
{
|
|
||||||
$message = $this->message();
|
|
||||||
|
|
||||||
$this->assertSame(['root@example.com', 'parent@example.com'], $message->references());
|
|
||||||
$this->assertSame(
|
|
||||||
['root@example.com', 'parent@example.com'],
|
|
||||||
$this->entity()->getProperties()->getReferences(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMessageRoundTripsThroughMetaAndContent(): void
|
|
||||||
{
|
|
||||||
$original = $this->entity();
|
|
||||||
|
|
||||||
$meta = json_decode(json_encode($original->toCacheMeta()), true);
|
|
||||||
$content = json_decode(json_encode($original->toCacheContent()), true);
|
|
||||||
|
|
||||||
$restored = (new EntityResource('imap', 'svc'))
|
|
||||||
->fromCacheContent($content)
|
|
||||||
->fromCacheMeta($meta);
|
|
||||||
|
|
||||||
$this->assertSame(json_encode($original), json_encode($restored));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMetaHoldsListFieldsInStoreFriendlyForm(): void
|
|
||||||
{
|
|
||||||
$meta = $this->entity()->toCacheMeta();
|
|
||||||
|
|
||||||
$this->assertSame('INBOX', $meta['mailbox']);
|
|
||||||
$this->assertSame(42, $meta['uid']);
|
|
||||||
$this->assertSame(EntityResource::CACHE_SCHEMA_VERSION, $meta['schemaVersion']);
|
|
||||||
$this->assertSame('2026-09-27T08:30:00Z', $meta['sent']);
|
|
||||||
$this->assertSame('Quarterly report', $meta['subject']);
|
|
||||||
$this->assertSame('msg@example.com', $meta['urid']);
|
|
||||||
$this->assertSame('parent@example.com', $meta['inReplyTo']);
|
|
||||||
$this->assertSame(['seen', '$forwarded'], $meta['flags']);
|
|
||||||
$this->assertSame('Hello team, the report is attached.', $meta['preview']);
|
|
||||||
$this->assertTrue($meta['hasAttachments']);
|
|
||||||
$this->assertArrayNotHasKey('body', $meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testContentExcludesFlags(): void
|
|
||||||
{
|
|
||||||
$content = $this->entity()->toCacheContent();
|
|
||||||
|
|
||||||
$this->assertArrayNotHasKey('flags', $content['properties']);
|
|
||||||
$this->assertArrayHasKey('body', $content['properties']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMetaFlagsOverrideAndContentKeepsThem(): void
|
|
||||||
{
|
|
||||||
$entity = $this->entity();
|
|
||||||
$meta = $entity->toCacheMeta();
|
|
||||||
$meta['flags'] = ['flagged'];
|
|
||||||
|
|
||||||
$restored = (new EntityResource('imap', 'svc'))
|
|
||||||
->fromCacheMeta($meta)
|
|
||||||
->fromCacheContent($entity->toCacheContent());
|
|
||||||
|
|
||||||
$this->assertSame(['flagged' => true], $restored->getProperties()->getFlags());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIncompleteSectionsRoundTripThroughContentButNotTheApi(): void
|
|
||||||
{
|
|
||||||
$entity = $this->entity();
|
|
||||||
$entity->getProperties()->setIncompleteSections('1');
|
|
||||||
|
|
||||||
$content = json_decode(json_encode($entity->toCacheContent()), true);
|
|
||||||
$restored = (new EntityResource('imap', 'svc'))->fromCacheContent($content);
|
|
||||||
|
|
||||||
$this->assertSame(['1'], $content['incomplete']);
|
|
||||||
$this->assertArrayNotHasKey('incomplete', $content['properties']);
|
|
||||||
$this->assertSame(['1'], $restored->getProperties()->getIncompleteSections());
|
|
||||||
$this->assertStringNotContainsString('incomplete', json_encode($restored));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testContentWithOtherSchemaVersionIsRejected(): void
|
|
||||||
{
|
|
||||||
$content = $this->entity()->toCacheContent();
|
|
||||||
$content['schemaVersion'] = EntityResource::CACHE_SCHEMA_VERSION + 1;
|
|
||||||
|
|
||||||
$this->expectException(UnexpectedValueException::class);
|
|
||||||
(new EntityResource('imap', 'svc'))->fromCacheContent($content);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCollectionRoundTripsWithoutStoreFields(): void
|
|
||||||
{
|
|
||||||
$original = (new CollectionResource('imap', 'svc'))
|
|
||||||
->fromImap(new Mailbox('INBOX/Reports', '/', ['\\HasNoChildren'], 12, 3));
|
|
||||||
|
|
||||||
$document = [
|
|
||||||
'tid' => 'tenant', 'sid' => 'svc', 'uidValidity' => 7, 'lock' => ['owner' => 'x'],
|
|
||||||
...json_decode(json_encode($original->toCacheMeta()), true),
|
|
||||||
];
|
|
||||||
$restored = (new CollectionResource('imap', 'svc'))->fromCacheMeta($document);
|
|
||||||
|
|
||||||
$this->assertSame(json_encode($original), json_encode($restored));
|
|
||||||
$this->assertSame('INBOX/Reports', $document['name']);
|
|
||||||
$this->assertSame('INBOX', $document['parent']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMessageStoreUpsertSetsKeyAndLeavesOtherFields(): void
|
|
||||||
{
|
|
||||||
$collection = $this->createMock(Collection::class);
|
|
||||||
$collection->expects($this->once())
|
|
||||||
->method('updateOne')
|
|
||||||
->with(
|
|
||||||
['sid' => 'svc', 'mailbox' => 'INBOX', 'uidValidity' => 7, 'uid' => 42],
|
|
||||||
$this->callback(function (array $update): bool {
|
|
||||||
$this->assertSame(['$set', '$setOnInsert'], array_keys($update));
|
|
||||||
$this->assertSame('tenant', $update['$set']['tid']);
|
|
||||||
$this->assertSame(7, $update['$set']['uidValidity']);
|
|
||||||
$this->assertSame(5, $update['$set']['modSeq']);
|
|
||||||
$this->assertSame(['addedSeq' => 5], $update['$setOnInsert']);
|
|
||||||
$this->assertArrayNotHasKey('blobs', $update['$set']);
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
['upsert' => true],
|
|
||||||
);
|
|
||||||
|
|
||||||
(new MessageStore($this->dataStore($collection)))->upsert('tenant', 'svc', 7, $this->entity(), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMailboxStoreUpsertLeavesHarmonizationState(): void
|
|
||||||
{
|
|
||||||
$collection = $this->createMock(Collection::class);
|
|
||||||
$collection->expects($this->once())
|
|
||||||
->method('updateOne')
|
|
||||||
->with(
|
|
||||||
['sid' => 'svc', 'name' => 'INBOX'],
|
|
||||||
$this->callback(function (array $update): bool {
|
|
||||||
$this->assertSame(['tid', 'sid', 'name', 'parent', 'signature', 'properties', 'listedAt'], array_keys($update['$set']));
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
['upsert' => true],
|
|
||||||
);
|
|
||||||
|
|
||||||
$mailbox = (new CollectionResource('imap', 'svc'))->fromImap(new Mailbox('INBOX', '/', []));
|
|
||||||
(new MailboxStore($this->dataStore($collection)))->upsert('tenant', 'svc', $mailbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dataStore(Collection $collection): DataStore
|
|
||||||
{
|
|
||||||
$dataStore = $this->createStub(DataStore::class);
|
|
||||||
$dataStore->method('selectCollection')->willReturn($collection);
|
|
||||||
return $dataStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function entity(): EntityResource
|
|
||||||
{
|
|
||||||
return (new EntityResource('imap', 'svc'))->fromImap($this->message(), 'INBOX');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function message(): Message
|
|
||||||
{
|
|
||||||
$header = "Subject: Quarterly report\r\n"
|
|
||||||
. "References: <root@example.com>\r\n <parent@example.com>\r\n"
|
|
||||||
. "\r\n";
|
|
||||||
$body = "--b\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello team,\r\n the report is attached.\r\n"
|
|
||||||
. "--b\r\nContent-Type: application/pdf\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0=\r\n--b--\r\n";
|
|
||||||
$structure = '(("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 40 2)'
|
|
||||||
. '("APPLICATION" "PDF" ("NAME" "report.pdf") NIL NIL "BASE64" 8 NIL ("ATTACHMENT" ("FILENAME" "report.pdf")) NIL)'
|
|
||||||
. ' "MIXED" ("BOUNDARY" "b") NIL NIL)';
|
|
||||||
$envelope = '("Sun, 27 Sep 2026 10:30:00 +0200" "Quarterly report"'
|
|
||||||
. ' (("Alice" NIL "alice" "example.com")) (("Alice" NIL "alice" "example.com")) NIL'
|
|
||||||
. ' (("Bob" NIL "bob" "example.com")) NIL NIL "<parent@example.com>" "<msg@example.com>")';
|
|
||||||
|
|
||||||
return FetchMessageParser::parse(sprintf(
|
|
||||||
"* 1 FETCH (UID 42 FLAGS (\\Seen \$Forwarded) RFC822.SIZE 1234 INTERNALDATE \"27-Sep-2026 10:31:00 +0200\""
|
|
||||||
. " ENVELOPE %s BODYSTRUCTURE %s BODY[HEADER] {%d}\r\n%s BODY[TEXT] {%d}\r\n%s)",
|
|
||||||
$envelope,
|
|
||||||
$structure,
|
|
||||||
strlen($header),
|
|
||||||
$header,
|
|
||||||
strlen($body),
|
|
||||||
$body,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use KTXF\Mail\Collection\CollectionRoles;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Providers\CachedService;
|
|
||||||
use KTXM\ProviderImap\Providers\MessageProperties;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageDeltaService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageQueryBuilder;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
|
||||||
use KTXF\Resource\Range\RangeAnchorType;
|
|
||||||
use KTXF\Resource\Range\RangeType;
|
|
||||||
use KTXF\Resource\Sort\Sort;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeLiveMailService;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeMailboxStore;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeMessageFileStore;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeMessageStore;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class CachedServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
private FakeMailboxStore $mailboxes;
|
|
||||||
private FakeMessageStore $messages;
|
|
||||||
private FakeMessageFileStore $files;
|
|
||||||
private FakeLiveMailService $live;
|
|
||||||
private CachedService $service;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$dataStore = $this->createStub(DataStore::class);
|
|
||||||
$this->mailboxes = new FakeMailboxStore($dataStore);
|
|
||||||
$messages = new FakeMessageStore($dataStore);
|
|
||||||
$files = new FakeMessageFileStore('/nonexistent');
|
|
||||||
|
|
||||||
$harmonizer = new HarmonizationService(
|
|
||||||
$this->mailboxes,
|
|
||||||
$messages,
|
|
||||||
$files,
|
|
||||||
new MessageIngestor($files, $messages, $this->mailboxes),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->messages = $messages;
|
|
||||||
$this->files = $files;
|
|
||||||
$this->service = new CachedServiceTestDouble(
|
|
||||||
$this->mailboxes,
|
|
||||||
$messages,
|
|
||||||
$files,
|
|
||||||
new MessageIngestor($files, $messages, $this->mailboxes),
|
|
||||||
new MessageQueryBuilder(),
|
|
||||||
$harmonizer,
|
|
||||||
new MessageDeltaService($this->mailboxes, $messages),
|
|
||||||
);
|
|
||||||
$this->service->fromStore(['tid' => 'tenant', 'sid' => 'svc']);
|
|
||||||
|
|
||||||
$this->live = new FakeLiveMailService($this->service);
|
|
||||||
$this->live->uidValidity = 7;
|
|
||||||
$this->live->uidNext = 10;
|
|
||||||
$this->live->mailboxList = [new Mailbox('INBOX', '/', []), new Mailbox('Sent', '/', ['\\Sent'])];
|
|
||||||
$this->service->liveMail = $this->live;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCollectionListComesFromTheCacheAfterHarmonizingTheList(): void
|
|
||||||
{
|
|
||||||
$list = $this->service->collectionList(null);
|
|
||||||
|
|
||||||
$this->assertSame(['INBOX', 'Sent'], array_keys($list));
|
|
||||||
$this->assertSame(CollectionRoles::Sent, $list['Sent']->getProperties()->getRole());
|
|
||||||
$this->assertCount(1, $this->listCalls());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFreshListIsNotHarmonizedAgain(): void
|
|
||||||
{
|
|
||||||
$this->service->collectionList(null);
|
|
||||||
$this->service->collectionList(null);
|
|
||||||
$this->service->collectionFetch('INBOX');
|
|
||||||
|
|
||||||
$this->assertCount(1, $this->listCalls());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStaleListIsHarmonizedAgain(): void
|
|
||||||
{
|
|
||||||
$this->mailboxes->clock = time() - CachedService::FRESHNESS_WINDOW - 1;
|
|
||||||
$this->service->collectionList(null);
|
|
||||||
$this->mailboxes->clock = null;
|
|
||||||
|
|
||||||
$this->live->mailboxList[] = new Mailbox('Archive', '/', []);
|
|
||||||
$list = $this->service->collectionList(null);
|
|
||||||
|
|
||||||
$this->assertSame(['INBOX', 'Sent', 'Archive'], array_keys($list));
|
|
||||||
$this->assertCount(2, $this->listCalls());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFilteredListGoesToTheServer(): void
|
|
||||||
{
|
|
||||||
$filter = $this->service->collectionListFilter();
|
|
||||||
$filter->condition('role', CollectionRoles::Sent->value);
|
|
||||||
|
|
||||||
$this->service->collectionList(null, $filter);
|
|
||||||
|
|
||||||
$this->assertSame([['collectionList', [null, $filter]]], $this->live->calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCollectionSignatureIsTheDeltaSignature(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => []];
|
|
||||||
$this->service->entityDelta('INBOX', '');
|
|
||||||
|
|
||||||
$this->assertSame('7:2', $this->service->collectionFetch('INBOX')?->signature());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCollectionExtantUsesTheCache(): void
|
|
||||||
{
|
|
||||||
$this->assertSame(['INBOX' => true, 'Missing' => false], $this->service->collectionExtant('INBOX', 'Missing'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeltaHarmonizesWhenStaleOnly(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => []];
|
|
||||||
$first = $this->service->entityDelta('INBOX', '');
|
|
||||||
|
|
||||||
$this->live->flags = [1 => [], 2 => []];
|
|
||||||
$second = $this->service->entityDelta('INBOX', $first->signature);
|
|
||||||
|
|
||||||
$this->assertSame('7:1', $first->signature);
|
|
||||||
$this->assertSame('7:1', $second->signature, 'fresh mailbox is not harmonized again');
|
|
||||||
|
|
||||||
$this->mailboxes->updateState('svc', 'INBOX', ['harmonizedAt' => time() - CachedService::FRESHNESS_WINDOW]);
|
|
||||||
$third = $this->service->entityDelta('INBOX', $first->signature);
|
|
||||||
|
|
||||||
$this->assertSame('7:2', $third->signature);
|
|
||||||
$this->assertSame(['2'], json_decode(json_encode($third->additions), true));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeltaIsAnsweredFromTheCacheWhenTheServerFails(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => []];
|
|
||||||
$signature = $this->service->entityDelta('INBOX', '')->signature;
|
|
||||||
|
|
||||||
$this->mailboxes->updateState('svc', 'INBOX', ['harmonizedAt' => 0]);
|
|
||||||
$this->live->uidValidity = null; // harmonization throws
|
|
||||||
|
|
||||||
$this->assertSame($signature, $this->service->entityDelta('INBOX', $signature)->signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWritesGoThroughTheLiveService(): void
|
|
||||||
{
|
|
||||||
$properties = new MessageProperties([]);
|
|
||||||
$properties->setFlag('seen', true);
|
|
||||||
|
|
||||||
$this->service->entityPatch($properties, new EntityIdentifier('imap', 'svc', 'INBOX', '5'));
|
|
||||||
|
|
||||||
$patches = array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === 'entityPatch'));
|
|
||||||
$this->assertCount(1, $patches);
|
|
||||||
[$collection, $added, , $uids] = $patches[0][1];
|
|
||||||
$this->assertSame('INBOX', $collection);
|
|
||||||
$this->assertContains('seen', $added);
|
|
||||||
$this->assertSame([5], $uids);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testListOfANotHarmonizedMailboxStreamsFromTheServerAndCaches(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => ['\\Seen']];
|
|
||||||
|
|
||||||
$uids = $this->listedUids($this->service->entityListStream('INBOX'));
|
|
||||||
|
|
||||||
$this->assertSame([1, 2], $uids);
|
|
||||||
$this->assertCount(1, $this->calls('messageList'));
|
|
||||||
$this->assertSame([1, 2], $this->files->uids(7));
|
|
||||||
$this->assertSame([1 => [], 2 => ['seen']], $this->messages->flags('svc', 'INBOX', 7));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testListOfAHarmonizedMailboxComesFromTheCache(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => [], 2 => ['\\Seen'], 3 => []]);
|
|
||||||
|
|
||||||
$entities = iterator_to_array($this->service->entityListStream('INBOX'), false);
|
|
||||||
|
|
||||||
$this->assertSame([1, 2, 3], array_map(static fn ($entity): int => (int) $entity->identifier(), $entities));
|
|
||||||
$this->assertSame([], $this->calls('messageList'));
|
|
||||||
$this->assertSame(['seen' => true], $entities[1]->getProperties()->getFlags());
|
|
||||||
$this->assertSame('INBOX', $entities[1]->collection());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testListRangeIsAppliedLikeTheLiveService(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => [], 2 => [], 3 => [], 4 => []]);
|
|
||||||
|
|
||||||
$absolute = $this->service->entityListRange(RangeType::TALLY);
|
|
||||||
$absolute->setAnchor(RangeAnchorType::ABSOLUTE);
|
|
||||||
$absolute->setPosition(1);
|
|
||||||
$absolute->setTally(2);
|
|
||||||
|
|
||||||
$relative = $this->service->entityListRange(RangeType::TALLY);
|
|
||||||
$relative->setAnchor(RangeAnchorType::RELATIVE);
|
|
||||||
$relative->setPosition(3);
|
|
||||||
$relative->setTally(2);
|
|
||||||
|
|
||||||
$this->assertSame([2, 3], $this->listedUids($this->service->entityListStream('INBOX', null, null, $absolute)));
|
|
||||||
$this->assertSame([3, 4], $this->listedUids($this->service->entityListStream('INBOX', null, null, $relative)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMessagesMissingFromTheContentStoreAreFetchedAndCached(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => [], 2 => []]);
|
|
||||||
$this->files->delete('tenant', 'svc', 'INBOX', 7, 2);
|
|
||||||
$this->live->fetchBatches = [];
|
|
||||||
|
|
||||||
$this->assertSame([1, 2], $this->listedUids($this->service->entityListStream('INBOX')));
|
|
||||||
$this->assertSame([[2]], $this->live->fetchBatches);
|
|
||||||
$this->assertSame([1, 2], $this->files->uids(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBodyFilterHasTheServerFindTheUids(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => [], 2 => [], 3 => []]);
|
|
||||||
$this->live->found = [3, 1];
|
|
||||||
$this->live->fetchBatches = [];
|
|
||||||
|
|
||||||
$filter = $this->service->entityListFilter();
|
|
||||||
$filter->condition('body', 'invoice');
|
|
||||||
|
|
||||||
$this->assertSame([3, 1], $this->listedUids($this->service->entityListStream('INBOX', $filter)));
|
|
||||||
$this->assertCount(1, $this->calls('entityFind'));
|
|
||||||
$this->assertSame([], $this->live->fetchBatches, 'content comes from the cache');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testOtherFiltersAreTranslatedForTheMetaStore(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => []]);
|
|
||||||
|
|
||||||
$filter = $this->service->entityListFilter();
|
|
||||||
$filter->condition('subject', 'report');
|
|
||||||
// keyed attributes: entityListSort() passes a plain list, which Sort::condition() ignores (existing bug)
|
|
||||||
$sort = new Sort(['received' => true]);
|
|
||||||
$sort->condition('received', false);
|
|
||||||
|
|
||||||
iterator_to_array($this->service->entityListStream('INBOX', $filter, $sort));
|
|
||||||
|
|
||||||
$this->assertSame([], $this->calls('entityFind'));
|
|
||||||
$this->assertArrayHasKey('subject', $this->messages->lastQuery[0]);
|
|
||||||
$this->assertSame(['received' => -1, 'uid' => 1], $this->messages->lastQuery[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testListBulkIsTheStreamCollected(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => [], 2 => []]);
|
|
||||||
|
|
||||||
$this->assertCount(2, $this->service->entityListBulk('INBOX'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFetchComesFromTheCache(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => [], 2 => ['\\Flagged']]);
|
|
||||||
$this->live->fetchBatches = [];
|
|
||||||
|
|
||||||
$entities = array_values($this->service->entityFetchBulk(
|
|
||||||
new EntityIdentifier('imap', 'svc', 'INBOX', '2'),
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertCount(1, $entities);
|
|
||||||
$this->assertSame(['flagged' => true], $entities[0]->getProperties()->getFlags());
|
|
||||||
$this->assertSame([], $this->live->fetchBatches);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFetchOfAnUncachedMessageGoesToTheServerAndCaches(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => []]);
|
|
||||||
$this->live->flags[5] = [];
|
|
||||||
$this->live->fetchBatches = [];
|
|
||||||
|
|
||||||
$entities = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'INBOX', '5')));
|
|
||||||
|
|
||||||
$this->assertSame('5', (string) $entities[0]->identifier());
|
|
||||||
$this->assertSame([[5]], $this->live->fetchBatches);
|
|
||||||
$this->assertContains(5, $this->files->uids(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFetchFromANeverHarmonizedMailboxIsReadFromTheServer(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [3 => []];
|
|
||||||
|
|
||||||
$entities = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'Sent', '3')));
|
|
||||||
|
|
||||||
$this->assertSame('3', (string) $entities[0]->identifier());
|
|
||||||
$this->assertSame([], $this->files->uids(7), 'nothing is cached without a mailbox generation');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFetchRejectsIdentifiersOfAnotherService(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
iterator_to_array($this->service->entityFetchStream(new EntityIdentifier('imap', 'other', 'INBOX', '1')));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIncompleteSectionsAreCompletedOnceAndStored(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => []]);
|
|
||||||
$content = $this->files->content(7, 1);
|
|
||||||
$content['incomplete'] = ['1'];
|
|
||||||
$content['properties']['body'] = ['partId' => '1', 'type' => 'text/plain', 'content' => 'Hello wor'];
|
|
||||||
$this->files->write('tenant', 'svc', 'INBOX', 7, 1, $content);
|
|
||||||
$this->live->sections = [1 => ['1' => 'Hello world, the full text.']];
|
|
||||||
|
|
||||||
$listed = array_values(iterator_to_array($this->service->entityListStream('INBOX'), false));
|
|
||||||
$fetched = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'INBOX', '1')));
|
|
||||||
|
|
||||||
$this->assertSame('Hello world, the full text.', $listed[0]->getProperties()->getBodyTextPlain());
|
|
||||||
$this->assertSame('Hello world, the full text.', $fetched[0]->getProperties()->getBodyTextPlain());
|
|
||||||
$this->assertSame([], $this->files->content(7, 1)['incomplete']);
|
|
||||||
$this->assertCount(1, $this->calls('messageSections'), 'completed once, then served from the cache');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIncompleteSectionsStayCutOffWhenTheServerDoesNotReturnThem(): void
|
|
||||||
{
|
|
||||||
$this->harmonized([1 => []]);
|
|
||||||
$content = $this->files->content(7, 1);
|
|
||||||
$content['incomplete'] = ['1'];
|
|
||||||
$content['properties']['body'] = ['partId' => '1', 'type' => 'text/plain', 'content' => 'Hello wor'];
|
|
||||||
$this->files->write('tenant', 'svc', 'INBOX', 7, 1, $content);
|
|
||||||
|
|
||||||
$fetched = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'INBOX', '1')));
|
|
||||||
|
|
||||||
$this->assertSame('Hello wor', $fetched[0]->getProperties()->getBodyTextPlain());
|
|
||||||
$this->assertSame(['1'], $this->files->content(7, 1)['incomplete']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harmonize INBOX with the given server UIDs and flags.
|
|
||||||
*
|
|
||||||
* @param array<int, list<string>> $flags
|
|
||||||
*/
|
|
||||||
private function harmonized(array $flags): void
|
|
||||||
{
|
|
||||||
$this->live->flags = $flags;
|
|
||||||
$this->service->entityDelta('INBOX', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return int[]
|
|
||||||
*/
|
|
||||||
private function listedUids(iterable $entities): array
|
|
||||||
{
|
|
||||||
$uids = [];
|
|
||||||
foreach ($entities as $entity) {
|
|
||||||
$uids[] = (int) $entity->identifier();
|
|
||||||
}
|
|
||||||
return $uids;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array>
|
|
||||||
*/
|
|
||||||
private function calls(string $method): array
|
|
||||||
{
|
|
||||||
return array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === $method));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array>
|
|
||||||
*/
|
|
||||||
private function listCalls(): array
|
|
||||||
{
|
|
||||||
return array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === 'collectionList'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CachedService with a replaceable IMAP side.
|
|
||||||
*/
|
|
||||||
final class CachedServiceTestDouble extends CachedService
|
|
||||||
{
|
|
||||||
public ?LiveMailService $liveMail = null;
|
|
||||||
|
|
||||||
protected function liveMail(): LiveMailService
|
|
||||||
{
|
|
||||||
return $this->liveMail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\{Client, ConnectionConfig};
|
|
||||||
use KTXM\ProviderImap\Client\Transport\{ConnectionInterface, ConnectionFactoryInterface};
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Providers\LiveService;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class CollectionFetchTest extends TestCase
|
|
||||||
{
|
|
||||||
private array $commands = [];
|
|
||||||
|
|
||||||
public function testWithListStatusUsesOneRoundTrip(): void
|
|
||||||
{
|
|
||||||
$mailbox = $this->service(listStatus: true)->mailboxFetch('INBOX');
|
|
||||||
|
|
||||||
$this->assertCount(1, $this->commands);
|
|
||||||
$this->assertStringContainsString('RETURN (STATUS', $this->commands[0]);
|
|
||||||
$this->assertSame(3, $mailbox?->messages());
|
|
||||||
$this->assertSame(7, $mailbox?->uidValidity());
|
|
||||||
$this->assertSame(10, $mailbox?->uidNext());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWithoutListStatusFallsBackToStatus(): void
|
|
||||||
{
|
|
||||||
$mailbox = $this->service(listStatus: false)->mailboxFetch('INBOX');
|
|
||||||
|
|
||||||
$this->assertCount(2, $this->commands);
|
|
||||||
$this->assertStringNotContainsString('RETURN', $this->commands[0]);
|
|
||||||
$this->assertStringStartsWith('STATUS', $this->commands[1]);
|
|
||||||
$this->assertSame(3, $mailbox?->messages());
|
|
||||||
$this->assertSame(7, $mailbox?->uidValidity());
|
|
||||||
$this->assertSame(10, $mailbox?->uidNext());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUnknownMailboxIsNull(): void
|
|
||||||
{
|
|
||||||
$this->assertNull($this->service(listStatus: false, exists: false)->mailboxFetch('Missing'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCollectionsAreResourcesKeyedByName(): void
|
|
||||||
{
|
|
||||||
$service = $this->service(listStatus: true);
|
|
||||||
|
|
||||||
$list = $service->collectionList();
|
|
||||||
$collection = $service->collectionFetch('INBOX');
|
|
||||||
|
|
||||||
$this->assertSame(['INBOX'], array_keys($list));
|
|
||||||
$this->assertInstanceOf(CollectionResource::class, $list['INBOX']);
|
|
||||||
$this->assertInstanceOf(CollectionResource::class, $collection);
|
|
||||||
$this->assertSame('INBOX', $collection?->identifier());
|
|
||||||
$this->assertNull($this->service(listStatus: true, exists: false)->collectionFetch('Missing'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDelimiterIsReadWithoutListingMailboxes(): void
|
|
||||||
{
|
|
||||||
$this->assertSame('/', $this->service(listStatus: false)->collectionDelimiter());
|
|
||||||
$this->assertSame(['LIST "" ""'], $this->commands);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCollectionExtantReportsMissingMailboxes(): void
|
|
||||||
{
|
|
||||||
$service = new LiveService();
|
|
||||||
$live = $this->service(listStatus: true, exists: false);
|
|
||||||
(new \ReflectionProperty(LiveService::class, 'mailService'))->setValue($service, $live);
|
|
||||||
|
|
||||||
$this->assertSame(['Missing' => false], $service->collectionExtant('Missing'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function service(bool $listStatus, bool $exists = true): LiveMailService
|
|
||||||
{
|
|
||||||
$lines = ["* PREAUTH Ready\r\n"];
|
|
||||||
$connection = $this->createStub(ConnectionInterface::class);
|
|
||||||
$connection->method('readLine')->willReturnCallback(static function () use (&$lines): string {
|
|
||||||
return array_shift($lines) ?? throw new \RuntimeException('Unexpected response read');
|
|
||||||
});
|
|
||||||
$connection->method('write')->willReturnCallback(function (string $wire) use (&$lines, $listStatus, $exists): void {
|
|
||||||
[$tag, $command] = explode(' ', trim($wire), 2);
|
|
||||||
$operation = explode(' ', $command)[0];
|
|
||||||
if ($operation === 'CAPABILITY') {
|
|
||||||
$lines[] = '* CAPABILITY IMAP4rev1' . ($listStatus ? ' LIST-STATUS' : '') . "\r\n";
|
|
||||||
} else {
|
|
||||||
$this->commands[] = $command;
|
|
||||||
}
|
|
||||||
if ($operation === 'LIST' && $command === 'LIST "" ""') {
|
|
||||||
$lines[] = "* LIST (\\Noselect) \"/\" \"\"\r\n";
|
|
||||||
$lines[] = "$tag OK Completed\r\n";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($operation === 'LIST') {
|
|
||||||
if (!$listStatus && str_contains($command, 'RETURN')) {
|
|
||||||
$lines[] = "$tag BAD Unknown RETURN option\r\n";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($exists) {
|
|
||||||
$lines[] = "* LIST (\\HasNoChildren) \"/\" \"INBOX\"\r\n";
|
|
||||||
if ($listStatus) {
|
|
||||||
$lines[] = "* STATUS \"INBOX\" (MESSAGES 3 UNSEEN 1 UIDNEXT 10 UIDVALIDITY 7)\r\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($operation === 'STATUS') {
|
|
||||||
$lines[] = "* STATUS \"INBOX\" (MESSAGES 3 UNSEEN 1 UIDNEXT 10 UIDVALIDITY 7)\r\n";
|
|
||||||
}
|
|
||||||
$lines[] = "$tag OK Completed\r\n";
|
|
||||||
});
|
|
||||||
$factory = $this->createStub(ConnectionFactoryInterface::class);
|
|
||||||
$factory->method('create')->willReturn($connection);
|
|
||||||
|
|
||||||
$client = new Client($factory);
|
|
||||||
$client->connect(new ConnectionConfig('localhost'));
|
|
||||||
|
|
||||||
return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService {
|
|
||||||
public function __construct(ServiceBase $service, private readonly Client $client)
|
|
||||||
{
|
|
||||||
parent::__construct($service);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function imapClient(): Client
|
|
||||||
{
|
|
||||||
return $this->client;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\{Client, ConnectionConfig, ImapException};
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\CreateCommand;
|
|
||||||
use KTXM\ProviderImap\Client\Result\CommandCompletion;
|
|
||||||
use KTXM\ProviderImap\Client\Transport\{ConnectionInterface, ConnectionFactoryInterface};
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class CommandCompletionTest extends TestCase
|
|
||||||
{
|
|
||||||
private array $commands = [];
|
|
||||||
|
|
||||||
private function client(?string $failure = null, bool $move = false, string $failureStatus = 'NO'): Client
|
|
||||||
{
|
|
||||||
$lines = ["* PREAUTH Ready\r\n"];
|
|
||||||
$connection = $this->createStub(ConnectionInterface::class);
|
|
||||||
$connection->method('readLine')->willReturnCallback(static function () use (&$lines): string {
|
|
||||||
return array_shift($lines) ?? throw new \RuntimeException('Unexpected response read');
|
|
||||||
});
|
|
||||||
$connection->method('write')->willReturnCallback(function (string $wire) use (&$lines, $failure, $move, $failureStatus): void {
|
|
||||||
[$tag, $command] = explode(' ', trim($wire), 2);
|
|
||||||
$this->commands[] = $command;
|
|
||||||
$operation = str_starts_with($command, 'UID ') ? explode(' ', $command)[1] : explode(' ', $command)[0];
|
|
||||||
if ($operation === 'CAPABILITY') {
|
|
||||||
$lines[] = '* CAPABILITY IMAP4rev1 UIDPLUS' . ($move ? ' MOVE' : '') . "\r\n";
|
|
||||||
}
|
|
||||||
$status = $operation === $failure ? $failureStatus : 'OK';
|
|
||||||
$text = $status === 'OK' ? 'Completed' : 'Denied';
|
|
||||||
if ($status === 'OK' && in_array($operation, ['COPY', 'MOVE'], true)) {
|
|
||||||
$text = '[COPYUID 7 42 142] Completed';
|
|
||||||
}
|
|
||||||
$lines[] = "$tag $status $text\r\n";
|
|
||||||
});
|
|
||||||
$factory = $this->createStub(ConnectionFactoryInterface::class);
|
|
||||||
$factory->method('create')->willReturn($connection);
|
|
||||||
$client = new Client($factory);
|
|
||||||
$client->connect(new ConnectionConfig('localhost'));
|
|
||||||
$this->commands = [];
|
|
||||||
return $client;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function service(Client $client): LiveMailService
|
|
||||||
{
|
|
||||||
return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService {
|
|
||||||
public function __construct(ServiceBase $service, private readonly Client $client)
|
|
||||||
{
|
|
||||||
parent::__construct($service);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function imapClient(): Client
|
|
||||||
{
|
|
||||||
return $this->client;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSuccessfulCommandReturnsCompletionText(): void
|
|
||||||
{
|
|
||||||
$result = $this->client()->perform(new CreateCommand('Archive'));
|
|
||||||
$this->assertInstanceOf(CommandCompletion::class, $result);
|
|
||||||
$this->assertSame('OK', $result->status());
|
|
||||||
$this->assertSame('Completed', $result->text());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function failures(): iterable
|
|
||||||
{
|
|
||||||
foreach (['NO', 'BAD'] as $status) {
|
|
||||||
yield "create $status" => ['collectionCreate', ['Archive'], 'CREATE', false, ['CREATE "Archive"'], $status];
|
|
||||||
yield "rename $status" => ['collectionRename', ['INBOX', 'Archive'], 'RENAME', false, ['RENAME "INBOX" "Archive"'], $status];
|
|
||||||
yield "delete $status" => ['collectionDestroy', ['Archive'], 'DELETE', false, ['DELETE "Archive"'], $status];
|
|
||||||
yield "copy $status" => ['entityCopy', ['Archive', 'INBOX', 42], 'COPY', false, ['SELECT "INBOX"', 'UID COPY 42 "Archive"'], $status];
|
|
||||||
yield "move $status" => ['entityMove', ['Archive', 'INBOX', 42], 'MOVE', true, ['SELECT "INBOX"', 'UID MOVE 42 "Archive"'], $status];
|
|
||||||
yield "fallback copy $status" => ['entityMove', ['Archive', 'INBOX', 42], 'COPY', false, ['SELECT "INBOX"', 'UID COPY 42 "Archive"'], $status];
|
|
||||||
yield "fallback store $status" => ['entityMove', ['Archive', 'INBOX', 42], 'STORE', false, ['SELECT "INBOX"', 'UID COPY 42 "Archive"', 'UID STORE 42 +FLAGS.SILENT (\\Deleted)'], $status];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('failures')]
|
|
||||||
public function testFailuresPropagateWithoutExecutingLaterCommands(string $method, array $arguments, string $failure, bool $move, array $commands, string $status): void
|
|
||||||
{
|
|
||||||
$service = $this->service($this->client($failure, $move, $status));
|
|
||||||
try {
|
|
||||||
$service->$method(...$arguments);
|
|
||||||
$this->fail('Expected command failure');
|
|
||||||
} catch (ImapException $exception) {
|
|
||||||
$this->assertSame($failure . ' failed: Denied', $exception->getMessage());
|
|
||||||
}
|
|
||||||
$this->assertSame($commands, $this->commands);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMoveFallbackDeletesOnlyAfterCopySucceeds(): void
|
|
||||||
{
|
|
||||||
$result = $this->service($this->client())->entityMove('Archive', 'INBOX', 42);
|
|
||||||
$this->assertSame([42 => '142'], $result);
|
|
||||||
$this->assertSame([
|
|
||||||
'SELECT "INBOX"',
|
|
||||||
'UID COPY 42 "Archive"',
|
|
||||||
'UID STORE 42 +FLAGS.SILENT (\\Deleted)',
|
|
||||||
'UID EXPUNGE 42',
|
|
||||||
], $this->commands);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\{CommandFailedException, ConnectionConfig, ImapException};
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\{AppendCommand, CopyCommand, MoveCommand, CommandInterface};
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListReturnOptions;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command as Commands;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\{CompletionChecker, ProtocolReader};
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\{ResponseStream, SessionContext};
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
|
||||||
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class CommandFailedExceptionTest extends TestCase
|
|
||||||
{
|
|
||||||
private function context(): SessionContext
|
|
||||||
{
|
|
||||||
$context = new SessionContext(new ConnectionConfig('localhost'), $this->createStub(ConnectionInterface::class));
|
|
||||||
$context->setSelectedMailbox('INBOX');
|
|
||||||
return $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function command(string $name): CommandInterface
|
|
||||||
{
|
|
||||||
return match ($name) {
|
|
||||||
'COPY' => new CopyCommand(MessageTarget::uid(42), 'Archive'),
|
|
||||||
'MOVE' => new MoveCommand(MessageTarget::uid(42), 'Archive'),
|
|
||||||
'APPEND' => new AppendCommand('Archive', 'body'),
|
|
||||||
'CAPABILITY' => new Commands\CapabilityCommand(),
|
|
||||||
'CREATE' => new Commands\CreateCommand('Archive'),
|
|
||||||
'DELETE' => new Commands\DeleteCommand('Archive'),
|
|
||||||
'RENAME' => new Commands\RenameCommand('INBOX', 'Archive'),
|
|
||||||
'LOGIN' => new Commands\LoginCommand('user', 'password'),
|
|
||||||
'LOGOUT' => new Commands\LogoutCommand(),
|
|
||||||
'NOOP' => new Commands\NoopCommand(),
|
|
||||||
'STARTTLS' => new Commands\StartTlsCommand(),
|
|
||||||
'SELECT' => new Commands\SelectCommand('INBOX', false),
|
|
||||||
'EXAMINE' => new Commands\SelectCommand('INBOX', true),
|
|
||||||
'STATUS' => new Commands\StatusCommand('INBOX'),
|
|
||||||
'SEARCH' => new Commands\SearchCommand(),
|
|
||||||
'SORT' => new Commands\SortCommand(['DATE']),
|
|
||||||
'STORE' => new Commands\StoreCommand(MessageTarget::uid(42), ['\\Seen']),
|
|
||||||
'EXPUNGE' => new Commands\ExpungeCommand(),
|
|
||||||
'UID EXPUNGE' => new Commands\ExpungeCommand(MessageTarget::uid(42)),
|
|
||||||
'LIST' => new Commands\ListCommand(),
|
|
||||||
'LIST-STATUS' => new Commands\ListCommand(returnOptions: ListReturnOptions::status('MESSAGES')),
|
|
||||||
'FETCH' => new Commands\FetchOneCommand(42),
|
|
||||||
'FETCH-MANY' => new Commands\FetchManyCommand(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function failures(): iterable
|
|
||||||
{
|
|
||||||
foreach (['COPY', 'MOVE', 'APPEND', 'CAPABILITY', 'CREATE', 'DELETE', 'RENAME', 'LOGIN', 'LOGOUT', 'NOOP', 'STARTTLS', 'SELECT', 'EXAMINE', 'STATUS', 'SEARCH', 'SORT', 'STORE', 'EXPUNGE', 'UID EXPUNGE', 'LIST', 'LIST-STATUS', 'FETCH', 'FETCH-MANY'] as $command) {
|
|
||||||
foreach (['NO', 'BAD'] as $status) {
|
|
||||||
foreach ([
|
|
||||||
['[trycreate] Create destination first', ['name' => 'TRYCREATE', 'arguments' => [], 'text' => 'Create destination first']],
|
|
||||||
['[LIMIT 10] Too many messages', ['name' => 'LIMIT', 'arguments' => ['10'], 'text' => 'Too many messages']],
|
|
||||||
['Denied', null],
|
|
||||||
['[Malformed', null],
|
|
||||||
] as [$text, $code]) {
|
|
||||||
yield $command . ' ' . $status . ' ' . $text => [$command, $status, $text, $code];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('failures')]
|
|
||||||
public function testServerFailurePreservesStructuredDetails(string $command, string $status, string $text, ?array $code): void
|
|
||||||
{
|
|
||||||
$responses = new ResponseStream(static function () use ($status, $text): \Generator {
|
|
||||||
yield new TaggedResponse('A1', $status, $text, "A1 $status $text");
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $this->command($command)->handle($responses, $this->context());
|
|
||||||
if ($result instanceof \Generator) {
|
|
||||||
iterator_to_array($result);
|
|
||||||
}
|
|
||||||
$this->fail('Expected server failure');
|
|
||||||
} catch (ImapException $exception) {
|
|
||||||
$command = match ($command) { 'LIST-STATUS' => 'LIST', 'FETCH-MANY' => 'FETCH', default => $command };
|
|
||||||
$this->assertInstanceOf(CommandFailedException::class, $exception);
|
|
||||||
$this->assertSame($command . ' failed: ' . $text, $exception->getMessage());
|
|
||||||
$this->assertSame($command, $exception->command());
|
|
||||||
$this->assertSame($status, $exception->status());
|
|
||||||
$this->assertSame($text, $exception->text());
|
|
||||||
$this->assertSame($code, $exception->responseCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMissingCompletionRemainsAProtocolError(): void
|
|
||||||
{
|
|
||||||
foreach (['COPY', 'MOVE', 'APPEND'] as $command) {
|
|
||||||
$responses = new ResponseStream(static function (): \Generator { yield from []; });
|
|
||||||
try {
|
|
||||||
$this->command($command)->handle($responses, $this->context());
|
|
||||||
$this->fail('Expected missing completion failure');
|
|
||||||
} catch (ImapException $exception) {
|
|
||||||
$this->assertNotInstanceOf(CommandFailedException::class, $exception);
|
|
||||||
$this->assertSame($command . ' did not receive a tagged completion response.', $exception->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSuccessfulAppendStillReturnsAssignedUid(): void
|
|
||||||
{
|
|
||||||
$responses = new ResponseStream(static function (): \Generator {
|
|
||||||
yield new TaggedResponse('A1', 'OK', '[APPENDUID 7 42] Done', 'A1 OK [APPENDUID 7 42] Done');
|
|
||||||
});
|
|
||||||
$this->assertSame(42, $this->command('APPEND')->handle($responses, $this->context()));
|
|
||||||
}
|
|
||||||
public function testDownloadFailuresUseStructuredExceptions(): void
|
|
||||||
{
|
|
||||||
foreach (['readUntilFetchLiteral', 'readToEnd'] as $method) {
|
|
||||||
foreach (['NO', 'BAD'] as $status) {
|
|
||||||
$connection = $this->createStub(ConnectionInterface::class);
|
|
||||||
$connection->method('readLine')->willReturn("A1 $status [LIMIT 10] Denied\r\n");
|
|
||||||
try {
|
|
||||||
(new ProtocolReader($connection))->$method('A1');
|
|
||||||
$this->fail('Expected download failure');
|
|
||||||
} catch (CommandFailedException $exception) {
|
|
||||||
$this->assertSame('FETCH', $exception->command());
|
|
||||||
$this->assertSame($status, $exception->status());
|
|
||||||
$this->assertSame(['name' => 'LIMIT', 'arguments' => ['10'], 'text' => 'Denied'], $exception->responseCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCheckerAcceptsOkAndRejectsMalformedStatusAsProtocolError(): void
|
|
||||||
{
|
|
||||||
CompletionChecker::assertSuccess('NOOP', new TaggedResponse('A1', 'OK', 'Done', 'A1 OK Done'));
|
|
||||||
try {
|
|
||||||
CompletionChecker::assertSuccess('NOOP', new TaggedResponse('A1', 'INVALID', 'Bad status', 'A1 INVALID Bad status'));
|
|
||||||
$this->fail('Expected malformed status error');
|
|
||||||
} catch (ImapException $exception) {
|
|
||||||
$this->assertNotInstanceOf(CommandFailedException::class, $exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTruncatedSectionsAreReported(): void
|
|
||||||
{
|
|
||||||
$complete = FetchMessageParser::parse($this->fetchResponse(
|
|
||||||
self::MIXED_STRUCTURE,
|
|
||||||
'BODY[TEXT]',
|
|
||||||
self::ALTERNATIVE_PREFIX . "<p>Hello</p>\r\n--alt--\r\n--mix--\r\n",
|
|
||||||
));
|
|
||||||
$cutInHtml = FetchMessageParser::parse($this->fetchResponse(
|
|
||||||
self::MIXED_STRUCTURE,
|
|
||||||
'BODY[TEXT]<0>',
|
|
||||||
self::ALTERNATIVE_PREFIX . '<p>Hello wor',
|
|
||||||
));
|
|
||||||
$cutInAttachment = FetchMessageParser::parse($this->fetchResponse(
|
|
||||||
self::MIXED_STRUCTURE,
|
|
||||||
'BODY[TEXT]<0>',
|
|
||||||
self::ALTERNATIVE_PREFIX . "<p>Hello</p>\r\n--alt--\r\n--mix\r\nContent-Type: application/pdf\r\n\r\nJVBE",
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame([], $complete->truncatedSections());
|
|
||||||
$this->assertSame(['1.2'], $cutInHtml->truncatedSections());
|
|
||||||
$this->assertSame([], $cutInAttachment->truncatedSections());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTruncatedSinglePartBodyIsReportedBySize(): void
|
|
||||||
{
|
|
||||||
$structure = '("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" 11 1)';
|
|
||||||
|
|
||||||
$partial = FetchMessageParser::parse($this->fetchResponse($structure, 'BODY[TEXT]<0>', 'Hello'));
|
|
||||||
$complete = FetchMessageParser::parse($this->fetchResponse($structure, 'BODY[TEXT]', 'Hello world'));
|
|
||||||
|
|
||||||
$this->assertSame(['1'], $partial->truncatedSections());
|
|
||||||
$this->assertSame([], $complete->truncatedSections());
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use Generator;
|
|
||||||
use KTXC\Db\Collection;
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Providers\LiveService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
use LogicException;
|
|
||||||
use MongoDB\UpdateResult;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class HarmonizationServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
private array $calls = [];
|
|
||||||
|
|
||||||
public function testMailboxesAreAddedUpdatedAndRemoved(): void
|
|
||||||
{
|
|
||||||
$service = $this->harmonizer(
|
|
||||||
cached: ['INBOX' => [], 'Old' => []],
|
|
||||||
remote: ['INBOX', 'Reports'],
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $service->harmonizeMailboxes();
|
|
||||||
|
|
||||||
$this->assertSame(['added' => ['Reports'], 'updated' => ['INBOX'], 'removed' => ['Old']], $result);
|
|
||||||
$this->assertSame([
|
|
||||||
['upsert', 'tenant', 'svc', 'INBOX'],
|
|
||||||
['upsert', 'tenant', 'svc', 'Reports'],
|
|
||||||
['messages.deleteByMailbox', 'svc', 'Old'],
|
|
||||||
['files.deleteByMailbox', 'tenant', 'svc', 'Old'],
|
|
||||||
['mailboxes.delete', 'svc', 'Old'],
|
|
||||||
], $this->calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNothingIsPurgedWhenTheServerListHasNoInbox(): void
|
|
||||||
{
|
|
||||||
$service = $this->harmonizer(
|
|
||||||
cached: ['INBOX' => [], 'Reports' => []],
|
|
||||||
remote: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$result = $service->harmonizeMailboxes();
|
|
||||||
|
|
||||||
$this->assertSame(['added' => [], 'updated' => [], 'removed' => []], $result);
|
|
||||||
$this->assertSame([], $this->calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUnboundServiceIsRejected(): void
|
|
||||||
{
|
|
||||||
$unbound = new HarmonizationService(
|
|
||||||
$this->createStub(MailboxStore::class),
|
|
||||||
$this->createStub(MessageStore::class),
|
|
||||||
$this->createStub(MessageFileStore::class),
|
|
||||||
$this->createStub(MessageIngestor::class),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(LogicException::class);
|
|
||||||
$unbound->harmonizeMailboxes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLockIsTakenOnlyWhenFreeExpiredOrOwned(): void
|
|
||||||
{
|
|
||||||
$collection = $this->createMock(Collection::class);
|
|
||||||
$collection->expects($this->exactly(2))
|
|
||||||
->method('updateOne')
|
|
||||||
->willReturnCallback(function (array $filter, array $update) use (&$matched): UpdateResult {
|
|
||||||
$this->assertSame(['lock' => null], $filter['$or'][0]);
|
|
||||||
$this->assertArrayHasKey('$lte', $filter['$or'][1]['lock.expiresAt']);
|
|
||||||
$this->assertSame(['lock.owner' => 'run-1'], $filter['$or'][2]);
|
|
||||||
$this->assertSame('run-1', $update['$set']['lock']['owner']);
|
|
||||||
$result = $this->createStub(UpdateResult::class);
|
|
||||||
$result->method('getMatchedCount')->willReturn(array_shift($matched));
|
|
||||||
return $result;
|
|
||||||
});
|
|
||||||
$matched = [1, 0];
|
|
||||||
|
|
||||||
$store = new MailboxStore($this->dataStore($collection));
|
|
||||||
|
|
||||||
$this->assertTrue($store->acquireLock('svc', 'INBOX', 'run-1', 300));
|
|
||||||
$this->assertFalse($store->acquireLock('svc', 'INBOX', 'run-1', 300));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStateFillsDefaultsAndIgnoresOtherFields(): void
|
|
||||||
{
|
|
||||||
$collection = $this->createStub(Collection::class);
|
|
||||||
$collection->method('findOne')->willReturn([
|
|
||||||
'sid' => 'svc', 'name' => 'INBOX', 'uidValidity' => 7, 'lock' => ['owner' => 'x'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$state = (new MailboxStore($this->dataStore($collection)))->state('svc', 'INBOX');
|
|
||||||
|
|
||||||
$this->assertSame([...MailboxStore::STATE_DEFAULTS, 'uidValidity' => 7], $state);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNewMailboxStartsWithDefaultState(): void
|
|
||||||
{
|
|
||||||
$collection = $this->createMock(Collection::class);
|
|
||||||
$collection->expects($this->once())
|
|
||||||
->method('updateOne')
|
|
||||||
->with(
|
|
||||||
$this->anything(),
|
|
||||||
$this->callback(fn (array $update): bool => $update['$setOnInsert'] === MailboxStore::STATE_DEFAULTS
|
|
||||||
&& array_intersect_key($update['$set'], MailboxStore::STATE_DEFAULTS) === []),
|
|
||||||
['upsert' => true],
|
|
||||||
);
|
|
||||||
|
|
||||||
$mailbox = (new CollectionResource('imap', 'svc'))->fromImap(new Mailbox('INBOX', '/', []));
|
|
||||||
(new MailboxStore($this->dataStore($collection)))->upsert('tenant', 'svc', $mailbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, array> $cached
|
|
||||||
* @param string[] $remote
|
|
||||||
*/
|
|
||||||
private function harmonizer(array $cached, array $remote): HarmonizationService
|
|
||||||
{
|
|
||||||
$mailboxes = $this->createStub(MailboxStore::class);
|
|
||||||
$mailboxes->method('list')->willReturn($cached);
|
|
||||||
$mailboxes->method('upsert')->willReturnCallback(function (string $tid, string $sid, $collection): void {
|
|
||||||
$this->calls[] = ['upsert', $tid, $sid, $collection->identifier()];
|
|
||||||
});
|
|
||||||
$mailboxes->method('delete')->willReturnCallback(function (string $sid, string $name): void {
|
|
||||||
$this->calls[] = ['mailboxes.delete', $sid, $name];
|
|
||||||
});
|
|
||||||
|
|
||||||
$messages = $this->createStub(MessageStore::class);
|
|
||||||
$messages->method('deleteByMailbox')->willReturnCallback(function (string $sid, string $name): void {
|
|
||||||
$this->calls[] = ['messages.deleteByMailbox', $sid, $name];
|
|
||||||
});
|
|
||||||
|
|
||||||
$files = $this->createStub(MessageFileStore::class);
|
|
||||||
$files->method('deleteByMailbox')->willReturnCallback(function (string $tid, string $sid, string $name): void {
|
|
||||||
$this->calls[] = ['files.deleteByMailbox', $tid, $sid, $name];
|
|
||||||
});
|
|
||||||
|
|
||||||
$service = (new LiveService())->fromStore(['tid' => 'tenant', 'sid' => 'svc']);
|
|
||||||
$live = new HarmonizationServiceTestLiveStub($service);
|
|
||||||
$live->mailboxes = array_map(static fn (string $name): Mailbox => new Mailbox($name, '/', []), $remote);
|
|
||||||
|
|
||||||
return (new HarmonizationService($mailboxes, $messages, $files, $this->createStub(MessageIngestor::class)))->for($service, $live);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dataStore(Collection $collection): DataStore
|
|
||||||
{
|
|
||||||
$dataStore = $this->createStub(DataStore::class);
|
|
||||||
$dataStore->method('selectCollection')->willReturn($collection);
|
|
||||||
return $dataStore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HarmonizationServiceTestLiveStub extends LiveMailService
|
|
||||||
{
|
|
||||||
/** @var Mailbox[] */
|
|
||||||
public array $mailboxes = [];
|
|
||||||
|
|
||||||
public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array
|
|
||||||
{
|
|
||||||
$list = [];
|
|
||||||
foreach ($this->mailboxes as $mailbox) {
|
|
||||||
$list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox);
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class LiveMailServiceTest extends TestCase {
|
|
||||||
|
|
||||||
public function testEntityReplaceReturnsAuthoritativeReplacementUid(): void {
|
|
||||||
$service = new LiveMailServiceStub($this->createStub(ServiceBase::class));
|
|
||||||
$service->appendUid = 202;
|
|
||||||
$service->destroyResults = [[101 => true]];
|
|
||||||
|
|
||||||
$result = $service->entityReplace('Drafts', 101, 'raw message', ['\\Draft']);
|
|
||||||
|
|
||||||
$this->assertSame(202, $result);
|
|
||||||
$this->assertSame([
|
|
||||||
['Drafts', 'raw message', ['\\Draft']],
|
|
||||||
], $service->createCalls);
|
|
||||||
$this->assertSame([
|
|
||||||
['Drafts', [101]],
|
|
||||||
], $service->destroyCalls);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEntityReplaceRemovesReplacementWhenOldUidCannotBeDeleted(): void {
|
|
||||||
$service = new LiveMailServiceStub($this->createStub(ServiceBase::class));
|
|
||||||
$service->appendUid = 202;
|
|
||||||
$service->destroyResults = [
|
|
||||||
[101 => false],
|
|
||||||
[202 => true],
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->entityReplace('Drafts', 101, 'raw message', ['\\Draft']);
|
|
||||||
$this->fail('Expected superseded message cleanup failure');
|
|
||||||
} catch (RuntimeException $exception) {
|
|
||||||
$this->assertSame('Failed to delete the superseded IMAP entity', $exception->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertSame([
|
|
||||||
['Drafts', [101]],
|
|
||||||
['Drafts', [202]],
|
|
||||||
], $service->destroyCalls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class LiveMailServiceStub extends LiveMailService {
|
|
||||||
|
|
||||||
public ?int $appendUid = null;
|
|
||||||
public array $createCalls = [];
|
|
||||||
public array $destroyCalls = [];
|
|
||||||
public array $destroyResults = [];
|
|
||||||
|
|
||||||
public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int {
|
|
||||||
$this->createCalls[] = [$collection, $rawMessage, $flags];
|
|
||||||
return $this->appendUid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityDestroy(string $collection, int ...$uids): array {
|
|
||||||
$this->destroyCalls[] = [$collection, $uids];
|
|
||||||
return array_shift($this->destroyResults) ?? [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Providers\LiveService;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use ReflectionProperty;
|
|
||||||
|
|
||||||
final class LiveServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testEntityExtantReportsExistenceOfEachIdentifier(): void
|
|
||||||
{
|
|
||||||
$service = new LiveService();
|
|
||||||
$live = new LiveServiceTestLiveStub($this->createStub(ServiceBase::class));
|
|
||||||
$live->existing = [5];
|
|
||||||
(new ReflectionProperty(LiveService::class, 'mailService'))->setValue($service, $live);
|
|
||||||
|
|
||||||
$result = $service->entityExtant('INBOX', 5, '6', 'abc', 0);
|
|
||||||
|
|
||||||
$this->assertSame([5 => true, 6 => false, 'abc' => false, 0 => false], $result);
|
|
||||||
$this->assertSame([['INBOX', [5, 6]]], $live->extantCalls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class LiveServiceTestLiveStub extends LiveMailService
|
|
||||||
{
|
|
||||||
/** @var int[] */
|
|
||||||
public array $existing = [];
|
|
||||||
public array $extantCalls = [];
|
|
||||||
|
|
||||||
public function entityExtant(string $collection, int ...$uids): array
|
|
||||||
{
|
|
||||||
$this->extantCalls[] = [$collection, $uids];
|
|
||||||
return array_values(array_intersect($uids, $this->existing));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\{Client, ConnectionConfig, ImapException};
|
|
||||||
use KTXM\ProviderImap\Client\Transport\{ConnectionInterface, ConnectionFactoryInterface};
|
|
||||||
use KTXM\ProviderImap\Providers\ServiceBase;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class MailboxSelectionTest extends TestCase
|
|
||||||
{
|
|
||||||
private array $commands = [];
|
|
||||||
private array $failing = [];
|
|
||||||
|
|
||||||
public function testSameMailboxIsSelectedOnce(): void
|
|
||||||
{
|
|
||||||
$service = $this->service();
|
|
||||||
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
$service->entityExtant('INBOX', 2);
|
|
||||||
|
|
||||||
$this->assertSame(1, $this->sent('EXAMINE "INBOX"'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testOtherMailboxIsSelected(): void
|
|
||||||
{
|
|
||||||
$service = $this->service();
|
|
||||||
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
$service->entityExtant('Sent', 1);
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
|
|
||||||
$this->assertSame(2, $this->sent('EXAMINE "INBOX"'));
|
|
||||||
$this->assertSame(1, $this->sent('EXAMINE "Sent"'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWriteAfterReadOnlySelectionSelectsReadWrite(): void
|
|
||||||
{
|
|
||||||
$service = $this->service();
|
|
||||||
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
$service->entityPatch('INBOX', ['\\Seen'], [], 1);
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
|
|
||||||
$this->assertSame(1, $this->sent('EXAMINE "INBOX"'));
|
|
||||||
$this->assertSame(1, $this->sent('SELECT "INBOX"'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFailedSelectionIsNotReused(): void
|
|
||||||
{
|
|
||||||
$service = $this->service();
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
|
|
||||||
$this->failing = ['EXAMINE "Gone"'];
|
|
||||||
try {
|
|
||||||
$service->entityExtant('Gone', 1);
|
|
||||||
$this->fail('Expected the selection to fail');
|
|
||||||
} catch (ImapException) {
|
|
||||||
}
|
|
||||||
|
|
||||||
$service->entityExtant('INBOX', 1);
|
|
||||||
|
|
||||||
$this->assertSame(2, $this->sent('EXAMINE "INBOX"'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sent(string $command): int
|
|
||||||
{
|
|
||||||
return count(array_filter($this->commands, static fn (string $sent): bool => $sent === $command));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function service(): LiveMailService
|
|
||||||
{
|
|
||||||
$lines = ["* PREAUTH Ready\r\n"];
|
|
||||||
$connection = $this->createStub(ConnectionInterface::class);
|
|
||||||
$connection->method('readLine')->willReturnCallback(static function () use (&$lines): string {
|
|
||||||
return array_shift($lines) ?? throw new \RuntimeException('Unexpected response read');
|
|
||||||
});
|
|
||||||
$connection->method('write')->willReturnCallback(function (string $wire) use (&$lines): void {
|
|
||||||
[$tag, $command] = explode(' ', trim($wire), 2);
|
|
||||||
$operation = explode(' ', $command)[0];
|
|
||||||
if ($operation === 'CAPABILITY') {
|
|
||||||
$lines[] = "* CAPABILITY IMAP4rev1\r\n";
|
|
||||||
} else {
|
|
||||||
$this->commands[] = $command;
|
|
||||||
}
|
|
||||||
if (in_array($command, $this->failing, true)) {
|
|
||||||
$lines[] = "$tag NO Mailbox does not exist\r\n";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (in_array($operation, ['SELECT', 'EXAMINE'], true)) {
|
|
||||||
$lines[] = "* 3 EXISTS\r\n";
|
|
||||||
}
|
|
||||||
$lines[] = "$tag OK Completed\r\n";
|
|
||||||
});
|
|
||||||
$factory = $this->createStub(ConnectionFactoryInterface::class);
|
|
||||||
$factory->method('create')->willReturn($connection);
|
|
||||||
|
|
||||||
$client = new Client($factory);
|
|
||||||
$client->connect(new ConnectionConfig('localhost'));
|
|
||||||
|
|
||||||
return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService {
|
|
||||||
public function __construct(ServiceBase $service, private readonly Client $client)
|
|
||||||
{
|
|
||||||
parent::__construct($service);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function imapClient(): Client
|
|
||||||
{
|
|
||||||
return $this->client;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Client\Result\MailboxStatusResult;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class MailboxTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testPartialStatusPreservesUnreportedValues(): void
|
|
||||||
{
|
|
||||||
$mailbox = new Mailbox('INBOX', '/', [], 12, 4, 123);
|
|
||||||
|
|
||||||
$updated = $mailbox->fromStatus(new MailboxStatusResult('INBOX', ['UNSEEN' => 2]));
|
|
||||||
|
|
||||||
$this->assertSame(12, $updated->messages());
|
|
||||||
$this->assertSame(2, $updated->unread());
|
|
||||||
$this->assertSame(123, $updated->uidValidity());
|
|
||||||
$this->assertSame(4, $mailbox->unread());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testExplicitZeroCountsReplaceKnownCounts(): void
|
|
||||||
{
|
|
||||||
$mailbox = new Mailbox('INBOX', '/', [], 12, 4, 123);
|
|
||||||
|
|
||||||
$updated = $mailbox->fromStatus(new MailboxStatusResult('INBOX', [
|
|
||||||
'MESSAGES' => 0,
|
|
||||||
'UNSEEN' => 0,
|
|
||||||
'UIDVALIDITY' => 456,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$this->assertSame(0, $updated->messages());
|
|
||||||
$this->assertSame(0, $updated->unread());
|
|
||||||
$this->assertSame(456, $updated->uidValidity());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEmptyStatusPreservesMailboxIncludingUnknownUidValidity(): void
|
|
||||||
{
|
|
||||||
foreach ([null, 123] as $uidValidity) {
|
|
||||||
$mailbox = new Mailbox('INBOX', '/', ['\\HasNoChildren'], 12, 4, $uidValidity, 2, ['\\Seen'], false);
|
|
||||||
|
|
||||||
$updated = $mailbox->fromStatus(new MailboxStatusResult('INBOX', []));
|
|
||||||
|
|
||||||
$this->assertEquals($mailbox, $updated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use FilesystemIterator;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use RecursiveDirectoryIterator;
|
|
||||||
use RecursiveIteratorIterator;
|
|
||||||
|
|
||||||
final class MessageFileStoreTest extends TestCase
|
|
||||||
{
|
|
||||||
private string $root;
|
|
||||||
private MessageFileStore $store;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->root = sys_get_temp_dir() . '/ktrix-imap-cache-' . bin2hex(random_bytes(4));
|
|
||||||
$this->store = new MessageFileStore($this->root);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
if (!is_dir($this->root)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$items = new RecursiveIteratorIterator(
|
|
||||||
new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS),
|
|
||||||
RecursiveIteratorIterator::CHILD_FIRST,
|
|
||||||
);
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
|
||||||
}
|
|
||||||
rmdir($this->root);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWriteThenReadReturnsContent(): void
|
|
||||||
{
|
|
||||||
$content = ['schemaVersion' => 1, 'properties' => ['subject' => 'Grüße / ✓']];
|
|
||||||
|
|
||||||
$this->store->write('tenant', 'svc', 'INBOX/Reports', 7, 42, $content);
|
|
||||||
|
|
||||||
$this->assertSame($content, $this->store->read('tenant', 'svc', 'INBOX/Reports', 7, 42));
|
|
||||||
$this->assertFileExists(sprintf(
|
|
||||||
'%s/storage/tenant/provider_imap/svc/%s/7/42/message.json',
|
|
||||||
$this->root,
|
|
||||||
sha1('INBOX/Reports'),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testWriteReplacesAndLeavesNoTemporaryFiles(): void
|
|
||||||
{
|
|
||||||
$this->store->write('tenant', 'svc', 'INBOX', 7, 42, ['v' => 1]);
|
|
||||||
$this->store->write('tenant', 'svc', 'INBOX', 7, 42, ['v' => 2]);
|
|
||||||
|
|
||||||
$this->assertSame(['v' => 2], $this->store->read('tenant', 'svc', 'INBOX', 7, 42));
|
|
||||||
$folder = sprintf('%s/storage/tenant/provider_imap/svc/%s/7/42', $this->root, sha1('INBOX'));
|
|
||||||
$this->assertSame(['message.json'], array_values(array_diff(scandir($folder), ['.', '..'])));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testReadOfMissingOrCorruptContentReturnsNull(): void
|
|
||||||
{
|
|
||||||
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 42));
|
|
||||||
|
|
||||||
$this->store->write('tenant', 'svc', 'INBOX', 7, 42, ['v' => 1]);
|
|
||||||
file_put_contents(
|
|
||||||
sprintf('%s/storage/tenant/provider_imap/svc/%s/7/42/message.json', $this->root, sha1('INBOX')),
|
|
||||||
'{truncated',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 42));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteScopes(): void
|
|
||||||
{
|
|
||||||
$this->store->write('tenant', 'svc', 'INBOX', 7, 1, ['v' => 1]);
|
|
||||||
$this->store->write('tenant', 'svc', 'INBOX', 7, 2, ['v' => 2]);
|
|
||||||
$this->store->write('tenant', 'svc', 'Sent', 3, 1, ['v' => 3]);
|
|
||||||
$this->store->write('tenant', 'other', 'INBOX', 7, 1, ['v' => 4]);
|
|
||||||
|
|
||||||
$this->store->delete('tenant', 'svc', 'INBOX', 7, 1);
|
|
||||||
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 1));
|
|
||||||
$this->assertNotNull($this->store->read('tenant', 'svc', 'INBOX', 7, 2));
|
|
||||||
|
|
||||||
$this->store->deleteByMailbox('tenant', 'svc', 'INBOX');
|
|
||||||
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 2));
|
|
||||||
$this->assertNotNull($this->store->read('tenant', 'svc', 'Sent', 3, 1));
|
|
||||||
|
|
||||||
$this->store->deleteByService('tenant', 'svc');
|
|
||||||
$this->assertNull($this->store->read('tenant', 'svc', 'Sent', 3, 1));
|
|
||||||
$this->assertDirectoryDoesNotExist($this->root . '/storage/tenant/provider_imap/svc');
|
|
||||||
$this->assertNotNull($this->store->read('tenant', 'other', 'INBOX', 7, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIdentifiersThatCouldEscapeTheRootAreRejected(): void
|
|
||||||
{
|
|
||||||
$this->expectException(InvalidArgumentException::class);
|
|
||||||
$this->store->write('tenant', '../escape', 'INBOX', 7, 1, []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\ProviderImap\Tests\Unit;
|
|
||||||
|
|
||||||
use Generator;
|
|
||||||
use KTXF\Resource\Delta\Delta;
|
|
||||||
use KTXF\Resource\Filter\IFilter;
|
|
||||||
use KTXF\Resource\Sort\ISort;
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use KTXM\ProviderImap\Client\Mailbox;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
|
|
||||||
use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
|
||||||
use KTXM\ProviderImap\Providers\LiveService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageDeltaService;
|
|
||||||
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
|
|
||||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
|
||||||
use KTXM\ProviderImap\Stores\MailboxStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageFileStore;
|
|
||||||
use KTXM\ProviderImap\Stores\MessageStore;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeLiveMailService;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeMailboxStore;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeMessageFileStore;
|
|
||||||
use KTXT\ProviderImap\Tests\Support\FakeMessageStore;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class MessageHarmonizationTest extends TestCase
|
|
||||||
{
|
|
||||||
private FakeMailboxStore $mailboxes;
|
|
||||||
private FakeMessageStore $messages;
|
|
||||||
private FakeMessageFileStore $files;
|
|
||||||
private FakeLiveMailService $live;
|
|
||||||
private HarmonizationService $harmonizer;
|
|
||||||
private MessageDeltaService $deltas;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$dataStore = $this->createStub(DataStore::class);
|
|
||||||
$this->mailboxes = new FakeMailboxStore($dataStore);
|
|
||||||
$this->messages = new FakeMessageStore($dataStore);
|
|
||||||
$this->files = new FakeMessageFileStore('/nonexistent');
|
|
||||||
|
|
||||||
$service = (new LiveService())->fromStore(['tid' => 'tenant', 'sid' => 'svc']);
|
|
||||||
$this->live = new FakeLiveMailService($service);
|
|
||||||
$this->live->uidValidity = 7;
|
|
||||||
$this->live->uidNext = 100;
|
|
||||||
|
|
||||||
$this->harmonizer = (new HarmonizationService(
|
|
||||||
$this->mailboxes,
|
|
||||||
$this->messages,
|
|
||||||
$this->files,
|
|
||||||
new MessageIngestor($this->files, $this->messages, $this->mailboxes),
|
|
||||||
))->for($service, $this->live);
|
|
||||||
$this->deltas = new MessageDeltaService($this->mailboxes, $this->messages);
|
|
||||||
|
|
||||||
$this->mailboxes->upsert('tenant', 'svc', (new CollectionResource('imap', 'svc'))->fromImap(new Mailbox('INBOX', '/', [])));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFirstRunIngestsEverythingNewestFirst(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => ['\\Seen'], 2 => [], 3 => ['\\Flagged']];
|
|
||||||
|
|
||||||
$result = $this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->assertSame(['status' => 'harmonized', 'reset' => false, 'added' => 3, 'updated' => 0, 'removed' => 0, 'complete' => true], $result);
|
|
||||||
$this->assertSame([[3, 2, 1]], $this->live->fetchBatches);
|
|
||||||
$this->assertSame([1 => ['seen'], 2 => [], 3 => ['flagged']], $this->messages->flags('svc', 'INBOX', 7));
|
|
||||||
$this->assertSame([1, 2, 3], $this->files->uids(7));
|
|
||||||
$this->assertSame(262144, $this->live->bodyTextLimit);
|
|
||||||
|
|
||||||
$state = $this->mailboxes->state('svc', 'INBOX');
|
|
||||||
$this->assertSame(7, $state['uidValidity']);
|
|
||||||
$this->assertSame(100, $state['uidNext']);
|
|
||||||
$this->assertTrue($state['harmonizationComplete']);
|
|
||||||
$this->assertIsInt($state['harmonizedAt']);
|
|
||||||
$this->assertNull($this->mailboxes->lockOwner);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIncrementalRunAddsUpdatesAndRemoves(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => ['\\Seen'], 2 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->live->flags = [2 => ['\\Seen', '$Forwarded'], 3 => []];
|
|
||||||
$this->live->fetchBatches = [];
|
|
||||||
$result = $this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->assertSame(['status' => 'harmonized', 'reset' => false, 'added' => 1, 'updated' => 1, 'removed' => 1, 'complete' => true], $result);
|
|
||||||
$this->assertSame([[3]], $this->live->fetchBatches);
|
|
||||||
$this->assertSame([2 => ['seen', '$forwarded'], 3 => []], $this->messages->flags('svc', 'INBOX', 7));
|
|
||||||
$this->assertSame([2, 3], $this->files->uids(7));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testChangedUidValidityDiscardsTheCache(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->live->uidValidity = 8;
|
|
||||||
$this->live->flags = [1 => []];
|
|
||||||
$result = $this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->assertTrue($result['reset']);
|
|
||||||
$this->assertSame(1, $result['added']);
|
|
||||||
$this->assertSame([], $this->messages->flags('svc', 'INBOX', 7));
|
|
||||||
$this->assertSame([1 => []], $this->messages->flags('svc', 'INBOX', 8));
|
|
||||||
$this->assertSame([], $this->files->uids(7));
|
|
||||||
$this->assertSame(8, $this->mailboxes->state('svc', 'INBOX')['uidValidity']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testHeldLockSkipsTheRun(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => []];
|
|
||||||
$this->mailboxes->lockOwner = 'someone-else';
|
|
||||||
|
|
||||||
$result = $this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->assertSame('skipped', $result['status']);
|
|
||||||
$this->assertSame(0, $this->live->selects);
|
|
||||||
$this->assertSame('someone-else', $this->mailboxes->lockOwner);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMessagesThatCannotBeFetchedLeaveTheMailboxIncomplete(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => []];
|
|
||||||
$this->live->unfetchable = [2];
|
|
||||||
|
|
||||||
$result = $this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->assertSame(1, $result['added']);
|
|
||||||
$this->assertFalse($result['complete']);
|
|
||||||
$this->assertFalse($this->mailboxes->state('svc', 'INBOX')['harmonizationComplete']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNewMessagesAreFetchedInBatches(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = array_fill_keys(range(1, 450), []);
|
|
||||||
|
|
||||||
$result = $this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$this->assertSame(450, $result['added']);
|
|
||||||
$this->assertSame([200, 200, 50], array_map('count', $this->live->fetchBatches));
|
|
||||||
$this->assertSame(450, $this->live->fetchBatches[0][0]);
|
|
||||||
$this->assertSame(1, $this->live->fetchBatches[2][49]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testHarmonizeAllSkipsUnselectableAndIsolatesFailures(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => []];
|
|
||||||
$this->live->mailboxList = [
|
|
||||||
new Mailbox('INBOX', '/', []),
|
|
||||||
new Mailbox('Archive', '/', ['\\Noselect']),
|
|
||||||
new Mailbox('Broken', '/', []),
|
|
||||||
];
|
|
||||||
$this->live->failing = ['Broken'];
|
|
||||||
|
|
||||||
$result = $this->harmonizer->harmonizeAll();
|
|
||||||
|
|
||||||
$this->assertSame(['Archive', 'Broken'], $result['mailboxes']['added']);
|
|
||||||
$this->assertSame(['INBOX', 'Broken'], array_keys($result['messages']));
|
|
||||||
$this->assertSame('harmonized', $result['messages']['INBOX']['status']);
|
|
||||||
$this->assertSame('Mailbox not found on server: Broken', $result['messages']['Broken']);
|
|
||||||
$this->assertNull($this->mailboxes->lockOwner);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEmptySignatureReturnsTheCurrentSignature(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => [], 3 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$delta = $this->deltas->delta('svc', 'INBOX', '');
|
|
||||||
|
|
||||||
$this->assertSame('7:3', $delta->signature);
|
|
||||||
$this->assertSame([[], [], []], $this->changeLists($delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEmptySignatureWhileIncompleteStartsFromZero(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => []];
|
|
||||||
$this->live->unfetchable = [2];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
$delta = $this->deltas->delta('svc', 'INBOX', '');
|
|
||||||
$this->assertSame('7:0', $delta->signature);
|
|
||||||
|
|
||||||
$this->assertSame([['1'], [], []], $this->changeLists($this->deltas->delta('svc', 'INBOX', '7:0')));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeltaReportsAdditionsModificationsAndDeletions(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => [], 3 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$signature = $this->deltas->delta('svc', 'INBOX', '')->signature;
|
|
||||||
|
|
||||||
$this->live->flags = [2 => ['\\Seen'], 3 => [], 4 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$delta = $this->deltas->delta('svc', 'INBOX', $signature);
|
|
||||||
|
|
||||||
$this->assertSame([['4'], ['2'], ['1']], $this->changeLists($delta));
|
|
||||||
$this->assertSame([[], [], []], $this->changeLists($this->deltas->delta('svc', 'INBOX', $delta->signature)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMessagesAddedSinceTheSignatureAreOnlyAdditions(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$signature = $this->deltas->delta('svc', 'INBOX', '')->signature;
|
|
||||||
|
|
||||||
$this->live->flags = [1 => [], 2 => [], 3 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$this->live->flags = [1 => [], 2 => ['\\Flagged']];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
|
|
||||||
// 2: added then flagged -> addition; 3: added then expunged -> not reported
|
|
||||||
$this->assertSame([['2'], [], []], $this->changeLists($this->deltas->delta('svc', 'INBOX', $signature)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSignatureThatCannotBeAnsweredIsReset(): void
|
|
||||||
{
|
|
||||||
$this->live->flags = [1 => [], 2 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$old = $this->deltas->delta('svc', 'INBOX', '')->signature;
|
|
||||||
|
|
||||||
$this->assertSame([['1', '2'], [], []], $this->changeLists($this->deltas->delta('svc', 'INBOX', 'garbage')));
|
|
||||||
$this->assertSame([['1', '2'], [], []], $this->changeLists($this->deltas->delta('svc', 'INBOX', '7:999')));
|
|
||||||
|
|
||||||
$this->mailboxes->updateState('svc', 'INBOX', ['purgedSeq' => 2]);
|
|
||||||
$this->assertSame([['1', '2'], [], []], $this->changeLists($this->deltas->delta('svc', 'INBOX', '7:1')));
|
|
||||||
|
|
||||||
$this->live->uidValidity = 8;
|
|
||||||
$this->live->flags = [5 => []];
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$delta = $this->deltas->delta('svc', 'INBOX', $old);
|
|
||||||
|
|
||||||
$this->assertSame([['5'], [], []], $this->changeLists($delta));
|
|
||||||
$this->assertStringStartsWith('8:', $delta->signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMailboxThatIsNotCachedHasAnEmptyDelta(): void
|
|
||||||
{
|
|
||||||
$delta = $this->deltas->delta('svc', 'Unknown', '7:1');
|
|
||||||
|
|
||||||
$this->assertSame('', $delta->signature);
|
|
||||||
$this->assertSame([[], [], []], $this->changeLists($delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLockIsReleasedWhenTheRunFails(): void
|
|
||||||
{
|
|
||||||
$this->live->uidValidity = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->harmonizer->harmonizeMessages('INBOX');
|
|
||||||
$this->fail('Expected missing UIDVALIDITY to fail the run');
|
|
||||||
} catch (\RuntimeException) {
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertNull($this->mailboxes->lockOwner);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: string[], 1: string[], 2: string[]}
|
|
||||||
*/
|
|
||||||
private function changeLists(Delta $delta): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
json_decode(json_encode($delta->additions), true),
|
|
||||||
json_decode(json_encode($delta->modifications), true),
|
|
||||||
json_decode(json_encode($delta->deletions), true),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user