43 Commits

Author SHA1 Message Date
8093c031d9 chore(deps): update dependency phpunit/phpunit to v13
Some checks failed
renovate/artifacts Artifact file update failure
2026-05-19 03:03:48 +00:00
49712b5f87 Merge pull request 'refactor: service entity list and fetch' (#27) from refactor/entity-list-fetch into main
Some checks failed
Renovate / renovate (push) Failing after 1m22s
Reviewed-on: #27
2026-05-17 21:50:00 +00:00
86c93e8d3e refactor: service entity list and fetch
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-17 17:49:01 -04:00
94ca156fdb Merge pull request 'fix: merge conflicts' (#26) from fix/merge-conflicts into main
Some checks failed
Renovate / renovate (push) Failing after 1m34s
Reviewed-on: #26
2026-05-15 14:24:47 +00:00
5b513424a6 fix: merge conflicts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-15 10:24:27 -04:00
55614b55f0 Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.7' (#25) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #25
2026-05-15 13:39:45 +00:00
766438c291 chore(deps): update dependency @vitejs/plugin-vue to v6.0.7 2026-05-15 13:39:35 +00:00
fd7004fe6a Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.9' (#16) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #16
2026-05-15 03:36:04 +00:00
c6ffa02bdc Merge pull request 'chore(deps): update dependency vite to v8' (#21) from renovate/vite-8.x into main
Reviewed-on: #21
2026-05-15 03:35:54 +00:00
48a3e33a81 chore(deps): update dependency vite to v8 2026-05-15 03:35:39 +00:00
98de03bd39 chore(deps): update dependency vue-tsc to v3.2.9 2026-05-15 03:35:36 +00:00
53b9368e48 Merge pull request 'chore(deps): update dependency typescript to v6' (#20) from renovate/typescript-6.x into main
Reviewed-on: #20
2026-05-15 03:34:12 +00:00
76168bda18 Merge pull request 'fix(deps): update dependency vue-router to v5' (#23) from renovate/vue-router-5.x into main
Reviewed-on: #23
2026-05-15 03:34:00 +00:00
adcbbc34f9 fix(deps): update dependency vue-router to v5 2026-05-15 03:33:42 +00:00
b455e07e86 chore(deps): update dependency typescript to v6 2026-05-15 03:33:36 +00:00
bed8652bd9 Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.6' (#12) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #12
2026-05-15 03:07:14 +00:00
420ed06020 Merge pull request 'fix(deps): update dependency pinia to v3' (#22) from renovate/pinia-3.x into main
Reviewed-on: #22
2026-05-15 03:06:58 +00:00
cc291fa86f Merge pull request 'fix(deps): update dependency vuetify to v4' (#24) from renovate/vuetify-4.x into main
Reviewed-on: #24
2026-05-15 03:06:42 +00:00
396210352a Merge pull request 'chore(deps): update dependency @vue/tsconfig to ^0.9.0' (#19) from renovate/vue-tsconfig-0.x into main
Reviewed-on: #19
2026-05-15 03:06:27 +00:00
d31d21d9e4 fix(deps): update dependency vuetify to v4 2026-05-15 03:05:30 +00:00
ab88864327 fix(deps): update dependency pinia to v3 2026-05-15 03:05:25 +00:00
f691c21e1a chore(deps): update dependency @vue/tsconfig to ^0.9.0 2026-05-15 03:05:16 +00:00
631819c282 chore(deps): update dependency @vitejs/plugin-vue to v6.0.6 2026-05-15 03:05:06 +00:00
fab4fae9c3 Merge pull request 'refactor: use new mail interface desing' (#17) from refactor/use-new-interface-design into main
All checks were successful
Renovate / renovate (push) Successful in 5m23s
Reviewed-on: #17
2026-05-15 02:50:45 +00:00
ca646eec3c refactor: use new mail interface desing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-14 22:50:21 -04:00
b37da945f5 Merge pull request 'feat: implement entity mutable interface' (#11) from feat/implement-EntityMutableInterface into main
All checks were successful
Renovate / renovate (push) Successful in 1m41s
Reviewed-on: #11
2026-05-09 21:04:18 +00:00
086d7a1366 feat: implement entity mutable interface
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-09 17:03:58 -04:00
d9bc526126 Merge pull request 'refactor: use custom imap client' (#10) from refactor--use-custom-imap-client into main
Some checks failed
Renovate / renovate (push) Failing after 1h12m59s
Reviewed-on: #10
2026-05-08 04:17:05 +00:00
a8764747fd refactor: use custom imap client
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-08 00:16:43 -04:00
a728aeb11c Merge pull request 'feat: console commands' (#9) from feat/console-commands into main
Reviewed-on: #9
2026-05-08 04:05:25 +00:00
1eb5f49a26 feat: console commands
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-08 00:04:22 -04:00
a7acb467d5 Merge pull request 'chore: renovate' (#8) from chore/renovate into main
Some checks failed
Renovate / renovate (push) Failing after 2m3s
Reviewed-on: #8
2026-05-07 02:19:46 +00:00
6a4e2759f1 chore: renovate
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-06 22:10:04 -04:00
d8cff9c2ed Merge pull request 'feat: lots more improvements' (#7) from feat/lots-more-improvements into main
Reviewed-on: #7
2026-04-25 19:44:20 +00:00
7485e4c897 feat: lots more improvements
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-25 15:43:56 -04:00
6ab61301dc Merge pull request 'refactor: bunch of improvements' (#6) from refactor/bunch-of-improvements into main
Reviewed-on: #6
2026-04-24 02:03:36 +00:00
2cd27b18e5 refactor: bunch of improvements
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-04-23 22:03:17 -04:00
ea527a1094 Merge pull request 'feat: implement provider' (#5) from feat/implement-provider into main
Reviewed-on: #5
2026-03-28 16:44:09 +00:00
4c948d177a feat: implement provider
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-28 12:43:42 -04:00
c322317ddc Merge pull request 'feat: speed improvements' (#4) from feat/speed-improvements into main
Reviewed-on: #4
2026-03-04 03:08:46 +00:00
Sebastian Krupinski
5dafcbd90d feat: speed improvements
Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
2026-03-03 22:08:30 -05:00
7889428a4c Merge pull request 'feat: speed improvements' (#3) from feat/speed-improvements into main
Reviewed-on: #3
2026-02-21 04:36:06 +00:00
Sebastian Krupinski
7446edced3 feat: speed improvements
Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
2026-02-20 23:34:30 -05:00
214 changed files with 10448 additions and 5988 deletions

35
.github/workflows/renovate.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Renovate
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Set up Node.js
uses: actions/setup-node@v6.2.0
with:
node-version: 24
cache: npm
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
tools: composer:v2
- name: Install Renovate
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: renovate ${{ gitea.repository }}

View File

@@ -1,42 +0,0 @@
# IMAP Mail Provider Module
This module provides an implementation of an IMAP mail provider using the `gricob/imap` library. It is designed to facilitate email operations such as managing mailboxes and messages through the IMAP protocol.
## Features
- **Service Location**: Configures connection details including host, port, and encryption type.
- **Service Identity**: Manages user credentials securely.
- **Mailbox Management**: Supports operations for listing, fetching, creating, modifying, and deleting mailboxes and messages.
- **Autodiscovery**: Implements methods for discovering IMAP services automatically.
## Installation
To install the module, run the following command in the module directory:
```bash
composer install
```
This will install the required dependencies, including `gricob/imap`.
## Usage
1. **Service Test**: Use the `serviceTest()` method to check connectivity to the IMAP server.
2. **Discover Services**: Call `serviceDiscover()` to find available mail services.
3. **Mailbox Operations**: Utilize the `RemoteMailService` to perform operations such as listing mailboxes and managing messages.
## Example
```php
$provider = new KTXM\ProviderImapMail\Providers\Provider();
$provider->serviceTest();
$mailboxes = $provider->serviceDiscover();
```
## Contributing
Contributions are welcome! Please submit a pull request or open an issue for any enhancements or bug fixes.
## License
This project is licensed under the MIT License. See the LICENSE file for details.

View File

@@ -1,5 +1,5 @@
{
"name": "ktxm/provider-imap-mail",
"name": "ktxm/provider-imap",
"description": "IMAP Mail Provider Module",
"type": "library",
"minimum-stability": "stable",
@@ -9,7 +9,7 @@
"platform": {
"php": "8.2"
},
"autoloader-suffix": "ProviderImapMail",
"autoloader-suffix": "ProviderImap",
"vendor-dir": "lib/vendor",
"allow-plugins": {
"bamarni/composer-bin-plugin": true
@@ -23,17 +23,16 @@
"doctrine/lexer": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
"phpunit/phpunit": "^13.0"
},
"autoload": {
"psr-4": {
"KTXM\\ProviderImapMail\\": "lib/",
"Gricob\\IMAP\\": "lib/Client"
"KTXM\\ProviderImap\\": "lib/"
}
},
"autoload-dev": {
"psr-4": {
"KTXT\\ProviderImapMail\\": "tests/php/"
"KTXM\\ProviderImap\\": "tests/php/"
}
},
"scripts": {

View File

@@ -2,514 +2,97 @@
declare(strict_types=1);
namespace Gricob\IMAP;
namespace KTXM\ProviderImap\Client;
use DateTimeImmutable;
use DateTimeInterface;
use Exception;
use Generator;
use Gricob\IMAP\Mime\LazyMessage;
use Gricob\IMAP\Mime\Message;
use Gricob\IMAP\Mime\Part\Disposition;
use Gricob\IMAP\Mime\Part\LazyBody;
use Gricob\IMAP\Mime\Part\MultiPart;
use Gricob\IMAP\Mime\Part\Part;
use Gricob\IMAP\Mime\Part\SinglePart;
use Gricob\IMAP\Protocol\Command\AppendCommand;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
use Gricob\IMAP\Protocol\Command\Argument\Store\Flags;
use Gricob\IMAP\Protocol\Command\Authenticate\SASLMechanism;
use Gricob\IMAP\Protocol\Command\AuthenticateCommand;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\CreateCommand;
use Gricob\IMAP\Protocol\Command\ExpungeCommand;
use Gricob\IMAP\Protocol\Command\FetchCommand;
use Gricob\IMAP\Protocol\Command\ListCommand;
use Gricob\IMAP\Protocol\Command\LogInCommand;
use Gricob\IMAP\Protocol\Command\SelectCommand;
use Gricob\IMAP\Protocol\Command\StoreCommand;
use Gricob\IMAP\Protocol\Imap;
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
use Gricob\IMAP\Protocol\Response\Line\Data\FlagsData;
use Gricob\IMAP\Protocol\Response\Line\Data\ExistsData;
use Gricob\IMAP\Protocol\Response\Line\Data\RecentData;
use Gricob\IMAP\Protocol\Response\Line\Data\ListData;
use Gricob\IMAP\Protocol\Response\Line\Data\SearchData;
use Gricob\IMAP\Protocol\Response\Line\Status\Code\AppendUidCode;
use Gricob\IMAP\Protocol\Response\Line\Status\Code\PermanentFlagsCode;
use Gricob\IMAP\Protocol\Response\Line\Status\Code\UidNextCode;
use Gricob\IMAP\Protocol\Response\Line\Status\Code\UidValidityCode;
use Gricob\IMAP\Protocol\Response\Line\Status\Code\UnseenCode;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Transport\Socket\SocketConnection;
use Gricob\IMAP\Transport\Traceable\TraceableConnection;
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;
use KTXM\ProviderImap\Client\Protocol\TagGenerator;
use KTXM\ProviderImap\Client\Transport\ConnectionFactoryInterface;
use KTXM\ProviderImap\Client\Transport\SocketConnectionFactory;
use Psr\Log\LoggerInterface;
use RuntimeException;
class Client
final class Client implements ClientInterface
{
public Configuration $configuration;
private Imap $imap;
private ?SessionContext $session = null;
private ?CommandExecutor $executor = null;
private Mailbox $selectedMailbox;
public function __construct(
private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(),
private readonly ?LoggerInterface $logger = null,
) {}
private function __construct(
Configuration $configuration,
?LoggerInterface $logger,
) {
$connection = new SocketConnection(
$configuration->transport,
$configuration->host,
$configuration->port,
$configuration->timeout,
$configuration->verifyPeer,
$configuration->verifyPeerName,
$configuration->allowSelfSigned,
);
public function connect(ConnectionConfig $config): void
{
$connection = $this->connectionFactory->create($config, $this->logger);
$connection->connect($config);
if (null !== $logger) {
$connection = new TraceableConnection($connection, $logger);
$reader = new ProtocolReader($connection, $this->logger);
$writer = new ProtocolWriter($connection, $this->logger);
$session = new SessionContext($config, $connection);
$greeting = $reader->readGreeting();
$session->setGreeting($greeting);
$session->setState(match ($greeting->status()) {
'OK' => SessionState::NotAuthenticated,
'PREAUTH' => SessionState::Authenticated,
'BYE' => SessionState::Logout,
default => throw new ImapException('Unexpected IMAP greeting status: ' . $greeting->status()),
});
if ($session->state() === SessionState::Logout) {
throw new ImapException('IMAP server rejected the connection: ' . $greeting->text());
}
$this->configuration = $configuration;
$this->imap = new Imap($connection);
$this->selectedMailbox = new Mailbox([], '', '');
}
$this->session = $session;
$this->executor = new CommandExecutor($reader, $writer, new TagGenerator(), $this->logger);
public static function create(Configuration $configuration, ?LoggerInterface $logger = null): self
{
return new self($configuration, $logger);
}
$this->perform(new CapabilityCommand());
public function connect(): void
{
$this->imap->connect();
}
/**
* Perform STARTTLS negotiation (patch).
*
* Call after connect() but before logIn(). The underlying Imap protocol
* layer sends the STARTTLS command and upgrades the socket to TLS.
*/
public function startTls(): void
{
$this->imap->startTls();
}
public function disconnect(): void
{
$this->imap->disconnect();
}
public function logIn(string $username, string $password): void
{
$this->send(new LogInCommand($username, $password));
}
public function authenticate(SASLMechanism $mechanism): void
{
$this->send(new AuthenticateCommand($mechanism));
}
/**
* @return array<Mailbox>
*/
public function mailboxes(string $referenceName = '', string $pattern = '*'): array
{
$response = $this->send(new ListCommand($referenceName, $pattern));
return array_map(
fn (ListData $data) => new Mailbox($data->nameAttributes, $data->hierarchyDelimiter, $data->name),
$response->getData(ListData::class),
);
}
public function select(Mailbox|string $mailbox): Mailbox
{
if (is_string($mailbox)) {
$mailbox = new Mailbox([], '', $mailbox);
if ($config->security() === ConnectionSecurity::StartTls) {
$this->perform(new StartTlsCommand());
$this->perform(new CapabilityCommand());
}
$response = $this->send(new SelectCommand($mailbox->name));
if ($flagsData = $response->getData(FlagsData::class)[0] ?? null) {
$mailbox->flags = $flagsData->flags;
}
if ($existsData = $response->getData(ExistsData::class)[0] ?? null) {
$mailbox->exists = $existsData->numberOfMessages;
}
if ($recentData = $response->getData(RecentData::class)[0] ?? null) {
$mailbox->recent = $recentData->numberOfMessages;
}
foreach ($response->getData(Status::class) as $status) {
if ($status->code instanceof UnseenCode) {
$mailbox->unseen = $status->code->seq;
} elseif ($status->code instanceof UidValidityCode) {
$mailbox->uidValidity = $status->code->value;
} elseif ($status->code instanceof UidNextCode) {
$mailbox->uidNext = $status->code->value;
} elseif ($status->code instanceof PermanentFlagsCode) {
$mailbox->permanentFlags = $status->code->flags;
}
}
return $this->selectedMailbox = $mailbox;
}
public function search(): Search
{
return new Search($this);
}
/**
* @throws MessageNotFound
*/
public function fetch(int $id): Message
{
$response = $this->imap->send(
new FetchCommand(
$this->configuration->useUid,
new SequenceSet($id),
['INTERNALDATE', 'BODY[HEADER]', 'BODYSTRUCTURE']
)
);
$data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound();
if (null === $internalDate = $data->internalDate) {
throw new Exception('Unable to fetch internal date from message '.$id);
}
if (null === $part = $data->bodyStructure?->part) {
throw new Exception('Unable to fetch body structure from message '.$id);
}
return new Message(
$id,
$this->createHeaders($data) ?? [],
$this->createMessagePart($id, '0', $part),
$internalDate,
);
}
/**
* Stream FetchData for a specific set of UIDs, one response line at a time.
*
* Uses the same sendStreaming path as fetchMultiple() so responses are
* processed as they arrive off the socket without buffering the entire
* server reply. Items can be tailored per call-site; defaults to a rich
* set that populates EntityResource fully (flags, envelope, body structure,
* size, arrival date).
*
* @param int[] $uids
* @param string[] $items IMAP fetch data items
* @return Generator<int, FetchData> Yields uid => FetchData
*/
public function streamByUids(
array $uids,
array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'],
): Generator {
$gen = $this->imap->sendStreaming(
new FetchCommand(
$this->configuration->useUid,
new SequenceSet(...$uids),
$items,
)
);
foreach ($gen as $line) {
if (!$line instanceof FetchData) {
continue;
}
$id = $line->id;
if ($this->configuration->useUid) {
$id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id);
}
yield $id => $line;
}
}
/**
* Stream messages from a sequence range as a Generator, yielding each
* LazyMessage as soon as its FETCH response line arrives off the socket —
* without waiting for the entire batch to complete.
*
* Usage with an NDJSON HTTP response:
*
* foreach ($client->fetchMultiple(1, 50) as $message) {
* echo json_encode($message) . "\n";
* flush();
* }
*
* @param int $from First sequence number (inclusive)
* @param int $to Last sequence number (inclusive)
* @return Generator<int, LazyMessage>
*/
public function fetchMultiple(int $from, int $to): Generator
{
$items = ['FLAGS', 'INTERNALDATE', 'BODY[HEADER]'];
$gen = $this->imap->sendStreaming(
new FetchCommand(
$this->configuration->useUid,
SequenceSet::range($from, $to),
$items,
)
);
foreach ($gen as $line) {
if (!$line instanceof FetchData) {
continue;
}
$id = $line->id;
if ($this->configuration->useUid) {
$id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id);
}
yield new LazyMessage(
$this,
$id,
$this->createHeaders($line),
$line->internalDate,
);
}
}
/**
* @return array<string, string>
* @throws MessageNotFound
*/
public function fetchHeaders(int $id): array
{
$response = $this->imap->send(
new FetchCommand(
$this->configuration->useUid,
new SequenceSet($id),
['BODY[HEADER]']
)
);
/** @var FetchData $data */
$data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound();
return $this->createHeaders($data) ?? [];
}
public function fetchBody(int $id): Part
{
$response = $this->send(
new FetchCommand(
$this->configuration->useUid,
new SequenceSet($id),
['BODYSTRUCTURE']
)
);
$data = $response->getData(FetchData::class)[0];
if (null === $part = $data->bodyStructure?->part) {
throw new Exception('Unable to fetch body from message '.$id);
}
return $this->createMessagePart($id, '0', $part);
}
public function fetchInternalDate(int $id): DateTimeImmutable
{
$response = $this->send(
new FetchCommand(
$this->configuration->useUid,
new SequenceSet($id),
['INTERNALDATE']
)
);
$data = $response->getData(FetchData::class)[0];
if (null === $internalDate = $data->internalDate) {
throw new Exception('Unable to fetch internal date from message '.$id);
}
return $internalDate;
}
public function fetchSectionBody(int $id, string $section): string
{
$response = $this->send(
new FetchCommand(
$this->configuration->useUid,
new SequenceSet($id),
["BODY[$section]"]
)
);
$data = $response->getData(FetchData::class)[0];
return $data->getBodySection($section)?->text ?? '';
}
public function deleteMessage(Message|int $message): void
{
$id = $message instanceof Message ? $message->id() : $message;
$this->send(
new StoreCommand(
$this->configuration->useUid,
new SequenceSet($id),
new Flags(['\Deleted'], '+')
)
);
$this->send(new ExpungeCommand());
}
public function createMailbox(string $name): void
{
$this->send(new CreateCommand($name));
}
/**
* @param list<string>|null $flags
*/
public function append(
string $message,
string $mailbox = 'INBOX',
?array $flags = null,
?DateTimeInterface $internalDate = null
): int
{
$response = $this->send(new AppendCommand($mailbox, $message, $flags, $internalDate));
$code = $response->status->code;
if ($code instanceof AppendUidCode) {
return $code->uid;
}
throw new RuntimeException('Unable to retrieve uid from append response');
}
public function send(Command $command): Response
{
$this->imap->connect();
return $this->imap->send($command);
}
/**
* @param array<Criteria> $criteria
* @return array<Message>
*/
public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array
{
$response = $this->send(
new Protocol\Command\SearchCommand(
$this->configuration->useUid,
...$criteria
)
);
$ids = [];
foreach ($response->data as $data) {
if ($data instanceof SearchData) {
array_push($ids, ...$data->numbers);
}
}
if (empty($ids)) {
return [];
}
if (null !== $preFetchOptions) {
$items = [];
if ($preFetchOptions->headers) {
$items[] = 'BODY[HEADER]';
}
if ($preFetchOptions->internalDate) {
$items[] = 'INTERNALDATE';
}
$preFetchResult = $this->send(new FetchCommand(
$this->configuration->useUid,
new SequenceSet(...$ids),
$items,
if ($config->hasCredentials()) {
$this->perform(new LoginCommand(
$config->username() ?? '',
$config->password() ?? '',
));
$messages = [];
foreach ($preFetchResult->data as $data) {
if ($data instanceof FetchData) {
$id = $data->id;
if ($this->configuration->useUid) {
$id = $data->uid ?? throw new RuntimeException('Unable to get uid from message '.$id);
}
$messages[] = new LazyMessage(
$this,
$id,
$this->createHeaders($data),
$data->internalDate,
);
}
}
return $messages;
$this->perform(new CapabilityCommand());
}
return array_map(fn (int $id) => new LazyMessage($this, $id), $ids);
}
/**
* @return array<string, string>|null
*/
private function createHeaders(FetchData $data): ?array
public function capabilities(): array
{
if (null === $headerSection = $data->getBodySection('HEADER')) {
return null;
}
return iconv_mime_decode_headers($headerSection->text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR) ?: [];
return $this->session()->capabilities();
}
private function createMessagePart(int $id, string $section, BodyStructure\Part $part): Mime\Part\Part
public function hasCapability(string $capability): bool
{
if ($part instanceof BodyStructure\SinglePart) {
return new SinglePart(
$part->type,
$part->subtype,
$part->attributes,
new LazyBody($this, $id, $section === '0' ? '1' : $section),
$part->attributes['charset'] ?? 'utf-8',
$part->encoding,
null !== $part->disposition
? new Disposition(
$part->disposition->type,
$part->disposition->attributes['filename'] ?? null
) : null,
);
}
if (!$part instanceof BodyStructure\MultiPart) {
throw new Exception('Unable to create message part from body structure part of class '.$part::class);
}
$childParts = [];
foreach ($part->parts as $index => $childPart) {
$childIndex = (string) ($index + 1);
$childSection = $section === '0' ? $childIndex : $section.'.'.$childIndex;
$childParts[] = $this->createMessagePart($id, $childSection, $childPart);
}
return new MultiPart($part->subtype, $part->attributes, $childParts);
return $this->session()->hasCapability($capability);
}
}
public function perform(CommandInterface $command): mixed
{
if ($this->session === null || $this->executor === null) {
throw new ImapException('IMAP client is not connected.');
}
return $this->executor->perform($command, $this->session);
}
public function session(): SessionContext
{
if ($this->session === null) {
throw new ImapException('IMAP client is not connected.');
}
return $this->session;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
use KTXM\ProviderImap\Client\Command\CommandInterface;
interface ClientInterface
{
public function connect(ConnectionConfig $config): void;
/**
* @return list<string>
*/
public function capabilities(): array;
/**
* @template TResult
* @param CommandInterface<TResult> $command
* @return TResult
*/
public function perform(CommandInterface $command): mixed;
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CapabilityResult>
*/
final class CapabilityCommand implements CommandInterface
{
public function name(): string
{
return 'CAPABILITY';
}
public function allowedStates(): array
{
return [
SessionState::NotAuthenticated,
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame('CAPABILITY');
}
public function handle(ResponseStream $responses, SessionContext $context): CapabilityResult
{
$capabilities = [];
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') {
$capabilities = array_map('strtoupper', $response->payloadTokens());
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('CAPABILITY failed: ' . $response->text());
}
}
}
$context->replaceCapabilities(...$capabilities);
return new CapabilityResult($context->capabilities());
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @template TResult
*/
interface CommandInterface
{
public function name(): string;
/**
* @return list<SessionState>
*/
public function allowedStates(): array;
public function encode(string $tag, SessionContext $context): RequestFrame;
/**
* @return TResult
*/
public function handle(ResponseStream $responses, SessionContext $context): mixed;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
/**
* @implements CommandInterface<MessageTransferResult>
*/
final class CopyCommand implements CommandInterface
{
private readonly MessageTransferCommand $command;
public function __construct(
FetchTarget|string|SequenceSet|null $target = null,
string $destinationMailbox = '',
) {
$this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox);
}
public function name(): string
{
return $this->command->name();
}
public function allowedStates(): array
{
return $this->command->allowedStates();
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
return $this->command->encode($tag, $context);
}
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
{
return $this->command->handle($responses, $context);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class CreateCommand implements CommandInterface
{
public function __construct(
private readonly string $mailbox,
) {}
public function name(): string
{
return 'CREATE';
}
public function allowedStates(): array
{
return [
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf('CREATE %s', $this->quote($this->mailbox)));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
unset($context);
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('CREATE failed: ' . $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, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class DeleteCommand implements CommandInterface
{
public function __construct(
private readonly string $mailbox,
) {}
public function name(): string
{
return 'DELETE';
}
public function allowedStates(): array
{
return [
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf('DELETE %s', $this->quote($this->mailbox)));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
if ($context->selectedMailbox() === $this->mailbox) {
$context->setSelectedMailbox(null);
$context->setState(SessionState::Authenticated);
}
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('DELETE failed: ' . $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, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<list<int>>
*/
final class ExpungeCommand implements CommandInterface
{
private readonly ?SequenceSet $sequenceSet;
public function __construct(FetchTarget|string|SequenceSet|null $target = null)
{
if ($target === null) {
$this->sequenceSet = null;
return;
}
$resolvedTarget = match (true) {
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => null,
};
if ($resolvedTarget === null || $resolvedTarget->identifierMode() !== IdentifierMode::Uid) {
throw new ImapException('Targeted EXPUNGE requires a UID target.');
}
$this->sequenceSet = $resolvedTarget->sequenceSet();
}
public function name(): string
{
return 'EXPUNGE';
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag);
if ($this->sequenceSet === null) {
unset($context);
return new RequestFrame('EXPUNGE');
}
if (!$context->hasCapability('UIDPLUS')) {
throw new ImapException('UID EXPUNGE requires the IMAP UIDPLUS capability.');
}
return new RequestFrame(sprintf(
'UID EXPUNGE %s',
$this->sequenceSet->toCommand(),
));
}
public function handle(ResponseStream $responses, SessionContext $context): array
{
if ($context->selectedMailbox() === null) {
throw new ImapException('EXPUNGE requires a selected mailbox.');
}
$expunged = [];
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) {
$expunged[] = (int) $matches[1];
continue;
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException($this->sequenceSet === null
? 'EXPUNGE failed: ' . $response->text()
: 'UID EXPUNGE failed: ' . $response->text());
}
return $expunged;
}
}
throw new ImapException($this->sequenceSet === null
? 'EXPUNGE did not receive a tagged completion response.'
: 'UID EXPUNGE did not receive a tagged completion response.');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
use Generator;
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\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<Generator<int, Message>>
*/
final class FetchManyCommand implements CommandInterface
{
private readonly FetchTarget $target;
private readonly FetchOptions $options;
public function __construct(FetchTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null)
{
$this->target = match (true) {
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => FetchTarget::all(),
};
$this->options = $options ?? FetchOptions::default();
}
public function name(): string
{
return 'FETCH';
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'%s %s (%s)',
$this->target->toCommand(),
$this->target->sequenceSet()->toCommand(),
$this->options->toCommand(),
));
}
public function handle(ResponseStream $responses, SessionContext $context): Generator
{
if ($context->selectedMailbox() === null) {
throw new ImapException('FETCH requires a selected mailbox.');
}
return (new FetchResponseParser())->parseMany($responses);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<Message>
*/
final class FetchOneCommand implements CommandInterface
{
private readonly FetchTarget $target;
private readonly FetchOptions $options;
public function __construct(FetchTarget|int|string $target, ?FetchOptions $options = null)
{
$this->target = $target instanceof FetchTarget
? $target
: FetchTarget::sequence($target);
$this->options = $options ?? FetchOptions::default();
}
public function name(): string
{
return 'FETCH';
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'%s %s (%s)',
$this->target->toCommand(),
$this->target->sequenceSet()->toCommand(),
$this->options->toCommand(),
));
}
public function handle(ResponseStream $responses, SessionContext $context): Message
{
if ($context->selectedMailbox() === null) {
throw new ImapException('FETCH requires a selected mailbox.');
}
return (new FetchResponseParser())->parseOne($responses);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
use Generator;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Message;
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;
final class FetchResponseParser
{
public function parseOne(ResponseStream $responses): Message
{
$message = null;
foreach ($this->parseMany($responses) as $summary) {
if ($message !== null) {
throw new ImapException('FETCH returned multiple messages for a single-message request.');
}
$message = $summary;
}
if ($message === null) {
throw new ImapException('FETCH did not return a message summary.');
}
return $message;
}
/**
* @return Generator<int, Message>
*/
public function parseMany(ResponseStream $responses): Generator
{
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) {
yield MessageParser::parse($response->raw());
continue;
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('FETCH failed: ' . $response->text());
}
return;
}
}
throw new ImapException('FETCH did not receive a tagged completion response.');
}
}

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, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class LoginCommand implements CommandInterface
{
public function __construct(
private readonly string $username,
private readonly string $password,
) {}
public function name(): string
{
return 'LOGIN';
}
public function allowedStates(): array
{
return [SessionState::NotAuthenticated];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'LOGIN %s %s',
$this->quote($this->username),
$this->quote($this->password),
));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('LOGIN failed: ' . $response->text());
}
$context->setState(SessionState::Authenticated);
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, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class LogoutCommand implements CommandInterface
{
public function name(): string
{
return 'LOGOUT';
}
public function allowedStates(): array
{
return [
SessionState::NotAuthenticated,
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame('LOGOUT');
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('LOGOUT failed: ' . $response->text());
}
$context->setSelectedMailbox(null);
$context->setState(SessionState::Logout);
$context->connection()->disconnect();
return new CommandStatusResult($response->status(), $response->text());
}
}
throw new ImapException('LOGOUT did not receive a tagged completion response.');
}
}

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<MessageTransferResult>
*/
final class MessageTransferCommand implements CommandInterface
{
private readonly string $operation;
private readonly SequenceSet $sequenceSet;
private readonly IdentifierMode $identifierMode;
public function __construct(
string $operation,
FetchTarget|string|SequenceSet|null $target = null,
private readonly string $destinationMailbox = '',
) {
$resolvedTarget = match (true) {
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => FetchTarget::all(),
};
$this->operation = strtoupper(trim($operation));
if (!in_array($this->operation, ['COPY', 'MOVE'], true)) {
throw new ImapException('Unsupported transfer operation: ' . $this->operation);
}
$this->sequenceSet = $resolvedTarget->sequenceSet();
$this->identifierMode = $resolvedTarget->identifierMode();
}
public function name(): string
{
return $this->operation;
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'%s%s %s %s',
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
$this->operation,
$this->sequenceSet->toCommand(),
$this->quote($this->destinationMailbox),
));
}
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
{
if ($context->selectedMailbox() === null) {
throw new ImapException($this->operation . ' requires a selected mailbox.');
}
$responseCodes = [];
$copyUid = null;
$tryCreate = false;
$highestModSeq = null;
$expunged = [];
$vanished = [];
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse) {
$this->collectUntaggedData(
$response,
$responseCodes,
$copyUid,
$tryCreate,
$highestModSeq,
$expunged,
$vanished,
);
continue;
}
if ($response instanceof TaggedResponse) {
$this->collectResponseCode(
'tagged',
$response->text(),
$responseCodes,
$copyUid,
$tryCreate,
$highestModSeq,
);
$result = new MessageTransferResult(
$response->status(),
$response->text(),
$responseCodes,
$copyUid,
$tryCreate,
$highestModSeq,
$expunged,
$vanished,
);
if (!$response->isOk()) {
throw new ImapException($this->operation . ' failed: ' . $response->text());
}
return $result;
}
}
throw new ImapException($this->operation . ' did not receive a tagged completion response.');
}
/**
* @param list<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
* @param list<int> $expunged
* @param list<array{earlier:bool, knownUids:string}> $vanished
*/
private function collectUntaggedData(
UntaggedResponse $response,
array &$responseCodes,
?array &$copyUid,
bool &$tryCreate,
?string &$highestModSeq,
array &$expunged,
array &$vanished,
): void {
$label = strtoupper($response->label());
if (in_array($label, ['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'], true)) {
$this->collectResponseCode(
'untagged',
$response->payload(),
$responseCodes,
$copyUid,
$tryCreate,
$highestModSeq,
);
}
if (preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) {
$expunged[] = (int) $matches[1];
return;
}
if (preg_match('/^\*\s+VANISHED(?:\s+\((EARLIER)\))?\s+(.+)$/i', $response->raw(), $matches) === 1) {
$vanished[] = [
'earlier' => isset($matches[1]) && strtoupper($matches[1]) === 'EARLIER',
'knownUids' => trim($matches[2]),
];
}
}
/**
* @param list<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
*/
private function collectResponseCode(
string $source,
string $text,
array &$responseCodes,
?array &$copyUid,
bool &$tryCreate,
?string &$highestModSeq,
): void {
$responseCode = $this->parseResponseCode($text);
if ($responseCode === null) {
return;
}
$responseCodes[] = [
'source' => $source,
'name' => $responseCode['name'],
'arguments' => $responseCode['arguments'],
'text' => $responseCode['text'],
];
if ($responseCode['name'] === 'TRYCREATE') {
$tryCreate = true;
return;
}
if ($responseCode['name'] === 'HIGHESTMODSEQ' && isset($responseCode['arguments'][0])) {
$highestModSeq = $responseCode['arguments'][0];
return;
}
if ($responseCode['name'] !== 'COPYUID' || count($responseCode['arguments']) < 3) {
return;
}
$copyUid = [
'uidValidity' => $responseCode['arguments'][0],
'sourceUids' => $responseCode['arguments'][1],
'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, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
/**
* @implements CommandInterface<MessageTransferResult>
*/
final class MoveCommand implements CommandInterface
{
private readonly MessageTransferCommand $command;
public function __construct(
FetchTarget|string|SequenceSet|null $target = null,
string $destinationMailbox = '',
) {
$this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox);
}
public function name(): string
{
return $this->command->name();
}
public function allowedStates(): array
{
return $this->command->allowedStates();
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
return $this->command->encode($tag, $context);
}
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
{
return $this->command->handle($responses, $context);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class NoopCommand implements CommandInterface
{
public function name(): string
{
return 'NOOP';
}
public function allowedStates(): array
{
return [
SessionState::NotAuthenticated,
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame('NOOP');
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
unset($context);
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('NOOP failed: ' . $response->text());
}
return new CommandStatusResult($response->status(), $response->text());
}
}
throw new ImapException('NOOP did not receive a tagged completion response.');
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class RenameCommand implements CommandInterface
{
public function __construct(
private readonly string $fromMailbox,
private readonly string $toMailbox,
) {}
public function name(): string
{
return 'RENAME';
}
public function allowedStates(): array
{
return [
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'RENAME %s %s',
$this->quote($this->fromMailbox),
$this->quote($this->toMailbox),
));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('RENAME failed: ' . $response->text());
}
if ($context->selectedMailbox() === $this->fromMailbox) {
$context->setSelectedMailbox($this->toMailbox);
}
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, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command\Result;
final class CapabilityResult
{
/**
* @param list<string> $capabilities
*/
public function __construct(
private readonly array $capabilities,
) {}
/**
* @return list<string>
*/
public function capabilities(): array
{
return $this->capabilities;
}
public function has(string $capability): bool
{
return in_array(strtoupper($capability), $this->capabilities, true);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command\Result;
final class CommandStatusResult
{
public function __construct(
private readonly string $status,
private readonly string $text,
) {}
public function status(): string
{
return $this->status;
}
public function text(): string
{
return $this->text;
}
public function isOk(): bool
{
return $this->status === 'OK';
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command\Result;
final class MessageTransferResult
{
/**
* @param list<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
* @param list<int> $expunged
* @param list<array{earlier:bool, knownUids:string}> $vanished
*/
public function __construct(
private readonly string $status,
private readonly string $text,
private readonly array $responseCodes = [],
private readonly ?array $copyUid = null,
private readonly bool $tryCreate = false,
private readonly ?string $highestModSeq = null,
private readonly array $expunged = [],
private readonly array $vanished = [],
) {}
public function status(): string
{
return $this->status;
}
public function text(): string
{
return $this->text;
}
public function isOk(): bool
{
return $this->status === 'OK';
}
/**
* @return list<array{source:string, name:string, arguments:list<string>, text:string}>
*/
public function responseCodes(): array
{
return $this->responseCodes;
}
/**
* @return ?array{uidValidity:string, sourceUids:string, destinationUids:string}
*/
public function copyUid(): ?array
{
return $this->copyUid;
}
/**
* @return array<string,string>
*/
public function copyUidMap(): array
{
if ($this->copyUid === null) {
return [];
}
$sourceUids = $this->expandUidSet($this->copyUid['sourceUids']);
$destinationUids = $this->expandUidSet($this->copyUid['destinationUids']);
if (count($sourceUids) !== count($destinationUids)) {
return [];
}
$mapping = [];
foreach ($sourceUids as $index => $sourceUid) {
$mapping[$sourceUid] = $destinationUids[$index];
}
return $mapping;
}
public function tryCreate(): bool
{
return $this->tryCreate;
}
public function highestModSeq(): ?string
{
return $this->highestModSeq;
}
/**
* @return list<int>
*/
public function expunged(): array
{
return $this->expunged;
}
/**
* @return list<array{earlier:bool, knownUids:string}>
*/
public function vanished(): array
{
return $this->vanished;
}
public function hasResponseCode(string $name): bool
{
$name = strtoupper(trim($name));
foreach ($this->responseCodes as $responseCode) {
if ($responseCode['name'] === $name) {
return true;
}
}
return false;
}
/**
* @return list<string>
*/
private function expandUidSet(string $value): array
{
$expanded = [];
foreach (explode(',', $value) as $segment) {
$segment = trim($segment);
if ($segment === '') {
continue;
}
if (!str_contains($segment, ':')) {
$expanded[] = $segment;
continue;
}
[$start, $end] = array_map('trim', explode(':', $segment, 2));
if (!ctype_digit($start) || !ctype_digit($end)) {
return [];
}
$range = range((int) $start, (int) $end, (int) $start <= (int) $end ? 1 : -1);
foreach ($range as $uid) {
$expanded[] = (string) $uid;
}
}
return $expanded;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command\Result;
use KTXM\ProviderImap\Client\IdentifierMode;
final class SearchResult
{
/**
* @param list<int> $matches
*/
public function __construct(
private readonly array $matches,
private readonly IdentifierMode $identifierMode,
) {}
/**
* @return list<int>
*/
public function matches(): array
{
return $this->matches;
}
public function identifierMode(): IdentifierMode
{
return $this->identifierMode;
}
public function isUidSearch(): bool
{
return $this->identifierMode === IdentifierMode::Uid;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command\Result;
use KTXM\ProviderImap\Client\IdentifierMode;
final class SortResult
{
/**
* @param list<int> $matches
*/
public function __construct(
private readonly array $matches,
private readonly IdentifierMode $identifierMode,
) {}
/**
* @return list<int>
*/
public function matches(): array
{
return $this->matches;
}
public function identifierMode(): IdentifierMode
{
return $this->identifierMode;
}
public function isUidSort(): bool
{
return $this->identifierMode === IdentifierMode::Uid;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command\Result;
final class StatusResult
{
/**
* @param array<string, int> $items
*/
public function __construct(
private readonly string $mailbox,
private readonly array $items,
) {}
public function mailbox(): string
{
return $this->mailbox;
}
/**
* @return array<string, int>
*/
public function items(): array
{
return $this->items;
}
public function value(string $item, int $default = 0): int
{
return $this->items[strtoupper(trim($item))] ?? $default;
}
public function messages(): int
{
return $this->value('MESSAGES');
}
public function unseen(): int
{
return $this->value('UNSEEN');
}
public function read(): int
{
return max(0, $this->messages() - $this->unseen());
}
public function state(): int
{
return $this->value('UIDVALIDITY');
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
use KTXM\ProviderImap\Client\Command\Result\SearchResult;
use KTXM\ProviderImap\Client\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<SearchResult>
*/
final class SearchCommand implements CommandInterface
{
/**
* @param SearchCriteriaBuilder|list<string> $criteria
*/
public function __construct(
private readonly SearchCriteriaBuilder|array $criteria = ['ALL'],
private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence,
private readonly ?string $charset = 'UTF-8',
) {}
public function name(): string
{
return 'SEARCH';
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
$criteria = $this->normalizeCriteria(
$this->criteria instanceof SearchCriteriaBuilder
? $this->criteria->toArray()
: $this->criteria,
);
$command = $this->identifierMode === IdentifierMode::Uid ? 'UID SEARCH' : 'SEARCH';
if ($this->charset !== null && $this->charset !== '') {
$command .= ' CHARSET ' . strtoupper(trim($this->charset));
}
$command .= ' ' . implode(' ', $criteria);
return new RequestFrame($command);
}
public function handle(ResponseStream $responses, SessionContext $context): SearchResult
{
if ($context->selectedMailbox() === null) {
throw new ImapException('SEARCH requires a selected mailbox.');
}
$matches = [];
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && $response->label() === 'SEARCH') {
$matches = $this->parseMatches($response->payloadTokens());
continue;
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('SEARCH failed: ' . $response->text());
}
return new SearchResult($matches, $this->identifierMode);
}
}
throw new ImapException('SEARCH did not receive a tagged completion response.');
}
/**
* @param list<string> $criteria
* @return list<string>
*/
private function normalizeCriteria(array $criteria): array
{
$normalized = [];
foreach ($criteria as $criterion) {
$criterion = trim($criterion);
if ($criterion === '') {
continue;
}
$normalized[] = $criterion;
}
return $normalized === [] ? ['ALL'] : $normalized;
}
/**
* @param list<string> $tokens
* @return list<int>
*/
private function parseMatches(array $tokens): array
{
$matches = [];
foreach ($tokens as $token) {
if (preg_match('/^[1-9]\d*$/', $token) !== 1) {
continue;
}
$matches[] = (int) $token;
}
return $matches;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<Mailbox>
*/
final class SelectCommand implements CommandInterface
{
public function __construct(
private readonly string $mailbox,
private readonly bool $readOnly = true,
) {}
public function name(): string
{
return $this->readOnly ? 'EXAMINE' : 'SELECT';
}
public function allowedStates(): array
{
return [
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'%s %s',
$this->name(),
$this->quote($this->mailbox),
));
}
public function handle(ResponseStream $responses, SessionContext $context): Mailbox
{
$exists = 0;
$recent = 0;
$flags = [];
$readOnly = $this->readOnly;
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse) {
$raw = $response->raw();
if (preg_match('/^\*\s+(\d+)\s+EXISTS$/i', $raw, $matches)) {
$exists = (int) $matches[1];
continue;
}
if (preg_match('/^\*\s+(\d+)\s+RECENT$/i', $raw, $matches)) {
$recent = (int) $matches[1];
continue;
}
if ($response->label() === 'FLAGS' && preg_match('/\(([^)]*)\)/', $response->payload(), $matches)) {
$flags = $this->parseFlags($matches[1]);
continue;
}
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException($this->name() . ' failed: ' . $response->text());
}
if (str_contains(strtoupper($response->text()), 'READ-ONLY')) {
$readOnly = true;
}
$context->setSelectedMailbox($this->mailbox);
$context->setState(SessionState::Selected);
return new Mailbox(
$this->mailbox,
null,
[],
$exists,
0,
null,
$recent,
$flags,
$readOnly,
);
}
}
throw new ImapException($this->name() . ' did not receive a tagged completion response.');
}
/**
* @return list<string>
*/
private function parseFlags(string $flags): array
{
$flags = trim($flags);
if ($flags === '') {
return [];
}
return preg_split('/\s+/', $flags) ?: [];
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SearchCriteriaBuilder;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<SortResult>
*/
final class SortCommand implements CommandInterface
{
/**
* @param SearchCriteriaBuilder|list<string> $criteria
* @param list<string> $sortCriteria
*/
public function __construct(
private readonly array $sortCriteria,
private readonly SearchCriteriaBuilder|array $criteria = ['ALL'],
private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence,
private readonly string $charset = 'UTF-8',
) {}
public function name(): string
{
return 'SORT';
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
$sortCriteria = $this->normalizeSortCriteria($this->sortCriteria);
if ($sortCriteria === []) {
throw new ImapException('SORT requires at least one sort criterion.');
}
$criteria = $this->normalizeCriteria(
$this->criteria instanceof SearchCriteriaBuilder
? $this->criteria->toArray()
: $this->criteria,
);
$command = $this->identifierMode === IdentifierMode::Uid ? 'UID SORT' : 'SORT';
$command .= sprintf(
' (%s) %s %s',
implode(' ', $sortCriteria),
strtoupper(trim($this->charset)),
implode(' ', $criteria),
);
return new RequestFrame($command);
}
public function handle(ResponseStream $responses, SessionContext $context): SortResult
{
if ($context->selectedMailbox() === null) {
throw new ImapException('SORT requires a selected mailbox.');
}
$matches = [];
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && $response->label() === 'SORT') {
$matches = $this->parseMatches($response->payloadTokens());
continue;
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('SORT failed: ' . $response->text());
}
return new SortResult($matches, $this->identifierMode);
}
}
throw new ImapException('SORT did not receive a tagged completion response.');
}
/**
* @param list<string> $criteria
* @return list<string>
*/
private function normalizeCriteria(array $criteria): array
{
$normalized = [];
foreach ($criteria as $criterion) {
$criterion = trim($criterion);
if ($criterion === '') {
continue;
}
$normalized[] = $criterion;
}
return $normalized === [] ? ['ALL'] : $normalized;
}
/**
* @param list<string> $criteria
* @return list<string>
*/
private function normalizeSortCriteria(array $criteria): array
{
$normalized = [];
foreach ($criteria as $criterion) {
$criterion = strtoupper(trim($criterion));
if ($criterion === '') {
continue;
}
$normalized[] = $criterion;
}
return $normalized;
}
/**
* @param list<string> $tokens
* @return list<int>
*/
private function parseMatches(array $tokens): array
{
$matches = [];
foreach ($tokens as $token) {
if (preg_match('/^[1-9]\d*$/', $token) !== 1) {
continue;
}
$matches[] = (int) $token;
}
return $matches;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class StartTlsCommand implements CommandInterface
{
public function name(): string
{
return 'STARTTLS';
}
public function allowedStates(): array
{
return [SessionState::NotAuthenticated];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame('STARTTLS');
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('STARTTLS failed: ' . $response->text());
}
$context->connection()->upgradeToTls();
$context->replaceCapabilities();
return new CommandStatusResult($response->status(), $response->text());
}
}
throw new ImapException('STARTTLS did not receive a tagged completion response.');
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<StatusResult>
*/
final class StatusCommand implements CommandInterface
{
private readonly StatusResponseParser $statusResponseParser;
/**
* @param list<string> $items
*/
public function __construct(
private readonly string $mailbox,
private readonly array $items = ['MESSAGES', 'UNSEEN'],
?StatusResponseParser $statusResponseParser = null,
) {
$this->statusResponseParser = $statusResponseParser ?? new StatusResponseParser();
}
public function name(): string
{
return 'STATUS';
}
public function allowedStates(): array
{
return [
SessionState::Authenticated,
SessionState::Selected,
];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'STATUS %s (%s)',
$this->quote($this->mailbox),
implode(' ', $this->normalizeItems($this->items)),
));
}
public function handle(ResponseStream $responses, SessionContext $context): StatusResult
{
unset($context);
$items = [];
$mailbox = $this->mailbox;
foreach ($responses as $response) {
if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') {
[$mailbox, $items] = $this->statusResponseParser->parse($response->payload());
continue;
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('STATUS failed: ' . $response->text());
}
return new StatusResult($mailbox, $items);
}
}
throw new ImapException('STATUS did not receive a tagged completion response.');
}
/**
* @param list<string> $items
* @return list<string>
*/
private function normalizeItems(array $items): array
{
$normalized = [];
foreach ($items as $item) {
$item = strtoupper(trim($item));
if ($item === '') {
continue;
}
if (!preg_match('/^[A-Z0-9.-]+$/', $item)) {
throw new ImapException('Invalid STATUS item: ' . $item);
}
if (in_array($item, $normalized, true)) {
continue;
}
$normalized[] = $item;
}
if ($normalized === []) {
throw new ImapException('STATUS requires at least one data item.');
}
return $normalized;
}
private function quote(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
use KTXM\ProviderImap\Client\ImapException;
final class StatusResponseParser
{
/**
* @return array{0: string, 1: array<string, int>}
*/
public function parse(string $payload): array
{
$payload = trim($payload);
$offset = 0;
$nameToken = $this->readToken($payload, $offset);
$statusToken = $this->readToken($payload, $offset);
if ($nameToken === null || $statusToken === null) {
throw new ImapException('Unable to parse STATUS response payload: ' . $payload);
}
$mailbox = $this->decodeAtom($nameToken);
if ($mailbox === null || $mailbox === '') {
throw new ImapException('STATUS response is missing a mailbox name: ' . $payload);
}
return [$mailbox, $this->parseItems($statusToken, $payload)];
}
/**
* @return array<string, int>
*/
private function parseItems(string $statusToken, string $payload): array
{
$statusToken = trim($statusToken);
if (!str_starts_with($statusToken, '(') || !str_ends_with($statusToken, ')')) {
throw new ImapException('Invalid STATUS data payload: ' . $payload);
}
$items = trim(substr($statusToken, 1, -1));
if ($items === '') {
return [];
}
$tokens = preg_split('/\s+/', $items) ?: [];
if (count($tokens) % 2 !== 0) {
throw new ImapException('Malformed STATUS item list: ' . $payload);
}
$status = [];
for ($index = 0; $index < count($tokens); $index += 2) {
$item = strtoupper($tokens[$index]);
$value = $tokens[$index + 1];
if (!preg_match('/^\d+$/', $value)) {
throw new ImapException('STATUS item value must be numeric: ' . $payload);
}
$status[$item] = (int) $value;
}
return $status;
}
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 STATUS item 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 STATUS 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;
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
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\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class StoreCommand implements CommandInterface
{
private readonly SequenceSet $sequenceSet;
private readonly IdentifierMode $identifierMode;
/**
* @param list<string> $flags
*/
public function __construct(
FetchTarget|string|SequenceSet|null $target = null,
private array $flags = [],
private string $action = '',
private bool $silent = true,
) {
$resolvedTarget = match (true) {
$target instanceof FetchTarget => $target,
$target instanceof SequenceSet => FetchTarget::sequence($target),
is_string($target) => FetchTarget::sequence($target),
default => FetchTarget::all(),
};
$normalizedAction = trim($this->action);
if (!in_array($normalizedAction, ['', '+', '-'], true)) {
throw new ImapException('STORE action must be one of "", "+", or "-".');
}
$normalizedFlags = array_values(array_filter(array_map(
static fn (string $flag): string => trim($flag),
$this->flags,
), static fn (string $flag): bool => $flag !== ''));
if ($normalizedFlags === []) {
throw new ImapException('STORE requires at least one flag.');
}
$this->flags = $normalizedFlags;
$this->action = $normalizedAction;
$this->sequenceSet = $resolvedTarget->sequenceSet();
$this->identifierMode = $resolvedTarget->identifierMode();
}
public function name(): string
{
return 'STORE';
}
public function allowedStates(): array
{
return [SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
return new RequestFrame(sprintf(
'%sSTORE %s %s (%s)',
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
$this->sequenceSet->toCommand(),
$this->itemName(),
implode(' ', $this->flags),
));
}
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
{
if ($context->selectedMailbox() === null) {
throw new ImapException('STORE requires a selected mailbox.');
}
foreach ($responses as $response) {
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('STORE failed: ' . $response->text());
}
return new CommandStatusResult($response->status(), $response->text());
}
}
throw new ImapException('STORE did not receive a tagged completion response.');
}
private function itemName(): string
{
return sprintf(
'%sFLAGS%s',
$this->action,
$this->silent ? '.SILENT' : '',
);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP;
final readonly class Configuration
{
public function __construct(
public string $transport,
public string $host,
public int $port = 993,
public int $timeout = 60,
public bool $verifyPeer = true,
public bool $verifyPeerName = true,
public bool $allowSelfSigned = false,
public bool $useUid = true,
) {
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class ConnectionConfig
{
public function __construct(
private readonly string $host,
private readonly int $port = 993,
private readonly ConnectionSecurity $security = ConnectionSecurity::Tls,
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,
) {}
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->password !== null;
}
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;
}
public function endpoint(): string
{
return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port);
}
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,
],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
enum ConnectionSecurity: string
{
case Plain = 'plain';
case Tls = 'tls';
case StartTls = 'starttls';
public function transport(): string
{
return $this === self::Tls ? 'ssl' : 'tcp';
}
}

147
lib/Client/FetchOptions.php Normal file
View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class FetchOptions
{
/**
* @param list<string> $items
*/
private function __construct(
private readonly array $items,
) {}
public static function default(): self
{
return (new self([]))
->withUid()
->withFlags()
->withInternalDate()
->withSize();
}
public static function message(): self
{
return self::default()
->withHeaders()
->withEnvelope()
->withBodyStructure();
}
public static function of(string ...$items): self
{
return new self(self::normalize($items));
}
public function withUid(): self
{
return $this->with('UID');
}
public function withFlags(): self
{
return $this->with('FLAGS');
}
public function withInternalDate(): self
{
return $this->with('INTERNALDATE');
}
public function withSize(): self
{
return $this->with('RFC822.SIZE');
}
public function withHeaders(): self
{
return $this->with('BODY[HEADER]');
}
public function withHeader(string ...$fields): self
{
$fields = array_values(array_filter(array_map(
static fn (string $field): string => strtoupper(trim($field)),
$fields,
), static fn (string $field): bool => $field !== ''));
if ($fields === []) {
return $this;
}
return $this->with(sprintf('BODY.PEEK[HEADER.FIELDS (%s)]', implode(' ', $fields)));
}
public function withEnvelope(): self
{
return $this->with('ENVELOPE');
}
public function withBodyStructure(): self
{
return $this->with('BODYSTRUCTURE');
}
public function withBodyText(): self
{
return $this->with('BODY[TEXT]');
}
public function withBodySection(string $section): self
{
$section = strtoupper(trim($section));
if ($section === '') {
return $this;
}
return $this->with(sprintf('BODY[%s]', $section));
}
public function with(string $item): self
{
return new self(self::normalize([
...$this->items,
$item,
]));
}
/**
* @return list<string>
*/
public function toArray(): array
{
return $this->items;
}
public function toCommand(): string
{
return implode(' ', $this->items);
}
/**
* @param list<string> $items
* @return list<string>
*/
private static function normalize(array $items): array
{
$normalized = [];
foreach ($items as $item) {
$item = trim($item);
if ($item === '' || in_array($item, $normalized, true)) {
continue;
}
$normalized[] = $item;
}
if (!in_array('UID', $normalized, true)) {
array_unshift($normalized, 'UID');
}
return $normalized;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class FetchTarget
{
private function __construct(
private readonly SequenceSet $sequenceSet,
private readonly IdentifierMode $identifierMode,
) {}
public static function all(IdentifierMode $identifierMode = IdentifierMode::Sequence): self
{
return new self(SequenceSet::all(), $identifierMode);
}
public static function sequence(int|string|SequenceSet $target): self
{
return new self(self::coerceSequenceSet($target), IdentifierMode::Sequence);
}
public static function uid(int|string|SequenceSet $target): self
{
return new self(self::coerceSequenceSet($target), IdentifierMode::Uid);
}
public function sequenceSet(): SequenceSet
{
return $this->sequenceSet;
}
public function identifierMode(): IdentifierMode
{
return $this->identifierMode;
}
public function toCommand(): string
{
return $this->identifierMode->toCommand();
}
private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet
{
return match (true) {
$target instanceof SequenceSet => $target,
is_int($target) => SequenceSet::single($target),
default => SequenceSet::parse($target),
};
}
}

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';
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
use RuntimeException;
class ImapException extends RuntimeException
{
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class ListReturnOptions
{
private const SUBSCRIBED = 'SUBSCRIBED';
private const CHILDREN = 'CHILDREN';
private const SPECIAL_USE = 'SPECIAL-USE';
/**
* @param list<string> $options
* @param list<string>|null $statusItems
*/
private function __construct(
private readonly array $options,
private readonly ?array $statusItems = null,
) {}
public static function none(): self
{
return new self([]);
}
public static function of(string ...$options): self
{
return new self(self::normalize($options));
}
public static function subscribed(): self
{
return self::none()->withSubscribed();
}
public static function children(): self
{
return self::none()->withChildren();
}
public static function specialUse(): self
{
return self::none()->withSpecialUse();
}
public static function status(string ...$items): self
{
return self::none()->withStatus(...$items);
}
public function withSubscribed(): self
{
return $this->with(self::SUBSCRIBED);
}
public function withChildren(): self
{
return $this->with(self::CHILDREN);
}
public function withSpecialUse(): self
{
return $this->with(self::SPECIAL_USE);
}
public function withStatus(string ...$items): self
{
return new self($this->options, self::normalizeStatusItems($items));
}
/**
* @return list<string>
*/
public function toArray(): array
{
$options = $this->options;
if ($this->statusItems !== null) {
$options[] = sprintf('STATUS (%s)', implode(' ', $this->statusItems));
}
return $options;
}
public function toCommand(): ?string
{
$options = $this->toArray();
if ($options === []) {
return null;
}
return '(' . implode(' ', $options) . ')';
}
public function hasStatus(): bool
{
return $this->statusItems !== null;
}
/**
* @return list<string>
*/
public function statusItems(): array
{
return $this->statusItems ?? [];
}
private function with(string $option): self
{
return new self(self::normalize([
...$this->options,
$option,
]), $this->statusItems);
}
/**
* @param list<string> $options
* @return list<string>
*/
private static function normalize(array $options): array
{
$normalized = [];
foreach ($options as $option) {
$option = strtoupper(trim($option));
if ($option === '') {
continue;
}
if (!in_array($option, [
self::SUBSCRIBED,
self::CHILDREN,
self::SPECIAL_USE,
], true)) {
throw new ImapException('Unsupported LIST return option: ' . $option);
}
if (in_array($option, $normalized, true)) {
continue;
}
$normalized[] = $option;
}
return $normalized;
}
/**
* @param list<string> $items
* @return list<string>
*/
private static function normalizeStatusItems(array $items): array
{
$normalized = [];
foreach ($items as $item) {
$item = strtoupper(trim($item));
if ($item === '') {
continue;
}
if (!preg_match('/^[A-Z0-9.-]+$/', $item)) {
throw new ImapException('Invalid LIST STATUS data item: ' . $item);
}
if (in_array($item, $normalized, true)) {
continue;
}
$normalized[] = $item;
}
if ($normalized === []) {
throw new ImapException('LIST STATUS return option requires at least one STATUS data item.');
}
return $normalized;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class ListSelectionOptions
{
private const SUBSCRIBED = 'SUBSCRIBED';
private const REMOTE = 'REMOTE';
private const RECURSIVEMATCH = 'RECURSIVEMATCH';
private const SPECIAL_USE = 'SPECIAL-USE';
/**
* @param list<string> $options
*/
private function __construct(
private readonly array $options,
) {}
public static function none(): self
{
return new self([]);
}
public static function of(string ...$options): self
{
return new self(self::normalize($options));
}
public static function subscribed(): self
{
return self::none()->withSubscribed();
}
public static function remote(): self
{
return self::none()->withRemote();
}
public static function specialUse(): self
{
return self::none()->withSpecialUse();
}
public function withSubscribed(): self
{
return $this->with(self::SUBSCRIBED);
}
public function withRemote(): self
{
return $this->with(self::REMOTE);
}
public function withRecursiveMatch(): self
{
return $this->with(self::RECURSIVEMATCH);
}
public function withSpecialUse(): self
{
return $this->with(self::SPECIAL_USE);
}
/**
* @return list<string>
*/
public function toArray(): array
{
return $this->options;
}
public function toCommand(): ?string
{
if ($this->options === []) {
return null;
}
return '(' . implode(' ', $this->options) . ')';
}
private function with(string $option): self
{
return new self(self::normalize([
...$this->options,
$option,
]));
}
/**
* @param list<string> $options
* @return list<string>
*/
private static function normalize(array $options): array
{
$normalized = [];
foreach ($options as $option) {
$option = strtoupper(trim($option));
if ($option === '') {
continue;
}
if (!in_array($option, [
self::SUBSCRIBED,
self::REMOTE,
self::RECURSIVEMATCH,
self::SPECIAL_USE,
], true)) {
throw new ImapException('Unsupported LIST selection option: ' . $option);
}
if (in_array($option, $normalized, true)) {
continue;
}
$normalized[] = $option;
}
if (in_array(self::RECURSIVEMATCH, $normalized, true)
&& !in_array(self::SUBSCRIBED, $normalized, true)) {
throw new ImapException('RECURSIVEMATCH requires SUBSCRIBED in LIST selection options.');
}
return $normalized;
}
}

View File

@@ -2,32 +2,101 @@
declare(strict_types=1);
namespace Gricob\IMAP;
namespace KTXM\ProviderImap\Client;
class Mailbox
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
final class Mailbox
{
private const ATTRIBUTE_NOSELECT = '\Noselect';
public array $flags = [];
public int $exists = 0;
public int $recent = 0;
public ?int $unseen = null;
public ?int $uidValidity = null;
public ?int $uidNext = null;
public array $permanentFlags = [];
/**
* @param list<string> $nameAttributes
* @param list<string> $attributes
* @param list<string> $flags
*/
public function __construct(
public array $nameAttributes,
public string $hierarchyDelimiter,
public string $name,
) {
private readonly string $name,
private readonly ?string $delimiter,
private readonly array $attributes,
private readonly int $messages = 0,
private readonly int $unread = 0,
private readonly ?int $state = null,
private readonly int $recent = 0,
private readonly array $flags = [],
private readonly bool $readOnly = true,
) {}
public function fromStatus(StatusResult $status): self
{
return new self(
$this->name,
$this->delimiter,
$this->attributes,
$status->messages() ?? $this->messages,
$status->unseen() ?? $this->unread,
$status->state() ?? $this->state,
$this->recent,
$this->flags,
$this->readOnly,
);
}
public function name(): string
{
return $this->name;
}
public function delimiter(): ?string
{
return $this->delimiter;
}
/**
* @return list<string>
*/
public function attributes(): array
{
return $this->attributes;
}
public function state(): ?int
{
return $this->state;
}
public function messages(): int
{
return $this->messages;
}
public function unread(): int
{
return $this->unread;
}
public function read(): int
{
return max(0, $this->messages - $this->unread);
}
public function recent(): int
{
return $this->recent;
}
/**
* @return list<string>
*/
public function flags(): array
{
return $this->flags;
}
public function readOnly(): bool
{
return $this->readOnly;
}
public function isSelectable(): bool
{
return !in_array(self::ATTRIBUTE_NOSELECT, $this->nameAttributes);
return !in_array('\\NOSELECT', $this->attributes, true);
}
}

185
lib/Client/Message.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class Message
{
/**
* @param list<string> $flags
* @param list<MessageAddress> $from
* @param list<MessageAddress> $sender
* @param list<MessageAddress> $replyTo
* @param list<MessageAddress> $to
* @param list<MessageAddress> $cc
* @param list<MessageAddress> $bcc
* @param array<string, string> $bodySections
*/
public function __construct(
private readonly int $sequence,
private readonly int $uid,
private readonly int $size,
private readonly ?string $internalDate,
private readonly ?string $receivedAt,
private readonly array $flags,
private readonly ?string $subject,
private readonly ?string $sentAt,
private readonly ?string $messageId,
private readonly ?string $inReplyTo,
private readonly array $from,
private readonly array $sender,
private readonly array $replyTo,
private readonly array $to,
private readonly array $cc,
private readonly array $bcc,
private readonly ?MessagePart $bodyStructure,
private readonly array $bodySections,
) {}
public function sequence(): int
{
return $this->sequence;
}
public function uid(): int
{
return $this->uid;
}
public function size(): int
{
return $this->size;
}
public function internalDate(): ?string
{
return $this->internalDate;
}
public function receivedAt(): ?string
{
return $this->receivedAt;
}
/**
* @return list<string>
*/
public function flags(): array
{
return $this->flags;
}
public function subject(): ?string
{
return $this->subject;
}
public function sentAt(): ?string
{
return $this->sentAt;
}
public function messageId(): ?string
{
return $this->messageId;
}
public function inReplyTo(): ?string
{
return $this->inReplyTo;
}
/**
* @return list<MessageAddress>
*/
public function from(): array
{
return $this->from;
}
/**
* @return list<MessageAddress>
*/
public function sender(): array
{
return $this->sender;
}
/**
* @return list<MessageAddress>
*/
public function replyTo(): array
{
return $this->replyTo;
}
/**
* @return list<MessageAddress>
*/
public function to(): array
{
return $this->to;
}
/**
* @return list<MessageAddress>
*/
public function cc(): array
{
return $this->cc;
}
/**
* @return list<MessageAddress>
*/
public function bcc(): array
{
return $this->bcc;
}
public function bodyStructure(): ?MessagePart
{
return $this->bodyStructure;
}
public function bodyText(): ?string
{
return $this->bodySections['TEXT'] ?? null;
}
/**
* @return array<string, string>
*/
public function bodySections(): array
{
return $this->bodySections;
}
/**
* @param array<string, string> $bodySections
*/
public function withBodyData(?MessagePart $bodyStructure, array $bodySections): self
{
return new self(
$this->sequence,
$this->uid,
$this->size,
$this->internalDate,
$this->receivedAt,
$this->flags,
$this->subject,
$this->sentAt,
$this->messageId,
$this->inReplyTo,
$this->from,
$this->sender,
$this->replyTo,
$this->to,
$this->cc,
$this->bcc,
$bodyStructure,
$bodySections,
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class MessageAddress
{
public function __construct(
private readonly ?string $name,
private readonly ?string $mailbox,
private readonly ?string $host,
) {}
public function name(): ?string
{
return $this->name;
}
public function mailbox(): ?string
{
return $this->mailbox;
}
public function host(): ?string
{
return $this->host;
}
public function email(): ?string
{
if ($this->mailbox === null || $this->mailbox === '') {
return null;
}
if ($this->host === null || $this->host === '') {
return $this->mailbox;
}
return $this->mailbox . '@' . $this->host;
}
public function toArray(): array
{
return [
'address' => $this->email(),
'label' => $this->name,
];
}
}

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP;
use Exception;
class MessageNotFound extends Exception
{
}

View File

@@ -0,0 +1,790 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
use DateTimeInterface;
final class MessageParser
{
public static function isFetchMessage(string $payload): bool
{
return str_contains(strtoupper($payload), 'FETCH (');
}
public static function parse(string $raw): Message
{
if (!preg_match('/^\*\s+(\d+)\s+FETCH\s+\((.*)\)$/is', $raw, $matches)) {
throw new ImapException('Unable to parse FETCH response: ' . $raw);
}
$sequence = (int) $matches[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;
$bodySections = self::parseBodySections($attributes, $bodyStructure);
$headers = self::parseFetchedHeaders($attributes);
return new Message(
$sequence,
$uid,
self::toOptionalInt($attributes['RFC822.SIZE'] ?? null) ?? 0,
self::toNullableString($attributes['INTERNALDATE'] ?? null),
self::extractReceivedAt($headers),
self::parseFlags($attributes['FLAGS'] ?? null),
self::decodeMimeHeader(self::envelopeString($envelope, 1)),
self::envelopeString($envelope, 0),
self::trimAngles(self::envelopeString($envelope, 9)),
self::envelopeString($envelope, 8),
self::parseAddressList($envelope[2] ?? null),
self::parseAddressList($envelope[3] ?? null),
self::parseAddressList($envelope[4] ?? null),
self::parseAddressList($envelope[5] ?? null),
self::parseAddressList($envelope[6] ?? null),
self::parseAddressList($envelope[7] ?? null),
$bodyStructure,
$bodySections,
);
}
/**
* @return array<string, mixed>
*/
private static function parseAttributes(string $payload): array
{
$attributes = [];
$offset = 0;
$length = strlen($payload);
while ($offset < $length) {
self::skipWhitespace($payload, $offset);
if ($offset >= $length) {
break;
}
$name = self::parseAttributeName($payload, $offset);
if (!is_string($name) || $name === '') {
throw new ImapException('Unable to parse FETCH attribute name: ' . $payload);
}
self::skipWhitespace($payload, $offset);
$attributes[strtoupper($name)] = self::parseToken($payload, $offset);
}
return $attributes;
}
private static function parseAttributeName(string $payload, int &$offset): string
{
self::skipWhitespace($payload, $offset);
if (preg_match('/\GBODY(?:\.PEEK)?\[/Ai', $payload, $matches, 0, $offset) === 1) {
$start = $offset;
$offset += strlen($matches[0]);
$depth = 1;
$length = strlen($payload);
while ($offset < $length) {
$char = $payload[$offset];
if ($char === '[') {
$depth++;
} elseif ($char === ']') {
$depth--;
if ($depth === 0) {
$offset++;
return substr($payload, $start, $offset - $start);
}
}
$offset++;
}
throw new ImapException('Unterminated FETCH BODY section attribute.');
}
$name = self::parseToken($payload, $offset);
if (!is_string($name)) {
throw new ImapException('Invalid FETCH attribute name.');
}
return $name;
}
private static function parseToken(string $payload, int &$offset): mixed
{
self::skipWhitespace($payload, $offset);
$length = strlen($payload);
if ($offset >= $length) {
throw new ImapException('Unexpected end of FETCH response.');
}
$char = $payload[$offset];
if ($char === '(') {
$offset++;
$items = [];
while (true) {
self::skipWhitespace($payload, $offset);
if ($offset >= $length) {
throw new ImapException('Unterminated FETCH list response.');
}
if ($payload[$offset] === ')') {
$offset++;
return $items;
}
$items[] = self::parseToken($payload, $offset);
}
}
if ($char === '"') {
return self::parseQuotedString($payload, $offset);
}
if ($char === '{') {
return self::parseLiteral($payload, $offset);
}
$start = $offset;
while ($offset < $length && !ctype_space($payload[$offset]) && $payload[$offset] !== '(' && $payload[$offset] !== ')') {
$offset++;
}
$atom = substr($payload, $start, $offset - $start);
if (strtoupper($atom) === 'NIL') {
return null;
}
return $atom;
}
private static function parseLiteral(string $payload, int &$offset): string
{
if (preg_match('/\G\{(\d+)\}\r\n/As', $payload, $matches, 0, $offset) !== 1
&& preg_match('/\G\{(\d+)\}\n/As', $payload, $matches, 0, $offset) !== 1) {
throw new ImapException('Invalid FETCH literal marker.');
}
$offset += strlen($matches[0]);
$length = (int) $matches[1];
$literal = substr($payload, $offset, $length);
if (strlen($literal) !== $length) {
throw new ImapException('FETCH literal length does not match payload.');
}
$offset += $length;
return $literal;
}
private static function parseQuotedString(string $payload, int &$offset): string
{
$offset++;
$length = strlen($payload);
$value = '';
while ($offset < $length) {
$char = $payload[$offset];
if ($char === '\\') {
$offset++;
if ($offset >= $length) {
break;
}
$value .= $payload[$offset];
$offset++;
continue;
}
if ($char === '"') {
$offset++;
return $value;
}
$value .= $char;
$offset++;
}
throw new ImapException('Unterminated quoted FETCH string.');
}
private static function skipWhitespace(string $payload, int &$offset): void
{
$length = strlen($payload);
while ($offset < $length && ctype_space($payload[$offset])) {
$offset++;
}
}
private static function toInt(mixed $value, string $message): int
{
if ($value === null || !preg_match('/^\d+$/', (string) $value)) {
throw new ImapException($message);
}
return (int) $value;
}
private static function toOptionalInt(mixed $value): ?int
{
if ($value === null || !preg_match('/^\d+$/', (string) $value)) {
return null;
}
return (int) $value;
}
/**
* @return list<string>
*/
private static function parseFlags(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_filter(array_map(
static fn (mixed $flag): ?string => is_string($flag) && $flag !== '' ? $flag : null,
$value,
)));
}
private static function toNullableString(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
/**
* @param array<string, mixed> $attributes
* @return array<string, list<string>>
*/
private static function parseFetchedHeaders(array $attributes): array
{
$headers = [];
foreach ($attributes as $name => $value) {
if (!is_string($value)) {
continue;
}
if (!preg_match('/^BODY(?:\.PEEK)?\[(.+)\]$/i', $name, $matches)) {
continue;
}
$section = strtoupper(trim($matches[1]));
if (!str_starts_with($section, 'HEADER')) {
continue;
}
foreach (self::parseHeaderBlock($value) as $headerName => $headerValues) {
$normalized = strtolower($headerName);
$headers[$normalized] ??= [];
array_push($headers[$normalized], ...$headerValues);
}
}
return $headers;
}
/**
* @return array<string, list<string>>
*/
private static function parseHeaderBlock(string $headers): array
{
$parsed = [];
$currentName = null;
$currentValue = '';
foreach (preg_split("/\r\n|\n|\r/", $headers) ?: [] as $line) {
if ($line === '') {
break;
}
if (($line[0] === ' ' || $line[0] === "\t") && $currentName !== null) {
$currentValue .= ' ' . trim($line);
continue;
}
if ($currentName !== null) {
$parsed[$currentName] ??= [];
$parsed[$currentName][] = trim($currentValue);
}
$separator = strpos($line, ':');
if ($separator === false) {
$currentName = null;
$currentValue = '';
continue;
}
$currentName = substr($line, 0, $separator);
$currentValue = substr($line, $separator + 1);
}
if ($currentName !== null) {
$parsed[$currentName] ??= [];
$parsed[$currentName][] = trim($currentValue);
}
return $parsed;
}
/**
* @param array<string, list<string>> $headers
*/
private static function extractReceivedAt(array $headers): ?string
{
foreach ($headers['delivery-date'] ?? [] as $value) {
$date = self::parseHeaderDate($value);
if ($date !== null) {
return $date;
}
}
foreach ($headers['received'] ?? [] as $value) {
$date = self::parseHeaderDateFromReceived($value);
if ($date !== null) {
return $date;
}
}
return null;
}
private static function parseHeaderDate(?string $value): ?string
{
$value = self::toNullableString($value);
if ($value === null) {
return null;
}
try {
return (new \DateTimeImmutable($value))->format(DateTimeInterface::ATOM);
} catch (\Exception) {
return null;
}
}
private static function parseHeaderDateFromReceived(string $value): ?string
{
$separator = strrpos($value, ';');
if ($separator === false) {
return self::parseHeaderDate($value);
}
return self::parseHeaderDate(substr($value, $separator + 1));
}
private static function envelopeString(?array $envelope, int $index): ?string
{
if ($envelope === null) {
return null;
}
return self::toNullableString($envelope[$index] ?? null);
}
private static function decodeMimeHeader(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
return function_exists('mb_decode_mimeheader') ? mb_decode_mimeheader($value) : $value;
}
private static function trimAngles(?string $value): ?string
{
if ($value === null) {
return null;
}
return trim($value, '<>');
}
/**
* @return list<MessageAddress>
*/
private static function parseAddressList(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$addresses = [];
foreach ($value as $address) {
if (!is_array($address)) {
continue;
}
$addresses[] = new MessageAddress(
self::decodeMimeHeader(self::toNullableString($address[0] ?? null)),
self::toNullableString($address[2] ?? null),
self::toNullableString($address[3] ?? null),
);
}
return $addresses;
}
private static function parseBodyPart(mixed $value, string $partId): ?MessagePart
{
if (!is_array($value) || $value === []) {
return null;
}
if (is_array($value[0] ?? null)) {
$parts = [];
$index = 0;
while (isset($value[$index]) && is_array($value[$index])) {
$childPartId = $partId === '' ? (string) ($index + 1) : $partId . '.' . ($index + 1);
$child = self::parseBodyPart($value[$index], $childPartId);
if ($child !== null) {
$parts[] = $child;
}
$index++;
}
$subtype = strtolower(self::toNullableString($value[$index] ?? null) ?? 'mixed');
$parameters = self::parsePairs($value[$index + 1] ?? null);
[$disposition, $dispositionParameters] = self::parseDisposition($value[$index + 2] ?? null);
$language = self::parseStringList($value[$index + 3] ?? null);
$location = self::toNullableString($value[$index + 4] ?? null);
return new MessagePart(
$partId,
'multipart/' . $subtype,
$parameters,
null,
null,
null,
null,
$disposition,
$dispositionParameters,
$language,
$location,
null,
$parts,
);
}
$type = strtolower(self::toNullableString($value[0] ?? null) ?? 'application');
$subtype = strtolower(self::toNullableString($value[1] ?? null) ?? 'octet-stream');
$parameters = self::parsePairs($value[2] ?? null);
$contentId = self::trimAngles(self::toNullableString($value[3] ?? null));
$description = self::toNullableString($value[4] ?? null);
$encoding = self::toNullableString($value[5] ?? null);
$size = self::toOptionalInt($value[6] ?? null);
$tailOffset = in_array($type, ['text', 'message'], true) ? 8 : 7;
[$disposition, $dispositionParameters] = self::parseDisposition($value[$tailOffset + 1] ?? null);
$language = self::parseStringList($value[$tailOffset + 2] ?? null);
$location = self::toNullableString($value[$tailOffset + 3] ?? null);
return new MessagePart(
$partId === '' ? '1' : $partId,
$type . '/' . $subtype,
$parameters,
$contentId,
$description,
$encoding,
$size,
$disposition,
$dispositionParameters,
$language,
$location,
null,
[],
);
}
/**
* @param array<string, mixed> $attributes
* @return array<string, string>
*/
private static function parseBodySections(array $attributes, ?MessagePart $bodyStructure = null): array
{
$sections = [];
foreach ($attributes as $name => $value) {
if (!preg_match('/^BODY(?:\.PEEK)?\[(.*)\]$/i', $name, $matches)) {
continue;
}
if (!is_string($value)) {
continue;
}
$section = strtoupper(trim($matches[1]));
if ($section === '') {
continue;
}
if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) {
$section = $partMatches[1];
}
$sections[$section] = $value;
}
if ($bodyStructure === null || !isset($sections['TEXT'])) {
return $bodyStructure === null ? $sections : self::decodeSections($sections, $bodyStructure);
}
if ($bodyStructure->isMultipart()) {
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure);
unset($sections['TEXT']);
foreach ($derivedSections as $section => $content) {
$sections[$section] ??= $content;
}
return self::decodeSections($sections, $bodyStructure);
}
if (str_starts_with($bodyStructure->mimeType(), 'text/')) {
$sections[$bodyStructure->partId()] ??= $sections['TEXT'];
unset($sections['TEXT']);
}
return self::decodeSections($sections, $bodyStructure);
}
/**
* @param array<string, string> $sections
* @return array<string, string>
*/
private static function decodeSections(array $sections, MessagePart $bodyStructure): array
{
$decodedSections = [];
foreach ($sections as $section => $content) {
$part = self::findBodyPart($bodyStructure, (string) $section);
if ($part === null || !str_starts_with($part->mimeType(), 'text/')) {
$decodedSections[$section] = $content;
continue;
}
$decodedSections[$section] = self::decodeSectionContent(
$content,
$part->encoding(),
$part->parameters()['charset'] ?? 'us-ascii',
);
}
return $decodedSections;
}
/**
* @return array<string, string>
*/
private static function sectionsFromBodyText(string $content, MessagePart $part): array
{
if ($part->isMultipart()) {
$boundary = $part->parameters()['boundary'] ?? '';
if ($boundary === '') {
return [];
}
$sections = [];
$segments = self::splitMultipartBody($content, $boundary);
foreach ($part->parts() as $index => $childPart) {
if (!isset($segments[$index])) {
break;
}
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) {
$sections[$section] = $childContent;
}
}
return $sections;
}
if (!str_starts_with($part->mimeType(), 'text/')) {
return [];
}
return [$part->partId() => $content];
}
/**
* @return array<string, string>
*/
private static function sectionsFromMimeEntity(string $content, MessagePart $part): array
{
[, $body] = self::splitMimeEntity($content);
if ($part->isMultipart()) {
return self::sectionsFromBodyText($body, $part);
}
if (!str_starts_with($part->mimeType(), 'text/')) {
return [];
}
return [$part->partId() => $body];
}
private static function findBodyPart(MessagePart $part, string $section): ?MessagePart
{
if ($part->partId() === $section) {
return $part;
}
foreach ($part->parts() as $childPart) {
$match = self::findBodyPart($childPart, $section);
if ($match !== null) {
return $match;
}
}
return null;
}
/**
* @return list<string>
*/
private static function splitMultipartBody(string $content, string $boundary): array
{
$pattern = '/(?:^|\r\n|\n)--' . preg_quote($boundary, '/') . '(--)?[ \t]*(?:\r\n|\n|$)/';
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) {
return [];
}
$segments = [];
$segmentStart = null;
foreach ($matches[0] as $index => [$match, $offset]) {
if ($segmentStart !== null) {
$segments[] = substr($content, $segmentStart, $offset - $segmentStart);
}
$isClosing = isset($matches[1][$index][1])
&& $matches[1][$index][1] !== -1
&& $matches[1][$index][0] === '--';
if ($isClosing) {
break;
}
$segmentStart = $offset + strlen($match);
}
return $segments;
}
/**
* @return array{0: string, 1: string}
*/
private static function splitMimeEntity(string $content): array
{
foreach (["\r\n\r\n", "\n\n"] as $separator) {
$position = strpos($content, $separator);
if ($position === false) {
continue;
}
return [
substr($content, 0, $position),
substr($content, $position + strlen($separator)),
];
}
return ['', $content];
}
private static function decodeSectionContent(string $content, ?string $encoding, string $charset): string
{
$decoded = match (strtolower($encoding ?? '7bit')) {
'quoted-printable' => quoted_printable_decode($content),
'base64' => base64_decode($content, true) ?: '',
default => $content,
};
if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) {
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
}
try {
$converted = mb_convert_encoding($decoded, 'UTF-8', $charset);
if ($converted !== false) {
return $converted;
}
} catch (\ValueError) {
}
$converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded);
$decoded = $converted !== false ? $converted : $decoded;
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
}
/**
* @return array<string, string>
*/
private static function parsePairs(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$pairs = [];
for ($index = 0; $index < count($value); $index += 2) {
$name = self::toNullableString($value[$index] ?? null);
if ($name === null) {
continue;
}
$pairs[strtolower($name)] = self::toNullableString($value[$index + 1] ?? null) ?? '';
}
return $pairs;
}
/**
* @return array{0: ?string, 1: array<string, string>}
*/
private static function parseDisposition(mixed $value): array
{
if (!is_array($value)) {
return [null, []];
}
return [
strtolower(self::toNullableString($value[0] ?? null) ?? ''),
self::parsePairs($value[1] ?? null),
];
}
/**
* @return list<string>
*/
private static function parseStringList(mixed $value): array
{
if ($value === null) {
return [];
}
if (is_string($value)) {
return [$value];
}
if (!is_array($value)) {
return [];
}
return array_values(array_filter(array_map(
static fn (mixed $item): ?string => is_string($item) && $item !== '' ? $item : null,
$value,
)));
}
}

217
lib/Client/MessagePart.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client;
final class MessagePart
{
/**
* @param array<string, string> $parameters
* @param array<string, string> $dispositionParameters
* @param list<string> $language
* @param list<MessagePart> $parts
*/
public function __construct(
private readonly string $partId,
private readonly string $mimeType,
private readonly array $parameters = [],
private readonly ?string $contentId = null,
private readonly ?string $description = null,
private readonly ?string $encoding = null,
private readonly ?int $size = null,
private readonly ?string $disposition = null,
private readonly array $dispositionParameters = [],
private readonly array $language = [],
private readonly ?string $location = null,
private readonly ?string $content = null,
private readonly array $parts = [],
) {}
public function partId(): string
{
return $this->partId;
}
public function mimeType(): string
{
return $this->mimeType;
}
/**
* @return array<string, string>
*/
public function parameters(): array
{
return $this->parameters;
}
public function contentId(): ?string
{
return $this->contentId;
}
public function description(): ?string
{
return $this->description;
}
public function encoding(): ?string
{
return $this->encoding;
}
public function size(): ?int
{
return $this->size;
}
public function disposition(): ?string
{
return $this->disposition;
}
/**
* @return array<string, string>
*/
public function dispositionParameters(): array
{
return $this->dispositionParameters;
}
/**
* @return list<string>
*/
public function language(): array
{
return $this->language;
}
public function location(): ?string
{
return $this->location;
}
public function content(): ?string
{
return $this->content;
}
/**
* @return list<MessagePart>
*/
public function parts(): array
{
return $this->parts;
}
public function isMultipart(): bool
{
return str_starts_with($this->mimeType, 'multipart/');
}
/**
* @param array<string, string> $sections
*/
public function withInjectedSections(array $sections): self
{
if ($this->parts !== []) {
$parts = [];
foreach ($this->parts as $part) {
$parts[] = $part->withInjectedSections($sections);
}
return new self(
$this->partId,
$this->mimeType,
$this->parameters,
$this->contentId,
$this->description,
$this->encoding,
$this->size,
$this->disposition,
$this->dispositionParameters,
$this->language,
$this->location,
$this->content,
$parts,
);
}
if (!str_starts_with($this->mimeType, 'text/')) {
return $this;
}
if (!array_key_exists($this->partId, $sections)) {
return $this;
}
return new self(
$this->partId,
$this->mimeType,
$this->parameters,
$this->contentId,
$this->description,
$this->encoding,
$this->size,
$this->disposition,
$this->dispositionParameters,
$this->language,
$this->location,
self::decodeContent($sections[$this->partId], $this->encoding, $this->parameters['charset'] ?? 'us-ascii'),
[],
);
}
public function toArray(): array
{
$data = [
'partId' => $this->partId,
'type' => $this->mimeType,
'blobId' => $this->contentId,
'charset' => $this->parameters['charset'] ?? null,
'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null,
'encoding' => $this->encoding,
'size' => $this->size,
'disposition' => $this->disposition,
'language' => $this->language === [] ? null : implode(',', $this->language),
'location' => $this->location,
'content' => $this->content,
];
$children = [];
foreach ($this->parts as $part) {
$children[] = $part->toArray();
}
$data['subParts'] = $children === [] ? null : $children;
return array_filter($data, static fn (mixed $value): bool => $value !== null);
}
private static function decodeContent(string $content, ?string $encoding, string $charset): string
{
$decoded = match (strtolower($encoding ?? '7bit')) {
'quoted-printable' => quoted_printable_decode($content),
'base64' => base64_decode($content, true) ?: '',
default => $content,
};
if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) {
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
}
try {
$converted = mb_convert_encoding($decoded, 'UTF-8', $charset);
if ($converted !== false) {
return $converted;
}
} catch (\ValueError) {
}
$converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded);
$decoded = $converted !== false ? $converted : $decoded;
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
}
}

View File

@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime;
use DateTimeImmutable;
use Gricob\IMAP\Client;
use Gricob\IMAP\Mime\Part\Part;
class LazyMessage extends Message
{
public function __construct(
private Client $client,
int $id,
?array $headers = null,
?DateTimeImmutable $internalDate = null,
) {
$this->id = $id;
if (null !== $headers) {
$this->headers = $headers;
}
if (null !== $internalDate) {
$this->internalDate = $internalDate;
}
}
public function headers(): array
{
if (!isset($this->headers)) {
$this->headers = $this->client->fetchHeaders($this->id);
}
return parent::headers();
}
public function body(): Part
{
if (!isset($this->body)) {
$this->body = $this->client->fetchBody($this->id);
}
return parent::body();
}
public function internalDate(): DateTimeImmutable
{
if (!isset($this->internalDate)) {
$this->internalDate = $this->client->fetchInternalDate($this->id);
}
return parent::internalDate();
}
}

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime;
use DateTimeImmutable;
use Gricob\IMAP\Mime\Part\Part;
class Message
{
/**
* @param array<string, string> $headers
*/
public function __construct(
protected int $id,
protected array $headers,
protected Part $body,
protected DateTimeImmutable $internalDate,
) {
}
public function id(): int
{
return $this->id;
}
/**
* @return array<string, string>
*/
public function headers(): array
{
return $this->headers;
}
public function body(): Part
{
return $this->body;
}
public function internalDate(): DateTimeImmutable
{
return $this->internalDate;
}
public function textBody(): ?string
{
return $this->body()->findPartByMimeType('text/plain')?->decodedBody();
}
public function htmlBody(): ?string
{
return $this->body()->findPartByMimeType('text/html')?->decodedBody();
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime\Part;
use Stringable;
class Body implements Stringable
{
public function __construct(
protected string $value
) {
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime\Part;
final readonly class Disposition
{
public function __construct(
public string $type,
public ?string $filename,
) {
}
}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime\Part;
use Gricob\IMAP\Client;
class LazyBody extends Body
{
public function __construct(
private Client $client,
private int $id,
private string $section,
) {
}
public function __toString(): string
{
if (!isset($this->value)) {
$this->value = $this->client->fetchSectionBody($this->id, $this->section);
}
return $this->value;
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime\Part;
final readonly class MultiPart extends Part
{
/**
* @param array<string,string> $attributes
* @param list<Part> $parts
*/
public function __construct(
string $subtype,
array $attributes,
public array $parts,
) {
parent::__construct('multipart', $subtype, $attributes);
}
public function findPartByMimeType(string $mimeType): ?SinglePart
{
foreach ($this->parts as $part) {
if ($matchedPart = $part->findPartByMimeType(strtolower($mimeType))) {
return $matchedPart;
}
}
return null;
}
}

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime\Part;
abstract readonly class Part
{
public string $type;
public string $subtype;
/**
* @var array<string, string>
*/
public array $attributes;
/**
* @param array<string,string> $attributes
*/
public function __construct(
string $type,
string $subtype,
array $attributes,
) {
$this->subtype = strtolower($subtype);
$this->type = strtolower($type);
$this->attributes = $attributes;
}
abstract public function findPartByMimeType(string $mimeType): ?SinglePart;
public function mimeType(): string
{
return $this->type.'/'.$this->subtype;
}
}

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Mime\Part;
final readonly class SinglePart extends Part
{
private string $encoding;
public function __construct(
string $type,
string $subtype,
array $attributes,
private Body $body,
private string $charset,
string $encoding,
private ?Disposition $disposition,
) {
$this->encoding = strtolower($encoding);
parent::__construct($type, $subtype, $attributes);
}
public function body(): string
{
return (string) $this->body;
}
public function decodedBody(): string
{
return match ($this->encoding) {
'quoted-printable' => quoted_printable_decode($this->body()),
'base64' => base64_decode($this->body()),
default => $this->body(),
};
}
public function charset(): string
{
return $this->charset;
}
public function encoding(): string
{
return $this->encoding;
}
public function disposition(): ?Disposition
{
return $this->disposition;
}
public function findPartByMimeType(string $mimeType): ?SinglePart
{
if ($this->mimeType() === strtolower($mimeType)) {
return $this;
}
return null;
}
}

View File

@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP;
final readonly class PreFetchOptions
{
public function __construct(
public bool $internalDate = false,
public bool $headers = false,
) {
}
}

View File

@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use DateTimeInterface;
use Gricob\IMAP\Protocol\Command\Argument\DateTime;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
use Gricob\IMAP\Protocol\Command\Argument\SynchronizingLiteral;
use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList;
final readonly class AppendCommand extends Command implements Continuable
{
/**
* @param list<string>|null $flags
*/
public function __construct(
string $mailboxName,
private string $message,
?array $flags,
?DateTimeInterface $internalDate
) {
parent::__construct(
'APPEND',
...array_filter([
new QuotedString($mailboxName),
ParenthesizedList::tryFrom($flags),
DateTime::tryFrom($internalDate),
new SynchronizingLiteral($this->message),
])
);
}
public function continue(): string
{
return $this->message;
}
}

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
interface Argument
{
public function __toString(): string;
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
use DateTimeInterface;
readonly class Date implements Argument
{
public function __construct(private DateTimeInterface $value)
{
}
public static function tryFrom(?DateTimeInterface $value): ?self
{
return is_null($value) ? null : new self($value);
}
public function __toString(): string
{
return $this->value->format('d-M-Y');
}
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
use DateTimeInterface;
readonly class DateTime implements Argument
{
public function __construct(private DateTimeInterface $value)
{
}
public static function tryFrom(?DateTimeInterface $value): ?self
{
return is_null($value) ? null : new self($value);
}
public function __toString(): string
{
return '"'.$this->value->format('d-M-Y H:i:s O').'"';
}
}

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final readonly class ParenthesizedList implements Argument
{
/**
* @param list<string> $items
*/
public function __construct(public array $items)
{
}
/**
* @param list<string> $items
*/
public static function tryFrom(?array $items): ?self
{
return empty($items) ? null : new self($items);
}
public function __toString(): string
{
return sprintf('(%s)', implode(' ', $this->items));
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final readonly class QuotedString implements Argument
{
public function __construct(private string $value)
{
}
public function __toString(): string
{
return sprintf('"%s"', $this->value);
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
class All implements Criteria
{
public function __toString(): string
{
return 'ALL';
}
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\Date;
readonly class Before extends Date implements Criteria
{
public function __toString(): string
{
return 'BEFORE '.parent::__toString();
}
}

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
interface Criteria extends Argument
{
}

View File

@@ -1,19 +0,0 @@
<?php
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
class Header implements Criteria
{
public function __construct(
private string $fieldName,
private string $value,
) {
}
public function __toString(): string
{
return sprintf('HEADER %s %s', $this->fieldName, new QuotedString($this->value));
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
final readonly class Not implements Criteria
{
public function __construct(private Criteria $criteria)
{
}
public function __toString(): string
{
return 'NOT ('.$this->criteria.')';
}
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\Date;
readonly class Since extends Date implements Criteria
{
public function __toString(): string
{
return 'SINCE '.parent::__toString();
}
}

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final class SequenceSet implements Argument
{
/**
* @var array<int>
*/
private array $numbers;
private ?string $range;
public function __construct(int ...$numbers)
{
$this->numbers = $numbers;
$this->range = null;
}
public static function range(int $from, int $to): self
{
$set = new self();
$set->range = $from . ':' . $to;
return $set;
}
public function __toString(): string
{
if ($this->range !== null) {
return $this->range;
}
return implode(',', $this->numbers);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Store;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
final readonly class Flags implements Argument
{
/**
* @param list<string> $flags
*/
public function __construct(
private array $flags,
private string $modifier = '',
private bool $silent = true,
) {
}
public function __toString(): string
{
return sprintf(
'%sFLAGS%s (%s)',
$this->modifier,
$this->silent ? '.SILENT' : '',
implode(' ', $this->flags),
);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final readonly class SynchronizingLiteral implements Argument
{
public function __construct(private string $value)
{
}
public function __toString(): string
{
return sprintf(
'{%s}',
strlen($this->value)
);
}
}

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Authenticate;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
use Gricob\IMAP\Protocol\Command\Continuable;
interface SASLMechanism extends Argument, Continuable
{
}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Authenticate;
final readonly class XOAuth2 implements SASLMechanism
{
public function __construct(
private string $user,
private string $accessToken
) {
}
public function __toString(): string
{
return 'XOAUTH2';
}
public function continue(): string
{
return base64_encode(
sprintf("user=%s\1auth=Bearer %s\1\1", $this->user, $this->accessToken)
);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Authenticate\SASLMechanism;
readonly class AuthenticateCommand extends Command implements Continuable
{
public function __construct(private SASLMechanism $mechanism)
{
parent::__construct('AUTHENTICATE', $mechanism);
}
public function continue(): string
{
return $this->mechanism->continue();
}
}

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
use Stringable;
abstract readonly class Command implements Stringable
{
private string $command;
/**
* @var Argument[]
*/
private array $arguments;
public function __construct(
string $command,
Argument ...$arguments,
) {
$this->command = $command;
$this->arguments = $arguments;
}
public function command(): string
{
return $this->command;
}
public function __toString(): string
{
return sprintf(
'%s %s',
$this->command,
implode(' ', $this->arguments)
);
}
}

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
interface Continuable
{
public function continue(): string;
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
final readonly class CreateCommand extends Command
{
public function __construct(string $mailboxName)
{
parent::__construct('CREATE', new QuotedString($mailboxName));
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
final readonly class ExpungeCommand extends Command
{
public function __construct()
{
parent::__construct('EXPUNGE');
}
}

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
final readonly class FetchCommand extends Command
{
/**
* @param bool $uid
* @param SequenceSet $sequenceSet
* @param list<string> $items
*/
public function __construct(
bool $uid,
SequenceSet $sequenceSet,
array $items,
) {
parent::__construct(
$uid ? 'UID FETCH' : 'FETCH',
$sequenceSet,
new ParenthesizedList($items),
);
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
readonly class ListCommand extends Command
{
public function __construct(string $referenceName, string $pattern)
{
parent::__construct(
'LIST',
new QuotedString($referenceName),
new QuotedString($pattern)
);
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
final readonly class LogInCommand extends Command
{
public function __construct(string $user, string $password)
{
parent::__construct(
'LOGIN',
new QuotedString($user),
new QuotedString($password)
);
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
final readonly class SearchCommand extends Command
{
public function __construct(
bool $uid,
Criteria ...$criteria,
) {
parent::__construct(
$uid ? 'UID SEARCH' : 'SEARCH',
...$criteria,
);
}
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
readonly class SelectCommand extends Command
{
public function __construct(string $mailbox)
{
parent::__construct('SELECT', new QuotedString($mailbox));
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
/**
* STARTTLS command (RFC 3501 §6.2.1) — patched into gricob/imap.
*
* After the server responds OK, upgradeTls() must be called on the underlying
* SocketConnection to complete the TLS handshake.
*/
final readonly class StartTlsCommand extends Command
{
public function __construct()
{
parent::__construct('STARTTLS');
}
}

View File

@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
use Gricob\IMAP\Protocol\Command\Argument\Store\Flags;
final readonly class StoreCommand extends Command
{
public function __construct(
bool $uid,
SequenceSet $sequenceSet,
Flags $dataItem
) {
parent::__construct(
$uid ? 'UID STORE' : 'STORE',
$sequenceSet,
$dataItem,
);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use Generator;
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\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
use Psr\Log\LoggerInterface;
final class CommandExecutor
{
public function __construct(
private readonly ProtocolReader $reader,
private readonly ProtocolWriter $writer,
private readonly TagGenerator $tags = new TagGenerator(),
private readonly ?LoggerInterface $logger = null,
) {}
/**
* @template TResult
* @param CommandInterface<TResult> $command
* @return TResult
*/
public function perform(CommandInterface $command, SessionContext $context): mixed
{
$this->assertState($command->allowedStates(), $context->state(), $command->name());
$this->logger?->debug('IMAP command execution started: {command} (state={state})', [
'command' => $command->name(),
'state' => $context->state()->value,
]);
$tag = $this->tags->next();
$frame = $command->encode($tag, $context);
$this->writer->write($tag, $frame);
return $command->handle(new ResponseStream(function () use ($tag, $context): Generator {
yield from $this->responsesUntilCompletion($tag, $context);
}), $context);
}
/**
* @param list<SessionState> $allowedStates
*/
private function assertState(array $allowedStates, SessionState $currentState, string $commandName): void
{
foreach ($allowedStates as $allowedState) {
if ($allowedState === $currentState) {
return;
}
}
throw new ImapException(sprintf(
'Command %s is not allowed while session is in state %s.',
$commandName,
$currentState->value,
));
}
private function responsesUntilCompletion(string $tag, SessionContext $context): Generator
{
while (true) {
$response = $this->reader->readResponse();
if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') {
$context->replaceCapabilities(...$response->payloadTokens());
}
yield $response;
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
$this->logger?->debug('IMAP command execution completed: tag={tag} status={status}', [
'tag' => $tag,
'status' => $response->status(),
]);
return;
}
}
}
}

View File

@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use RuntimeException;
class CommandFailed extends RuntimeException
{
public static function withStatus(Status $status): self
{
return new self($status->message);
}
}

View File

@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Generator;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Continuable;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Transport\Connection;
use RuntimeException;
final readonly class CommandInteraction implements ContinuationHandler
{
public function __construct(
private Connection $connection,
private ResponseHandler $responseHandler,
private string $tag,
private Command $command,
) {
}
public function interact(): Response
{
$request = sprintf(
"%s %s\r\n",
$this->tag,
$this->command,
);
$this->connection->send($request);
$streamResponse = $this->connection->receive();
return $this->responseHandler->handle($this->tag, $streamResponse, $this);
}
/**
* Like interact() but yields each untagged Line immediately as it arrives.
* The terminal Status is the generator's return value.
*
* @return Generator<int, Line, mixed, Status>
*/
public function streamInteract(): Generator
{
$request = sprintf(
"%s %s\r\n",
$this->tag,
$this->command,
);
$this->connection->send($request);
$streamResponse = $this->connection->receive();
yield from $this->responseHandler->stream($this->tag, $streamResponse, $this);
}
public function continue(): void
{
if (!$this->command instanceof Continuable) {
throw new RuntimeException(
sprintf('Command %s does not support continuable interaction', $this->command->command())
);
}
$this->connection->send($this->command->continue()."\r\n");
}
}

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use RuntimeException;
class ConnectionRejected extends RuntimeException
{
}

View File

@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
interface ContinuationHandler
{
public function continue(): void;
}

View File

@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Generator;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\StartTlsCommand;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Line\Status\StatusType;
use Gricob\IMAP\Protocol\Response\Parser\Parser;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Transport\Connection;
use RuntimeException;
class Imap
{
protected Connection $connection;
private TagGenerator $tagGenerator;
private ResponseHandler $responseHandler;
public function __construct(Connection $connection)
{
$this->connection = $connection;
$this->tagGenerator = new TagGenerator();
$this->responseHandler = new ResponseHandler(new Parser());
}
public function __destruct()
{
$this->disconnect();
}
public function connect(): void
{
if ($this->connection->isOpen()) {
return;
}
$this->connection->open();
$responseStream = $this->connection->receive();
$greeting = $this->responseHandler->handle('*', $responseStream, new UnexpectedContinuationHandler());
match ($greeting->status->type) {
StatusType::OK => null, // Do nothing
StatusType::PREAUTH => throw new RuntimeException('pre-auth is not supported'),
StatusType::BAD,
StatusType::NO,
StatusType::BYE => throw new ConnectionRejected($greeting->status->message),
};
}
public function disconnect(): void
{
$this->connection->close();
}
/**
* Perform STARTTLS negotiation (patch).
*
* Sends the STARTTLS command and upgrades the underlying socket to TLS.
* The connection must be a SocketConnection (or any Connection that
* implements upgradeTls()). Call this after connect() but before logIn().
*
* @throws \RuntimeException if the server rejects STARTTLS
* @throws \BadMethodCallException if the connection does not support TLS upgrade
*/
public function startTls(): void
{
if (!method_exists($this->connection, 'upgradeTls')) {
throw new \BadMethodCallException(
'The current Connection implementation does not support STARTTLS upgrade'
);
}
$response = $this->send(new StartTlsCommand());
if ($response->status->type !== StatusType::OK) {
throw new \RuntimeException(
'Server rejected STARTTLS: ' . $response->status->message
);
}
$this->connection->upgradeTls();
}
public function send(Command $command): Response
{
$interaction = new CommandInteraction(
$this->connection,
$this->responseHandler,
$this->tagGenerator->next(),
$command,
);
$response = $interaction->interact();
if ($response->status->type != StatusType::OK) {
throw CommandFailed::withStatus($response->status);
}
return $response;
}
/**
* Sends $command and returns a Generator that yields each untagged Line as
* it arrives from the socket. CommandFailed is thrown (inside the generator)
* if the server responds with NO or BAD.
*
* @return Generator<int, Line, mixed, Status>
*/
public function sendStreaming(Command $command): Generator
{
$this->connect();
$interaction = new CommandInteraction(
$this->connection,
$this->responseHandler,
$this->tagGenerator->next(),
$command,
);
yield from $interaction->streamInteract();
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
use KTXM\ProviderImap\Client\Protocol\Response\GreetingResponse;
use KTXM\ProviderImap\Client\Protocol\Response\ResponseInterface;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
use Psr\Log\LoggerInterface;
final class ProtocolReader
{
public function __construct(
private readonly ConnectionInterface $connection,
private readonly ?LoggerInterface $logger = null,
) {}
public function readGreeting(): GreetingResponse
{
$raw = $this->trimTrailingLineEnding($this->connection->readLine());
if (!str_starts_with($raw, '* ')) {
throw new ImapException(sprintf('Expected IMAP greeting, got: %s', $raw));
}
$parts = preg_split('/\s+/', substr($raw, 2), 2) ?: [];
$status = strtoupper($parts[0] ?? '');
$text = $parts[1] ?? '';
$this->logger?->debug('IMAP greeting received: {raw}', [
'status' => $status,
'raw' => $raw,
]);
return new GreetingResponse($status, $text, $raw);
}
public function readResponse(): ResponseInterface
{
$raw = $this->readRawResponse();
if ($raw === '') {
throw new ImapException('Received empty IMAP response line.');
}
if (str_starts_with($raw, '* ')) {
$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,
);
}
if (str_starts_with($raw, '+')) {
$this->logger?->debug('IMAP continuation response received: {raw}', [
'raw' => $raw,
]);
return new ContinuationResponse(ltrim(substr($raw, 1)), $raw);
}
$parts = preg_split('/\s+/', $raw, 3) ?: [];
if (count($parts) < 2) {
throw new ImapException(sprintf('Malformed tagged IMAP response: %s', $raw));
}
$status = strtoupper($parts[1]);
$this->logger?->debug('IMAP tagged response received: {raw}', [
'tag' => $parts[0],
'status' => $status,
'raw' => $raw,
]);
return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw);
}
private function readRawResponse(): string
{
$raw = $this->connection->readLine();
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
$raw .= $this->connection->readBytes($literalLength);
$raw .= $this->connection->readLine();
}
return $this->trimTrailingLineEnding($raw);
}
private function trailingLiteralLength(string $raw): ?int
{
if (preg_match('/\{(\d+)\}\r?\n$/', $raw, $matches) !== 1) {
return null;
}
return (int) $matches[1];
}
private function trimTrailingLineEnding(string $raw): string
{
if (str_ends_with($raw, "\r\n")) {
return substr($raw, 0, -2);
}
if (str_ends_with($raw, "\n")) {
return substr($raw, 0, -1);
}
return $raw;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
use Psr\Log\LoggerInterface;
final class ProtocolWriter
{
public function __construct(
private readonly ConnectionInterface $connection,
private readonly ?LoggerInterface $logger = null,
) {}
public function write(string $tag, RequestFrame $frame): void
{
$wire = $frame->toWire($tag);
$this->logger?->debug('IMAP command sent: {raw}', [
'tag' => $tag,
'command' => strtok($frame->commandLine(), ' ') ?: $frame->commandLine(),
'raw' => $this->sanitizeWire($wire),
]);
$this->connection->write($wire);
}
private function sanitizeWire(string $wire): string
{
$trimmed = rtrim($wire, "\r\n");
if (preg_match('/^(\S+\s+LOGIN\s+".*?"\s+)".*"$/i', $trimmed, $matches)) {
return $matches[1] . '"[REDACTED]"';
}
if (preg_match('/^(\S+\s+AUTHENTICATE\s+\S+)(?:\s+.+)?$/i', $trimmed, $matches)) {
return $matches[1] . ' [REDACTED]';
}
return $trimmed;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
final class RequestFrame
{
public function __construct(
private readonly string $commandLine,
) {}
public function commandLine(): string
{
return $this->commandLine;
}
public function toWire(string $tag): string
{
return $tag . ' ' . $this->commandLine . "\r\n";
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Response;
final class ContinuationResponse implements ResponseInterface
{
public function __construct(
private readonly string $text,
private readonly string $raw,
) {}
public function text(): string
{
return $this->text;
}
public function raw(): string
{
return $this->raw;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Response;
final class GreetingResponse implements ResponseInterface
{
public function __construct(
private readonly string $status,
private readonly string $text,
private readonly string $raw,
) {}
public function status(): string
{
return $this->status;
}
public function text(): string
{
return $this->text;
}
public function raw(): string
{
return $this->raw;
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line;
final readonly class CommandContinuation implements Line
{
public function __construct(
public string $message,
) {
}
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final readonly class CapabilityData implements Data
{
/**
* @param list<string> $capabilities
*/
public function __construct(public array $capabilities)
{
}
}

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