1 Commits

Author SHA1 Message Date
Sebastian 7cb1737831 chore(deps): update dependency vite to v8 2026-05-15 03:33:39 +00:00
146 changed files with 3295 additions and 12913 deletions
-50
View File
@@ -1,50 +0,0 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_imap
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/provider_imap
- name: Build
run: npm run build
working-directory: server/modules/provider_imap
-50
View File
@@ -1,50 +0,0 @@
name: JS Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Mail Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/mail_manager
ref: main
path: server/modules/mail_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_imap
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/provider_imap
- name: Run tests
run: npm run test:unit
working-directory: server/modules/provider_imap
@@ -1,59 +0,0 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install server
uses: ./action-server-install
with:
install-php: 'true'
php-version: '8.5'
server-path: './server'
database-uri: 'mongodb://mongo:27017/?tls=false'
database-name: 'ktrix_ci'
app-environment: 'test'
- name: Checkout module under test
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
path: server/modules/provider_imap
github-server-url: https://git.ktrix.dev
- name: Install module dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/provider_imap
- name: Install and enable module
working-directory: server
run: |
php bin/console module:install provider_imap
php bin/console module:enable provider_imap
- name: Run integration tests
working-directory: server/modules/provider_imap
run: composer test:integration
-42
View File
@@ -1,42 +0,0 @@
name: PHP Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'true'
install-node: 'false'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/provider_imap
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/provider_imap
- name: Run tests
run: composer test:unit
working-directory: server/modules/provider_imap
+2 -8
View File
@@ -25,17 +25,11 @@ jobs:
tools: composer:v2
- name: Install Renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
run: npm install -g renovate
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
run: renovate ${{ gitea.repository }}
+5 -1
View File
@@ -14,7 +14,11 @@ node_modules/
# Backend development
/lib/vendor/
coverage/
*.cache
phpunit.xml.cache
.phpunit.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
+8 -11
View File
@@ -7,7 +7,7 @@
"config": {
"optimize-autoloader": true,
"platform": {
"php": "8.3"
"php": "8.2"
},
"autoloader-suffix": "ProviderImap",
"vendor-dir": "lib/vendor",
@@ -16,16 +16,14 @@
}
},
"require": {
"php": ">=8.3 <=8.5",
"php": ">=8.2 <=8.5",
"ext-iconv": "*",
"ext-ctype": "*",
"psr/log": "^1.0|^2.0|^3.0",
"doctrine/lexer": "^3.0",
"symfony/mime": "^7.0",
"egulias/email-validator": "^4.0"
"doctrine/lexer": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
@@ -34,7 +32,7 @@
},
"autoload-dev": {
"psr-4": {
"KTXT\\ProviderImap\\Tests\\": "tests/php/"
"KTXM\\ProviderImap\\": "tests/php/"
}
},
"scripts": {
@@ -42,8 +40,7 @@
],
"post-update-cmd": [
],
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
}
}
}
Generated
+359 -743
View File
File diff suppressed because it is too large Load Diff
+5 -17
View File
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\Protocol\Command\CapabilityCommand;
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
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\Command\CapabilityCommand;
use KTXM\ProviderImap\Client\Command\CommandInterface;
use KTXM\ProviderImap\Client\Command\LoginCommand;
use KTXM\ProviderImap\Client\Command\StatusCommand;
use KTXM\ProviderImap\Client\Command\StartTlsCommand;
use KTXM\ProviderImap\Client\Protocol\CommandExecutor;
use KTXM\ProviderImap\Client\Protocol\ProtocolReader;
use KTXM\ProviderImap\Client\Protocol\ProtocolWriter;
@@ -90,15 +87,6 @@ final class Client implements ClientInterface
return $this->executor->perform($command, $this->session);
}
public function download(MessageTarget $target, string $section, int $chunkSize = 8192): \Generator
{
if ($this->session === null || $this->executor === null) {
throw new ImapException('IMAP client is not connected.');
}
return $this->executor->download($target, $section, $chunkSize, $this->session);
}
public function session(): SessionContext
{
if ($this->session === null) {
+1 -13
View File
@@ -4,8 +4,7 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Command\CommandInterface;
interface ClientInterface
{
@@ -17,20 +16,9 @@ interface ClientInterface
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
* @param CommandInterface<TResult> $command
* @return TResult
*/
public function perform(CommandInterface $command): mixed;
/**
* Stream the raw bytes of a single IMAP BODY section without buffering.
*
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
*/
public function download(MessageTarget $target, string $section, int $chunkSize = 8192): \Generator;
}
@@ -2,17 +2,16 @@
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\Result\CapabilityResult;
use KTXM\ProviderImap\Client\Command\Result\CapabilityResult;
use KTXM\ProviderImap\Client\ImapException;
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;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CapabilityResult>
@@ -50,7 +49,9 @@ final class CapabilityCommand implements CommandInterface
}
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess($this->name(), $response);
if (!$response->isOk()) {
throw new ImapException('CAPABILITY failed: ' . $response->text());
}
}
}
@@ -2,12 +2,12 @@
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\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @template TResult
@@ -2,14 +2,14 @@
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\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
/**
* @implements CommandInterface<MessageTransferResult>
@@ -19,7 +19,7 @@ final class CopyCommand implements CommandInterface
private readonly MessageTransferCommand $command;
public function __construct(
MessageTarget|string|SequenceSet|null $target = null,
FetchTarget|string|SequenceSet|null $target = null,
string $destinationMailbox = '',
) {
$this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox);
@@ -2,20 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class CreateCommand implements CommandInterface
{
@@ -40,21 +38,28 @@ final class CreateCommand implements CommandInterface
{
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);
foreach ($responses as $response) {
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.');
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,20 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class DeleteCommand implements CommandInterface
{
@@ -40,10 +38,10 @@ final class DeleteCommand implements CommandInterface
{
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) {
$context->setSelectedMailbox(null);
@@ -52,12 +50,19 @@ final class DeleteCommand implements CommandInterface
foreach ($responses as $response) {
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.');
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,19 +2,18 @@
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\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
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\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<list<int>>
@@ -23,7 +22,7 @@ final class ExpungeCommand implements CommandInterface
{
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) {
$this->sequenceSet = null;
@@ -32,9 +31,9 @@ final class ExpungeCommand implements CommandInterface
}
$resolvedTarget = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => null,
};
@@ -90,7 +89,11 @@ final class ExpungeCommand implements CommandInterface
}
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;
}
@@ -2,37 +2,34 @@
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 KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\FetchOptions;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Message;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<Generator<int, Message>>
*/
final class FetchManyCommand implements CommandInterface
{
private readonly MessageTarget $target;
private readonly FetchTarget $target;
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) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
default => MessageTarget::all(),
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => FetchTarget::all(),
};
$this->options = $options ?? FetchOptions::default();
}
@@ -53,7 +50,7 @@ final class FetchManyCommand implements CommandInterface
return new RequestFrame(sprintf(
'%s %s (%s)',
$this->target->identifierMode() === IdentifierMode::Uid ? 'UID FETCH' : 'FETCH',
$this->target->toCommand(),
$this->target->sequenceSet()->toCommand(),
$this->options->toCommand(),
));
@@ -65,6 +62,6 @@ final class FetchManyCommand implements CommandInterface
throw new ImapException('FETCH requires a selected mailbox.');
}
return (new FetchResultReader())->readMany($responses);
return (new FetchResponseParser())->parseMany($responses);
}
}
@@ -2,33 +2,30 @@
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 KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\FetchOptions;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Message;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<Message>
*/
final class FetchOneCommand implements CommandInterface
{
private readonly MessageTarget $target;
private readonly FetchTarget $target;
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
: MessageTarget::sequence($target);
: FetchTarget::sequence($target);
$this->options = $options ?? FetchOptions::default();
}
@@ -48,7 +45,7 @@ final class FetchOneCommand implements CommandInterface
return new RequestFrame(sprintf(
'%s %s (%s)',
$this->target->identifierMode() === IdentifierMode::Uid ? 'UID FETCH' : 'FETCH',
$this->target->toCommand(),
$this->target->sequenceSet()->toCommand(),
$this->options->toCommand(),
));
@@ -60,6 +57,6 @@ final class FetchOneCommand implements CommandInterface
throw new ImapException('FETCH requires a selected mailbox.');
}
return (new FetchResultReader())->readOne($responses);
return (new FetchResponseParser())->parseOne($responses);
}
}
@@ -2,25 +2,23 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
namespace KTXM\ProviderImap\Client\Command;
use Generator;
use KTXM\ProviderImap\Client\ImapException;
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\UntaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
/**
* Reads FETCH messages from a response stream and validates command completion.
*/
final class FetchResultReader
final class FetchResponseParser
{
public function readOne(ResponseStream $responses): Message
public function parseOne(ResponseStream $responses): Message
{
$message = null;
foreach ($this->readMany($responses) as $summary) {
foreach ($this->parseMany($responses) as $summary) {
if ($message !== null) {
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>
*/
public function readMany(ResponseStream $responses): Generator
public function parseMany(ResponseStream $responses): Generator
{
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && FetchMessageParser::isFetchMessage($response->raw())) {
yield FetchMessageParser::parse($response->raw());
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) {
yield MessageParser::parse($response->raw());
continue;
}
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess('FETCH', $response);
if (!$response->isOk()) {
throw new ImapException('FETCH failed: ' . $response->text());
}
return;
}
@@ -57,4 +55,4 @@ final class FetchResultReader
throw new ImapException('FETCH did not receive a tagged completion response.');
}
}
}
+263
View File
@@ -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, "\\\"") . '"';
}
}
@@ -2,20 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class LoginCommand implements CommandInterface
{
@@ -40,23 +38,30 @@ final class LoginCommand implements CommandInterface
return new RequestFrame(sprintf(
'LOGIN %s %s',
StringEncoder::quote($this->username),
StringEncoder::quote($this->password),
$this->quote($this->username),
$this->quote($this->password),
));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess($this->name(), $response);
if (!$response->isOk()) {
throw new ImapException('LOGIN failed: ' . $response->text());
}
$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.');
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,19 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class LogoutCommand implements CommandInterface
{
@@ -39,17 +38,19 @@ final class LogoutCommand implements CommandInterface
return new RequestFrame('LOGOUT');
}
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess($this->name(), $response);
if (!$response->isOk()) {
throw new ImapException('LOGOUT failed: ' . $response->text());
}
$context->setSelectedMailbox(null);
$context->setState(SessionState::Logout);
$context->connection()->disconnect();
return new CommandCompletion($response->status(), $response->text());
return new CommandStatusResult($response->status(), $response->text());
}
}
@@ -2,45 +2,39 @@
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\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
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\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<MessageTransferResult>
*/
final class MessageTransferCommand implements CommandInterface
{
private readonly ResponseCodeParser $responseCodeParser;
private readonly string $operation;
private readonly SequenceSet $sequenceSet;
private readonly IdentifierMode $identifierMode;
public function __construct(
string $operation,
MessageTarget|string|SequenceSet|null $target = null,
FetchTarget|string|SequenceSet|null $target = null,
private readonly string $destinationMailbox = '',
) {
$this->responseCodeParser = new ResponseCodeParser();
$resolvedTarget = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
default => MessageTarget::all(),
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => FetchTarget::all(),
};
$this->operation = strtoupper(trim($operation));
@@ -72,7 +66,7 @@ final class MessageTransferCommand implements CommandInterface
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
$this->operation,
$this->sequenceSet->toCommand(),
StringEncoder::quote($this->destinationMailbox),
$this->quote($this->destinationMailbox),
));
}
@@ -114,9 +108,7 @@ final class MessageTransferCommand implements CommandInterface
$highestModSeq,
);
CompletionChecker::assertSuccess($this->name(), $response);
return new MessageTransferResult(
$result = new MessageTransferResult(
$response->status(),
$response->text(),
$responseCodes,
@@ -126,6 +118,12 @@ final class MessageTransferCommand implements CommandInterface
$expunged,
$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,
?string &$highestModSeq,
): void {
$responseCode = $this->responseCodeParser->parse($text);
$responseCode = $this->parseResponseCode($text);
if ($responseCode === null) {
return;
}
@@ -217,4 +215,29 @@ final class MessageTransferCommand implements CommandInterface
'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);
namespace KTXM\ProviderImap\Client\Protocol\Command;
namespace KTXM\ProviderImap\Client\Command;
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
/**
* @implements CommandInterface<MessageTransferResult>
@@ -19,7 +19,7 @@ final class MoveCommand implements CommandInterface
private readonly MessageTransferCommand $command;
public function __construct(
MessageTarget|string|SequenceSet|null $target = null,
FetchTarget|string|SequenceSet|null $target = null,
string $destinationMailbox = '',
) {
$this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox);
@@ -2,19 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class NoopCommand implements CommandInterface
{
@@ -39,15 +38,17 @@ final class NoopCommand implements CommandInterface
return new RequestFrame('NOOP');
}
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
unset($context);
foreach ($responses as $response) {
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());
}
}
@@ -2,20 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class RenameCommand implements CommandInterface
{
@@ -43,25 +41,32 @@ final class RenameCommand implements CommandInterface
return new RequestFrame(sprintf(
'RENAME %s %s',
StringEncoder::quote($this->fromMailbox),
StringEncoder::quote($this->toMailbox),
$this->quote($this->fromMailbox),
$this->quote($this->toMailbox),
));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess($this->name(), $response);
if (!$response->isOk()) {
throw new ImapException('RENAME failed: ' . $response->text());
}
if ($context->selectedMailbox() === $this->fromMailbox) {
$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.');
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Result;
namespace KTXM\ProviderImap\Client\Command\Result;
final class CapabilityResult
{
@@ -2,12 +2,9 @@
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 CommandCompletion
final class CommandStatusResult
{
public function __construct(
private readonly string $status,
@@ -23,4 +20,9 @@ final class CommandCompletion
{
return $this->text;
}
public function isOk(): bool
{
return $this->status === 'OK';
}
}
@@ -2,11 +2,8 @@
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
{
/**
@@ -36,6 +33,11 @@ final class MessageTransferResult
return $this->text;
}
public function isOk(): bool
{
return $this->status === 'OK';
}
/**
* @return list<array{source:string, name:string, arguments:list<string>, text:string}>
*/
@@ -2,9 +2,9 @@
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
{
@@ -2,9 +2,9 @@
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
{
@@ -2,9 +2,9 @@
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
@@ -47,7 +47,7 @@ final class MailboxStatusResult
return max(0, $this->messages() - $this->unseen());
}
public function uidValidity(): int
public function state(): int
{
return $this->value('UIDVALIDITY');
}
@@ -2,19 +2,18 @@
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\Result\SearchResult;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Command\Result\SearchResult;
use KTXM\ProviderImap\Client\IdentifierMode;
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\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;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<SearchResult>
@@ -75,7 +74,9 @@ final class SearchCommand implements CommandInterface
}
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);
}
@@ -2,18 +2,16 @@
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\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<Mailbox>
@@ -45,7 +43,7 @@ final class SelectCommand implements CommandInterface
return new RequestFrame(sprintf(
'%s %s',
$this->name(),
StringEncoder::quote($this->mailbox),
$this->quote($this->mailbox),
));
}
@@ -53,9 +51,6 @@ final class SelectCommand implements CommandInterface
{
$exists = 0;
$recent = 0;
$uidValidity = null;
$uidNext = null;
$highestModSeq = null;
$flags = [];
$readOnly = $this->readOnly;
@@ -73,16 +68,6 @@ final class SelectCommand implements CommandInterface
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)) {
$flags = $this->parseFlags($matches[1]);
continue;
@@ -90,11 +75,9 @@ final class SelectCommand implements CommandInterface
}
if ($response instanceof TaggedResponse) {
// a failed SELECT leaves no mailbox selected (RFC 3501 6.3.1)
if ($response->status() !== 'OK') {
$context->setSelectedMailbox(null);
if (!$response->isOk()) {
throw new ImapException($this->name() . ' failed: ' . $response->text());
}
CompletionChecker::assertSuccess($this->name(), $response);
if (str_contains(strtoupper($response->text()), 'READ-ONLY')) {
$readOnly = true;
@@ -109,12 +92,10 @@ final class SelectCommand implements CommandInterface
[],
$exists,
0,
$uidValidity,
null,
$recent,
$flags,
$readOnly,
$uidNext,
$highestModSeq,
);
}
}
@@ -135,4 +116,9 @@ final class SelectCommand implements CommandInterface
return preg_split('/\s+/', $flags) ?: [];
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,19 +2,18 @@
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\Result\SortResult;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Command\Result\SortResult;
use KTXM\ProviderImap\Client\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
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\Command\Argument\SearchCriteriaBuilder;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<SortResult>
@@ -83,7 +82,9 @@ final class SortCommand implements CommandInterface
}
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);
}
@@ -2,19 +2,18 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class StartTlsCommand implements CommandInterface
{
@@ -35,16 +34,18 @@ final class StartTlsCommand implements CommandInterface
return new RequestFrame('STARTTLS');
}
public function handle(ResponseStream $responses, SessionContext $context): CommandCompletion
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess($this->name(), $response);
if (!$response->isOk()) {
throw new ImapException('STARTTLS failed: ' . $response->text());
}
$context->connection()->upgradeToTls();
$context->replaceCapabilities();
return new CommandCompletion($response->status(), $response->text());
return new CommandStatusResult($response->status(), $response->text());
}
}
@@ -2,22 +2,19 @@
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\Protocol\Parser\StatusResponseParser;
use KTXM\ProviderImap\Client\Result\MailboxStatusResult;
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
use KTXM\ProviderImap\Client\ImapException;
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;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<MailboxStatusResult>
* @implements CommandInterface<StatusResult>
*/
final class StatusCommand implements CommandInterface
{
@@ -53,12 +50,12 @@ final class StatusCommand implements CommandInterface
return new RequestFrame(sprintf(
'STATUS %s (%s)',
StringEncoder::quote($this->mailbox),
$this->quote($this->mailbox),
implode(' ', $this->normalizeItems($this->items)),
));
}
public function handle(ResponseStream $responses, SessionContext $context): MailboxStatusResult
public function handle(ResponseStream $responses, SessionContext $context): StatusResult
{
unset($context);
@@ -72,9 +69,11 @@ final class StatusCommand implements CommandInterface
}
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;
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Parser;
namespace KTXM\ProviderImap\Client\Command;
use KTXM\ProviderImap\Client\ImapException;
@@ -2,22 +2,21 @@
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\Result\CommandCompletion;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandCompletion>
* @implements CommandInterface<CommandStatusResult>
*/
final class StoreCommand implements CommandInterface
{
@@ -28,16 +27,16 @@ final class StoreCommand implements CommandInterface
* @param list<string> $flags
*/
public function __construct(
MessageTarget|string|SequenceSet|null $target = null,
private array $flags = [],
private string $action = '',
private bool $silent = true,
FetchTarget|string|SequenceSet|null $target = null,
private readonly array $flags = [],
private readonly string $action = '',
private readonly bool $silent = true,
) {
$resolvedTarget = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
default => MessageTarget::all(),
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => FetchTarget::all(),
};
$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) {
throw new ImapException('STORE requires a selected mailbox.');
@@ -91,9 +90,11 @@ final class StoreCommand implements CommandInterface
foreach ($responses as $response) {
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());
}
}
-48
View File
@@ -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;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
namespace KTXM\ProviderImap\Client;
final class FetchOptions
{
@@ -57,7 +57,7 @@ final class FetchOptions
public function withHeaders(): self
{
return $this->with('BODY.PEEK[HEADER]');
return $this->with('BODY[HEADER]');
}
public function withHeader(string ...$fields): self
@@ -84,21 +84,9 @@ final class FetchOptions
return $this->with('BODYSTRUCTURE');
}
/**
* @param int|null $limit Maximum number of octets to fetch (partial fetch `<0.limit>`), null for all
*/
public function withBodyText(?int $limit = null): self
public function withBodyText(): self
{
if ($limit !== null && $limit > 0) {
return $this->with(sprintf('BODY.PEEK[TEXT]<0.%d>', $limit));
}
return $this->with('BODY.PEEK[TEXT]');
}
public function withBody(): self
{
return $this->with('BODY.PEEK[]');
return $this->with('BODY[TEXT]');
}
public function withBodySection(string $section): self
@@ -109,7 +97,7 @@ final class FetchOptions
return $this;
}
return $this->with(sprintf('BODY.PEEK[%s]', $section));
return $this->with(sprintf('BODY[%s]', $section));
}
public function with(string $item): self
@@ -2,12 +2,9 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
namespace KTXM\ProviderImap\Client;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
final class MessageTarget
final class FetchTarget
{
private function __construct(
private readonly SequenceSet $sequenceSet,
@@ -39,6 +36,11 @@ final class MessageTarget
return $this->identifierMode;
}
public function toCommand(): string
{
return $this->identifierMode->toCommand();
}
private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet
{
return match (true) {
+16
View File
@@ -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';
}
}
@@ -2,9 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\ImapException;
namespace KTXM\ProviderImap\Client;
final class ListReturnOptions
{
@@ -2,9 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\ImapException;
namespace KTXM\ProviderImap\Client;
final class ListSelectionOptions
{
+8 -24
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
use KTXM\ProviderImap\Client\Result\MailboxStatusResult;
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
final class Mailbox
{
@@ -18,30 +18,24 @@ final class Mailbox
private readonly array $attributes,
private readonly int $messages = 0,
private readonly int $unread = 0,
private readonly ?int $uidValidity = null,
private readonly ?int $state = null,
private readonly int $recent = 0,
private readonly array $flags = [],
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(
$this->name,
$this->delimiter,
$this->attributes,
$items['MESSAGES'] ?? $this->messages,
$items['UNSEEN'] ?? $this->unread,
$items['UIDVALIDITY'] ?? $this->uidValidity,
$status->messages() ?? $this->messages,
$status->unseen() ?? $this->unread,
$status->state() ?? $this->state,
$this->recent,
$this->flags,
$this->readOnly,
$items['UIDNEXT'] ?? $this->uidNext,
$items['HIGHESTMODSEQ'] ?? $this->highestModSeq,
);
}
@@ -63,19 +57,9 @@ final class Mailbox
return $this->attributes;
}
public function uidValidity(): ?int
public function state(): ?int
{
return $this->uidValidity;
}
public function uidNext(): ?int
{
return $this->uidNext;
}
public function highestModSeq(): ?int
{
return $this->highestModSeq;
return $this->state;
}
public function messages(): int
-29
View File
@@ -15,8 +15,6 @@ final class Message
* @param list<MessageAddress> $cc
* @param list<MessageAddress> $bcc
* @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(
private readonly int $sequence,
@@ -37,8 +35,6 @@ final class Message
private readonly array $bcc,
private readonly ?MessagePart $bodyStructure,
private readonly array $bodySections,
private readonly array $references = [],
private readonly array $truncatedSections = [],
) {}
public function sequence(): int
@@ -94,14 +90,6 @@ final class Message
return $this->inReplyTo;
}
/**
* @return list<string>
*/
public function references(): array
{
return $this->references;
}
/**
* @return list<MessageAddress>
*/
@@ -168,21 +156,6 @@ final class Message
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
{
return $this->bodySections[''] ?? null;
}
/**
* @param array<string, string> $bodySections
*/
@@ -207,8 +180,6 @@ final class Message
$this->bcc,
$bodyStructure,
$bodySections,
$this->references,
$this->truncatedSections,
);
}
}
@@ -2,39 +2,29 @@
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;
/**
* Decodes an individual untagged FETCH response into a message.
*/
final class FetchMessageParser
final class MessageParser
{
public static function isFetchMessage(string $raw): bool
public static function isFetchMessage(string $payload): bool
{
return preg_match('/^\*\s+\d+\s+FETCH\s+\(/i', $raw) === 1;
return str_contains(strtoupper($payload), 'FETCH (');
}
public static function parse(string $raw): Message
{
if (preg_match('/^\*\s+(\d+)\s+FETCH\s+\(/iA', $raw, $matches) !== 1
|| !str_ends_with($raw, ')')) {
if (!preg_match('/^\*\s+(\d+)\s+FETCH\s+\((.*)\)$/is', $raw, $matches)) {
throw new ImapException('Unable to parse FETCH response: ' . $raw);
}
$sequence = (int) $matches[1];
$offset = strlen($matches[0]);
$attributes = self::parseAttributes($raw, $offset, strlen($raw) - 1);
$attributes = self::parseAttributes($matches[2]);
$uid = self::toInt($attributes['UID'] ?? null, 'FETCH response is missing UID: ' . $raw);
$envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null;
$bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null;
$truncatedSections = [];
$bodySections = self::parseBodySections($attributes, $bodyStructure, $truncatedSections);
$bodySections = self::parseBodySections($attributes, $bodyStructure);
$headers = self::parseFetchedHeaders($attributes);
return new Message(
@@ -47,7 +37,7 @@ final class FetchMessageParser
self::decodeMimeHeader(self::envelopeString($envelope, 1)),
self::envelopeString($envelope, 0),
self::trimAngles(self::envelopeString($envelope, 9)),
self::trimAngles(self::envelopeString($envelope, 8)),
self::envelopeString($envelope, 8),
self::parseAddressList($envelope[2] ?? null),
self::parseAddressList($envelope[3] ?? null),
self::parseAddressList($envelope[4] ?? null),
@@ -56,21 +46,21 @@ final class FetchMessageParser
self::parseAddressList($envelope[7] ?? null),
$bodyStructure,
$bodySections,
self::extractReferences($headers),
$truncatedSections,
);
}
/**
* @return array<string, mixed>
*/
private static function parseAttributes(string $payload, int &$offset, int $end): array
private static function parseAttributes(string $payload): array
{
$attributes = [];
$offset = 0;
$length = strlen($payload);
while ($offset < $end) {
while ($offset < $length) {
self::skipWhitespace($payload, $offset);
if ($offset >= $end) {
if ($offset >= $length) {
break;
}
@@ -105,14 +95,7 @@ final class FetchMessageParser
$depth--;
if ($depth === 0) {
$offset++;
$name = substr($payload, $start, $offset - $start);
// partial fetch responses carry the origin octet, e.g. BODY[TEXT]<0>
if (preg_match('/\G<\d+>/A', $payload, $originMatches, 0, $offset) === 1) {
$offset += strlen($originMatches[0]);
}
return $name;
return substr($payload, $start, $offset - $start);
}
}
@@ -354,24 +337,6 @@ final class FetchMessageParser
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
*/
@@ -545,7 +510,7 @@ final class FetchMessageParser
* @param array<string, mixed> $attributes
* @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 = [];
@@ -559,6 +524,9 @@ final class FetchMessageParser
}
$section = strtoupper(trim($matches[1]));
if ($section === '') {
continue;
}
if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) {
$section = $partMatches[1];
@@ -572,7 +540,7 @@ final class FetchMessageParser
}
if ($bodyStructure->isMultipart()) {
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure, $truncated);
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure);
unset($sections['TEXT']);
foreach ($derivedSections as $section => $content) {
@@ -583,11 +551,6 @@ final class FetchMessageParser
}
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'];
unset($sections['TEXT']);
}
@@ -623,7 +586,7 @@ final class FetchMessageParser
/**
* @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()) {
$boundary = $part->parameters()['boundary'] ?? '';
@@ -632,15 +595,13 @@ final class FetchMessageParser
}
$sections = [];
$segments = self::splitMultipartBody($content, $boundary, $truncated);
$lastIndex = count($segments) - 1;
$segments = self::splitMultipartBody($content, $boundary);
foreach ($part->parts() as $index => $childPart) {
if (!isset($segments[$index])) {
break;
}
$segmentTruncated = $truncated && $index === $lastIndex;
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart, $segmentTruncated, $truncatedParts) as $section => $childContent) {
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) {
$sections[$section] = $childContent;
}
}
@@ -658,27 +619,18 @@ final class FetchMessageParser
/**
* @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);
if ($part->isMultipart()) {
return self::sectionsFromBodyText($body, $part, $truncatedParts);
return self::sectionsFromBodyText($body, $part);
}
if (!str_starts_with($part->mimeType(), 'text/')) {
return [];
}
if ($truncated) {
$truncatedParts[] = $part->partId();
}
return [$part->partId() => $body];
}
@@ -699,16 +651,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>
*/
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|$)/';
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) {
return [];
@@ -727,17 +673,12 @@ final class FetchMessageParser
&& $matches[1][$index][0] === '--';
if ($isClosing) {
return $segments;
break;
}
$segmentStart = $offset + strlen($match);
}
if ($segmentStart !== null && $segmentStart < strlen($content)) {
$segments[] = substr($content, $segmentStart);
$truncated = true;
}
return $segments;
}
@@ -761,22 +702,11 @@ final class FetchMessageParser
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
{
$decoded = match (strtolower($encoding ?? '7bit')) {
'quoted-printable' => quoted_printable_decode($content),
'base64' => self::decodeBase64($content),
'base64' => base64_decode($content, true) ?: '',
default => $content,
};
@@ -857,4 +787,4 @@ final class FetchMessageParser
$value,
)));
}
}
}
+1 -2
View File
@@ -167,9 +167,8 @@ final class MessagePart
{
$data = [
'partId' => $this->partId,
'blobId' => $this->partId,
'cid' => $this->contentId,
'type' => $this->mimeType,
'blobId' => $this->contentId,
'charset' => $this->parameters['charset'] ?? null,
'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null,
'encoding' => $this->encoding,
@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\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\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
/**
* APPEND a raw RFC822 message to a mailbox (RFC 3501 §6.3.11).
*
* Uses a synchronizing literal: the command line ends with `{<len>}`, the
* server replies with a continuation request ("+"), and only then is the
* message streamed back via {@see ResponseStream::respond()}. Returns the
* assigned UID when the server reports APPENDUID (RFC 4315 / UIDPLUS),
* otherwise null.
*
* @implements CommandInterface<int|null>
*/
final class AppendCommand implements CommandInterface
{
private readonly ResponseCodeParser $responseCodeParser;
private readonly string $literal;
/**
* @param list<string> $flags optional initial flags, e.g. ['\\Seen']
*/
public function __construct(
private readonly string $mailbox,
string $message,
private readonly array $flags = [],
) {
$this->responseCodeParser = new ResponseCodeParser();
// IMAP literals are octet-counted; normalise to CRLF line endings.
$this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message);
}
public function name(): string
{
return 'APPEND';
}
public function allowedStates(): array
{
return [SessionState::Authenticated, SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
$flagSegment = $this->flags === [] ? '' : '(' . implode(' ', $this->flags) . ') ';
return new RequestFrame(sprintf(
'APPEND %s %s{%d}',
StringEncoder::quote($this->mailbox),
$flagSegment,
strlen($this->literal),
));
}
public function handle(ResponseStream $responses, SessionContext $context): ?int
{
unset($context);
foreach ($responses as $response) {
if ($response instanceof ContinuationResponse) {
// Continuation granted: stream the literal, then CRLF to end the command.
$responses->respond($this->literal . "\r\n");
continue;
}
if ($response instanceof TaggedResponse) {
CompletionChecker::assertSuccess($this->name(), $response);
return $this->parseAppendUid($response->text());
}
}
throw new ImapException('APPEND did not receive a tagged completion response.');
}
private function parseAppendUid(string $text): ?int
{
$responseCode = $this->responseCodeParser->parse($text);
if ($responseCode === null || $responseCode['name'] !== 'APPENDUID') {
return null;
}
$arguments = $responseCode['arguments'];
if (count($arguments) !== 2 || !ctype_digit($arguments[0]) || !ctype_digit($arguments[1])) {
return null;
}
return (int) $arguments[1];
}
}
-150
View File
@@ -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));
}
}
+7 -46
View File
@@ -5,14 +5,12 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use Generator;
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Command\CommandInterface;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
use Psr\Log\LoggerInterface;
final class CommandExecutor
@@ -42,46 +40,9 @@ final class CommandExecutor
$frame = $command->encode($tag, $context);
$this->writer->write($tag, $frame);
return $command->handle(new ResponseStream(
function () use ($tag, $context): Generator {
yield from $this->processPerform($tag, $context);
},
function (string $payload): void {
$this->writer->writeRaw($payload);
},
), $context);
}
/**
* Stream the raw bytes of a single IMAP BODY section without buffering.
*
* Sends a UID FETCH for the given section and yields the literal bytes
* directly from the socket in chunks, never assembling a full string.
* The caller MUST fully exhaust the returned Generator before issuing
* any further IMAP commands.
*
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
*/
public function download(MessageTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
{
$this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)');
$tag = $this->tags->next();
$this->writer->write($tag, new RequestFrame(sprintf(
'UID FETCH %s (UID BODY.PEEK[%s])',
$target->sequenceSet()->toCommand(),
$section,
)));
$result = $this->reader->readUntilFetchLiteral($tag);
if ($result === null) {
return; // UID not found or empty FETCH result
}
yield from $this->reader->streamLiteral($result['literalLength'], $chunkSize);
$this->reader->readToEnd($tag);
return $command->handle(new ResponseStream(function () use ($tag, $context): Generator {
yield from $this->responsesUntilCompletion($tag, $context);
}), $context);
}
/**
@@ -102,7 +63,7 @@ final class CommandExecutor
));
}
private function processPerform(string $tag, SessionContext $context): Generator
private function responsesUntilCompletion(string $tag, SessionContext $context): Generator
{
while (true) {
$response = $this->reader->readResponse();
-31
View File
@@ -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()),
);
}
}
-11
View File
@@ -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] ?? ''),
];
}
}
+11 -86
View File
@@ -42,41 +42,23 @@ final class ProtocolReader
public function readResponse(): ResponseInterface
{
$raw = $this->connection->readLine();
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
$raw .= $this->connection->readBytes($literalLength);
$raw .= $this->connection->readLine();
}
$raw = $this->trimTrailingLineEnding($raw);
$raw = $this->readRawResponse();
if ($raw === '') {
throw new ImapException('Received empty IMAP response line.');
}
if (str_starts_with($raw, '* ')) {
// Keep the payload as a slice of the raw response. FETCH payloads can
// contain large message literals, so eagerly splitting here would keep
// a second full copy alive while the message is parsed.
$labelEnd = 2;
while (isset($raw[$labelEnd]) && !ctype_space($raw[$labelEnd])) {
$labelEnd++;
}
$label = strtoupper(substr($raw, 2, $labelEnd - 2));
$payloadOffset = $labelEnd;
while (isset($raw[$payloadOffset]) && ctype_space($raw[$payloadOffset])) {
$payloadOffset++;
}
$parts = preg_split('/\s+/', substr($raw, 2), 2) ?: [];
$label = strtoupper($parts[0] ?? '');
$this->logger?->debug('IMAP untagged response received: {raw}', [
'label' => $label,
'raw' => $raw,
]);
return new UntaggedResponse(
$label,
'',
$parts[1] ?? '',
$raw,
$payloadOffset,
);
}
@@ -103,73 +85,16 @@ final class ProtocolReader
return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw);
}
/**
* Read responses until an untagged FETCH response containing a literal marker is found,
* returning the literal byte count WITHOUT consuming the literal bytes from the socket.
* Returns null if the tagged OK/NO/BAD for $tag arrives before any literal is detected.
*
* ⚠️ After a non-null return the literal bytes MUST be consumed (via streamLiteral())
* before any further reads are made on this reader.
*
* @return array{literalLength: int, prefixLine: string}|null
*/
public function readUntilFetchLiteral(string $tag): ?array
private function readRawResponse(): string
{
while (true) {
$line = $this->connection->readLine();
$trimmed = $this->trimTrailingLineEnding($line);
$raw = $this->connection->readLine();
// Literal marker at the end of the line — stop before consuming the bytes
$literalLength = $this->trailingLiteralLength($line);
if ($literalLength !== null) {
return ['literalLength' => $literalLength, 'prefixLine' => $trimmed];
}
// Tagged completion for our command
if (str_starts_with($trimmed, $tag . ' ')) {
$parts = preg_split('/\s+/', $trimmed, 3) ?: [];
$status = strtoupper($parts[1] ?? '');
CompletionChecker::assertSuccess('FETCH', new TaggedResponse(
$tag,
$status,
$parts[2] ?? '',
$trimmed,
));
return null; // Tagged OK without ever finding a literal → UID not found
}
// Any other untagged response — discard and continue
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
$raw .= $this->connection->readBytes($literalLength);
$raw .= $this->connection->readLine();
}
}
public function readToEnd(string $tag): TaggedResponse
{
while (true) {
$response = $this->readResponse();
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
CompletionChecker::assertSuccess('FETCH', $response);
return $response;
}
}
}
/**
* Yield the literal bytes already waiting in the socket as chunks.
* After the generator is fully exhausted this method reads the one trailing
* line that closes the FETCH parenthesised response (e.g. ")\r\n").
*
* Contract: the caller MUST exhaust this generator before issuing any further
* reads on this reader.
*
* @return \Generator<string>
*/
public function streamLiteral(int $length, int $chunkSize = 8192): \Generator
{
yield from $this->connection->readBytesChunked($length, $chunkSize);
// Consume the closing portion of the FETCH parenthesised list (e.g. ")\r\n")
$this->connection->readLine();
return $this->trimTrailingLineEnding($raw);
}
private function trailingLiteralLength(string $raw): ?int
@@ -193,4 +118,4 @@ final class ProtocolReader
return $raw;
}
}
}
-15
View File
@@ -27,21 +27,6 @@ final class ProtocolWriter
$this->connection->write($wire);
}
/**
* Write raw bytes to the connection without tagging or logging the payload.
*
* Used to send literal data (e.g. an APPEND message body) after the server
* has issued a command continuation request.
*/
public function writeRaw(string $payload): void
{
$this->logger?->debug('IMAP literal sent (bytes={bytes})', [
'bytes' => strlen($payload),
]);
$this->connection->write($payload);
}
private function sanitizeWire(string $wire): string
{
$trimmed = rtrim($wire, "\r\n");
@@ -10,7 +10,6 @@ final class UntaggedResponse implements ResponseInterface
private readonly string $label,
private readonly string $payload,
private readonly string $raw,
private readonly ?int $payloadOffset = null,
) {}
public function label(): string
@@ -20,12 +19,6 @@ final class UntaggedResponse implements ResponseInterface
public function payload(): string
{
// ProtocolReader records an offset for potentially large responses and
// only materializes the payload for command parsers that actually need it.
if ($this->payloadOffset !== null) {
return substr($this->raw, $this->payloadOffset);
}
return $this->payload;
}
@@ -34,7 +27,7 @@ final class UntaggedResponse implements ResponseInterface
*/
public function payloadTokens(): array
{
$payload = trim($this->payload());
$payload = trim($this->payload);
if ($payload === '') {
return [];
@@ -47,4 +40,4 @@ final class UntaggedResponse implements ResponseInterface
{
return $this->raw;
}
}
}
+3 -27
View File
@@ -6,7 +6,6 @@ namespace KTXM\ProviderImap\Client\Protocol;
use Generator;
use IteratorAggregate;
use KTXM\ProviderImap\Client\ImapException;
use Traversable;
final class ResponseStream implements IteratorAggregate
@@ -14,39 +13,16 @@ final class ResponseStream implements IteratorAggregate
/** @var \Closure():Generator */
private readonly \Closure $generatorFactory;
/** @var (\Closure(string):void)|null */
private readonly ?\Closure $continuationWriter;
/**
* @param \Closure():Generator $generatorFactory
* @param (\Closure(string):void)|null $continuationWriter writes raw bytes
* back to the server in response to a command continuation request
* @param \Closure():Generator $generatorFactory
*/
public function __construct(\Closure $generatorFactory, ?\Closure $continuationWriter = null)
public function __construct(\Closure $generatorFactory)
{
$this->generatorFactory = $generatorFactory;
$this->continuationWriter = $continuationWriter;
}
public function getIterator(): Traversable
{
return ($this->generatorFactory)();
}
/**
* Send raw bytes to the server after a command continuation request (a "+"
* response) — e.g. the literal payload of an APPEND.
*
* Must only be called while iterating this stream, in response to a
* {@see \KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse};
* the next pulled response then reflects the server's reaction to the data.
*/
public function respond(string $payload): void
{
if ($this->continuationWriter === null) {
throw new ImapException('This response stream does not support continuation replies.');
}
($this->continuationWriter)($payload);
}
}
}
-13
View File
@@ -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, "\\\"") . '"';
}
}
@@ -2,10 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\Protocol\StringEncoder;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
namespace KTXM\ProviderImap\Client;
use DateTimeInterface;
use InvalidArgumentException;
@@ -191,8 +188,8 @@ final class SearchCriteriaBuilder
{
return $this->pushExpression(sprintf(
'HEADER %s %s',
StringEncoder::quote($name),
StringEncoder::quote($value),
$this->formatString($name),
$this->formatString($value),
));
}
@@ -288,7 +285,7 @@ final class SearchCriteriaBuilder
return $this->pushExpression(sprintf(
'%s %s',
$key,
$quote ? StringEncoder::quote($value) : $value,
$quote ? $this->formatString($value) : $value,
));
}
@@ -372,4 +369,9 @@ final class SearchCriteriaBuilder
default => SequenceSet::parse($value)->toCommand(),
};
}
private function formatString(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
namespace KTXM\ProviderImap\Client;
use InvalidArgumentException;
@@ -2,9 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use KTXM\ProviderImap\Client\ConnectionConfig;
namespace KTXM\ProviderImap\Client;
use KTXM\ProviderImap\Client\Protocol\Response\GreetingResponse;
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
namespace KTXM\ProviderImap\Client;
enum SessionState: string
{
@@ -20,13 +20,5 @@ interface ConnectionInterface
public function readBytes(int $length): string;
/**
* Yield the literal payload in chunks without buffering the full content.
* Reads exactly $length bytes from the socket, never crossing the literal boundary.
*
* @return \Generator<string>
*/
public function readBytesChunked(int $length, int $chunkSize = 8192): \Generator;
public function upgradeToTls(): void;
}
-20
View File
@@ -135,26 +135,6 @@ final class SocketConnection implements ConnectionInterface
return $buffer;
}
public function readBytesChunked(int $length, int $chunkSize = 8192): \Generator
{
if ($length < 0) {
throw new ImapException('IMAP socket cannot read a negative number of bytes.');
}
$remaining = $length;
while ($remaining > 0) {
$chunk = fread($this->stream(), min($chunkSize, $remaining));
if ($chunk === false || $chunk === '') {
throw new ImapException('Failed to read literal payload from IMAP socket.');
}
$remaining -= strlen($chunk);
yield $chunk;
}
}
public function upgradeToTls(): void
{
$stream = $this->stream();
+11 -10
View File
@@ -10,10 +10,10 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\LiveService;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXC\Context\TenantContext;
use KTXC\SessionTenant;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -41,7 +41,7 @@ class ConnectCommand extends Command
{
public function __construct(
private readonly Provider $provider,
private readonly TenantContext $tenantContext,
private readonly SessionTenant $sessionTenant,
) {
parent::__construct();
}
@@ -150,11 +150,12 @@ class ConnectCommand extends Command
// ── Build service object ────────────────────────────────────────────
$location = new ServiceLocation(
inboundHost: $host,
inboundPort: $port > 0 ? $port : 993,
inboundEncryption: $encryption,
inboundVerifyPeer: !$noVerify,
inboundVerifyHost: !$noVerify,
host: $host,
port: $port > 0 ? $port : 993,
encryption: $encryption,
verifyPeer: !$noVerify,
verifyPeerName: !$noVerify,
allowSelfSigned: $noVerify,
);
$identity = (new ServiceIdentityBasic())->jsonDeserialize([
@@ -162,7 +163,7 @@ class ConnectCommand extends Command
'secret' => $password,
]);
$service = new LiveService();
$service = new Service();
$service->setLocation($location);
$service->setIdentity($identity);
@@ -186,7 +187,7 @@ class ConnectCommand extends Command
// ── Persist ──────────────────────────────────────────────────────────
$this->tenantContext->resolveIdentifier($tenantId);
$this->sessionTenant->configureById($tenantId);
$label = $io->ask('Service label', $username);
if ($label) {
+7 -7
View File
@@ -10,8 +10,8 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXC\Context\TenantContext;
use KTXM\ProviderImap\Providers\Service;
use KTXC\SessionTenant;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -37,7 +37,7 @@ class DisconnectCommand extends Command
{
public function __construct(
private readonly Provider $provider,
private readonly TenantContext $tenantContext,
private readonly SessionTenant $sessionTenant,
) {
parent::__construct();
}
@@ -86,7 +86,7 @@ class DisconnectCommand extends Command
$userId = (string) ($input->getOption('user') ?? '');
if ($tenantId !== '' && $userId !== '') {
$this->tenantContext->resolveIdentifier($tenantId);
$this->sessionTenant->configureById($tenantId);
$services = $this->provider->serviceList($tenantId, $userId);
if (empty($services)) {
@@ -96,7 +96,7 @@ class DisconnectCommand extends Command
$choices = [];
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}]";
}
@@ -130,7 +130,7 @@ class DisconnectCommand extends Command
}
// ── Fetch service for display ────────────────────────────────────────
$this->tenantContext->resolveIdentifier($tenantId);
$this->sessionTenant->configureById($tenantId);
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
if ($service === null) {
@@ -139,7 +139,7 @@ class DisconnectCommand extends Command
}
$label = $service->getLabel() ?? $serviceId;
$host = $service->getLocation()?->getInboundHost() ?? 'unknown';
$host = $service->getLocation()?->getHost() ?? 'unknown';
$io->title('Disconnect IMAP Service');
$io->definitionList(
+10 -9
View File
@@ -10,11 +10,12 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\LiveService;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXM\ProviderImap\Service\Discovery;
use KTXC\Context\TenantContext;
use KTXM\ProviderImap\Service\Remote\RemoteService;
use KTXC\SessionTenant;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -43,7 +44,7 @@ class DiscoverCommand extends Command
public function __construct(
private readonly Provider $provider,
private readonly Discovery $discovery,
private readonly TenantContext $tenantContext,
private readonly SessionTenant $sessionTenant,
) {
parent::__construct();
}
@@ -150,7 +151,7 @@ class DiscoverCommand extends Command
/** @var array<string, ServiceLocation> $choiceMap */
$choiceMap = [];
foreach ($candidates as $c) {
$label = sprintf('%s : %d [%s]', $c->getInboundHost(), $c->getInboundPort(), $encLabel($c->getInboundEncryption()));
$label = sprintf('%s : %d [%s]', $c->getHost(), $c->getPort(), $encLabel($c->getEncryption()));
$choiceMap[$label] = $c;
}
@@ -169,9 +170,9 @@ class DiscoverCommand extends Command
$io->success('Server selected:');
$io->definitionList(
['Host' => $location->getInboundHost()],
['Port' => (string) $location->getInboundPort()],
['Encryption' => $encLabel($location->getInboundEncryption())],
['Host' => $location->getHost()],
['Port' => (string) $location->getPort()],
['Encryption' => $encLabel($location->getEncryption())],
);
if (!$save) {
@@ -191,7 +192,7 @@ class DiscoverCommand extends Command
// ── Test before saving ───────────────────────────────────────────────
$service = new LiveService();
$service = new Service();
$service->setLocation($location);
$service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([
'identity' => $username,
@@ -210,7 +211,7 @@ class DiscoverCommand extends Command
// ── Persist ──────────────────────────────────────────────────────────
$this->tenantContext->resolveIdentifier($tenantId);
$this->sessionTenant->configureById($tenantId);
$label = $io->ask('Service label', $address);
if ($label) {
-186
View File
@@ -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);
}
}
+18 -16
View File
@@ -9,15 +9,15 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXC\Context\TenantContext;
use KTXM\ProviderImap\Client\Protocol\Command\SelectCommand;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXC\SessionTenant;
use KTXM\ProviderImap\Client\Command\SelectCommand;
use KTXM\ProviderImap\Client\FetchOptions;
use KTXM\ProviderImap\Client\FetchTarget;
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\ServiceBase;
use KTXM\ProviderImap\Service\Live\LiveMailService;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Service\Remote\RemoteService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -41,7 +41,7 @@ class TestCommand extends Command
{
public function __construct(
private readonly Provider $provider,
private readonly TenantContext $tenantContext,
private readonly SessionTenant $sessionTenant,
) {
parent::__construct();
}
@@ -108,7 +108,7 @@ class TestCommand extends Command
return Command::FAILURE;
}
$this->tenantContext->resolveIdentifier($tenantId);
$this->sessionTenant->configureById($tenantId);
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
if ($service === null) {
@@ -119,7 +119,8 @@ class TestCommand extends Command
$startedAt = microtime(true);
try {
$mailService = new LiveMailService($service);
$client = RemoteService::freshClient($service);
$mailService = RemoteService::mailService($service, $client);
$mailboxes = $mailService->collectionList();
} catch (\Throwable $e) {
$io->error('IMAP diagnostic failed: ' . $e->getMessage());
@@ -183,8 +184,9 @@ class TestCommand extends Command
$io->section(sprintf('Recent Messages: %s', $mailboxName));
try {
$mailboxService = new LiveMailService($service);
$selectedMailbox = $mailboxService->imapClient()->perform(new SelectCommand($mailboxName, true));
$mailboxClient = RemoteService::freshClient($service);
$mailboxService = RemoteService::mailService($service, $mailboxClient);
$selectedMailbox = $mailboxClient->perform(new SelectCommand($mailboxName, true));
} catch (\Throwable $e) {
$io->error('Mailbox inspection failed: ' . $e->getMessage());
return Command::FAILURE;
@@ -209,7 +211,7 @@ class TestCommand extends Command
try {
foreach ($mailboxService->messageList(
$mailboxName,
MessageTarget::sequence(SequenceSet::range($startSequence, $totalMessages)),
FetchTarget::sequence(SequenceSet::range($startSequence, $totalMessages)),
FetchOptions::message(),
) as $message) {
$messageRows[] = [
@@ -269,7 +271,7 @@ class TestCommand extends Command
return;
}
$this->tenantContext->resolveIdentifier($tenantId);
$this->sessionTenant->configureById($tenantId);
$services = $this->provider->serviceList($tenantId, $userId);
if ($services === []) {
@@ -299,14 +301,14 @@ class TestCommand extends Command
return $limit >= 0 ? $limit : $default;
}
private function formatTarget(ServiceBase $service): string
private function formatTarget(Service $service): string
{
$location = $service->getLocation();
if ($location === null) {
return 'unknown';
}
return sprintf('%s://%s:%d', $location->getInboundEncryption(), $location->getInboundHost(), $location->getInboundPort());
return sprintf('%s://%s:%d', $location->getEncryption(), $location->getHost(), $location->getPort());
}
private function formatSender(Message $message): string
-27
View File
@@ -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());
}
}
-271
View File
@@ -1,271 +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\Mime;
use DateTimeImmutable;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePartInterface;
use KTXF\Mail\Object\MessagePropertiesBaseInterface;
use Symfony\Component\Mime\Address as MimeAddress;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Part\DataPart;
/**
* MIME Message Builder
*
* Converts a framework {@see MessagePropertiesBaseInterface} into raw RFC822
* bytes. The output is transport-neutral and used by both of this module's
* outbound paths: the SMTP DATA payload (submission) and the IMAP APPEND
* literal (saving a copy to the Sent collection).
*
* The MIME engine (symfony/mime) is intentionally hidden behind this API so it
* can be swapped without touching consumers.
*
* @since 2026.06.22
*/
final class MessageBuilder
{
/**
* Headers that are derived from structured properties and therefore must
* not be copied verbatim from {@see MessagePropertiesBaseInterface::getHeaders()}.
*
* @var list<string>
*/
private const RESERVED_HEADERS = [
'from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'subject', 'date',
'message-id', 'mime-version', 'content-type', 'content-transfer-encoding',
'in-reply-to', 'references', 'bcc',
];
/**
* Build the complete RFC822 message bytes for the given properties.
*/
public function build(MessagePropertiesBaseInterface $message): string
{
return $this->toEmail($message)->toString();
}
/**
* Collect the envelope recipients (To + Cc + Bcc) as bare e-mail addresses.
*
* Useful for the SMTP RCPT TO phase, which needs every recipient including
* Bcc, even though Bcc never appears in the rendered headers.
*
* @return list<string>
*/
public static function recipients(MessagePropertiesBaseInterface $message): array
{
$recipients = [];
foreach ([...$message->getTo(), ...$message->getCc(), ...$message->getBcc()] as $address) {
$email = trim($address->getAddress());
if ($email !== '' && !in_array($email, $recipients, true)) {
$recipients[] = $email;
}
}
return $recipients;
}
private function toEmail(MessagePropertiesBaseInterface $message): Email
{
$email = new Email();
if (($from = $message->getFrom()) !== null) {
$email->from($this->address($from));
}
if (($sender = $message->getSender()) !== null) {
$email->sender($this->address($sender));
}
$replyTo = $this->addresses($message->getReplyTo());
if ($replyTo !== []) {
$email->replyTo(...$replyTo);
}
$to = $this->addresses($message->getTo());
if ($to !== []) {
$email->to(...$to);
}
$cc = $this->addresses($message->getCc());
if ($cc !== []) {
$email->cc(...$cc);
}
$bcc = $this->addresses($message->getBcc());
if ($bcc !== []) {
$email->bcc(...$bcc);
}
$email->subject($message->getSubject());
if (($sent = $message->getSent()) !== null) {
$email->date($sent instanceof DateTimeImmutable ? $sent : DateTimeImmutable::createFromInterface($sent));
}
$text = $message->getBodyTextPlain();
$html = $message->getBodyTextHtml();
// Inline attachments may carry Content-IDs without an "@"; those must be
// normalised (symfony requires an "@") and the matching cid: references
// in the HTML body rewritten so the parts still resolve.
$cidRewrites = $this->inlineCidRewrites($message);
if ($text !== null && $text !== '') {
$email->text($text);
}
if ($html !== null && $html !== '') {
$email->html($this->rewriteCids($html, $cidRewrites));
}
// Guarantee at least an (empty) text part so the message is well-formed.
if (($text === null || $text === '') && ($html === null || $html === '')) {
$email->text('');
}
foreach ($message->getAttachments() as $attachment) {
$part = $this->attachmentPart($attachment);
if ($part !== null) {
$email->addPart($part);
}
}
$this->applyThreadHeaders($email, $message);
$this->applyCustomHeaders($email, $message);
return $email;
}
private function attachmentPart(MessagePartInterface $attachment): ?DataPart
{
$content = $attachment->getContent();
if ($content === null) {
return null;
}
$part = new DataPart(
$content,
$attachment->getName(),
$attachment->getType() ?? 'application/octet-stream',
);
if ($attachment->getDisposition() === 'inline') {
$part->asInline();
$cid = $attachment->getContentId();
if ($cid !== null && $cid !== '') {
$part->setContentId($this->normalizeContentId(trim($cid, '<>')));
}
}
return $part;
}
/**
* Build a map of original => normalised Content-IDs for inline parts whose
* id required normalisation (i.e. lacked an "@").
*
* @return array<string,string>
*/
private function inlineCidRewrites(MessagePropertiesBaseInterface $message): array
{
$rewrites = [];
foreach ($message->getAttachments() as $attachment) {
if ($attachment->getDisposition() !== 'inline') {
continue;
}
$cid = $attachment->getContentId();
if ($cid === null || $cid === '') {
continue;
}
$original = trim($cid, '<>');
$normalized = $this->normalizeContentId($original);
if ($original !== $normalized) {
$rewrites[$original] = $normalized;
}
}
return $rewrites;
}
/**
* @param array<string,string> $rewrites
*/
private function rewriteCids(string $html, array $rewrites): string
{
foreach ($rewrites as $original => $normalized) {
$html = str_replace('cid:' . $original, 'cid:' . $normalized, $html);
}
return $html;
}
private function normalizeContentId(string $cid): string
{
return str_contains($cid, '@') ? $cid : $cid . '@ktrix';
}
private function applyThreadHeaders(Email $email, MessagePropertiesBaseInterface $message): void
{
$headers = $email->getHeaders();
$inReplyTo = $message->getInReplyTo();
if ($inReplyTo !== null && $inReplyTo !== '') {
$headers->addIdHeader('In-Reply-To', $this->stripBrackets($inReplyTo));
}
$references = array_values(array_filter(array_map(
fn (string $id): string => $this->stripBrackets($id),
$message->getReferences(),
), static fn (string $id): bool => $id !== ''));
if ($references !== []) {
$headers->addIdHeader('References', $references);
}
}
private function applyCustomHeaders(Email $email, MessagePropertiesBaseInterface $message): void
{
$headers = $email->getHeaders();
foreach ($message->getHeaders() as $name => $value) {
if (!is_string($name) || $value === null) {
continue;
}
if (in_array(strtolower($name), self::RESERVED_HEADERS, true)) {
continue;
}
$headers->addTextHeader($name, is_array($value) ? implode(', ', $value) : (string) $value);
}
}
/**
* @param array<int,AddressInterface> $addresses
* @return list<MimeAddress>
*/
private function addresses(array $addresses): array
{
$result = [];
foreach ($addresses as $address) {
if ($address instanceof AddressInterface && trim($address->getAddress()) !== '') {
$result[] = $this->address($address);
}
}
return $result;
}
private function address(AddressInterface $address): MimeAddress
{
return new MimeAddress($address->getAddress(), $address->getLabel() ?? '');
}
private function stripBrackets(string $id): string
{
return trim($id, " \t<>");
}
}
+20 -51
View File
@@ -10,37 +10,25 @@ declare(strict_types=1);
namespace KTXM\ProviderImap;
use KTXC\Resource\ProviderManager;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
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\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract;
use KTXF\Resource\Provider\ProviderInterface;
use KTXM\ProviderImap\Console\ConnectCommand;
use KTXM\ProviderImap\Console\DiscoverCommand;
use KTXM\ProviderImap\Console\DisconnectCommand;
use KTXM\ProviderImap\Console\HarmonizeCommand;
use KTXM\ProviderImap\Console\TestCommand;
use KTXM\ProviderImap\Listeners\UserEventListener;
use KTXM\ProviderImap\Providers\Provider as MailProvider;
use KTXM\ProviderImap\Stores\MailboxStore;
use KTXM\ProviderImap\Stores\MessageStore;
/**
* IMAP Mail Provider Module
*
* Registers the IMAP mail provider with the Ktrix provider manager.
*/
class Module extends ModuleInstanceAbstract
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{
public function __construct(
private readonly ProviderManager $providerManager,
private readonly EventListenerRegistrarInterface $events,
private readonly MailboxStore $mailboxStore,
private readonly MessageStore $messageStore,
) {}
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
{
// 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);
}
public function configure(ModuleContextInterface $context): void
public function registerCI(): array
{
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'ProviderImap', 'static/module.mjs');
}
if ($context instanceof ConsoleModuleContextInterface) {
foreach ([
DiscoverCommand::class,
ConnectCommand::class,
DisconnectCommand::class,
TestCommand::class,
HarmonizeCommand::class,
] as $command) {
$context->registerCommand($command);
}
}
return [
DiscoverCommand::class,
ConnectCommand::class,
DisconnectCommand::class,
TestCommand::class,
];
}
/**
* Create the cache collection indexes (idempotent).
*/
private function ensureIndexes(): void
public function registerBI(): array
{
$this->mailboxStore->ensureIndexes();
$this->messageStore->ensureIndexes();
return [
'handle' => $this->handle(),
'namespace' => 'ProviderImap',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}
-490
View File
@@ -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());
}
}
+5 -5
View File
@@ -16,8 +16,8 @@ use KTXF\Mail\Collection\CollectionRoles;
/**
* IMAP Mail Collection Properties
*
* Backed by the same internal $data shape as the JMAP provider so that
* cache documents are interchangeable with fromCacheMeta() / toCacheMeta().
* Backed by the same internal $data shape as the JMAP provider so that cache
* documents are interchangeable with fromStore() / toStore().
*/
class CollectionProperties extends CollectionPropertiesMutableAbstract
{
@@ -50,14 +50,14 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract
return $this;
}
// ── Cache (meta store) ───────────────────────────────────────────────────
// ── Store (MongoDB cache) ────────────────────────────────────────────────
public function toCacheMeta(): array
public function toStore(): array
{
return $this->data;
}
public function fromCacheMeta(array $data): static
public function fromStore(array $data): static
{
$this->data = $data;
return $this;
+14 -23
View File
@@ -54,38 +54,29 @@ class CollectionResource extends CollectionMutableAbstract
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 [
'name' => (string) $this->data[static::PROPERTY_IDENTIFIER],
'parent' => $this->data[static::PROPERTY_COLLECTION] ?? null,
'signature' => $this->data[static::PROPERTY_SIGNATURE] ?? null,
'properties' => $this->getProperties()->toCacheMeta(),
];
return array_merge(
$this->data,
[
'name' => $this->data['identifier'],
'properties' => $this->getProperties()->toStore(),
],
);
}
/**
* 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
public function fromStore(array $data): static
{
$this->data[static::PROPERTY_IDENTIFIER] = (string) $data['name'];
$this->data[static::PROPERTY_COLLECTION] = $data['parent'] ?? null;
if (isset($data['signature'])) {
$this->data[static::PROPERTY_SIGNATURE] = $data['signature'];
}
$this->data = $data;
if (isset($data['properties'])) {
$this->getProperties()->fromCacheMeta((array) $data['properties']);
$this->getProperties()->fromStore($data['properties']);
}
return $this;
}
-90
View File
@@ -11,16 +11,12 @@ namespace KTXM\ProviderImap\Providers;
use KTXM\ProviderImap\Client\Message;
use KTXF\Mail\Entity\EntityMutableAbstract;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
/**
* Mail Entity Resource Implementation
*/
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(
string $provider = 'imap',
string|int|null $service = null,
@@ -46,92 +42,6 @@ class EntityResource extends EntityMutableAbstract {
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
*/
-617
View File
@@ -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);
}
}
}
+2 -14
View File
@@ -9,14 +9,12 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Providers;
use KTXF\Mail\Object\MessagePartInterface;
/**
* Mail Attachment Object
*
* @since 1.0.0
*/
class MessageAttachment implements MessagePartInterface {
class MessageAttachment implements \KTXF\Mail\Object\MessagePartInterface {
protected MessagePart $_meta;
protected ?string $_contents = null;
@@ -121,20 +119,10 @@ class MessageAttachment implements MessagePartInterface {
public function getBlobId(): ?string { return $this->_meta->getBlobId(); }
public function getId(): ?string { return $this->_meta->getId(); }
public function getSize(): ?int { return $this->_meta->getSize(); }
public function getDisposition(): ?string { return $this->_meta->getDisposition(); }
public function getContentId(): ?string { return $this->_meta->getContentId(); }
public function getCharset(): ?string { return $this->_meta->getCharset(); }
public function getLanguage(): ?string { return $this->_meta->getLanguage(); }
public function getLocation(): ?string { return $this->_meta->getLocation(); }
public function getContent(): ?string { return $this->_contents; }
public function getParts(): array { return $this->_meta->getParts(); }
public function jsonSerialize(): array {
$data = $this->_meta->jsonSerialize();
if ($this->_contents !== null) {
$data['content'] = $this->_contents;
}
return $data;
}
public function jsonSerialize(): array { return $this->_meta->jsonSerialize(); }
}
+151 -18
View File
@@ -9,8 +9,10 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Providers;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart;
use KTXF\Mail\Object\MessagePartMutableAbstract;
use KTXM\ProviderImap\Client\MessagePart as ImapMessagePart;
/**
* Mail Message Part Implementation
@@ -18,35 +20,166 @@ use KTXM\ProviderImap\Client\MessagePart as ImapMessagePart;
class MessagePart extends MessagePartMutableAbstract {
/**
* @param array<string,mixed> $data
* Convert gricob BodyStructure part to message part object
*
* @param Part $part gricob BodyStructure Part (SinglePart or MultiPart)
* @param string $partId numeric part identifier (e.g. "1", "1.1", "2")
*/
private function hydrateArray(array $data): static {
$this->data = $data;
public function fromImap(Part $part, string $partId = '1'): static {
if (isset($this->data['subParts']) && is_array($this->data['subParts'])) {
foreach ($this->data['subParts'] as $entry) {
if (is_array($entry)) {
$this->parts[] = (new self())->hydrateArray($entry);
$this->data['partId'] = $partId;
if ($part instanceof SinglePart) {
$mimeType = strtolower($part->type) . '/' . strtolower($part->subtype);
$this->data['type'] = $mimeType;
if ($part->id !== null) {
$this->data['blobId'] = trim($part->id, '<>');
}
// Content-Type parameters (name, charset, etc.)
if (!empty($part->attributes)) {
foreach ($part->attributes as $key => $value) {
$keyLower = strtolower($key);
if ($keyLower === 'name') {
$this->data['name'] = $value;
} elseif ($keyLower === 'charset') {
$this->data['charset'] = $value;
}
}
}
unset($this->data['subParts']);
if ($part->encoding !== null) {
$this->data['encoding'] = strtolower($part->encoding);
}
if ($part->size !== null) {
$this->data['size'] = $part->size;
}
if ($part->disposition !== null) {
$this->data['disposition'] = strtolower($part->disposition->type);
// disposition filename attribute
if (!empty($part->disposition->attributes)) {
foreach ($part->disposition->attributes as $key => $value) {
if (strtolower($key) === 'filename') {
$this->data['name'] = $this->data['name'] ?? $value;
}
}
}
}
if (!empty($part->language)) {
$this->data['language'] = implode(',', $part->language);
}
if ($part->location !== null) {
$this->data['location'] = $part->location;
}
} elseif ($part instanceof MultiPart) {
$this->data['type'] = 'multipart/' . strtolower($part->subtype);
if ($part->disposition !== null) {
$this->data['disposition'] = strtolower($part->disposition->type);
}
if (!empty($part->language)) {
$this->data['language'] = implode(',', $part->language);
}
if ($part->location !== null) {
$this->data['location'] = $part->location;
}
// Recursively process sub-parts
// When this part has no section ID (root multipart) children are
// numbered "1", "2", … to match IMAP section numbering.
foreach ($part->parts as $index => $subPart) {
$subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1);
$this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId);
}
}
return $this;
}
/**
* Convert gricob BodyStructure part to message part object
*
* @param Part $part gricob BodyStructure Part (SinglePart or MultiPart)
* @param string $partId numeric part identifier (e.g. "1", "1.1", "2")
* Convert message part to store array
*/
public function fromImap(ImapMessagePart $part, string $partId = '1'): static {
$data = $part->toArray();
$data['partId'] = $partId;
$data['blobId'] = $data['blobId'] ?? $partId;
public function toStore(): array {
$data = $this->data;
if (count($this->parts) > 0) {
$data['subParts'] = [];
foreach ($this->parts as $subPart) {
if ($subPart instanceof MessagePart) {
$data['subParts'][] = $subPart->toStore();
}
}
} else {
$data['subParts'] = null;
}
return $data;
}
return $this->hydrateArray($data);
/**
* Hydrate message part from store array
*/
public function fromStore(array $data): static {
if (isset($data['subParts']) && is_array($data['subParts'])) {
foreach ($data['subParts'] as $subPart) {
$this->parts[] = (new MessagePart())->fromStore($subPart);
}
unset($data['subParts']);
}
$this->data = $data;
return $this;
}
/**
* Inject decoded body content from a map of IMAP section-ID → raw encoded text.
*
* Walks the MessagePart tree recursively. For each text/* leaf part whose
* partId is present in $sectionMap the raw text is decoded according to the
* part's Content-Transfer-Encoding and converted to UTF-8 before being
* stored in 'content'. Binary parts (images, PDFs, …) are skipped.
*
* @param array<string,string> $sectionMap Keys: IMAP section IDs (e.g. "1", "1.2");
* Values: raw (transfer-encoded) body text
*/
public function injectSections(array $sectionMap): void
{
// MultiPart: recurse into children
if (!empty($this->parts)) {
foreach ($this->parts as $childPart) {
if ($childPart instanceof MessagePart) {
$childPart->injectSections($sectionMap);
}
}
return;
}
// SinglePart: only inject decoded content for text/* MIME types
$type = strtolower($this->data['type'] ?? '');
if (!str_starts_with($type, 'text/')) {
return;
}
$partId = $this->data['partId'] ?? null;
if ($partId === null || !array_key_exists($partId, $sectionMap)) {
return;
}
$raw = $sectionMap[$partId];
$encoding = strtolower($this->data['encoding'] ?? '7bit');
$decoded = match ($encoding) {
'quoted-printable' => quoted_printable_decode($raw),
'base64' => base64_decode($raw, strict: false),
default => $raw, // 7bit, 8bit, binary
};
$charset = $this->data['charset'] ?? 'us-ascii';
$this->data['content'] = MessageProperties::toUtf8($decoded, $charset);
}
}
+43 -189
View File
@@ -20,20 +20,12 @@ use KTXF\Mail\Object\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.
*/
public function fromImap(Message $message): static
{
$this->data[static::PROPERTY_SIZE] = $message->size();
$this->incompleteSections = $message->truncatedSections();
if ($message->messageId() !== null) {
$this->data[static::PROPERTY_URID] = $message->messageId();
@@ -43,9 +35,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
$this->data[static::PROPERTY_IN_REPLY_TO] = $message->inReplyTo();
}
if ($message->references() !== []) {
$this->data[static::PROPERTY_REFERENCES] = $message->references();
}
//if ($message->references() !== []) {
// $this->data[static::PROPERTY_REFERENCES] = $message->references();
//}
$receivedAt = $message->receivedAt() ?? $message->internalDate();
if ($receivedAt !== null) {
@@ -90,197 +82,59 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
}
if ($message->bodyStructure() !== null) {
$body = $message->bodyStructure()->withInjectedSections($message->bodySections() ?? [])->toArray();
$this->data[static::PROPERTY_BODY] = $body;
$this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray();
$attachments = [];
$this->collectAttachments($body, $attachments);
$this->collectAttachments($message->bodyStructure(), $attachments);
if ($attachments !== []) {
$this->data[static::PROPERTY_ATTACHMENTS] = $attachments;
}
}
$this->data[static::PROPERTY_FLAGS] = array_fill_keys(self::normalizeFlags($message->flags()), true);
if ($message->bodyStructure() !== null) {
$this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray();
// Recursively add content from bodyValues to matching parts
if (is_array($message->bodySections())) {
$addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) {
// If this part has a partId and matching bodyValue, add content
if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) {
$structure['content'] = $bodyValues[$structure['partId']] ?? null;
}
// Recursively process subParts
if (isset($structure['subParts']) && is_array($structure['subParts'])) {
foreach ($structure['subParts'] as &$subPart) {
$addContentToParts($subPart, $bodyValues);
}
}
};
$addContentToParts($this->data[static::PROPERTY_BODY], $message->bodySections());
}
}
return $this;
}
/**
* Normalise IMAP flags (e.g. "\\Seen", "$Forwarded") to flag names (e.g. "seen", "$forwarded").
*
* @param string[] $flags
* @return list<string>
*/
public static function normalizeFlags(array $flags): array
{
$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;
$this->data[static::PROPERTY_FLAGS] = [];
foreach ($message->flags() as $flag) {
$flag = ltrim($flag, '\\');
$normalized = match (strtolower($flag)) {
'seen' => 'read',
'flagged' => 'flagged',
'answered' => 'answered',
'draft' => 'draft',
'deleted' => 'deleted',
default => strtolower($flag),
};
$this->data[static::PROPERTY_FLAGS][$normalized] = true;
}
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
*/
private function collectAttachments(array $part, array &$attachments): void
private function collectAttachments(ClientMessagePart $part, array &$attachments): void
{
$children = $part['subParts'] ?? [];
$children = $part->parts();
if ($children !== []) {
foreach ($children as $childPart) {
$this->collectAttachments($childPart, $attachments);
@@ -288,9 +142,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
return;
}
$mimeType = strtolower((string)($part['type'] ?? ''));
$disposition = strtolower((string)($part['disposition'] ?? ''));
$name = $part['name'] ?? null;
$mimeType = strtolower($part->mimeType());
$disposition = strtolower($part->disposition() ?? '');
$name = $part->parameters()['name'] ?? $part->dispositionParameters()['filename'] ?? null;
$isInlineText = str_starts_with($mimeType, 'text/')
&& in_array($mimeType, ['text/plain', 'text/html'], true)
&& $disposition !== 'attachment';
@@ -299,7 +153,7 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
return;
}
$attachments[] = $part;
$attachments[] = $part->toArray();
}
}
+46 -17
View File
@@ -18,7 +18,7 @@ use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
use KTXM\ProviderImap\Service\Discovery;
use KTXM\ProviderImap\Service\Live\LiveMailService;
use KTXM\ProviderImap\Service\Remote\RemoteService;
use KTXM\ProviderImap\Stores\ServiceStore;
/**
@@ -27,6 +27,7 @@ use KTXM\ProviderImap\Stores\ServiceStore;
class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface
{
public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE;
protected const PROVIDER_IDENTIFIER = 'imap';
protected const PROVIDER_LABEL = 'IMAP Mail Provider';
protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol';
@@ -49,10 +50,10 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
public function jsonSerialize(): array
{
return [
self::PROPERTY_TYPE => self::JSON_TYPE,
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
self::PROPERTY_LABEL => self::PROVIDER_LABEL,
self::PROPERTY_CAPABILITIES => $this->providerAbilities,
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL,
self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities,
];
}
@@ -106,7 +107,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
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);
if ($serviceData === null) {
@@ -116,9 +117,9 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
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);
foreach ($services as $service) {
if ($service->hasAddress($address)) {
@@ -133,14 +134,14 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
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
{
if (!($service instanceof ServiceBase)) {
if (!($service instanceof Service)) {
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
}
@@ -150,7 +151,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
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');
}
@@ -160,7 +161,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
{
if (!($service instanceof ServiceBase)) {
if (!($service instanceof Service)) {
return false;
}
@@ -185,15 +186,16 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
$startTime = microtime(true);
try {
if (!($service instanceof ServiceBase)) {
if (!($service instanceof 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)
$service->fromStore(['sid' => 'test']);
// Attempt to authenticate and list mailboxes as a connectivity check
$live = new LiveMailService($service);
$mailboxes = $live->collectionList();
$client = RemoteService::freshClient($service);
$service = RemoteService::mailService($service, $client);
$mailboxes = $service->collectionList();
$latency = (int) round((microtime(true) - $startTime) * 1000);
@@ -204,9 +206,36 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
. ' (Latency: ' . $latency . ' ms)',
];
} catch (\Throwable $e) {
$latency = (int) round((microtime(true) - $startTime) * 1000);
$location = ($service instanceof Service) ? $service->getLocation() : null;
$target = $location
? $location->getEncryption() . '://' . $location->getHost() . ':' . $location->getPort()
: 'unknown host';
// stream_socket_client errors are suppressed with @ in gricob — recover them
$phpError = error_get_last();
$detail = $e->getMessage() !== '' ? $e->getMessage() : ($phpError['message'] ?? '');
if ($detail === '' && $location !== null) {
$host = $location->getHost();
if ($host !== '' && gethostbyname($host) === $host) {
$detail = "hostname '{$host}' could not be resolved";
} else {
$detail = 'connection refused or timed out — check port and encryption settings';
}
} elseif ($detail === '') {
$detail = 'no details — check host, port, and encryption settings';
}
return [
'success' => false,
'message' => 'Test failed: ' . $e->getMessage(),
'message' => sprintf(
'Connection to %s failed (%s): %s',
$target,
(new \ReflectionClass($e))->getShortName(),
$detail,
),
];
}
}
+785
View File
@@ -0,0 +1,785 @@
<?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\CollectionMutableInterface;
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\ServiceConfigurableInterface;
use KTXF\Mail\Service\ServiceMutableInterface;
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\Service\Remote\RemoteMailService;
use KTXM\ProviderImap\Service\Remote\RemoteService;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXM\ProviderImap\Providers\EntityResource;
/**
* IMAP Mail Service
*/
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface
{
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 string $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_EXTANT => true,
self::CAPABILITY_COLLECTION_FETCH => 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_LIST_RANGE => ['tally' => ['absolute', 'relative']],
self::CAPABILITY_ENTITY_EXTANT => true,
self::CAPABILITY_ENTITY_FETCH => true,
self::CAPABILITY_ENTITY_CREATE => false,
self::CAPABILITY_ENTITY_MODIFY => false,
self::CAPABILITY_ENTITY_DELETE => true,
self::CAPABILITY_ENTITY_MOVE => true,
self::CAPABILITY_ENTITY_COPY => false,
];
private RemoteMailService $mailService;
public function __construct() {}
// ── Lazy initialisation ───────────────────────────────────────────────────
private function initialize(): void
{
if (!isset($this->mailService)) {
$wrapper = RemoteService::freshClient($this);
$this->mailService = RemoteService::mailService($this, $wrapper);
}
}
// ── Store (MongoDB persistence) ───────────────────────────────────────────
public function toStore(): array
{
return array_filter([
'tid' => $this->serviceTenantId,
'uid' => $this->serviceUserId,
'sid' => $this->serviceIdentifier,
'label' => $this->serviceLabel,
'enabled' => $this->serviceEnabled,
'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;
}
// ── JSON ──────────────────────────────────────────────────────────────────
public function jsonSerialize(): array
{
return array_filter([
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier,
self::JSON_PROPERTY_LABEL => $this->serviceLabel,
self::JSON_PROPERTY_ENABLED => $this->serviceEnabled,
self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities,
self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(),
self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
self::JSON_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::JSON_PROPERTY_LABEL])) {
$this->setLabel($data[self::JSON_PROPERTY_LABEL]);
}
if (isset($data[self::JSON_PROPERTY_ENABLED])) {
$this->setEnabled($data[self::JSON_PROPERTY_ENABLED]);
}
if (isset($data[self::JSON_PROPERTY_LOCATION])) {
$this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION]));
}
if (isset($data[self::JSON_PROPERTY_IDENTITY])) {
$this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY]));
}
if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) {
$this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]));
}
if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) {
$this->setSecondaryAddresses(array_map(
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
$data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]
));
}
if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) {
$this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]);
}
return $this;
}
// ── ServiceBaseInterface ──────────────────────────────────────────────────
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;
}
// ── ServiceMutableInterface ───────────────────────────────────────────────
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 new Address($this->primaryAddress);
}
public function setPrimaryAddress(AddressInterface $value): static
{
$this->primaryAddress = $value->getAddress();
return $this;
}
public function getSecondaryAddresses(): array
{
return $this->secondaryAddresses;
}
public function setSecondaryAddresses(array $addresses): static
{
$this->secondaryAddresses = $addresses;
return $this;
}
public function hasAddress(string $address): bool
{
$address = strtolower(trim($address));
if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) {
return true;
}
foreach ($this->secondaryAddresses as $secondary) {
$secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary;
if (strtolower($secondaryAddr) === $address) {
return true;
}
}
return false;
}
// ── ServiceConfigurableInterface ──────────────────────────────────────────
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;
}
// ── Collection operations ─────────────────────────────────────────────────
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
{
$this->initialize();
$list = [];
foreach ($this->mailService->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->mailService->collectionFetch($key);
$list[$key] = $result !== false;
}
return $list;
}
public function collectionFetch(string|int $identifier): ?CollectionBaseInterface
{
$this->initialize();
$mailbox = $this->mailService->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->mailService->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->mailService->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->mailService->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($target, new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget));
}
if ($deleteMode === 'soft' && $deleteTarget === null) {
$filter = $this->collectionListFilter();
$filter->condition('role', CollectionRoles::Trash->value);
$mailboxes = iterator_to_array($this->mailService->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($target, new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget)),
'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->delimiter() ?? '/';
$targetDelimiter = $targetMailbox->delimiter() ?? '/';
$targetPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end(explode($sourceDelimiter, $sourceMailbox->name()));
$mutatedMailbox = $this->mailService->collectionRename($sourceMailbox->name(), $targetPath);
$collection = $this->collectionFresh();
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
return $collection;
}
// ── Entity operations ─────────────────────────────────────────────────────
public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
{
return iterator_to_array($this->entityList((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 $identifier => $message) {
$resource = $this->entityFresh();
$resource->fromImap($message, $collection);
yield $identifier => $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 entityFetch(string|int $collection, string|int ...$identifiers): array
{
$this->initialize();
$uids = array_map('intval', $identifiers);
return $this->mailService->entityFetch((string) $collection, ...$uids);
}
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->mailService->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 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 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);
$mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null));
if (empty($mailboxes)) {
throw new \RuntimeException('No Trash collection configured or found for deletion');
}
$rootMailbox = reset($mailboxes);
if ($rootMailbox === false) {
throw new \RuntimeException('No Trash collection configured or found for deletion');
}
$deleteTargetNative = $rootMailbox->name();
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
} else {
$deleteTargetNative = $deleteTarget;
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
}
// 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 = $mutations[$uid] ?? null;
$list[(string)$sourceEntities[$uid]] = [
'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted',
'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null,
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null,
];
}
}
return $list;
}
public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array
{
throw new \RuntimeException('Entity patching 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);
$mutations = $this->mailService->entityPatch($targetCollection, $properties, ...$uids);
foreach ($uids as $uid) {
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
}
}
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;
}
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,
];
}
}
return $list;
}
public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array
{
throw new \RuntimeException('Entity copying is not supported in this service');
}
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;
}
}
-404
View File
@@ -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());
}
}
+56 -116
View File
@@ -11,29 +11,22 @@ namespace KTXM\ProviderImap\Providers;
use KTXM\ProviderImap\Client\ConnectionConfig;
use KTXM\ProviderImap\Client\ConnectionSecurity;
use KTXM\ProviderImap\Smtp\ConnectionConfig as SmtpConnectionConfig;
use KTXM\ProviderImap\Smtp\ConnectionSecurity as SmtpConnectionSecurity;
use KTXF\Resource\Provider\ResourceServiceLocationSocketSplit;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
/**
* IMAP/SMTP Service Location
* IMAP Service Location
*
* Split socket location: the inbound side describes the IMAP server, the
* outbound side describes the SMTP submission server.
* Connection details for an IMAP server (host / port / encryption).
*/
class ServiceLocation implements ResourceServiceLocationSocketSplit
class ServiceLocation implements ResourceServiceLocationInterface
{
public function __construct(
private string $inboundHost = '',
private int $inboundPort = 993,
private string $inboundEncryption = 'ssl', // none | ssl | tls | starttls
private bool $inboundVerifyPeer = true,
private bool $inboundVerifyHost = true,
private string $outboundHost = '',
private int $outboundPort = 587,
private string $outboundEncryption = 'starttls', // none | ssl | tls | starttls
private bool $outboundVerifyPeer = true,
private bool $outboundVerifyHost = true,
private string $host = '',
private int $port = 993,
private string $encryption = 'ssl', // ssl | tls | starttls | none
private bool $verifyPeer = true,
private bool $verifyPeerName = true,
private bool $allowSelfSigned = false,
) {}
// ── Serialisation ────────────────────────────────────────────────────────
@@ -50,19 +43,15 @@ class ServiceLocation implements ResourceServiceLocationSocketSplit
public function jsonSerialize(): array
{
return [
'type' => self::TYPE_SOCKET_SPLIT,
'inboundHost' => $this->inboundHost,
'inboundPort' => $this->inboundPort,
'inboundEncryption' => $this->inboundEncryption,
'inboundVerifyPeer' => $this->inboundVerifyPeer,
'inboundVerifyHost' => $this->inboundVerifyHost,
'outboundHost' => $this->outboundHost,
'outboundPort' => $this->outboundPort,
'outboundEncryption' => $this->outboundEncryption,
'outboundVerifyPeer' => $this->outboundVerifyPeer,
'outboundVerifyHost' => $this->outboundVerifyHost,
];
return array_filter([
'type' => self::TYPE_URI,
'host' => $this->host,
'port' => $this->port,
'encryption' => $this->encryption,
'verifyPeer' => $this->verifyPeer,
'verifyPeerName' => $this->verifyPeerName,
'allowSelfSigned' => $this->allowSelfSigned,
], fn($v) => $v !== null && $v !== '');
}
public function jsonDeserialize(array|string $data): static
@@ -71,119 +60,70 @@ class ServiceLocation implements ResourceServiceLocationSocketSplit
$data = json_decode($data, true);
}
$this->inboundHost = $data['inboundHost'] ?? '';
$this->inboundPort = (int) ($data['inboundPort'] ?? 993);
$this->inboundEncryption = $data['inboundEncryption'] ?? 'ssl';
$this->inboundVerifyPeer = $data['inboundVerifyPeer'] ?? true;
$this->inboundVerifyHost = $data['inboundVerifyHost'] ?? true;
$this->outboundHost = $data['outboundHost'] ?? '';
$this->outboundPort = (int) ($data['outboundPort'] ?? 587);
$this->outboundEncryption = $data['outboundEncryption'] ?? 'starttls';
$this->outboundVerifyPeer = $data['outboundVerifyPeer'] ?? true;
$this->outboundVerifyHost = $data['outboundVerifyHost'] ?? true;
$this->host = $data['host'] ?? '';
$this->port = (int)($data['port'] ?? 993);
$this->encryption = $data['encryption'] ?? 'ssl';
$this->verifyPeer = $data['verifyPeer'] ?? true;
$this->verifyPeerName = $data['verifyPeerName'] ?? true;
$this->allowSelfSigned = $data['allowSelfSigned'] ?? false;
return $this;
}
// ── ResourceServiceLocationSocketSplit ───────────────────────────────────
// ── ResourceServiceLocationInterface ─────────────────────────────────────
public function type(): string
{
return self::TYPE_SOCKET_SPLIT;
return self::TYPE_URI;
}
public function locationInbound(): string
public function location(): string
{
return $this->inboundEncryption . '://' . $this->inboundHost . ':' . $this->inboundPort;
return $this->encryption . '://' . $this->host . ':' . $this->port;
}
public function locationOutbound(): string
{
return $this->outboundEncryption . '://' . $this->outboundHost . ':' . $this->outboundPort;
}
// ── Accessors ────────────────────────────────────────────────────────────
public function getInboundHost(): string { return $this->inboundHost; }
public function setInboundHost(string $value): void { $this->inboundHost = $value; }
public function getHost(): string { return $this->host; }
public function setHost(string $v): void { $this->host = $v; }
public function getOutboundHost(): string { return $this->outboundHost; }
public function setOutboundHost(string $value): void { $this->outboundHost = $value; }
public function getPort(): int { return $this->port; }
public function setPort(int $v): void { $this->port = $v; }
public function getInboundPort(): int { return $this->inboundPort; }
public function setInboundPort(int $value): void { $this->inboundPort = $value; }
public function getEncryption(): string { return $this->encryption; }
public function setEncryption(string $v): void { $this->encryption = $v; }
public function getOutboundPort(): int { return $this->outboundPort; }
public function setOutboundPort(int $value): void { $this->outboundPort = $value; }
public function getVerifyPeer(): bool { return $this->verifyPeer; }
public function setVerifyPeer(bool $v): void { $this->verifyPeer = $v; }
public function getInboundEncryption(): string { return $this->inboundEncryption; }
public function setInboundEncryption(string $value): void { $this->inboundEncryption = $value; }
public function getVerifyPeerName(): bool { return $this->verifyPeerName; }
public function setVerifyPeerName(bool $v): void { $this->verifyPeerName = $v; }
public function getOutboundEncryption(): string { return $this->outboundEncryption; }
public function setOutboundEncryption(string $value): void { $this->outboundEncryption = $value; }
public function getInboundVerifyPeer(): bool { return $this->inboundVerifyPeer; }
public function setInboundVerifyPeer(bool $value): void { $this->inboundVerifyPeer = $value; }
public function getInboundVerifyHost(): bool { return $this->inboundVerifyHost; }
public function setInboundVerifyHost(bool $value): void { $this->inboundVerifyHost = $value; }
public function getOutboundVerifyPeer(): bool { return $this->outboundVerifyPeer; }
public function setOutboundVerifyPeer(bool $value): void { $this->outboundVerifyPeer = $value; }
public function getOutboundVerifyHost(): bool { return $this->outboundVerifyHost; }
public function setOutboundVerifyHost(bool $value): void { $this->outboundVerifyHost = $value; }
public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; }
public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; }
// ── Client helpers ───────────────────────────────────────────────────────
/**
* Build an IMAP ConnectionConfig from the inbound side.
* Build a standalone IMAP client ConnectionConfig from this location.
*/
public function toConnectionConfig(?string $username = null, ?string $password = null): ConnectionConfig
{
return new ConnectionConfig(
host: $this->inboundHost,
port: $this->inboundPort,
security: $this->imapSecurity($this->inboundEncryption),
username: $username,
password: $password,
verifyPeer: $this->inboundVerifyPeer,
verifyPeerName: $this->inboundVerifyHost,
allowSelfSigned: !$this->inboundVerifyPeer,
);
}
/**
* Build an SMTP submission ConnectionConfig from the outbound side.
*/
public function toSmtpConnectionConfig(?string $username = null, ?string $password = null): SmtpConnectionConfig
{
return new SmtpConnectionConfig(
host: $this->outboundHost,
port: $this->outboundPort,
security: $this->smtpSecurity($this->outboundEncryption),
username: $username,
password: $password,
verifyPeer: $this->outboundVerifyPeer,
verifyPeerName: $this->outboundVerifyHost,
allowSelfSigned: !$this->outboundVerifyPeer,
);
}
private function imapSecurity(string $encryption): ConnectionSecurity
{
return match ($encryption) {
$security = match ($this->encryption) {
'ssl', 'tls' => ConnectionSecurity::Tls,
'starttls' => ConnectionSecurity::StartTls,
default => ConnectionSecurity::Plain,
'starttls' => ConnectionSecurity::StartTls,
default => ConnectionSecurity::Plain,
};
}
private function smtpSecurity(string $encryption): SmtpConnectionSecurity
{
return match ($encryption) {
'ssl', 'tls' => SmtpConnectionSecurity::Tls,
'starttls' => SmtpConnectionSecurity::StartTls,
default => SmtpConnectionSecurity::Plain,
};
return new ConnectionConfig(
host: $this->host,
port: $this->port,
security: $security,
username: $username,
password: $password,
verifyPeer: $this->verifyPeer,
verifyPeerName: $this->verifyPeerName,
allowSelfSigned: $this->allowSelfSigned,
);
}
}
+115
View File
@@ -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;
}
}
+193
View File
@@ -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 (['read', 'flagged', 'answered', 'draft', 'deleted', 'junk'] as $field) {
if (isset($updatedProperties[$field])) {
$existingProperties[$field] = $updatedProperties[$field];
}
}
$existing['properties'] = $existingProperties;
$this->messageStore->upsertMessage($existing);
}
}
}
-307
View File
@@ -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;
}
}
-88
View File
@@ -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;
}
}
-65
View File
@@ -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;
}
}
-233
View File
@@ -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'));
}
}
+5 -6
View File
@@ -110,7 +110,7 @@ class Discovery
];
$seen = [];
foreach ($results as $r) {
$seen[$r->getInboundHost()] = true;
$seen[$r->getHost()] = true;
}
foreach ($candidates as $host) {
if (isset($seen[$host])) {
@@ -203,11 +203,10 @@ class Discovery
$loc = new ServiceLocation();
$loc->jsonDeserialize([
'inboundHost' => $host,
'inboundPort' => $port,
'inboundEncryption' => $encryption,
'inboundVerifyPeer' => $verifySSL,
'inboundVerifyHost' => $verifySSL,
'host' => $host,
'port' => $port,
'encryption' => $encryption,
'verifyPeer' => $verifySSL,
]);
return $loc;
} catch (\Throwable) {
@@ -7,40 +7,35 @@ declare(strict_types=1);
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImap\Service\Live;
namespace KTXM\ProviderImap\Service\Remote;
use DateTimeImmutable;
use Generator;
use KTXC\Logger\PlainFileLogger;
use KTXM\ProviderImap\Client\Client as ImapClient;
use KTXM\ProviderImap\Client\Protocol\Command\AppendCommand;
use KTXM\ProviderImap\Client\Protocol\Command\FetchManyCommand;
use KTXM\ProviderImap\Client\Protocol\Command\FetchOneCommand;
use KTXM\ProviderImap\Client\Protocol\Command\ExpungeCommand;
use KTXM\ProviderImap\Client\Protocol\Command\ListCommand;
use KTXM\ProviderImap\Client\Protocol\Command\SearchCommand;
use KTXM\ProviderImap\Client\Protocol\Command\SelectCommand;
use KTXM\ProviderImap\Client\Protocol\Command\SortCommand;
use KTXM\ProviderImap\Client\Protocol\Command\StatusCommand;
use KTXM\ProviderImap\Client\Protocol\Command\StoreCommand;
use KTXM\ProviderImap\Client\Protocol\Command\CopyCommand;
use KTXM\ProviderImap\Client\Protocol\Command\CreateCommand;
use KTXM\ProviderImap\Client\Protocol\Command\RenameCommand;
use KTXM\ProviderImap\Client\Protocol\Command\DeleteCommand;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Client;
use KTXM\ProviderImap\Client\Command\FetchManyCommand;
use KTXM\ProviderImap\Client\Command\ExpungeCommand;
use KTXM\ProviderImap\Client\Command\ListCommand;
use KTXM\ProviderImap\Client\Command\SearchCommand;
use KTXM\ProviderImap\Client\Command\SelectCommand;
use KTXM\ProviderImap\Client\Command\SortCommand;
use KTXM\ProviderImap\Client\Command\StatusCommand;
use KTXM\ProviderImap\Client\Command\StoreCommand;
use KTXM\ProviderImap\Client\Command\CopyCommand;
use KTXM\ProviderImap\Client\Command\CreateCommand;
use KTXM\ProviderImap\Client\Command\RenameCommand;
use KTXM\ProviderImap\Client\Command\DeleteCommand;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\FetchOptions;
use KTXM\ProviderImap\Client\IdentifierMode;
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\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Client\Message;
use KTXM\ProviderImap\Client\MessageAddress;
use KTXM\ProviderImap\Client\MessagePart;
use KTXM\ProviderImap\Client\Protocol\Command\MoveCommand;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\SearchCriteriaBuilder;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Command\MoveCommand;
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Filter\FilterComparisonOperator;
@@ -50,140 +45,28 @@ use KTXF\Resource\Range\IRangeTally;
use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeTally;
use KTXF\Resource\Sort\ISort;
use KTXF\Resource\BinaryResource;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXM\ProviderImap\Smtp\Client as SmtpClient;
use RuntimeException;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
/**
* IMAP Live Mail Service
* IMAP Remote Mail Service
*/
class LiveMailService
class RemoteMailService
{
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
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 ?SmtpClient $smtpClient = null;
/** Result of the last SELECT on the current connection, reused while still selected */
private ?Mailbox $selected = null;
public function __construct(
private readonly ServiceBase $service,
private readonly Client $client,
) {}
/**
* The connected IMAP client, established on first use.
*/
public function imapClient(): ImapClient
{
if ($this->imapClient instanceof ImapClient) {
return $this->imapClient;
}
$location = $this->service->getLocation();
if ($location === null || $location->getInboundHost() === '') {
throw new RuntimeException('No IMAP (inbound) host is configured for this service.');
}
$identity = $this->service->getIdentity();
$config = $location->toConnectionConfig(
$identity?->getIdentity(),
$identity?->getSecret(),
);
$client = new ImapClient(logger: $this->logger('imap'));
$client->connect($config);
return $this->imapClient = $client;
}
/**
* The connected SMTP client, established on first use.
*/
public function smtpClient(): SmtpClient
{
if ($this->smtpClient instanceof SmtpClient) {
return $this->smtpClient;
}
$location = $this->service->getLocation();
if ($location === null || $location->getOutboundHost() === '') {
throw new RuntimeException('No SMTP submission (outbound) host is configured for this service.');
}
$identity = $this->service->getIdentity();
$config = $location->toSmtpConnectionConfig(
$identity?->getIdentity(),
$identity?->getSecret(),
);
$client = new SmtpClient(logger: $this->logger('smtp'));
$client->connect($config);
return $this->smtpClient = $client;
}
/**
* Build a per-protocol file logger when the service has debug enabled.
*/
private function logger(string $channel): ?PlainFileLogger
{
if (!$this->service->getDebug()) {
return null;
}
$logDir = dirname(__DIR__, 5) . '/var/logs/provider_imap';
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
}
/**
* List the collections of the account, keyed by mailbox name.
*
* @return array<string, CollectionResource>
*/
public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array
{
$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
* list of collections in remote storage
*
* @since Release 1.0.0
*/
public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
{
// Prepare location filter
if (!empty($location)) {
@@ -195,7 +78,7 @@ class LiveMailService
$location = '';
}
// construct the most efficient LIST command based on server capabilities
if ($this->imapClient()->hasCapability('LIST-STATUS') && !empty($depth)) {
if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) {
$command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS));
$rfc5258 = true;
} else {
@@ -204,7 +87,7 @@ class LiveMailService
}
// retrieve list of mailboxes from remote
$mailboxes = [];
foreach ($this->imapClient()->perform($command) as $mailbox) {
foreach ($this->client->perform($command) as $mailbox) {
// apply filter
if ($filter === null || $this->mailboxFilter($mailbox, $filter)) {
if ($rfc5258) {
@@ -222,7 +105,7 @@ class LiveMailService
continue;
}
try {
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
$mailbox = $mailbox->fromStatus($status);
} catch (ImapException) {
// do nothing
@@ -235,67 +118,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.
*/
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
$mailbox = iterator_to_array($this->imapClient()->perform($command));
$mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
if (empty($mailbox)) {
return null;
}
$mailbox = reset($mailbox);
// enrich with STATUS
$status = $this->client->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()) {
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
$mailbox = $mailbox->fromStatus($status);
/**
* Create a new IMAP mailbox and return it.
*
* 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->client->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;
}
/**
* 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.
*/
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->client->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) {
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
}
return $this->collectionResource($mailbox, $delimiter);
return $mailbox;
}
/**
@@ -303,7 +185,11 @@ class LiveMailService
*/
public function collectionDestroy(string $name): bool
{
$this->imapClient()->perform(new DeleteCommand($name));
$result = $this->client->perform(new DeleteCommand($name));
if (!$result->isOk()) {
throw new ImapException('Failed to delete mailbox: ' . $name);
}
return true;
}
@@ -320,18 +206,18 @@ class LiveMailService
$nativeFilter = $this->buildEntitySearchCriteria($filter);
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
$this->select($collection);
$rfc5258 = $this->imapClient()->hasCapability('SORT');
$this->client->perform(new SelectCommand($collection, true));
$rfc5258 = $this->client->hasCapability('SORT');
$uids = [];
if ($nativeSort !== [] && $rfc5258) {
$uids = $this->imapClient()->perform(new SortCommand(
$uids = $this->client->perform(new SortCommand(
$nativeSort,
$nativeFilter,
IdentifierMode::Uid,
))->matches();
} else {
$uids = $this->imapClient()->perform(new SearchCommand(
$uids = $this->client->perform(new SearchCommand(
$nativeFilter,
IdentifierMode::Uid,
))->matches();
@@ -349,95 +235,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 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
* @return Message[] list of messages matching the filter, sorted and paginated as requested
*/
public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator
{
foreach ($this->messageList($collection, $filter, $sort, $range) as $message) {
$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);
$options = FetchOptions::message()->withBodyText();
// fast path: fetch all messages without filtering, sorting or pagination
if ($filter === null && $sort === null && $range === null) {
$mailbox = $this->select($collection, refresh: true);
$mailbox = $this->client->perform(new SelectCommand($collection, true));
// "1:*" on an empty mailbox is rejected by some servers
if ($mailbox->messages() === 0) {
if ($mailbox === null) {
return [];
}
yield from $this->imapClient()->perform(new FetchManyCommand(
MessageTarget::all(),
yield from $this->client->perform(new FetchManyCommand(
FetchTarget::all(),
$options,
));
@@ -451,216 +266,43 @@ class LiveMailService
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
* @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)) {
return [];
}
$options ??= FetchOptions::message()->withBodyText();
$this->select($collection);
$this->client->perform(new SelectCommand($collection, true));
$request = new FetchManyCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$options,
);
foreach ($this->imapClient()->perform($request) as $message) {
foreach ($this->client->perform($request) as $message) {
$uid = $message->uid() ?: $message->sequence();
yield $uid => $message;
}
}
/**
* 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.
*
* When $partId is given, first fetches BODYSTRUCTURE to determine the
* correct filename and MIME type, then starts the streaming body fetch.
*
* @param string $collection Mailbox name
* @param int $uid Message UID
* @param string|null $partId MIME section (e.g. '1', '1.2'); null = full RFC 822
*/
public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource
{
$this->select($collection);
$encoding = null;
if ($partId === null) {
$filename = 'message.eml';
$mimeType = 'message/rfc822';
} else {
// Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred)
$message = $this->imapClient()->perform(new FetchOneCommand(
MessageTarget::uid(SequenceSet::items($uid)),
FetchOptions::of('BODYSTRUCTURE'),
));
$bodyStructure = $message->bodyStructure();
$part = $bodyStructure !== null ? $this->findBodyPart($bodyStructure, $partId) : null;
$mimeType = $part?->mimeType() ?? 'application/octet-stream';
$partData = $part?->toArray() ?? [];
$filename = isset($partData['name']) && $partData['name'] !== ''
? $partData['name']
: "attachment-{$partId}";
$encoding = $part?->encoding();
}
// Start download stream
$stream = $this->decodeStream(
$this->imapClient()->download(MessageTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
$encoding
);
return new BinaryResource($filename, $mimeType, $stream);
}
private function findBodyPart(MessagePart $root, string $partId): ?MessagePart
{
if ($root->partId() === $partId) {
return $root;
}
foreach ($root->parts() as $child) {
$found = $this->findBodyPart($child, $partId);
if ($found !== null) {
return $found;
}
}
return null;
}
/**
* Wraps a raw IMAP body stream with a transfer-encoding decoder.
*
* IMAP BODY[n] literals are delivered in the transfer encoding declared
* by BODYSTRUCTURE (typically base64 or quoted-printable for attachments).
* 7bit / 8bit / binary sections pass through unchanged.
*/
private function decodeStream(\Generator $stream, ?string $encoding): \Generator
{
return match (strtolower($encoding ?? '')) {
'base64' => $this->decodeBase64Stream($stream),
'quoted-printable' => $this->decodeQpStream($stream),
default => $stream,
};
}
private function decodeBase64Stream(\Generator $source): \Generator
{
$buffer = '';
foreach ($source as $chunk) {
// IMAP folds base64 at 76 chars with CRLF — strip all whitespace
$buffer .= preg_replace('/\s+/', '', $chunk);
// Decode complete 4-character groups; keep any partial tail
$remainder = strlen($buffer) % 4;
$complete = strlen($buffer) - $remainder;
if ($complete > 0) {
yield base64_decode(substr($buffer, 0, $complete), true);
$buffer = substr($buffer, $complete);
}
}
// Flush remainder (handles padded or stripped trailing '=')
if ($buffer !== '') {
yield base64_decode($buffer, true);
}
}
private function decodeQpStream(\Generator $source): \Generator
{
// Buffer until we have complete lines so soft-line-breaks are intact
$buffer = '';
foreach ($source as $chunk) {
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
yield quoted_printable_decode(substr($buffer, 0, $pos + 1));
$buffer = substr($buffer, $pos + 1);
}
}
if ($buffer !== '') {
yield quoted_printable_decode($buffer);
}
}
/**
* Append a raw RFC 822 message to a mailbox and return the assigned UID.
*
* @param string[] $flags optional initial flags, e.g. ['\\Seen']
*/
public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int
public function entityCreate(string $collection, string $rawMessage, array $flags = []): int
{
return $this->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;
return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null);
}
/**
@@ -676,9 +318,9 @@ class LiveMailService
return;
}
$this->select($collection, readOnly: false);
$this->imapClient()->perform(new StoreCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$this->client->perform(new SelectCommand($collection, false));
$this->client->perform(new StoreCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$flags,
$action,
));
@@ -693,11 +335,11 @@ class LiveMailService
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 StoreCommand($target, ['\\Deleted'], '+'));
$this->imapClient()->perform(new ExpungeCommand($target));
$this->client->perform(new SelectCommand($collection, false));
$this->client->perform(new StoreCommand($target, ['\\Deleted'], '+'));
$this->client->perform(new ExpungeCommand($target));
// TODO: find a way to determine which actual UID's were deleted
return array_fill_keys($uids, true);
@@ -709,22 +351,19 @@ class LiveMailService
return;
}
$this->select($collection, readOnly: false);
$flagsToAdd = $this->normalizeFlags($flagsToAdd);
$flagsToRemove = $this->normalizeFlags($flagsToRemove);
$this->client->perform(new SelectCommand($collection, false));
if (!empty($flagsToAdd)) {
$this->imapClient()->perform(new StoreCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$this->client->perform(new StoreCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$flagsToAdd,
'+',
));
}
if (!empty($flagsToRemove)) {
$this->imapClient()->perform(new StoreCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$this->client->perform(new StoreCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$flagsToRemove,
'-',
));
@@ -737,29 +376,35 @@ class LiveMailService
return [];
}
$rfc6851 = $this->imapClient()->hasCapability('MOVE');
$rfc6851 = $this->client->hasCapability('MOVE');
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
if ($rfc6851) {
$this->select($sourceCollection, readOnly: false);
$response = $this->imapClient()->perform(new MoveCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$this->client->perform(new SelectCommand($sourceCollection, false));
$response = $this->client->perform(new MoveCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$targetCollection,
));
} else {
$this->select($sourceCollection, readOnly: false);
$response = $this->imapClient()->perform(new CopyCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$this->client->perform(new SelectCommand($sourceCollection, false));
$response = $this->client->perform(new CopyCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$targetCollection,
));
$this->imapClient()->perform(new StoreCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
['\\Deleted'],
'+',
));
$this->imapClient()->perform(new ExpungeCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
));
if ($response->isOk()) {
$this->client->perform(new StoreCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
['\\Deleted'],
'+',
));
$this->client->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
@@ -777,32 +422,6 @@ class LiveMailService
}
public function entityCopy(string $targetCollection, string $sourceCollection, int ...$uids): array
{
if (empty($uids)) {
return [];
}
$this->select($sourceCollection, readOnly: false);
$response = $this->imapClient()->perform(new CopyCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$targetCollection,
));
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
$map = $response->copyUidMap();
if ($map === []) {
$result = array_fill_keys(array_map('strval', $uids), true);
} else {
$result = array_fill_keys(array_map('strval', $uids), false);
foreach ($uids as $uid) {
$result[$uid] = $map[$uid] ?? false;
}
}
return $result;
}
private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder
{
if ($filter === null || $filter->conditions() === []) {
@@ -1005,8 +624,8 @@ class LiveMailService
}
}
$messages = iterator_to_array($this->imapClient()->perform(new FetchManyCommand(
MessageTarget::uid(SequenceSet::items(...array_values($uids))),
$messages = iterator_to_array($this->client->perform(new FetchManyCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$options,
)));
@@ -1250,59 +869,4 @@ class LiveMailService
return CollectionRoles::None->value;
}
private function normalizeFlags(array $flags): array
{
$map = [
'seen' => '\\Seen',
'answered' => '\\Answered',
'flagged' => '\\Flagged',
'deleted' => '\\Deleted',
'draft' => '\\Draft',
];
$normalized = [];
foreach ($flags as $flag) {
$flag = strtolower(trim($flag));
if (isset($map[$flag])) {
$normalized[] = $map[$flag];
}
}
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;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImap\Service\Remote;
use KTXC\Server;
use KTXC\Logger\PlainFileLogger;
use KTXM\ProviderImap\Client\Client;
use KTXM\ProviderImap\Providers\Service;
/**
* Static factory for IMAP remote service objects.
*
* - freshClient() → builds a gricob Client from service config
* - mailService() → constructs a RemoteMailService from the client
*/
class RemoteService
{
/**
* Build and bootstrap a fully-configured IMAP client from a Service.
*/
public static function freshClient(Service $service): Client
{
$location = $service->getLocation();
$identity = $service->getIdentity();
// Build a file logger when debug mode is enabled, otherwise pass null
$logger = null;
if ($service->getDebug()) {
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
$logger = new PlainFileLogger($logDir . '/imap', $service->identifier());
}
$config = $location->toConnectionConfig(
$identity?->getIdentity(),
$identity?->getSecret(),
);
$client = new Client(logger: $logger);
$client->connect($config);
return $client;
}
/**
* Build a RemoteMailService from a Service and a pre-authenticated client.
*
* The provider identifier and service ID are taken directly from the Service
* object so the caller does not have to repeat them.
*/
public static function mailService(Service $service, Client $client): RemoteMailService
{
return new RemoteMailService($client, $service->provider(), $service->identifier());
}
}
-44
View File
@@ -1,44 +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\Smtp\Auth;
/**
* Supported SASL authentication mechanisms, in preference order.
*/
enum AuthMechanism: string
{
case Plain = 'PLAIN';
case Login = 'LOGIN';
/**
* Select the most preferred mechanism the server advertises.
*
* @param list<string> $advertised mechanisms offered by the server (uppercased)
*/
public static function select(array $advertised): ?self
{
foreach ([self::Plain, self::Login] as $mechanism) {
if (in_array($mechanism->value, $advertised, true)) {
return $mechanism;
}
}
return null;
}
/**
* The initial AUTH response for the PLAIN mechanism:
* base64( authzid NUL authcid NUL passwd ).
*/
public static function plainToken(string $username, string $password): string
{
return base64_encode("\0" . $username . "\0" . $password);
}
}
-264
View File
@@ -1,264 +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\Smtp;
use KTXM\ProviderImap\Smtp\Auth\AuthMechanism;
use KTXM\ProviderImap\Smtp\Protocol\ProtocolWriter;
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
use KTXM\ProviderImap\Smtp\Protocol\ProtocolReader;
use KTXM\ProviderImap\Smtp\Transport\ConnectionFactoryInterface;
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
use KTXM\ProviderImap\Smtp\Transport\SocketConnectionFactory;
use Psr\Log\LoggerInterface;
/**
* Minimal SMTP submission client.
*
* Implements the ESMTP submission handshake (RFC 5321 / RFC 4954):
* greeting → EHLO → [STARTTLS → EHLO] → [AUTH] → MAIL FROM → RCPT TO → DATA.
*
* The client carries opaque RFC822 bytes; message construction lives in the
* {@see \KTXM\ProviderImap\Mime\MessageBuilder}.
*/
final class Client
{
private ?ConnectionInterface $connection = null;
private ?ProtocolReader $reader = null;
private ?ProtocolWriter $writer = null;
private ?ConnectionConfig $config = null;
private ?SmtpCapabilities $capabilities = null;
public function __construct(
private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(),
private readonly ?LoggerInterface $logger = null,
) {}
public function connect(ConnectionConfig $config): void
{
$connection = $this->connectionFactory->create($this->logger);
$connection->connect($config);
$this->connection = $connection;
$this->config = $config;
$this->reader = new ProtocolReader($connection, $this->logger);
$this->writer = new ProtocolWriter($connection, $this->logger);
// Server greeting.
$greeting = $this->reader()->read();
if (!$greeting->isPositiveCompletion()) {
throw new SmtpException('SMTP server rejected the connection: ' . $greeting->text());
}
$this->capabilities = $this->ehlo();
if ($config->security() === ConnectionSecurity::StartTls) {
$this->startTls();
$this->capabilities = $this->ehlo();
}
if ($config->hasCredentials()) {
$this->authenticate();
}
}
public function capabilities(): SmtpCapabilities
{
return $this->caps();
}
public function isConnected(): bool
{
return $this->connection !== null && $this->connection->isConnected();
}
/**
* Submit a message envelope and its raw RFC822 body.
*
* @param string $from envelope sender (bare address)
* @param list<string> $recipients envelope recipients (bare addresses, incl. Bcc)
* @param string $rawMessage complete RFC822 message bytes
*
* @return string the queue/transport id reported by the server, when available
*/
public function send(string $from, array $recipients, string $rawMessage): string
{
if ($recipients === []) {
throw new SmtpException('Cannot send a message without recipients.');
}
$mailFrom = sprintf('MAIL FROM:<%s>', $from);
if (($max = $this->caps()->maxSize()) !== null) {
$size = strlen($rawMessage);
if ($size > $max) {
throw new SmtpException(sprintf('Message size %d exceeds server limit %d.', $size, $max));
}
$mailFrom .= ' SIZE=' . $size;
}
$this->command($mailFrom, static fn (Response $r): bool => $r->isPositiveCompletion(), 'MAIL FROM');
foreach ($recipients as $recipient) {
$this->command(
sprintf('RCPT TO:<%s>', $recipient),
static fn (Response $r): bool => $r->isPositiveCompletion(),
'RCPT TO',
);
}
$this->command('DATA', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'DATA');
$this->writer()->writeRaw($this->dotStuff($rawMessage) . "\r\n.\r\n");
$reply = $this->reader()->read();
if (!$reply->isPositiveCompletion()) {
throw new SmtpException('SMTP DATA delivery failed: ' . $reply->text());
}
return $reply->text();
}
public function quit(): void
{
if ($this->connection === null) {
return;
}
try {
$this->writer?->writeLine('QUIT');
$this->reader?->read();
} catch (SmtpException) {
// Best-effort: the peer may already have closed the stream.
} finally {
$this->connection->disconnect();
$this->connection = null;
$this->reader = null;
$this->writer = null;
$this->capabilities = null;
$this->config = null;
}
}
private function ehlo(): SmtpCapabilities
{
$this->writer()->writeLine('EHLO ' . $this->config()->ehloDomain());
$reply = $this->reader()->read();
if (!$reply->isPositiveCompletion()) {
throw new SmtpException('EHLO was rejected: ' . $reply->text());
}
return SmtpCapabilities::fromEhlo($reply);
}
private function startTls(): void
{
if (!$this->caps()->supportsStartTls()) {
throw new SmtpException('SMTP server does not advertise STARTTLS.');
}
$this->command('STARTTLS', static fn (Response $r): bool => $r->isPositiveCompletion(), 'STARTTLS');
$this->connection()->upgradeToTls();
}
private function authenticate(): void
{
$username = $this->config()->username() ?? '';
$password = $this->config()->password() ?? '';
$mechanism = AuthMechanism::select($this->caps()->authMechanisms());
if ($mechanism === null) {
throw new SmtpException('No supported SMTP AUTH mechanism is available.');
}
match ($mechanism) {
AuthMechanism::Plain => $this->authPlain($username, $password),
AuthMechanism::Login => $this->authLogin($username, $password),
};
}
private function authPlain(string $username, string $password): void
{
$this->command(
'AUTH PLAIN ' . AuthMechanism::plainToken($username, $password),
static fn (Response $r): bool => $r->isPositiveCompletion(),
'AUTH PLAIN',
true,
);
}
private function authLogin(string $username, string $password): void
{
$this->command('AUTH LOGIN', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'AUTH LOGIN');
$this->writer()->writeLine(base64_encode($username), true);
$userResponse = $this->reader()->read();
if (!$userResponse->isPositiveIntermediate()) {
throw new SmtpException('SMTP AUTH LOGIN username rejected: ' . $userResponse->text());
}
$this->writer()->writeLine(base64_encode($password), true);
$passResponse = $this->reader()->read();
if (!$passResponse->isPositiveCompletion()) {
throw new SmtpException('SMTP authentication failed: ' . $passResponse->text());
}
}
/**
* Issue a command line and assert the reply satisfies $accept.
*
* @param callable(Response):bool $accept
*/
private function command(string $line, callable $accept, string $label, bool $sensitive = false): Response
{
$this->writer()->writeLine($line, $sensitive);
$reply = $this->reader()->read();
if (!$accept($reply)) {
throw new SmtpException(sprintf('SMTP %s failed (%d): %s', $label, $reply->code(), $reply->text()));
}
return $reply;
}
/**
* Dot-stuffing per RFC 5321 §4.5.2: any line beginning with a period gets
* an extra leading period so it is not mistaken for the end-of-data marker.
* Line endings are normalised to CRLF.
*/
private function dotStuff(string $message): string
{
$normalized = preg_replace('/\r\n|\r|\n/', "\r\n", $message) ?? $message;
return preg_replace('/^\./m', '..', $normalized) ?? $normalized;
}
private function connection(): ConnectionInterface
{
return $this->connection ?? throw new SmtpException('SMTP client is not connected.');
}
private function reader(): ProtocolReader
{
return $this->reader ?? throw new SmtpException('SMTP client is not connected.');
}
private function writer(): ProtocolWriter
{
return $this->writer ?? throw new SmtpException('SMTP client is not connected.');
}
private function config(): ConnectionConfig
{
return $this->config ?? throw new SmtpException('SMTP client is not connected.');
}
private function caps(): SmtpCapabilities
{
return $this->capabilities ?? throw new SmtpException('SMTP client is not connected.');
}
}
-113
View File
@@ -1,113 +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\Smtp;
final class ConnectionConfig
{
public function __construct(
private readonly string $host,
private readonly int $port = 587,
private readonly ConnectionSecurity $security = ConnectionSecurity::StartTls,
private readonly ?string $username = null,
private readonly ?string $password = null,
private readonly float $timeout = 30.0,
private readonly bool $verifyPeer = true,
private readonly bool $verifyPeerName = true,
private readonly bool $allowSelfSigned = false,
private readonly ?string $ehloDomain = null,
) {}
public function host(): string
{
return $this->host;
}
public function port(): int
{
return $this->port;
}
public function security(): ConnectionSecurity
{
return $this->security;
}
public function username(): ?string
{
return $this->username;
}
public function password(): ?string
{
return $this->password;
}
public function hasCredentials(): bool
{
return $this->username !== null && $this->username !== ''
&& $this->password !== null && $this->password !== '';
}
public function timeout(): float
{
return $this->timeout;
}
public function verifyPeer(): bool
{
return $this->verifyPeer;
}
public function verifyPeerName(): bool
{
return $this->verifyPeerName;
}
public function allowSelfSigned(): bool
{
return $this->allowSelfSigned;
}
/**
* The domain announced in the EHLO command. Falls back to the local
* hostname, then to a literal so a value is always sent.
*/
public function ehloDomain(): string
{
if ($this->ehloDomain !== null && $this->ehloDomain !== '') {
return $this->ehloDomain;
}
$hostname = gethostname();
return $hostname !== false && $hostname !== '' ? $hostname : 'localhost';
}
public function endpoint(): string
{
return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port);
}
/**
* @return array<string,array<string,mixed>>
*/
public function streamContextOptions(): array
{
return [
'ssl' => [
'verify_peer' => $this->verifyPeer,
'verify_peer_name' => $this->verifyPeerName,
'allow_self_signed' => $this->allowSelfSigned,
'SNI_enabled' => true,
'peer_name' => $this->host,
],
];
}
}
-22
View File
@@ -1,22 +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\Smtp;
enum ConnectionSecurity: string
{
case Plain = 'plain';
case Tls = 'tls'; // implicit TLS (SMTPS, port 465)
case StartTls = 'starttls';
public function transport(): string
{
return $this === self::Tls ? 'ssl' : 'tcp';
}
}

Some files were not shown because too many files have changed in this diff Show More