generated from Nodarx/template
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b73615793 | |||
| f4edaade10 | |||
| 8624ee9d83 | |||
| 98f5f85242 | |||
| 067c748b98 | |||
| fba0f06677 | |||
| bfab500d5e | |||
| ef6962c73b | |||
| 680fe80c33 | |||
| ab33d2c38b | |||
| 82120075b3 | |||
| 54e1c496d7 | |||
| d7d95967e0 | |||
| 6048c03dfe | |||
| ec8012a528 | |||
| 6e440d018d | |||
| 7dee3d58df | |||
| eeace9d613 | |||
| 894eeedd17 | |||
| 75a04809eb | |||
| 264d036321 | |||
| 0f0a3046ac | |||
| fc1e4250d7 | |||
| 6b8ef9c75f | |||
| 5694a2a5d5 | |||
| 062bf6fed5 | |||
| 5a94bd7e55 | |||
| 75e327cbc7 | |||
| 0082d884d5 | |||
| 534e897f9e | |||
| ea9fb1333a | |||
| 17d71a3bbb | |||
| cf7f951134 | |||
| a88a9f9881 | |||
| 0e257b5c3a | |||
| 9e6f241690 | |||
| f9f608f408 | |||
| 8ec4107405 | |||
| 2f37025e45 | |||
| 826940087d | |||
| 5b27535723 | |||
| 9e8f546075 | |||
| e21b7ae75d | |||
| 75a8455f24 | |||
| 06264dfddf | |||
| 67a0dbbee7 | |||
| ad6b7c5eba | |||
| e031831719 | |||
| d94b1ea211 | |||
| 3dd9c2f983 | |||
| c8f0f8328b | |||
| db45636bd4 | |||
| 5b05f76928 | |||
| 624f7a9690 | |||
| fc47b6abd0 | |||
| b87265adac | |||
| f022af4b1d | |||
| 7cbfb3d8c2 | |||
| a0347225cb | |||
| a3d9bb0aed | |||
| 695bdfc1b8 | |||
| fbc9dab0b0 | |||
| 15d27fd72f | |||
| cfc8c92101 | |||
| 7df98223f8 | |||
| 37d88d992d | |||
| 3163e7f857 | |||
| 7813a5a8b2 |
@@ -0,0 +1,50 @@
|
||||
name: Build Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Retrieve Server Install Action
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/action-server-install
|
||||
ref: main
|
||||
path: action-server-install
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install Server
|
||||
uses: ./action-server-install
|
||||
with:
|
||||
install-php: 'false'
|
||||
install-node: 'true'
|
||||
php-version: '8.5'
|
||||
node-version: '24'
|
||||
server-path: './server'
|
||||
|
||||
- name: Install Mail Manager
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/mail_manager
|
||||
ref: main
|
||||
path: server/modules/mail_manager
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Checkout Pull Request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: server/modules/provider_imap
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: server/modules/provider_imap
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: server/modules/provider_imap
|
||||
@@ -0,0 +1,50 @@
|
||||
name: JS Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Retrieve Server Install Action
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/action-server-install
|
||||
ref: main
|
||||
path: action-server-install
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install Server
|
||||
uses: ./action-server-install
|
||||
with:
|
||||
install-php: 'false'
|
||||
install-node: 'true'
|
||||
php-version: '8.5'
|
||||
node-version: '24'
|
||||
server-path: './server'
|
||||
|
||||
- name: Install Mail Manager
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/mail_manager
|
||||
ref: main
|
||||
path: server/modules/mail_manager
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Checkout Pull Request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: server/modules/provider_imap
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: server/modules/provider_imap
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:unit
|
||||
working-directory: server/modules/provider_imap
|
||||
@@ -0,0 +1,59 @@
|
||||
name: PHP Integration Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:8
|
||||
options: >-
|
||||
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
|
||||
steps:
|
||||
- name: Retrieve Server Install Action
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/action-server-install
|
||||
ref: main
|
||||
path: action-server-install
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install server
|
||||
uses: ./action-server-install
|
||||
with:
|
||||
install-php: 'true'
|
||||
php-version: '8.5'
|
||||
server-path: './server'
|
||||
database-uri: 'mongodb://mongo:27017/?tls=false'
|
||||
database-name: 'ktrix_ci'
|
||||
app-environment: 'test'
|
||||
|
||||
- name: Checkout module under test
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: server/modules/provider_imap
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install module dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
working-directory: server/modules/provider_imap
|
||||
|
||||
- name: Install and enable module
|
||||
working-directory: server
|
||||
run: |
|
||||
php bin/console module:install provider_imap
|
||||
php bin/console module:enable provider_imap
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: server/modules/provider_imap
|
||||
run: composer test:integration
|
||||
@@ -0,0 +1,42 @@
|
||||
name: PHP Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Retrieve Server Install Action
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/action-server-install
|
||||
ref: main
|
||||
path: action-server-install
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install Server
|
||||
uses: ./action-server-install
|
||||
with:
|
||||
install-php: 'true'
|
||||
install-node: 'false'
|
||||
php-version: '8.5'
|
||||
node-version: '24'
|
||||
server-path: './server'
|
||||
|
||||
- name: Checkout Pull Request
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: server/modules/provider_imap
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
working-directory: server/modules/provider_imap
|
||||
|
||||
- name: Run tests
|
||||
run: composer test:unit
|
||||
working-directory: server/modules/provider_imap
|
||||
@@ -25,11 +25,17 @@ jobs:
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install Renovate
|
||||
run: npm install -g renovate
|
||||
run: |
|
||||
npm install --global --no-audit --fund=false \
|
||||
--prefix "${{ runner.temp }}/renovate-npm" \
|
||||
--cache "${{ runner.temp }}/renovate-npm-cache" \
|
||||
renovate
|
||||
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||
run: renovate ${{ gitea.repository }}
|
||||
run: |
|
||||
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
|
||||
+1
-5
@@ -14,11 +14,7 @@ node_modules/
|
||||
# Backend development
|
||||
/lib/vendor/
|
||||
coverage/
|
||||
phpunit.xml.cache
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
*.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
|
||||
+11
-8
@@ -7,7 +7,7 @@
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"platform": {
|
||||
"php": "8.2"
|
||||
"php": "8.3"
|
||||
},
|
||||
"autoloader-suffix": "ProviderImap",
|
||||
"vendor-dir": "lib/vendor",
|
||||
@@ -16,14 +16,16 @@
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2 <=8.5",
|
||||
"php": ">=8.3 <=8.5",
|
||||
"ext-iconv": "*",
|
||||
"ext-ctype": "*",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"doctrine/lexer": "^3.0"
|
||||
"doctrine/lexer": "^3.0",
|
||||
"symfony/mime": "^7.0",
|
||||
"egulias/email-validator": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
"phpunit/phpunit": "^12.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -32,7 +34,7 @@
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"KTXM\\ProviderImap\\": "tests/php/"
|
||||
"KTXT\\ProviderImap\\Tests\\": "tests/php/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -40,7 +42,8 @@
|
||||
],
|
||||
"post-update-cmd": [
|
||||
],
|
||||
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
|
||||
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
|
||||
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
|
||||
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
|
||||
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+738
-354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* APPEND a raw RFC822 message to a mailbox (RFC 3501 §6.3.11).
|
||||
*
|
||||
* Uses a synchronizing literal: the command line ends with `{<len>}`, the
|
||||
* server replies with a continuation request ("+"), and only then is the
|
||||
* message streamed back via {@see ResponseStream::respond()}. Returns the
|
||||
* assigned UID when the server reports APPENDUID (RFC 4315 / UIDPLUS),
|
||||
* otherwise null.
|
||||
*
|
||||
* @implements CommandInterface<int|null>
|
||||
*/
|
||||
final class AppendCommand implements CommandInterface
|
||||
{
|
||||
private readonly string $literal;
|
||||
|
||||
/**
|
||||
* @param list<string> $flags optional initial flags, e.g. ['\\Seen']
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
string $message,
|
||||
private readonly array $flags = [],
|
||||
) {
|
||||
// IMAP literals are octet-counted; normalise to CRLF line endings.
|
||||
$this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'APPEND';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Authenticated, SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
$flagSegment = $this->flags === [] ? '' : '(' . implode(' ', $this->flags) . ') ';
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'APPEND %s %s{%d}',
|
||||
$this->quote($this->mailbox),
|
||||
$flagSegment,
|
||||
strlen($this->literal),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): ?int
|
||||
{
|
||||
unset($context);
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof ContinuationResponse) {
|
||||
// Continuation granted: stream the literal, then CRLF to end the command.
|
||||
$responses->respond($this->literal . "\r\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('APPEND failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return $this->parseAppendUid($response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('APPEND did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $mailbox): string
|
||||
{
|
||||
return '"' . addcslashes($mailbox, "\\\"") . '"';
|
||||
}
|
||||
|
||||
private function parseAppendUid(string $text): ?int
|
||||
{
|
||||
if (preg_match('/\[APPENDUID\s+\d+\s+(\d+)\]/i', $text, $matches) === 1) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ final class FetchResponseParser
|
||||
public function parseMany(ResponseStream $responses): Generator
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) {
|
||||
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->raw())) {
|
||||
yield MessageParser::parse($response->raw());
|
||||
continue;
|
||||
}
|
||||
@@ -55,4 +55,4 @@ final class FetchResponseParser
|
||||
|
||||
throw new ImapException('FETCH did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,21 @@ use DateTimeInterface;
|
||||
|
||||
final class MessageParser
|
||||
{
|
||||
public static function isFetchMessage(string $payload): bool
|
||||
public static function isFetchMessage(string $raw): bool
|
||||
{
|
||||
return str_contains(strtoupper($payload), 'FETCH (');
|
||||
return preg_match('/^\*\s+\d+\s+FETCH\s+\(/i', $raw) === 1;
|
||||
}
|
||||
|
||||
public static function parse(string $raw): Message
|
||||
{
|
||||
if (!preg_match('/^\*\s+(\d+)\s+FETCH\s+\((.*)\)$/is', $raw, $matches)) {
|
||||
if (preg_match('/^\*\s+(\d+)\s+FETCH\s+\(/iA', $raw, $matches) !== 1
|
||||
|| !str_ends_with($raw, ')')) {
|
||||
throw new ImapException('Unable to parse FETCH response: ' . $raw);
|
||||
}
|
||||
|
||||
$sequence = (int) $matches[1];
|
||||
$attributes = self::parseAttributes($matches[2]);
|
||||
$offset = strlen($matches[0]);
|
||||
$attributes = self::parseAttributes($raw, $offset, strlen($raw) - 1);
|
||||
$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;
|
||||
@@ -52,15 +54,13 @@ final class MessageParser
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function parseAttributes(string $payload): array
|
||||
private static function parseAttributes(string $payload, int &$offset, int $end): array
|
||||
{
|
||||
$attributes = [];
|
||||
$offset = 0;
|
||||
$length = strlen($payload);
|
||||
|
||||
while ($offset < $length) {
|
||||
while ($offset < $end) {
|
||||
self::skipWhitespace($payload, $offset);
|
||||
if ($offset >= $length) {
|
||||
if ($offset >= $end) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -784,4 +784,4 @@ final class MessageParser
|
||||
$value,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ final class MessagePart
|
||||
$data = [
|
||||
'partId' => $this->partId,
|
||||
'blobId' => $this->partId,
|
||||
'cId' => $this->contentId,
|
||||
'cid' => $this->contentId,
|
||||
'type' => $this->mimeType,
|
||||
'charset' => $this->parameters['charset'] ?? null,
|
||||
'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null,
|
||||
|
||||
@@ -42,9 +42,14 @@ final class CommandExecutor
|
||||
$frame = $command->encode($tag, $context);
|
||||
$this->writer->write($tag, $frame);
|
||||
|
||||
return $command->handle(new ResponseStream(function () use ($tag, $context): Generator {
|
||||
yield from $this->processPerform($tag, $context);
|
||||
}), $context);
|
||||
return $command->handle(new ResponseStream(
|
||||
function () use ($tag, $context): Generator {
|
||||
yield from $this->processPerform($tag, $context);
|
||||
},
|
||||
function (string $payload): void {
|
||||
$this->writer->writeRaw($payload);
|
||||
},
|
||||
), $context);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,16 +56,27 @@ final class ProtocolReader
|
||||
}
|
||||
|
||||
if (str_starts_with($raw, '* ')) {
|
||||
$parts = preg_split('/\s+/', substr($raw, 2), 2) ?: [];
|
||||
$label = strtoupper($parts[0] ?? '');
|
||||
// Keep the payload as a slice of the raw response. FETCH payloads can
|
||||
// contain large message literals, so eagerly splitting here would keep
|
||||
// a second full copy alive while the message is parsed.
|
||||
$labelEnd = 2;
|
||||
while (isset($raw[$labelEnd]) && !ctype_space($raw[$labelEnd])) {
|
||||
$labelEnd++;
|
||||
}
|
||||
$label = strtoupper(substr($raw, 2, $labelEnd - 2));
|
||||
$payloadOffset = $labelEnd;
|
||||
while (isset($raw[$payloadOffset]) && ctype_space($raw[$payloadOffset])) {
|
||||
$payloadOffset++;
|
||||
}
|
||||
$this->logger?->debug('IMAP untagged response received: {raw}', [
|
||||
'label' => $label,
|
||||
'raw' => $raw,
|
||||
]);
|
||||
return new UntaggedResponse(
|
||||
$label,
|
||||
$parts[1] ?? '',
|
||||
'',
|
||||
$raw,
|
||||
$payloadOffset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,4 +192,4 @@ final class ProtocolReader
|
||||
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,21 @@ final class ProtocolWriter
|
||||
$this->connection->write($wire);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write raw bytes to the connection without tagging or logging the payload.
|
||||
*
|
||||
* Used to send literal data (e.g. an APPEND message body) after the server
|
||||
* has issued a command continuation request.
|
||||
*/
|
||||
public function writeRaw(string $payload): void
|
||||
{
|
||||
$this->logger?->debug('IMAP literal sent (bytes={bytes})', [
|
||||
'bytes' => strlen($payload),
|
||||
]);
|
||||
|
||||
$this->connection->write($payload);
|
||||
}
|
||||
|
||||
private function sanitizeWire(string $wire): string
|
||||
{
|
||||
$trimmed = rtrim($wire, "\r\n");
|
||||
|
||||
@@ -10,6 +10,7 @@ final class UntaggedResponse implements ResponseInterface
|
||||
private readonly string $label,
|
||||
private readonly string $payload,
|
||||
private readonly string $raw,
|
||||
private readonly ?int $payloadOffset = null,
|
||||
) {}
|
||||
|
||||
public function label(): string
|
||||
@@ -19,6 +20,12 @@ final class UntaggedResponse implements ResponseInterface
|
||||
|
||||
public function payload(): string
|
||||
{
|
||||
// ProtocolReader records an offset for potentially large responses and
|
||||
// only materializes the payload for command parsers that actually need it.
|
||||
if ($this->payloadOffset !== null) {
|
||||
return substr($this->raw, $this->payloadOffset);
|
||||
}
|
||||
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
@@ -27,7 +34,7 @@ final class UntaggedResponse implements ResponseInterface
|
||||
*/
|
||||
public function payloadTokens(): array
|
||||
{
|
||||
$payload = trim($this->payload);
|
||||
$payload = trim($this->payload());
|
||||
|
||||
if ($payload === '') {
|
||||
return [];
|
||||
@@ -40,4 +47,4 @@ final class UntaggedResponse implements ResponseInterface
|
||||
{
|
||||
return $this->raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
use Generator;
|
||||
use IteratorAggregate;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use Traversable;
|
||||
|
||||
final class ResponseStream implements IteratorAggregate
|
||||
@@ -13,16 +14,39 @@ final class ResponseStream implements IteratorAggregate
|
||||
/** @var \Closure():Generator */
|
||||
private readonly \Closure $generatorFactory;
|
||||
|
||||
/** @var (\Closure(string):void)|null */
|
||||
private readonly ?\Closure $continuationWriter;
|
||||
|
||||
/**
|
||||
* @param \Closure():Generator $generatorFactory
|
||||
* @param \Closure():Generator $generatorFactory
|
||||
* @param (\Closure(string):void)|null $continuationWriter writes raw bytes
|
||||
* back to the server in response to a command continuation request
|
||||
*/
|
||||
public function __construct(\Closure $generatorFactory)
|
||||
public function __construct(\Closure $generatorFactory, ?\Closure $continuationWriter = null)
|
||||
{
|
||||
$this->generatorFactory = $generatorFactory;
|
||||
$this->continuationWriter = $continuationWriter;
|
||||
}
|
||||
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return ($this->generatorFactory)();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send raw bytes to the server after a command continuation request (a "+"
|
||||
* response) — e.g. the literal payload of an APPEND.
|
||||
*
|
||||
* Must only be called while iterating this stream, in response to a
|
||||
* {@see \KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse};
|
||||
* the next pulled response then reflects the server's reaction to the data.
|
||||
*/
|
||||
public function respond(string $payload): void
|
||||
{
|
||||
if ($this->continuationWriter === null) {
|
||||
throw new ImapException('This response stream does not support continuation replies.');
|
||||
}
|
||||
|
||||
($this->continuationWriter)($payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use KTXM\ProviderImap\Providers\Provider;
|
||||
use KTXM\ProviderImap\Providers\Service;
|
||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContext;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -41,7 +41,7 @@ class ConnectCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Provider $provider,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContext $tenantContext,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -150,12 +150,11 @@ class ConnectCommand extends Command
|
||||
// ── Build service object ────────────────────────────────────────────
|
||||
|
||||
$location = new ServiceLocation(
|
||||
host: $host,
|
||||
port: $port > 0 ? $port : 993,
|
||||
encryption: $encryption,
|
||||
verifyPeer: !$noVerify,
|
||||
verifyPeerName: !$noVerify,
|
||||
allowSelfSigned: $noVerify,
|
||||
inboundHost: $host,
|
||||
inboundPort: $port > 0 ? $port : 993,
|
||||
inboundEncryption: $encryption,
|
||||
inboundVerifyPeer: !$noVerify,
|
||||
inboundVerifyHost: !$noVerify,
|
||||
);
|
||||
|
||||
$identity = (new ServiceIdentityBasic())->jsonDeserialize([
|
||||
@@ -187,7 +186,7 @@ class ConnectCommand extends Command
|
||||
|
||||
// ── Persist ──────────────────────────────────────────────────────────
|
||||
|
||||
$this->sessionTenant->configureById($tenantId);
|
||||
$this->tenantContext->resolveIdentifier($tenantId);
|
||||
|
||||
$label = $io->ask('Service label', $username);
|
||||
if ($label) {
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace KTXM\ProviderImap\Console;
|
||||
|
||||
use KTXM\ProviderImap\Providers\Provider;
|
||||
use KTXM\ProviderImap\Providers\Service;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContext;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -37,7 +37,7 @@ class DisconnectCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Provider $provider,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContext $tenantContext,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class DisconnectCommand extends Command
|
||||
$userId = (string) ($input->getOption('user') ?? '');
|
||||
|
||||
if ($tenantId !== '' && $userId !== '') {
|
||||
$this->sessionTenant->configureById($tenantId);
|
||||
$this->tenantContext->resolveIdentifier($tenantId);
|
||||
$services = $this->provider->serviceList($tenantId, $userId);
|
||||
|
||||
if (empty($services)) {
|
||||
@@ -130,7 +130,7 @@ class DisconnectCommand extends Command
|
||||
}
|
||||
|
||||
// ── Fetch service for display ────────────────────────────────────────
|
||||
$this->sessionTenant->configureById($tenantId);
|
||||
$this->tenantContext->resolveIdentifier($tenantId);
|
||||
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
||||
|
||||
if ($service === null) {
|
||||
@@ -139,7 +139,7 @@ class DisconnectCommand extends Command
|
||||
}
|
||||
|
||||
$label = $service->getLabel() ?? $serviceId;
|
||||
$host = $service->getLocation()?->getHost() ?? 'unknown';
|
||||
$host = $service->getLocation()?->getInboundHost() ?? 'unknown';
|
||||
|
||||
$io->title('Disconnect IMAP Service');
|
||||
$io->definitionList(
|
||||
|
||||
@@ -14,8 +14,7 @@ use KTXM\ProviderImap\Providers\Service;
|
||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||
use KTXM\ProviderImap\Service\Discovery;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContext;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -44,7 +43,7 @@ class DiscoverCommand extends Command
|
||||
public function __construct(
|
||||
private readonly Provider $provider,
|
||||
private readonly Discovery $discovery,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContext $tenantContext,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -151,7 +150,7 @@ class DiscoverCommand extends Command
|
||||
/** @var array<string, ServiceLocation> $choiceMap */
|
||||
$choiceMap = [];
|
||||
foreach ($candidates as $c) {
|
||||
$label = sprintf('%s : %d [%s]', $c->getHost(), $c->getPort(), $encLabel($c->getEncryption()));
|
||||
$label = sprintf('%s : %d [%s]', $c->getInboundHost(), $c->getInboundPort(), $encLabel($c->getInboundEncryption()));
|
||||
$choiceMap[$label] = $c;
|
||||
}
|
||||
|
||||
@@ -170,9 +169,9 @@ class DiscoverCommand extends Command
|
||||
|
||||
$io->success('Server selected:');
|
||||
$io->definitionList(
|
||||
['Host' => $location->getHost()],
|
||||
['Port' => (string) $location->getPort()],
|
||||
['Encryption' => $encLabel($location->getEncryption())],
|
||||
['Host' => $location->getInboundHost()],
|
||||
['Port' => (string) $location->getInboundPort()],
|
||||
['Encryption' => $encLabel($location->getInboundEncryption())],
|
||||
);
|
||||
|
||||
if (!$save) {
|
||||
@@ -211,7 +210,7 @@ class DiscoverCommand extends Command
|
||||
|
||||
// ── Persist ──────────────────────────────────────────────────────────
|
||||
|
||||
$this->sessionTenant->configureById($tenantId);
|
||||
$this->tenantContext->resolveIdentifier($tenantId);
|
||||
|
||||
$label = $io->ask('Service label', $address);
|
||||
if ($label) {
|
||||
|
||||
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Console;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXM\ProviderImap\Client\Command\SelectCommand;
|
||||
use KTXM\ProviderImap\Client\FetchOptions;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
@@ -17,7 +17,7 @@ use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Providers\Provider;
|
||||
use KTXM\ProviderImap\Providers\Service;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -41,7 +41,7 @@ class TestCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Provider $provider,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContext $tenantContext,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -108,7 +108,7 @@ class TestCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->sessionTenant->configureById($tenantId);
|
||||
$this->tenantContext->resolveIdentifier($tenantId);
|
||||
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
|
||||
|
||||
if ($service === null) {
|
||||
@@ -119,8 +119,7 @@ class TestCommand extends Command
|
||||
$startedAt = microtime(true);
|
||||
|
||||
try {
|
||||
$client = RemoteService::freshClient($service);
|
||||
$mailService = RemoteService::mailService($service, $client);
|
||||
$mailService = new RemoteMailService($service);
|
||||
$mailboxes = $mailService->collectionList();
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('IMAP diagnostic failed: ' . $e->getMessage());
|
||||
@@ -184,9 +183,8 @@ class TestCommand extends Command
|
||||
$io->section(sprintf('Recent Messages: %s', $mailboxName));
|
||||
|
||||
try {
|
||||
$mailboxClient = RemoteService::freshClient($service);
|
||||
$mailboxService = RemoteService::mailService($service, $mailboxClient);
|
||||
$selectedMailbox = $mailboxClient->perform(new SelectCommand($mailboxName, true));
|
||||
$mailboxService = new RemoteMailService($service);
|
||||
$selectedMailbox = $mailboxService->imapClient()->perform(new SelectCommand($mailboxName, true));
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Mailbox inspection failed: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
@@ -271,7 +269,7 @@ class TestCommand extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sessionTenant->configureById($tenantId);
|
||||
$this->tenantContext->resolveIdentifier($tenantId);
|
||||
$services = $this->provider->serviceList($tenantId, $userId);
|
||||
|
||||
if ($services === []) {
|
||||
@@ -308,7 +306,7 @@ class TestCommand extends Command
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return sprintf('%s://%s:%d', $location->getEncryption(), $location->getHost(), $location->getPort());
|
||||
return sprintf('%s://%s:%d', $location->getInboundEncryption(), $location->getInboundHost(), $location->getInboundPort());
|
||||
}
|
||||
|
||||
private function formatSender(Message $message): string
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Mime;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use KTXF\Mail\Object\AddressInterface;
|
||||
use KTXF\Mail\Object\MessagePartInterface;
|
||||
use KTXF\Mail\Object\MessagePropertiesBaseInterface;
|
||||
use Symfony\Component\Mime\Address as MimeAddress;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
|
||||
/**
|
||||
* MIME Message Builder
|
||||
*
|
||||
* Converts a framework {@see MessagePropertiesBaseInterface} into raw RFC822
|
||||
* bytes. The output is transport-neutral and used by both of this module's
|
||||
* outbound paths: the SMTP DATA payload (submission) and the IMAP APPEND
|
||||
* literal (saving a copy to the Sent collection).
|
||||
*
|
||||
* The MIME engine (symfony/mime) is intentionally hidden behind this API so it
|
||||
* can be swapped without touching consumers.
|
||||
*
|
||||
* @since 2026.06.22
|
||||
*/
|
||||
final class MessageBuilder
|
||||
{
|
||||
/**
|
||||
* Headers that are derived from structured properties and therefore must
|
||||
* not be copied verbatim from {@see MessagePropertiesBaseInterface::getHeaders()}.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const RESERVED_HEADERS = [
|
||||
'from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'subject', 'date',
|
||||
'message-id', 'mime-version', 'content-type', 'content-transfer-encoding',
|
||||
'in-reply-to', 'references', 'bcc',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the complete RFC822 message bytes for the given properties.
|
||||
*/
|
||||
public function build(MessagePropertiesBaseInterface $message): string
|
||||
{
|
||||
return $this->toEmail($message)->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the envelope recipients (To + Cc + Bcc) as bare e-mail addresses.
|
||||
*
|
||||
* Useful for the SMTP RCPT TO phase, which needs every recipient including
|
||||
* Bcc, even though Bcc never appears in the rendered headers.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function recipients(MessagePropertiesBaseInterface $message): array
|
||||
{
|
||||
$recipients = [];
|
||||
|
||||
foreach ([...$message->getTo(), ...$message->getCc(), ...$message->getBcc()] as $address) {
|
||||
$email = trim($address->getAddress());
|
||||
if ($email !== '' && !in_array($email, $recipients, true)) {
|
||||
$recipients[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
return $recipients;
|
||||
}
|
||||
|
||||
private function toEmail(MessagePropertiesBaseInterface $message): Email
|
||||
{
|
||||
$email = new Email();
|
||||
|
||||
if (($from = $message->getFrom()) !== null) {
|
||||
$email->from($this->address($from));
|
||||
}
|
||||
if (($sender = $message->getSender()) !== null) {
|
||||
$email->sender($this->address($sender));
|
||||
}
|
||||
|
||||
$replyTo = $this->addresses($message->getReplyTo());
|
||||
if ($replyTo !== []) {
|
||||
$email->replyTo(...$replyTo);
|
||||
}
|
||||
|
||||
$to = $this->addresses($message->getTo());
|
||||
if ($to !== []) {
|
||||
$email->to(...$to);
|
||||
}
|
||||
|
||||
$cc = $this->addresses($message->getCc());
|
||||
if ($cc !== []) {
|
||||
$email->cc(...$cc);
|
||||
}
|
||||
|
||||
$bcc = $this->addresses($message->getBcc());
|
||||
if ($bcc !== []) {
|
||||
$email->bcc(...$bcc);
|
||||
}
|
||||
|
||||
$email->subject($message->getSubject());
|
||||
|
||||
if (($sent = $message->getSent()) !== null) {
|
||||
$email->date($sent instanceof DateTimeImmutable ? $sent : DateTimeImmutable::createFromInterface($sent));
|
||||
}
|
||||
|
||||
$text = $message->getBodyTextPlain();
|
||||
$html = $message->getBodyTextHtml();
|
||||
// Inline attachments may carry Content-IDs without an "@"; those must be
|
||||
// normalised (symfony requires an "@") and the matching cid: references
|
||||
// in the HTML body rewritten so the parts still resolve.
|
||||
$cidRewrites = $this->inlineCidRewrites($message);
|
||||
if ($text !== null && $text !== '') {
|
||||
$email->text($text);
|
||||
}
|
||||
if ($html !== null && $html !== '') {
|
||||
$email->html($this->rewriteCids($html, $cidRewrites));
|
||||
}
|
||||
// Guarantee at least an (empty) text part so the message is well-formed.
|
||||
if (($text === null || $text === '') && ($html === null || $html === '')) {
|
||||
$email->text('');
|
||||
}
|
||||
|
||||
foreach ($message->getAttachments() as $attachment) {
|
||||
$part = $this->attachmentPart($attachment);
|
||||
if ($part !== null) {
|
||||
$email->addPart($part);
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyThreadHeaders($email, $message);
|
||||
$this->applyCustomHeaders($email, $message);
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function attachmentPart(MessagePartInterface $attachment): ?DataPart
|
||||
{
|
||||
$content = $attachment->getContent();
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$part = new DataPart(
|
||||
$content,
|
||||
$attachment->getName(),
|
||||
$attachment->getType() ?? 'application/octet-stream',
|
||||
);
|
||||
|
||||
if ($attachment->getDisposition() === 'inline') {
|
||||
$part->asInline();
|
||||
$cid = $attachment->getContentId();
|
||||
if ($cid !== null && $cid !== '') {
|
||||
$part->setContentId($this->normalizeContentId(trim($cid, '<>')));
|
||||
}
|
||||
}
|
||||
|
||||
return $part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of original => normalised Content-IDs for inline parts whose
|
||||
* id required normalisation (i.e. lacked an "@").
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function inlineCidRewrites(MessagePropertiesBaseInterface $message): array
|
||||
{
|
||||
$rewrites = [];
|
||||
|
||||
foreach ($message->getAttachments() as $attachment) {
|
||||
if ($attachment->getDisposition() !== 'inline') {
|
||||
continue;
|
||||
}
|
||||
$cid = $attachment->getContentId();
|
||||
if ($cid === null || $cid === '') {
|
||||
continue;
|
||||
}
|
||||
$original = trim($cid, '<>');
|
||||
$normalized = $this->normalizeContentId($original);
|
||||
if ($original !== $normalized) {
|
||||
$rewrites[$original] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return $rewrites;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $rewrites
|
||||
*/
|
||||
private function rewriteCids(string $html, array $rewrites): string
|
||||
{
|
||||
foreach ($rewrites as $original => $normalized) {
|
||||
$html = str_replace('cid:' . $original, 'cid:' . $normalized, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function normalizeContentId(string $cid): string
|
||||
{
|
||||
return str_contains($cid, '@') ? $cid : $cid . '@ktrix';
|
||||
}
|
||||
|
||||
private function applyThreadHeaders(Email $email, MessagePropertiesBaseInterface $message): void
|
||||
{
|
||||
$headers = $email->getHeaders();
|
||||
|
||||
$inReplyTo = $message->getInReplyTo();
|
||||
if ($inReplyTo !== null && $inReplyTo !== '') {
|
||||
$headers->addIdHeader('In-Reply-To', $this->stripBrackets($inReplyTo));
|
||||
}
|
||||
|
||||
$references = array_values(array_filter(array_map(
|
||||
fn (string $id): string => $this->stripBrackets($id),
|
||||
$message->getReferences(),
|
||||
), static fn (string $id): bool => $id !== ''));
|
||||
|
||||
if ($references !== []) {
|
||||
$headers->addIdHeader('References', $references);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyCustomHeaders(Email $email, MessagePropertiesBaseInterface $message): void
|
||||
{
|
||||
$headers = $email->getHeaders();
|
||||
|
||||
foreach ($message->getHeaders() as $name => $value) {
|
||||
if (!is_string($name) || $value === null) {
|
||||
continue;
|
||||
}
|
||||
if (in_array(strtolower($name), self::RESERVED_HEADERS, true)) {
|
||||
continue;
|
||||
}
|
||||
$headers->addTextHeader($name, is_array($value) ? implode(', ', $value) : (string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,AddressInterface> $addresses
|
||||
* @return list<MimeAddress>
|
||||
*/
|
||||
private function addresses(array $addresses): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($addresses as $address) {
|
||||
if ($address instanceof AddressInterface && trim($address->getAddress()) !== '') {
|
||||
$result[] = $this->address($address);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function address(AddressInterface $address): MimeAddress
|
||||
{
|
||||
return new MimeAddress($address->getAddress(), $address->getLabel() ?? '');
|
||||
}
|
||||
|
||||
private function stripBrackets(string $id): string
|
||||
{
|
||||
return trim($id, " \t<>");
|
||||
}
|
||||
}
|
||||
@@ -121,10 +121,20 @@ class MessageAttachment implements MessagePartInterface {
|
||||
|
||||
public function getBlobId(): ?string { return $this->_meta->getBlobId(); }
|
||||
public function getId(): ?string { return $this->_meta->getId(); }
|
||||
public function getSize(): ?int { return $this->_meta->getSize(); }
|
||||
public function getDisposition(): ?string { return $this->_meta->getDisposition(); }
|
||||
public function getContentId(): ?string { return $this->_meta->getContentId(); }
|
||||
public function getCharset(): ?string { return $this->_meta->getCharset(); }
|
||||
public function getLanguage(): ?string { return $this->_meta->getLanguage(); }
|
||||
public function getLocation(): ?string { return $this->_meta->getLocation(); }
|
||||
public function getContent(): ?string { return $this->_contents; }
|
||||
public function getParts(): array { return $this->_meta->getParts(); }
|
||||
public function jsonSerialize(): array { return $this->_meta->jsonSerialize(); }
|
||||
public function jsonSerialize(): array {
|
||||
$data = $this->_meta->jsonSerialize();
|
||||
if ($this->_contents !== null) {
|
||||
$data['content'] = $this->_contents;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,24 @@ use KTXM\ProviderImap\Client\MessagePart as ImapMessagePart;
|
||||
*/
|
||||
class MessagePart extends MessagePartMutableAbstract {
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
private function hydrateArray(array $data): static {
|
||||
$this->data = $data;
|
||||
|
||||
if (isset($this->data['subParts']) && is_array($this->data['subParts'])) {
|
||||
foreach ($this->data['subParts'] as $entry) {
|
||||
if (is_array($entry)) {
|
||||
$this->parts[] = (new self())->hydrateArray($entry);
|
||||
}
|
||||
}
|
||||
unset($this->data['subParts']);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert gricob BodyStructure part to message part object
|
||||
*
|
||||
@@ -24,82 +42,11 @@ class MessagePart extends MessagePartMutableAbstract {
|
||||
* @param string $partId numeric part identifier (e.g. "1", "1.1", "2")
|
||||
*/
|
||||
public function fromImap(ImapMessagePart $part, string $partId = '1'): static {
|
||||
$data = $part->toArray();
|
||||
$data['partId'] = $partId;
|
||||
$data['blobId'] = $data['blobId'] ?? $partId;
|
||||
|
||||
$this->data['partId'] = $partId;
|
||||
|
||||
if ($part instanceof SinglePart) {
|
||||
$mimeType = strtolower($part->type) . '/' . strtolower($part->subtype);
|
||||
$this->data['type'] = $mimeType;
|
||||
|
||||
if ($part->id !== null) {
|
||||
$this->data['blobId'] = trim($part->id, '<>');
|
||||
}
|
||||
|
||||
// Content-Type parameters (name, charset, etc.)
|
||||
if (!empty($part->attributes)) {
|
||||
foreach ($part->attributes as $key => $value) {
|
||||
$keyLower = strtolower($key);
|
||||
if ($keyLower === 'name') {
|
||||
$this->data['name'] = $value;
|
||||
} elseif ($keyLower === 'charset') {
|
||||
$this->data['charset'] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($part->encoding !== null) {
|
||||
$this->data['encoding'] = strtolower($part->encoding);
|
||||
}
|
||||
|
||||
if ($part->size !== null) {
|
||||
$this->data['size'] = $part->size;
|
||||
}
|
||||
|
||||
if ($part->disposition !== null) {
|
||||
$this->data['disposition'] = strtolower($part->disposition->type);
|
||||
// disposition filename attribute
|
||||
if (!empty($part->disposition->attributes)) {
|
||||
foreach ($part->disposition->attributes as $key => $value) {
|
||||
if (strtolower($key) === 'filename') {
|
||||
$this->data['name'] = $this->data['name'] ?? $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($part->language)) {
|
||||
$this->data['language'] = implode(',', $part->language);
|
||||
}
|
||||
|
||||
if ($part->location !== null) {
|
||||
$this->data['location'] = $part->location;
|
||||
}
|
||||
|
||||
} elseif ($part instanceof MultiPart) {
|
||||
$this->data['type'] = 'multipart/' . strtolower($part->subtype);
|
||||
|
||||
if ($part->disposition !== null) {
|
||||
$this->data['disposition'] = strtolower($part->disposition->type);
|
||||
}
|
||||
|
||||
if (!empty($part->language)) {
|
||||
$this->data['language'] = implode(',', $part->language);
|
||||
}
|
||||
|
||||
if ($part->location !== null) {
|
||||
$this->data['location'] = $part->location;
|
||||
}
|
||||
|
||||
// Recursively process sub-parts
|
||||
// When this part has no section ID (root multipart) children are
|
||||
// numbered "1", "2", … to match IMAP section numbering.
|
||||
foreach ($part->parts as $index => $subPart) {
|
||||
$subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1);
|
||||
$this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
return $this->hydrateArray($data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -82,41 +82,21 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
}
|
||||
|
||||
if ($message->bodyStructure() !== null) {
|
||||
$this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray();
|
||||
$body = $message->bodyStructure()->withInjectedSections($message->bodySections() ?? [])->toArray();
|
||||
$this->data[static::PROPERTY_BODY] = $body;
|
||||
|
||||
$attachments = [];
|
||||
$this->collectAttachments($message->bodyStructure(), $attachments);
|
||||
$this->collectAttachments($body, $attachments);
|
||||
if ($attachments !== []) {
|
||||
$this->data[static::PROPERTY_ATTACHMENTS] = $attachments;
|
||||
}
|
||||
}
|
||||
|
||||
if ($message->bodyStructure() !== null) {
|
||||
$this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray();
|
||||
// Recursively add content from bodyValues to matching parts
|
||||
if (is_array($message->bodySections())) {
|
||||
$addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) {
|
||||
// If this part has a partId and matching bodyValue, add content
|
||||
if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) {
|
||||
$structure['content'] = $bodyValues[$structure['partId']] ?? null;
|
||||
}
|
||||
// Recursively process subParts
|
||||
if (isset($structure['subParts']) && is_array($structure['subParts'])) {
|
||||
foreach ($structure['subParts'] as &$subPart) {
|
||||
$addContentToParts($subPart, $bodyValues);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$addContentToParts($this->data[static::PROPERTY_BODY], $message->bodySections());
|
||||
}
|
||||
}
|
||||
|
||||
$this->data[static::PROPERTY_FLAGS] = [];
|
||||
foreach ($message->flags() as $flag) {
|
||||
$flag = ltrim($flag, '\\');
|
||||
$normalized = match (strtolower($flag)) {
|
||||
'seen' => 'read',
|
||||
'seen' => 'seen',
|
||||
'flagged' => 'flagged',
|
||||
'answered' => 'answered',
|
||||
'draft' => 'draft',
|
||||
@@ -132,9 +112,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
/**
|
||||
* Recursively collect attachment parts from body structure
|
||||
*/
|
||||
private function collectAttachments(ClientMessagePart $part, array &$attachments): void
|
||||
private function collectAttachments(array $part, array &$attachments): void
|
||||
{
|
||||
$children = $part->parts();
|
||||
$children = $part['subParts'] ?? [];
|
||||
if ($children !== []) {
|
||||
foreach ($children as $childPart) {
|
||||
$this->collectAttachments($childPart, $attachments);
|
||||
@@ -142,9 +122,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
return;
|
||||
}
|
||||
|
||||
$mimeType = strtolower($part->mimeType());
|
||||
$disposition = strtolower($part->disposition() ?? '');
|
||||
$name = $part->parameters()['name'] ?? $part->dispositionParameters()['filename'] ?? null;
|
||||
$mimeType = strtolower((string)($part['type'] ?? ''));
|
||||
$disposition = strtolower((string)($part['disposition'] ?? ''));
|
||||
$name = $part['name'] ?? null;
|
||||
$isInlineText = str_starts_with($mimeType, 'text/')
|
||||
&& in_array($mimeType, ['text/plain', 'text/html'], true)
|
||||
&& $disposition !== 'attachment';
|
||||
@@ -153,7 +133,7 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachments[] = $part->toArray();
|
||||
$attachments[] = $part;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use KTXF\Mail\Service\ServiceMutableInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
||||
use KTXM\ProviderImap\Service\Discovery;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||
use KTXM\ProviderImap\Stores\ServiceStore;
|
||||
|
||||
/**
|
||||
@@ -49,10 +49,10 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
||||
self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL,
|
||||
self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities,
|
||||
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
||||
self::PROPERTY_LABEL => self::PROVIDER_LABEL,
|
||||
self::PROPERTY_CAPABILITIES => $this->providerAbilities,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -192,9 +192,8 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
||||
$service->fromStore(['sid' => 'test']);
|
||||
|
||||
// Attempt to authenticate and list mailboxes as a connectivity check
|
||||
$client = RemoteService::freshClient($service);
|
||||
$service = RemoteService::mailService($service, $client);
|
||||
$mailboxes = iterator_to_array($service->collectionList());
|
||||
$remote = new RemoteMailService($service);
|
||||
$mailboxes = iterator_to_array($remote->collectionList());
|
||||
|
||||
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
|
||||
+117
-39
@@ -17,8 +17,10 @@ use KTXF\Mail\Object\AddressInterface;
|
||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
||||
use KTXF\Mail\Service\ServiceMutableInterface;
|
||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||
@@ -35,8 +37,8 @@ use KTXF\Resource\Sort\ISort;
|
||||
use KTXF\Resource\Sort\Sort;
|
||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||
use KTXM\ProviderImap\Mime\MessageBuilder;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
||||
use KTXF\Mail\Collection\CollectionRoles;
|
||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||
@@ -44,10 +46,7 @@ use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
||||
use KTXM\ProviderImap\Providers\EntityResource;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
|
||||
/**
|
||||
* IMAP Mail Service
|
||||
*/
|
||||
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface
|
||||
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface
|
||||
{
|
||||
private const PROVIDER_IDENTIFIER = 'imap';
|
||||
|
||||
@@ -56,7 +55,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
private ?string $serviceIdentifier = null;
|
||||
private ?string $serviceLabel = null;
|
||||
private bool $serviceEnabled = false;
|
||||
private string $primaryAddress = '';
|
||||
private array $primaryAddress = [];
|
||||
private array $secondaryAddresses = [];
|
||||
private ?ServiceLocation $location = null;
|
||||
private ?ServiceIdentityBasic $identity = null;
|
||||
@@ -109,6 +108,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
self::CAPABILITY_ENTITY_DELETE => true,
|
||||
self::CAPABILITY_ENTITY_MOVE => true,
|
||||
self::CAPABILITY_ENTITY_COPY => false,
|
||||
'EntityTransmit' => true,
|
||||
];
|
||||
|
||||
private RemoteMailService $remoteService;
|
||||
@@ -118,8 +118,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
private function initialize(): void
|
||||
{
|
||||
if (!isset($this->remoteService)) {
|
||||
$wrapper = RemoteService::freshClient($this);
|
||||
$this->remoteService = RemoteService::mailService($this, $wrapper);
|
||||
$this->remoteService = new RemoteMailService($this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,17 +168,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return array_filter([
|
||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
||||
self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
||||
self::JSON_PROPERTY_LABEL => $this->serviceLabel,
|
||||
self::JSON_PROPERTY_ENABLED => $this->serviceEnabled,
|
||||
self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
||||
self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
|
||||
self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
|
||||
self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
||||
self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
||||
self::JSON_PROPERTY_AUXILIARY => $this->auxiliary,
|
||||
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
||||
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
||||
self::PROPERTY_LABEL => $this->serviceLabel,
|
||||
self::PROPERTY_ENABLED => $this->serviceEnabled,
|
||||
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
||||
self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
|
||||
self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
|
||||
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
||||
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
||||
self::PROPERTY_AUXILIARY => $this->auxiliary,
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
@@ -189,29 +188,30 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
if (isset($data[self::JSON_PROPERTY_ENABLED])) {
|
||||
$this->setEnabled($data[self::JSON_PROPERTY_ENABLED]);
|
||||
if (isset($data[self::PROPERTY_ENABLED])) {
|
||||
$this->setEnabled($data[self::PROPERTY_ENABLED]);
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_LABEL])) {
|
||||
$this->setLabel($data[self::JSON_PROPERTY_LABEL]);
|
||||
if (isset($data[self::PROPERTY_LABEL])) {
|
||||
$this->setLabel($data[self::PROPERTY_LABEL]);
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_LOCATION])) {
|
||||
$this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION]));
|
||||
if (isset($data[self::PROPERTY_LOCATION])) {
|
||||
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_IDENTITY])) {
|
||||
$this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY]));
|
||||
if (isset($data[self::PROPERTY_IDENTITY])) {
|
||||
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) {
|
||||
$this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]));
|
||||
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) {
|
||||
$value = $data[self::PROPERTY_PRIMARY_ADDRESS];
|
||||
$this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) {
|
||||
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
|
||||
$this->setSecondaryAddresses(array_map(
|
||||
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
|
||||
$data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]
|
||||
$data[self::PROPERTY_SECONDARY_ADDRESSES]
|
||||
));
|
||||
}
|
||||
if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) {
|
||||
$this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]);
|
||||
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
|
||||
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -261,23 +261,28 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
|
||||
public function getPrimaryAddress(): AddressInterface
|
||||
{
|
||||
return new Address($this->primaryAddress);
|
||||
return Address::fromArray($this->primaryAddress);
|
||||
}
|
||||
|
||||
public function setPrimaryAddress(AddressInterface $value): static
|
||||
{
|
||||
$this->primaryAddress = $value->getAddress();
|
||||
$this->primaryAddress = $value->toArray();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSecondaryAddresses(): array
|
||||
{
|
||||
return $this->secondaryAddresses;
|
||||
return array_map(
|
||||
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr])
|
||||
, $this->secondaryAddresses);
|
||||
}
|
||||
|
||||
public function setSecondaryAddresses(array $addresses): static
|
||||
{
|
||||
$this->secondaryAddresses = $addresses;
|
||||
$this->secondaryAddresses = array_map(
|
||||
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]),
|
||||
$addresses
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -285,7 +290,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
{
|
||||
$address = strtolower(trim($address));
|
||||
|
||||
if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) {
|
||||
if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) {
|
||||
return true;
|
||||
}
|
||||
foreach ($this->secondaryAddresses as $secondary) {
|
||||
@@ -580,7 +585,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
|
||||
foreach ($identifiers as $collection => $entities) {
|
||||
$uids = array_keys($entities);
|
||||
foreach ($this->remoteService->entityFetch((string) $collection, ...$uids) as $uid => $message) {
|
||||
foreach ($this->remoteService->entityFetch((string) $collection, null, ...$uids) as $uid => $message) {
|
||||
$resource = $this->entityFresh();
|
||||
$resource->fromImap($message, $collection);
|
||||
yield $resource->urn() => $resource;
|
||||
@@ -621,6 +626,79 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
return new EntityResource($this->provider(), $this->identifier());
|
||||
}
|
||||
|
||||
public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult
|
||||
{
|
||||
if ($message === null) {
|
||||
return new EntitySubmitResult(
|
||||
EntitySubmitResult::DISPOSITION_ERROR,
|
||||
errorCode: 'invalid_message',
|
||||
errorMessage: 'No message properties were provided for submission.',
|
||||
);
|
||||
}
|
||||
|
||||
// Outbound submission via SMTP — independent of the IMAP connection.
|
||||
try {
|
||||
$raw = (new MessageBuilder())->build($message);
|
||||
$recipients = MessageBuilder::recipients($message);
|
||||
if ($recipients === []) {
|
||||
throw new \RuntimeException('Message has no recipients.');
|
||||
}
|
||||
|
||||
$this->initialize();
|
||||
$smtp = $this->remoteService->smtpClient();
|
||||
try {
|
||||
$queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw);
|
||||
} finally {
|
||||
$smtp->quit();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return new EntitySubmitResult(
|
||||
EntitySubmitResult::DISPOSITION_ERROR,
|
||||
errorCode: 'submission_failed',
|
||||
errorMessage: $e->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort: store a copy in the Sent collection. A failure here must
|
||||
// not turn a successful delivery into an error.
|
||||
$sentEntity = null;
|
||||
try {
|
||||
$this->initialize();
|
||||
$sentCollection = $this->resolveSentCollection();
|
||||
if ($sentCollection !== null) {
|
||||
$uid = $this->remoteService->entityCreate($sentCollection, $raw, ['\\Seen']);
|
||||
if ($uid !== null && $uid > 0) {
|
||||
$sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// ignore — the message was already delivered
|
||||
}
|
||||
|
||||
return new EntitySubmitResult(
|
||||
disposition: EntitySubmitResult::DISPOSITION_SENT,
|
||||
transportId: $queueId !== '' ? $queueId : null,
|
||||
sentEntity: $sentEntity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the native name of the collection flagged with the Sent role.
|
||||
*/
|
||||
private function resolveSentCollection(): ?string
|
||||
{
|
||||
$filter = $this->collectionListFilter();
|
||||
$filter->condition('role', CollectionRoles::Sent->value);
|
||||
|
||||
/** @var Mailbox[] $mailboxes */
|
||||
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||
if ($mailboxes === []) {
|
||||
return null;
|
||||
}
|
||||
$mailbox = reset($mailboxes);
|
||||
return $mailbox === false ? null : $mailbox->name();
|
||||
}
|
||||
|
||||
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
|
||||
{
|
||||
throw new \RuntimeException('Entity creation is not supported in this service');
|
||||
|
||||
@@ -11,22 +11,29 @@ namespace KTXM\ProviderImap\Providers;
|
||||
|
||||
use KTXM\ProviderImap\Client\ConnectionConfig;
|
||||
use KTXM\ProviderImap\Client\ConnectionSecurity;
|
||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||
use KTXM\ProviderImap\Smtp\ConnectionConfig as SmtpConnectionConfig;
|
||||
use KTXM\ProviderImap\Smtp\ConnectionSecurity as SmtpConnectionSecurity;
|
||||
use KTXF\Resource\Provider\ResourceServiceLocationSocketSplit;
|
||||
|
||||
/**
|
||||
* IMAP Service Location
|
||||
* IMAP/SMTP Service Location
|
||||
*
|
||||
* Connection details for an IMAP server (host / port / encryption).
|
||||
* Split socket location: the inbound side describes the IMAP server, the
|
||||
* outbound side describes the SMTP submission server.
|
||||
*/
|
||||
class ServiceLocation implements ResourceServiceLocationInterface
|
||||
class ServiceLocation implements ResourceServiceLocationSocketSplit
|
||||
{
|
||||
public function __construct(
|
||||
private string $host = '',
|
||||
private int $port = 993,
|
||||
private string $encryption = 'ssl', // ssl | tls | starttls | none
|
||||
private bool $verifyPeer = true,
|
||||
private bool $verifyPeerName = true,
|
||||
private bool $allowSelfSigned = false,
|
||||
private string $inboundHost = '',
|
||||
private int $inboundPort = 993,
|
||||
private string $inboundEncryption = 'ssl', // none | ssl | tls | starttls
|
||||
private bool $inboundVerifyPeer = true,
|
||||
private bool $inboundVerifyHost = true,
|
||||
private string $outboundHost = '',
|
||||
private int $outboundPort = 587,
|
||||
private string $outboundEncryption = 'starttls', // none | ssl | tls | starttls
|
||||
private bool $outboundVerifyPeer = true,
|
||||
private bool $outboundVerifyHost = true,
|
||||
) {}
|
||||
|
||||
// ── Serialisation ────────────────────────────────────────────────────────
|
||||
@@ -43,15 +50,19 @@ class ServiceLocation implements ResourceServiceLocationInterface
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return array_filter([
|
||||
'type' => self::TYPE_URI,
|
||||
'host' => $this->host,
|
||||
'port' => $this->port,
|
||||
'encryption' => $this->encryption,
|
||||
'verifyPeer' => $this->verifyPeer,
|
||||
'verifyPeerName' => $this->verifyPeerName,
|
||||
'allowSelfSigned' => $this->allowSelfSigned,
|
||||
], fn($v) => $v !== null && $v !== '');
|
||||
return [
|
||||
'type' => self::TYPE_SOCKET_SPLIT,
|
||||
'inboundHost' => $this->inboundHost,
|
||||
'inboundPort' => $this->inboundPort,
|
||||
'inboundEncryption' => $this->inboundEncryption,
|
||||
'inboundVerifyPeer' => $this->inboundVerifyPeer,
|
||||
'inboundVerifyHost' => $this->inboundVerifyHost,
|
||||
'outboundHost' => $this->outboundHost,
|
||||
'outboundPort' => $this->outboundPort,
|
||||
'outboundEncryption' => $this->outboundEncryption,
|
||||
'outboundVerifyPeer' => $this->outboundVerifyPeer,
|
||||
'outboundVerifyHost' => $this->outboundVerifyHost,
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
@@ -60,70 +71,119 @@ class ServiceLocation implements ResourceServiceLocationInterface
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
$this->host = $data['host'] ?? '';
|
||||
$this->port = (int)($data['port'] ?? 993);
|
||||
$this->encryption = $data['encryption'] ?? 'ssl';
|
||||
$this->verifyPeer = $data['verifyPeer'] ?? true;
|
||||
$this->verifyPeerName = $data['verifyPeerName'] ?? true;
|
||||
$this->allowSelfSigned = $data['allowSelfSigned'] ?? false;
|
||||
$this->inboundHost = $data['inboundHost'] ?? '';
|
||||
$this->inboundPort = (int) ($data['inboundPort'] ?? 993);
|
||||
$this->inboundEncryption = $data['inboundEncryption'] ?? 'ssl';
|
||||
$this->inboundVerifyPeer = $data['inboundVerifyPeer'] ?? true;
|
||||
$this->inboundVerifyHost = $data['inboundVerifyHost'] ?? true;
|
||||
|
||||
$this->outboundHost = $data['outboundHost'] ?? '';
|
||||
$this->outboundPort = (int) ($data['outboundPort'] ?? 587);
|
||||
$this->outboundEncryption = $data['outboundEncryption'] ?? 'starttls';
|
||||
$this->outboundVerifyPeer = $data['outboundVerifyPeer'] ?? true;
|
||||
$this->outboundVerifyHost = $data['outboundVerifyHost'] ?? true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// ── ResourceServiceLocationInterface ─────────────────────────────────────
|
||||
// ── ResourceServiceLocationSocketSplit ───────────────────────────────────
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return self::TYPE_URI;
|
||||
return self::TYPE_SOCKET_SPLIT;
|
||||
}
|
||||
|
||||
public function location(): string
|
||||
public function locationInbound(): string
|
||||
{
|
||||
return $this->encryption . '://' . $this->host . ':' . $this->port;
|
||||
return $this->inboundEncryption . '://' . $this->inboundHost . ':' . $this->inboundPort;
|
||||
}
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────────────────
|
||||
public function locationOutbound(): string
|
||||
{
|
||||
return $this->outboundEncryption . '://' . $this->outboundHost . ':' . $this->outboundPort;
|
||||
}
|
||||
|
||||
public function getHost(): string { return $this->host; }
|
||||
public function setHost(string $v): void { $this->host = $v; }
|
||||
public function getInboundHost(): string { return $this->inboundHost; }
|
||||
public function setInboundHost(string $value): void { $this->inboundHost = $value; }
|
||||
|
||||
public function getPort(): int { return $this->port; }
|
||||
public function setPort(int $v): void { $this->port = $v; }
|
||||
public function getOutboundHost(): string { return $this->outboundHost; }
|
||||
public function setOutboundHost(string $value): void { $this->outboundHost = $value; }
|
||||
|
||||
public function getEncryption(): string { return $this->encryption; }
|
||||
public function setEncryption(string $v): void { $this->encryption = $v; }
|
||||
public function getInboundPort(): int { return $this->inboundPort; }
|
||||
public function setInboundPort(int $value): void { $this->inboundPort = $value; }
|
||||
|
||||
public function getVerifyPeer(): bool { return $this->verifyPeer; }
|
||||
public function setVerifyPeer(bool $v): void { $this->verifyPeer = $v; }
|
||||
public function getOutboundPort(): int { return $this->outboundPort; }
|
||||
public function setOutboundPort(int $value): void { $this->outboundPort = $value; }
|
||||
|
||||
public function getVerifyPeerName(): bool { return $this->verifyPeerName; }
|
||||
public function setVerifyPeerName(bool $v): void { $this->verifyPeerName = $v; }
|
||||
public function getInboundEncryption(): string { return $this->inboundEncryption; }
|
||||
public function setInboundEncryption(string $value): void { $this->inboundEncryption = $value; }
|
||||
|
||||
public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; }
|
||||
public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; }
|
||||
public function getOutboundEncryption(): string { return $this->outboundEncryption; }
|
||||
public function setOutboundEncryption(string $value): void { $this->outboundEncryption = $value; }
|
||||
|
||||
public function getInboundVerifyPeer(): bool { return $this->inboundVerifyPeer; }
|
||||
public function setInboundVerifyPeer(bool $value): void { $this->inboundVerifyPeer = $value; }
|
||||
|
||||
public function getInboundVerifyHost(): bool { return $this->inboundVerifyHost; }
|
||||
public function setInboundVerifyHost(bool $value): void { $this->inboundVerifyHost = $value; }
|
||||
|
||||
public function getOutboundVerifyPeer(): bool { return $this->outboundVerifyPeer; }
|
||||
public function setOutboundVerifyPeer(bool $value): void { $this->outboundVerifyPeer = $value; }
|
||||
|
||||
public function getOutboundVerifyHost(): bool { return $this->outboundVerifyHost; }
|
||||
public function setOutboundVerifyHost(bool $value): void { $this->outboundVerifyHost = $value; }
|
||||
|
||||
// ── Client helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a standalone IMAP client ConnectionConfig from this location.
|
||||
* Build an IMAP ConnectionConfig from the inbound side.
|
||||
*/
|
||||
public function toConnectionConfig(?string $username = null, ?string $password = null): ConnectionConfig
|
||||
{
|
||||
$security = match ($this->encryption) {
|
||||
'ssl', 'tls' => ConnectionSecurity::Tls,
|
||||
'starttls' => ConnectionSecurity::StartTls,
|
||||
default => ConnectionSecurity::Plain,
|
||||
};
|
||||
|
||||
return new ConnectionConfig(
|
||||
host: $this->host,
|
||||
port: $this->port,
|
||||
security: $security,
|
||||
host: $this->inboundHost,
|
||||
port: $this->inboundPort,
|
||||
security: $this->imapSecurity($this->inboundEncryption),
|
||||
username: $username,
|
||||
password: $password,
|
||||
verifyPeer: $this->verifyPeer,
|
||||
verifyPeerName: $this->verifyPeerName,
|
||||
allowSelfSigned: $this->allowSelfSigned,
|
||||
verifyPeer: $this->inboundVerifyPeer,
|
||||
verifyPeerName: $this->inboundVerifyHost,
|
||||
allowSelfSigned: !$this->inboundVerifyPeer,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SMTP submission ConnectionConfig from the outbound side.
|
||||
*/
|
||||
public function toSmtpConnectionConfig(?string $username = null, ?string $password = null): SmtpConnectionConfig
|
||||
{
|
||||
return new SmtpConnectionConfig(
|
||||
host: $this->outboundHost,
|
||||
port: $this->outboundPort,
|
||||
security: $this->smtpSecurity($this->outboundEncryption),
|
||||
username: $username,
|
||||
password: $password,
|
||||
verifyPeer: $this->outboundVerifyPeer,
|
||||
verifyPeerName: $this->outboundVerifyHost,
|
||||
allowSelfSigned: !$this->outboundVerifyPeer,
|
||||
);
|
||||
}
|
||||
|
||||
private function imapSecurity(string $encryption): ConnectionSecurity
|
||||
{
|
||||
return match ($encryption) {
|
||||
'ssl', 'tls' => ConnectionSecurity::Tls,
|
||||
'starttls' => ConnectionSecurity::StartTls,
|
||||
default => ConnectionSecurity::Plain,
|
||||
};
|
||||
}
|
||||
|
||||
private function smtpSecurity(string $encryption): SmtpConnectionSecurity
|
||||
{
|
||||
return match ($encryption) {
|
||||
'ssl', 'tls' => SmtpConnectionSecurity::Tls,
|
||||
'starttls' => SmtpConnectionSecurity::StartTls,
|
||||
default => SmtpConnectionSecurity::Plain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ class CacheService
|
||||
$updatedProperties = $resource->toStore()['properties'] ?? [];
|
||||
|
||||
// Only overwrite flag-related fields
|
||||
foreach (['read', 'flagged', 'answered', 'draft', 'deleted', 'junk'] as $field) {
|
||||
foreach (['seen', 'flagged', 'answered', 'draft', 'deleted', 'junk'] as $field) {
|
||||
if (isset($updatedProperties[$field])) {
|
||||
$existingProperties[$field] = $updatedProperties[$field];
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class Discovery
|
||||
];
|
||||
$seen = [];
|
||||
foreach ($results as $r) {
|
||||
$seen[$r->getHost()] = true;
|
||||
$seen[$r->getInboundHost()] = true;
|
||||
}
|
||||
foreach ($candidates as $host) {
|
||||
if (isset($seen[$host])) {
|
||||
@@ -203,10 +203,11 @@ class Discovery
|
||||
|
||||
$loc = new ServiceLocation();
|
||||
$loc->jsonDeserialize([
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'encryption' => $encryption,
|
||||
'verifyPeer' => $verifySSL,
|
||||
'inboundHost' => $host,
|
||||
'inboundPort' => $port,
|
||||
'inboundEncryption' => $encryption,
|
||||
'inboundVerifyPeer' => $verifySSL,
|
||||
'inboundVerifyHost' => $verifySSL,
|
||||
]);
|
||||
return $loc;
|
||||
} catch (\Throwable) {
|
||||
|
||||
@@ -11,7 +11,9 @@ namespace KTXM\ProviderImap\Service\Remote;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\Client;
|
||||
use KTXC\Logger\PlainFileLogger;
|
||||
use KTXM\ProviderImap\Client\Client as ImapClient;
|
||||
use KTXM\ProviderImap\Client\Command\AppendCommand;
|
||||
use KTXM\ProviderImap\Client\Command\FetchManyCommand;
|
||||
use KTXM\ProviderImap\Client\Command\FetchOneCommand;
|
||||
use KTXM\ProviderImap\Client\Command\ExpungeCommand;
|
||||
@@ -47,6 +49,9 @@ use KTXF\Resource\Range\RangeAnchorType;
|
||||
use KTXF\Resource\Range\RangeTally;
|
||||
use KTXF\Resource\Sort\ISort;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXM\ProviderImap\Providers\Service;
|
||||
use KTXM\ProviderImap\Smtp\Client as SmtpClient;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* IMAP Remote Mail Service
|
||||
@@ -57,10 +62,80 @@ class RemoteMailService
|
||||
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
|
||||
private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY'];
|
||||
|
||||
private ?ImapClient $imapClient = null;
|
||||
private ?SmtpClient $smtpClient = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Client $client,
|
||||
private readonly Service $service,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The connected IMAP client, established on first use.
|
||||
*/
|
||||
public function imapClient(): ImapClient
|
||||
{
|
||||
if ($this->imapClient instanceof ImapClient) {
|
||||
return $this->imapClient;
|
||||
}
|
||||
|
||||
$location = $this->service->getLocation();
|
||||
if ($location === null || $location->getInboundHost() === '') {
|
||||
throw new RuntimeException('No IMAP (inbound) host is configured for this service.');
|
||||
}
|
||||
$identity = $this->service->getIdentity();
|
||||
|
||||
$config = $location->toConnectionConfig(
|
||||
$identity?->getIdentity(),
|
||||
$identity?->getSecret(),
|
||||
);
|
||||
|
||||
$client = new ImapClient(logger: $this->logger('imap'));
|
||||
$client->connect($config);
|
||||
|
||||
return $this->imapClient = $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The connected SMTP client, established on first use.
|
||||
*/
|
||||
public function smtpClient(): SmtpClient
|
||||
{
|
||||
if ($this->smtpClient instanceof SmtpClient) {
|
||||
return $this->smtpClient;
|
||||
}
|
||||
|
||||
$location = $this->service->getLocation();
|
||||
if ($location === null || $location->getOutboundHost() === '') {
|
||||
throw new RuntimeException('No SMTP submission (outbound) host is configured for this service.');
|
||||
}
|
||||
|
||||
$identity = $this->service->getIdentity();
|
||||
|
||||
$config = $location->toSmtpConnectionConfig(
|
||||
$identity?->getIdentity(),
|
||||
$identity?->getSecret(),
|
||||
);
|
||||
|
||||
$client = new SmtpClient(logger: $this->logger('smtp'));
|
||||
$client->connect($config);
|
||||
|
||||
return $this->smtpClient = $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-protocol file logger when the service has debug enabled.
|
||||
*/
|
||||
private function logger(string $channel): ?PlainFileLogger
|
||||
{
|
||||
if (!$this->service->getDebug()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$logDir = dirname(__DIR__, 5) . '/var/log';
|
||||
|
||||
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* list of collections in remote storage
|
||||
*
|
||||
@@ -78,7 +153,7 @@ class RemoteMailService
|
||||
$location = '';
|
||||
}
|
||||
// construct the most efficient LIST command based on server capabilities
|
||||
if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) {
|
||||
if ($this->imapClient()->hasCapability('LIST-STATUS') && !empty($depth)) {
|
||||
$command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||
$rfc5258 = true;
|
||||
} else {
|
||||
@@ -87,7 +162,7 @@ class RemoteMailService
|
||||
}
|
||||
// retrieve list of mailboxes from remote
|
||||
$mailboxes = [];
|
||||
foreach ($this->client->perform($command) as $mailbox) {
|
||||
foreach ($this->imapClient()->perform($command) as $mailbox) {
|
||||
// apply filter
|
||||
if ($filter === null || $this->mailboxFilter($mailbox, $filter)) {
|
||||
if ($rfc5258) {
|
||||
@@ -105,7 +180,7 @@ class RemoteMailService
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||
$mailbox = $mailbox->fromStatus($status);
|
||||
} catch (ImapException) {
|
||||
// do nothing
|
||||
@@ -125,13 +200,13 @@ class RemoteMailService
|
||||
public function collectionFetch(string $identifier): ?Mailbox
|
||||
{
|
||||
// retrieve mailbox from remote
|
||||
$mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
|
||||
$mailbox = iterator_to_array($this->imapClient()->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
|
||||
if (empty($mailbox)) {
|
||||
return null;
|
||||
}
|
||||
$mailbox = reset($mailbox);
|
||||
// enrich with STATUS
|
||||
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||
$mailbox = $mailbox->fromStatus($status);
|
||||
|
||||
return $mailbox;
|
||||
@@ -145,7 +220,7 @@ class RemoteMailService
|
||||
*/
|
||||
public function collectionCreate(string $name): Mailbox
|
||||
{
|
||||
$result = $this->client->perform(new CreateCommand($name));
|
||||
$result = $this->imapClient()->perform(new CreateCommand($name));
|
||||
|
||||
if (!$result->isOk()) {
|
||||
throw new ImapException('Failed to create mailbox: ' . $name);
|
||||
@@ -165,7 +240,7 @@ class RemoteMailService
|
||||
*/
|
||||
public function collectionRename(string $oldName, string $newName): Mailbox
|
||||
{
|
||||
$result = $this->client->perform(new RenameCommand($oldName, $newName));
|
||||
$result = $this->imapClient()->perform(new RenameCommand($oldName, $newName));
|
||||
|
||||
if (!$result->isOk()) {
|
||||
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
||||
@@ -185,7 +260,7 @@ class RemoteMailService
|
||||
*/
|
||||
public function collectionDestroy(string $name): bool
|
||||
{
|
||||
$result = $this->client->perform(new DeleteCommand($name));
|
||||
$result = $this->imapClient()->perform(new DeleteCommand($name));
|
||||
|
||||
if (!$result->isOk()) {
|
||||
throw new ImapException('Failed to delete mailbox: ' . $name);
|
||||
@@ -206,18 +281,18 @@ class RemoteMailService
|
||||
$nativeFilter = $this->buildEntitySearchCriteria($filter);
|
||||
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
|
||||
|
||||
$this->client->perform(new SelectCommand($collection, true));
|
||||
$rfc5258 = $this->client->hasCapability('SORT');
|
||||
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||
$rfc5258 = $this->imapClient()->hasCapability('SORT');
|
||||
|
||||
$uids = [];
|
||||
if ($nativeSort !== [] && $rfc5258) {
|
||||
$uids = $this->client->perform(new SortCommand(
|
||||
$uids = $this->imapClient()->perform(new SortCommand(
|
||||
$nativeSort,
|
||||
$nativeFilter,
|
||||
IdentifierMode::Uid,
|
||||
))->matches();
|
||||
} else {
|
||||
$uids = $this->client->perform(new SearchCommand(
|
||||
$uids = $this->imapClient()->perform(new SearchCommand(
|
||||
$nativeFilter,
|
||||
IdentifierMode::Uid,
|
||||
))->matches();
|
||||
@@ -245,13 +320,13 @@ class RemoteMailService
|
||||
|
||||
// fast path: fetch all messages without filtering, sorting or pagination
|
||||
if ($filter === null && $sort === null && $range === null) {
|
||||
$mailbox = $this->client->perform(new SelectCommand($collection, true));
|
||||
$mailbox = $this->imapClient()->perform(new SelectCommand($collection, true));
|
||||
|
||||
if ($mailbox === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
yield from $this->client->perform(new FetchManyCommand(
|
||||
yield from $this->imapClient()->perform(new FetchManyCommand(
|
||||
FetchTarget::all(),
|
||||
$options,
|
||||
));
|
||||
@@ -282,14 +357,14 @@ class RemoteMailService
|
||||
}
|
||||
|
||||
$options ??= FetchOptions::message()->withBodyText();
|
||||
$this->client->perform(new SelectCommand($collection, true));
|
||||
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||
|
||||
$request = new FetchManyCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$options,
|
||||
);
|
||||
|
||||
foreach ($this->client->perform($request) as $message) {
|
||||
foreach ($this->imapClient()->perform($request) as $message) {
|
||||
$uid = $message->uid() ?: $message->sequence();
|
||||
yield $uid => $message;
|
||||
}
|
||||
@@ -307,7 +382,7 @@ class RemoteMailService
|
||||
*/
|
||||
public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource
|
||||
{
|
||||
$this->client->perform(new SelectCommand($collection, true));
|
||||
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||
|
||||
$encoding = null;
|
||||
|
||||
@@ -316,7 +391,7 @@ class RemoteMailService
|
||||
$mimeType = 'message/rfc822';
|
||||
} else {
|
||||
// Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred)
|
||||
$message = $this->client->perform(new FetchOneCommand(
|
||||
$message = $this->imapClient()->perform(new FetchOneCommand(
|
||||
FetchTarget::uid(SequenceSet::items($uid)),
|
||||
FetchOptions::of('BODYSTRUCTURE'),
|
||||
));
|
||||
@@ -332,7 +407,7 @@ class RemoteMailService
|
||||
|
||||
// Start download stream
|
||||
$stream = $this->decodeStream(
|
||||
$this->client->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
|
||||
$this->imapClient()->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
|
||||
$encoding
|
||||
);
|
||||
|
||||
@@ -410,9 +485,9 @@ class RemoteMailService
|
||||
*
|
||||
* @param string[] $flags optional initial flags, e.g. ['\\Seen']
|
||||
*/
|
||||
public function entityCreate(string $collection, string $rawMessage, array $flags = []): int
|
||||
public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int
|
||||
{
|
||||
return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null);
|
||||
return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -428,8 +503,8 @@ class RemoteMailService
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->perform(new SelectCommand($collection, false));
|
||||
$this->client->perform(new StoreCommand(
|
||||
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||
$this->imapClient()->perform(new StoreCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$flags,
|
||||
$action,
|
||||
@@ -447,9 +522,9 @@ class RemoteMailService
|
||||
|
||||
$target = FetchTarget::uid(SequenceSet::items(...array_values($uids)));
|
||||
|
||||
$this->client->perform(new SelectCommand($collection, false));
|
||||
$this->client->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
||||
$this->client->perform(new ExpungeCommand($target));
|
||||
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||
$this->imapClient()->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
||||
$this->imapClient()->perform(new ExpungeCommand($target));
|
||||
|
||||
// TODO: find a way to determine which actual UID's were deleted
|
||||
return array_fill_keys($uids, true);
|
||||
@@ -461,13 +536,13 @@ class RemoteMailService
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->perform(new SelectCommand($collection, false));
|
||||
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||
|
||||
$flagsToAdd = $this->normalizeFlags($flagsToAdd);
|
||||
$flagsToRemove = $this->normalizeFlags($flagsToRemove);
|
||||
|
||||
if (!empty($flagsToAdd)) {
|
||||
$this->client->perform(new StoreCommand(
|
||||
$this->imapClient()->perform(new StoreCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$flagsToAdd,
|
||||
'+',
|
||||
@@ -475,7 +550,7 @@ class RemoteMailService
|
||||
}
|
||||
|
||||
if (!empty($flagsToRemove)) {
|
||||
$this->client->perform(new StoreCommand(
|
||||
$this->imapClient()->perform(new StoreCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$flagsToRemove,
|
||||
'-',
|
||||
@@ -489,28 +564,28 @@ class RemoteMailService
|
||||
return [];
|
||||
}
|
||||
|
||||
$rfc6851 = $this->client->hasCapability('MOVE');
|
||||
$rfc6851 = $this->imapClient()->hasCapability('MOVE');
|
||||
|
||||
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
|
||||
if ($rfc6851) {
|
||||
$this->client->perform(new SelectCommand($sourceCollection, false));
|
||||
$response = $this->client->perform(new MoveCommand(
|
||||
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||
$response = $this->imapClient()->perform(new MoveCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$targetCollection,
|
||||
));
|
||||
} else {
|
||||
$this->client->perform(new SelectCommand($sourceCollection, false));
|
||||
$response = $this->client->perform(new CopyCommand(
|
||||
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||
$response = $this->imapClient()->perform(new CopyCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$targetCollection,
|
||||
));
|
||||
if ($response->isOk()) {
|
||||
$this->client->perform(new StoreCommand(
|
||||
$this->imapClient()->perform(new StoreCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
['\\Deleted'],
|
||||
'+',
|
||||
));
|
||||
$this->client->perform(new ExpungeCommand(
|
||||
$this->imapClient()->perform(new ExpungeCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
));
|
||||
}
|
||||
@@ -541,8 +616,8 @@ class RemoteMailService
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->client->perform(new SelectCommand($sourceCollection, false));
|
||||
$response = $this->client->perform(new CopyCommand(
|
||||
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||
$response = $this->imapClient()->perform(new CopyCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$targetCollection,
|
||||
));
|
||||
@@ -767,7 +842,7 @@ class RemoteMailService
|
||||
}
|
||||
}
|
||||
|
||||
$messages = iterator_to_array($this->client->perform(new FetchManyCommand(
|
||||
$messages = iterator_to_array($this->imapClient()->perform(new FetchManyCommand(
|
||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||
$options,
|
||||
)));
|
||||
@@ -1016,7 +1091,7 @@ class RemoteMailService
|
||||
private function normalizeFlags(array $flags): array
|
||||
{
|
||||
$map = [
|
||||
'read' => '\\Seen',
|
||||
'seen' => '\\Seen',
|
||||
'answered' => '\\Answered',
|
||||
'flagged' => '\\Flagged',
|
||||
'deleted' => '\\Deleted',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Service\Remote;
|
||||
|
||||
use KTXC\Server;
|
||||
use KTXC\Logger\PlainFileLogger;
|
||||
use KTXM\ProviderImap\Client\Client;
|
||||
use KTXM\ProviderImap\Providers\Service;
|
||||
|
||||
/**
|
||||
* Static factory for IMAP remote service objects.
|
||||
*
|
||||
* - freshClient() → builds a gricob Client from service config
|
||||
* - mailService() → constructs a RemoteMailService from the client
|
||||
*/
|
||||
class RemoteService
|
||||
{
|
||||
/**
|
||||
* Build and bootstrap a fully-configured IMAP client from a Service.
|
||||
*/
|
||||
public static function freshClient(Service $service): Client
|
||||
{
|
||||
$location = $service->getLocation();
|
||||
$identity = $service->getIdentity();
|
||||
|
||||
// Build a file logger when debug mode is enabled, otherwise pass null
|
||||
$logger = null;
|
||||
if ($service->getDebug()) {
|
||||
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
|
||||
$logger = new PlainFileLogger($logDir . '/imap', $service->identifier());
|
||||
}
|
||||
|
||||
$config = $location->toConnectionConfig(
|
||||
$identity?->getIdentity(),
|
||||
$identity?->getSecret(),
|
||||
);
|
||||
|
||||
$client = new Client(logger: $logger);
|
||||
$client->connect($config);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RemoteMailService from a Service and a pre-authenticated client.
|
||||
*
|
||||
* The provider identifier and service ID are taken directly from the Service
|
||||
* object so the caller does not have to repeat them.
|
||||
*/
|
||||
public static function mailService(Service $service, Client $client): RemoteMailService
|
||||
{
|
||||
return new RemoteMailService($client, $service->provider(), $service->identifier());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Auth;
|
||||
|
||||
/**
|
||||
* Supported SASL authentication mechanisms, in preference order.
|
||||
*/
|
||||
enum AuthMechanism: string
|
||||
{
|
||||
case Plain = 'PLAIN';
|
||||
case Login = 'LOGIN';
|
||||
|
||||
/**
|
||||
* Select the most preferred mechanism the server advertises.
|
||||
*
|
||||
* @param list<string> $advertised mechanisms offered by the server (uppercased)
|
||||
*/
|
||||
public static function select(array $advertised): ?self
|
||||
{
|
||||
foreach ([self::Plain, self::Login] as $mechanism) {
|
||||
if (in_array($mechanism->value, $advertised, true)) {
|
||||
return $mechanism;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial AUTH response for the PLAIN mechanism:
|
||||
* base64( authzid NUL authcid NUL passwd ).
|
||||
*/
|
||||
public static function plainToken(string $username, string $password): string
|
||||
{
|
||||
return base64_encode("\0" . $username . "\0" . $password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp;
|
||||
|
||||
use KTXM\ProviderImap\Smtp\Auth\AuthMechanism;
|
||||
use KTXM\ProviderImap\Smtp\Protocol\ProtocolWriter;
|
||||
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
|
||||
use KTXM\ProviderImap\Smtp\Protocol\ProtocolReader;
|
||||
use KTXM\ProviderImap\Smtp\Transport\ConnectionFactoryInterface;
|
||||
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
|
||||
use KTXM\ProviderImap\Smtp\Transport\SocketConnectionFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Minimal SMTP submission client.
|
||||
*
|
||||
* Implements the ESMTP submission handshake (RFC 5321 / RFC 4954):
|
||||
* greeting → EHLO → [STARTTLS → EHLO] → [AUTH] → MAIL FROM → RCPT TO → DATA.
|
||||
*
|
||||
* The client carries opaque RFC822 bytes; message construction lives in the
|
||||
* {@see \KTXM\ProviderImap\Mime\MessageBuilder}.
|
||||
*/
|
||||
final class Client
|
||||
{
|
||||
private ?ConnectionInterface $connection = null;
|
||||
private ?ProtocolReader $reader = null;
|
||||
private ?ProtocolWriter $writer = null;
|
||||
private ?ConnectionConfig $config = null;
|
||||
private ?SmtpCapabilities $capabilities = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(),
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function connect(ConnectionConfig $config): void
|
||||
{
|
||||
$connection = $this->connectionFactory->create($this->logger);
|
||||
$connection->connect($config);
|
||||
|
||||
$this->connection = $connection;
|
||||
$this->config = $config;
|
||||
$this->reader = new ProtocolReader($connection, $this->logger);
|
||||
$this->writer = new ProtocolWriter($connection, $this->logger);
|
||||
|
||||
// Server greeting.
|
||||
$greeting = $this->reader()->read();
|
||||
if (!$greeting->isPositiveCompletion()) {
|
||||
throw new SmtpException('SMTP server rejected the connection: ' . $greeting->text());
|
||||
}
|
||||
|
||||
$this->capabilities = $this->ehlo();
|
||||
|
||||
if ($config->security() === ConnectionSecurity::StartTls) {
|
||||
$this->startTls();
|
||||
$this->capabilities = $this->ehlo();
|
||||
}
|
||||
|
||||
if ($config->hasCredentials()) {
|
||||
$this->authenticate();
|
||||
}
|
||||
}
|
||||
|
||||
public function capabilities(): SmtpCapabilities
|
||||
{
|
||||
return $this->caps();
|
||||
}
|
||||
|
||||
public function isConnected(): bool
|
||||
{
|
||||
return $this->connection !== null && $this->connection->isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a message envelope and its raw RFC822 body.
|
||||
*
|
||||
* @param string $from envelope sender (bare address)
|
||||
* @param list<string> $recipients envelope recipients (bare addresses, incl. Bcc)
|
||||
* @param string $rawMessage complete RFC822 message bytes
|
||||
*
|
||||
* @return string the queue/transport id reported by the server, when available
|
||||
*/
|
||||
public function send(string $from, array $recipients, string $rawMessage): string
|
||||
{
|
||||
if ($recipients === []) {
|
||||
throw new SmtpException('Cannot send a message without recipients.');
|
||||
}
|
||||
|
||||
$mailFrom = sprintf('MAIL FROM:<%s>', $from);
|
||||
if (($max = $this->caps()->maxSize()) !== null) {
|
||||
$size = strlen($rawMessage);
|
||||
if ($size > $max) {
|
||||
throw new SmtpException(sprintf('Message size %d exceeds server limit %d.', $size, $max));
|
||||
}
|
||||
$mailFrom .= ' SIZE=' . $size;
|
||||
}
|
||||
$this->command($mailFrom, static fn (Response $r): bool => $r->isPositiveCompletion(), 'MAIL FROM');
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
$this->command(
|
||||
sprintf('RCPT TO:<%s>', $recipient),
|
||||
static fn (Response $r): bool => $r->isPositiveCompletion(),
|
||||
'RCPT TO',
|
||||
);
|
||||
}
|
||||
|
||||
$this->command('DATA', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'DATA');
|
||||
|
||||
$this->writer()->writeRaw($this->dotStuff($rawMessage) . "\r\n.\r\n");
|
||||
$reply = $this->reader()->read();
|
||||
if (!$reply->isPositiveCompletion()) {
|
||||
throw new SmtpException('SMTP DATA delivery failed: ' . $reply->text());
|
||||
}
|
||||
|
||||
return $reply->text();
|
||||
}
|
||||
|
||||
public function quit(): void
|
||||
{
|
||||
if ($this->connection === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->writer?->writeLine('QUIT');
|
||||
$this->reader?->read();
|
||||
} catch (SmtpException) {
|
||||
// Best-effort: the peer may already have closed the stream.
|
||||
} finally {
|
||||
$this->connection->disconnect();
|
||||
$this->connection = null;
|
||||
$this->reader = null;
|
||||
$this->writer = null;
|
||||
$this->capabilities = null;
|
||||
$this->config = null;
|
||||
}
|
||||
}
|
||||
|
||||
private function ehlo(): SmtpCapabilities
|
||||
{
|
||||
$this->writer()->writeLine('EHLO ' . $this->config()->ehloDomain());
|
||||
$reply = $this->reader()->read();
|
||||
|
||||
if (!$reply->isPositiveCompletion()) {
|
||||
throw new SmtpException('EHLO was rejected: ' . $reply->text());
|
||||
}
|
||||
|
||||
return SmtpCapabilities::fromEhlo($reply);
|
||||
}
|
||||
|
||||
private function startTls(): void
|
||||
{
|
||||
if (!$this->caps()->supportsStartTls()) {
|
||||
throw new SmtpException('SMTP server does not advertise STARTTLS.');
|
||||
}
|
||||
|
||||
$this->command('STARTTLS', static fn (Response $r): bool => $r->isPositiveCompletion(), 'STARTTLS');
|
||||
$this->connection()->upgradeToTls();
|
||||
}
|
||||
|
||||
private function authenticate(): void
|
||||
{
|
||||
$username = $this->config()->username() ?? '';
|
||||
$password = $this->config()->password() ?? '';
|
||||
|
||||
$mechanism = AuthMechanism::select($this->caps()->authMechanisms());
|
||||
if ($mechanism === null) {
|
||||
throw new SmtpException('No supported SMTP AUTH mechanism is available.');
|
||||
}
|
||||
|
||||
match ($mechanism) {
|
||||
AuthMechanism::Plain => $this->authPlain($username, $password),
|
||||
AuthMechanism::Login => $this->authLogin($username, $password),
|
||||
};
|
||||
}
|
||||
|
||||
private function authPlain(string $username, string $password): void
|
||||
{
|
||||
$this->command(
|
||||
'AUTH PLAIN ' . AuthMechanism::plainToken($username, $password),
|
||||
static fn (Response $r): bool => $r->isPositiveCompletion(),
|
||||
'AUTH PLAIN',
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private function authLogin(string $username, string $password): void
|
||||
{
|
||||
$this->command('AUTH LOGIN', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'AUTH LOGIN');
|
||||
|
||||
$this->writer()->writeLine(base64_encode($username), true);
|
||||
$userResponse = $this->reader()->read();
|
||||
if (!$userResponse->isPositiveIntermediate()) {
|
||||
throw new SmtpException('SMTP AUTH LOGIN username rejected: ' . $userResponse->text());
|
||||
}
|
||||
|
||||
$this->writer()->writeLine(base64_encode($password), true);
|
||||
$passResponse = $this->reader()->read();
|
||||
if (!$passResponse->isPositiveCompletion()) {
|
||||
throw new SmtpException('SMTP authentication failed: ' . $passResponse->text());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a command line and assert the reply satisfies $accept.
|
||||
*
|
||||
* @param callable(Response):bool $accept
|
||||
*/
|
||||
private function command(string $line, callable $accept, string $label, bool $sensitive = false): Response
|
||||
{
|
||||
$this->writer()->writeLine($line, $sensitive);
|
||||
$reply = $this->reader()->read();
|
||||
|
||||
if (!$accept($reply)) {
|
||||
throw new SmtpException(sprintf('SMTP %s failed (%d): %s', $label, $reply->code(), $reply->text()));
|
||||
}
|
||||
|
||||
return $reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dot-stuffing per RFC 5321 §4.5.2: any line beginning with a period gets
|
||||
* an extra leading period so it is not mistaken for the end-of-data marker.
|
||||
* Line endings are normalised to CRLF.
|
||||
*/
|
||||
private function dotStuff(string $message): string
|
||||
{
|
||||
$normalized = preg_replace('/\r\n|\r|\n/', "\r\n", $message) ?? $message;
|
||||
|
||||
return preg_replace('/^\./m', '..', $normalized) ?? $normalized;
|
||||
}
|
||||
|
||||
private function connection(): ConnectionInterface
|
||||
{
|
||||
return $this->connection ?? throw new SmtpException('SMTP client is not connected.');
|
||||
}
|
||||
|
||||
private function reader(): ProtocolReader
|
||||
{
|
||||
return $this->reader ?? throw new SmtpException('SMTP client is not connected.');
|
||||
}
|
||||
|
||||
private function writer(): ProtocolWriter
|
||||
{
|
||||
return $this->writer ?? throw new SmtpException('SMTP client is not connected.');
|
||||
}
|
||||
|
||||
private function config(): ConnectionConfig
|
||||
{
|
||||
return $this->config ?? throw new SmtpException('SMTP client is not connected.');
|
||||
}
|
||||
|
||||
private function caps(): SmtpCapabilities
|
||||
{
|
||||
return $this->capabilities ?? throw new SmtpException('SMTP client is not connected.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp;
|
||||
|
||||
final class ConnectionConfig
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $host,
|
||||
private readonly int $port = 587,
|
||||
private readonly ConnectionSecurity $security = ConnectionSecurity::StartTls,
|
||||
private readonly ?string $username = null,
|
||||
private readonly ?string $password = null,
|
||||
private readonly float $timeout = 30.0,
|
||||
private readonly bool $verifyPeer = true,
|
||||
private readonly bool $verifyPeerName = true,
|
||||
private readonly bool $allowSelfSigned = false,
|
||||
private readonly ?string $ehloDomain = null,
|
||||
) {}
|
||||
|
||||
public function host(): string
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
public function port(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function security(): ConnectionSecurity
|
||||
{
|
||||
return $this->security;
|
||||
}
|
||||
|
||||
public function username(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function password(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function hasCredentials(): bool
|
||||
{
|
||||
return $this->username !== null && $this->username !== ''
|
||||
&& $this->password !== null && $this->password !== '';
|
||||
}
|
||||
|
||||
public function timeout(): float
|
||||
{
|
||||
return $this->timeout;
|
||||
}
|
||||
|
||||
public function verifyPeer(): bool
|
||||
{
|
||||
return $this->verifyPeer;
|
||||
}
|
||||
|
||||
public function verifyPeerName(): bool
|
||||
{
|
||||
return $this->verifyPeerName;
|
||||
}
|
||||
|
||||
public function allowSelfSigned(): bool
|
||||
{
|
||||
return $this->allowSelfSigned;
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain announced in the EHLO command. Falls back to the local
|
||||
* hostname, then to a literal so a value is always sent.
|
||||
*/
|
||||
public function ehloDomain(): string
|
||||
{
|
||||
if ($this->ehloDomain !== null && $this->ehloDomain !== '') {
|
||||
return $this->ehloDomain;
|
||||
}
|
||||
|
||||
$hostname = gethostname();
|
||||
|
||||
return $hostname !== false && $hostname !== '' ? $hostname : 'localhost';
|
||||
}
|
||||
|
||||
public function endpoint(): string
|
||||
{
|
||||
return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
public function streamContextOptions(): array
|
||||
{
|
||||
return [
|
||||
'ssl' => [
|
||||
'verify_peer' => $this->verifyPeer,
|
||||
'verify_peer_name' => $this->verifyPeerName,
|
||||
'allow_self_signed' => $this->allowSelfSigned,
|
||||
'SNI_enabled' => true,
|
||||
'peer_name' => $this->host,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp;
|
||||
|
||||
enum ConnectionSecurity: string
|
||||
{
|
||||
case Plain = 'plain';
|
||||
case Tls = 'tls'; // implicit TLS (SMTPS, port 465)
|
||||
case StartTls = 'starttls';
|
||||
|
||||
public function transport(): string
|
||||
{
|
||||
return $this === self::Tls ? 'ssl' : 'tcp';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Protocol;
|
||||
|
||||
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
|
||||
use KTXM\ProviderImap\Smtp\SmtpException;
|
||||
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Reads SMTP replies, assembling multi-line responses.
|
||||
*
|
||||
* A multi-line reply repeats the status code on every line, using a hyphen
|
||||
* ("250-text") to indicate continuation and a space ("250 text") on the final
|
||||
* line. See RFC 5321 §4.2.
|
||||
*/
|
||||
final class ProtocolReader
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConnectionInterface $connection,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function read(): Response
|
||||
{
|
||||
$lines = [];
|
||||
$raw = '';
|
||||
$code = null;
|
||||
|
||||
while (true) {
|
||||
$rawLine = $this->connection->readLine();
|
||||
$raw .= $rawLine;
|
||||
$trimmed = rtrim($rawLine, "\r\n");
|
||||
|
||||
if (!preg_match('/^(\d{3})([ -]?)(.*)$/', $trimmed, $matches)) {
|
||||
throw new SmtpException(sprintf('Malformed SMTP reply line: %s', $trimmed));
|
||||
}
|
||||
|
||||
$lineCode = (int) $matches[1];
|
||||
$separator = $matches[2];
|
||||
$lines[] = $matches[3];
|
||||
|
||||
if ($code === null) {
|
||||
$code = $lineCode;
|
||||
} elseif ($lineCode !== $code) {
|
||||
throw new SmtpException(sprintf('Inconsistent SMTP reply code: %s', $trimmed));
|
||||
}
|
||||
|
||||
// A space separator (or no separator) marks the final line.
|
||||
if ($separator !== '-') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger?->debug('SMTP reply received: {raw}', [
|
||||
'code' => $code,
|
||||
'raw' => rtrim($raw, "\r\n"),
|
||||
]);
|
||||
|
||||
return new Response($code ?? 0, $lines, rtrim($raw, "\r\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Protocol;
|
||||
|
||||
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class ProtocolWriter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConnectionInterface $connection,
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Write a single command line, appending the CRLF terminator.
|
||||
*
|
||||
* @param bool $sensitive when true the payload is redacted from logs
|
||||
* (used for AUTH credential exchanges)
|
||||
*/
|
||||
public function writeLine(string $line, bool $sensitive = false): void
|
||||
{
|
||||
$this->logger?->debug('SMTP command sent: {command}', [
|
||||
'command' => $sensitive ? $this->redact($line) : $line,
|
||||
]);
|
||||
|
||||
$this->connection->write($line . "\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write raw bytes verbatim (e.g. the DATA payload). Never logged.
|
||||
*/
|
||||
public function writeRaw(string $payload): void
|
||||
{
|
||||
$this->connection->write($payload);
|
||||
}
|
||||
|
||||
private function redact(string $line): string
|
||||
{
|
||||
if (preg_match('/^(AUTH\s+\S+)(\s+.*)?$/i', $line, $matches) === 1) {
|
||||
return $matches[1] . (isset($matches[2]) ? ' [REDACTED]' : '');
|
||||
}
|
||||
|
||||
return '[REDACTED]';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Protocol\Response;
|
||||
|
||||
/**
|
||||
* A parsed SMTP reply: a 3-digit status code plus one or more text lines.
|
||||
*/
|
||||
final class Response
|
||||
{
|
||||
/**
|
||||
* @param int $code 3-digit SMTP status code
|
||||
* @param list<string> $lines text portion of each reply line (code/separator stripped)
|
||||
* @param string $raw the raw wire text (for diagnostics)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $code,
|
||||
private readonly array $lines,
|
||||
private readonly string $raw,
|
||||
) {}
|
||||
|
||||
public function code(): int
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function lines(): array
|
||||
{
|
||||
return $this->lines;
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return implode(' ', $this->lines);
|
||||
}
|
||||
|
||||
public function raw(): string
|
||||
{
|
||||
return $this->raw;
|
||||
}
|
||||
|
||||
/** 2xx — requested action completed. */
|
||||
public function isPositiveCompletion(): bool
|
||||
{
|
||||
return $this->code >= 200 && $this->code < 300;
|
||||
}
|
||||
|
||||
/** 3xx — more input required (e.g. DATA, AUTH challenge). */
|
||||
public function isPositiveIntermediate(): bool
|
||||
{
|
||||
return $this->code >= 300 && $this->code < 400;
|
||||
}
|
||||
|
||||
/** 4xx — transient negative completion (try again later). */
|
||||
public function isTransientError(): bool
|
||||
{
|
||||
return $this->code >= 400 && $this->code < 500;
|
||||
}
|
||||
|
||||
/** 5xx — permanent negative completion. */
|
||||
public function isPermanentError(): bool
|
||||
{
|
||||
return $this->code >= 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp;
|
||||
|
||||
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
|
||||
|
||||
/**
|
||||
* Parsed EHLO capabilities.
|
||||
*
|
||||
* The first line of an EHLO reply is the server greeting/domain; every
|
||||
* subsequent line is a capability keyword optionally followed by parameters
|
||||
* (e.g. "AUTH PLAIN LOGIN", "SIZE 35882577").
|
||||
*/
|
||||
final class SmtpCapabilities
|
||||
{
|
||||
/**
|
||||
* @param array<string,list<string>> $keywords capability keyword => parameters
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly array $keywords,
|
||||
) {}
|
||||
|
||||
public static function fromEhlo(Response $reply): self
|
||||
{
|
||||
$keywords = [];
|
||||
$lines = $reply->lines();
|
||||
|
||||
// Skip the first line (greeting/domain); the rest are capabilities.
|
||||
foreach (array_slice($lines, 1) as $line) {
|
||||
$parts = preg_split('/\s+/', trim($line)) ?: [];
|
||||
$keyword = strtoupper((string) array_shift($parts));
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
$keywords[$keyword] = array_map('strtoupper', $parts);
|
||||
}
|
||||
|
||||
return new self($keywords);
|
||||
}
|
||||
|
||||
public function has(string $keyword): bool
|
||||
{
|
||||
return isset($this->keywords[strtoupper($keyword)]);
|
||||
}
|
||||
|
||||
public function supportsStartTls(): bool
|
||||
{
|
||||
return $this->has('STARTTLS');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Advertised AUTH mechanisms (uppercased).
|
||||
*/
|
||||
public function authMechanisms(): array
|
||||
{
|
||||
return $this->keywords['AUTH'] ?? [];
|
||||
}
|
||||
|
||||
public function maxSize(): ?int
|
||||
{
|
||||
$size = $this->keywords['SIZE'][0] ?? null;
|
||||
|
||||
return $size !== null && ctype_digit($size) ? (int) $size : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* SMTP client exception.
|
||||
*/
|
||||
final class SmtpException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
interface ConnectionFactoryInterface
|
||||
{
|
||||
public function create(?LoggerInterface $logger = null): ConnectionInterface;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||
|
||||
use KTXM\ProviderImap\Smtp\ConnectionConfig;
|
||||
|
||||
interface ConnectionInterface
|
||||
{
|
||||
public function connect(ConnectionConfig $config): void;
|
||||
|
||||
public function disconnect(): void;
|
||||
|
||||
public function isConnected(): bool;
|
||||
|
||||
public function write(string $payload): void;
|
||||
|
||||
public function readLine(): string;
|
||||
|
||||
public function upgradeToTls(): void;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||
|
||||
use KTXM\ProviderImap\Smtp\ConnectionConfig;
|
||||
use KTXM\ProviderImap\Smtp\SmtpException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class SocketConnection implements ConnectionInterface
|
||||
{
|
||||
/** @var resource|null */
|
||||
private $stream = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
public function connect(ConnectionConfig $config): void
|
||||
{
|
||||
if ($this->isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = stream_context_create($config->streamContextOptions());
|
||||
$errorCode = 0;
|
||||
$errorMessage = '';
|
||||
|
||||
$stream = @stream_socket_client(
|
||||
$config->endpoint(),
|
||||
$errorCode,
|
||||
$errorMessage,
|
||||
$config->timeout(),
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context,
|
||||
);
|
||||
|
||||
if (!is_resource($stream)) {
|
||||
$this->logger?->error('SMTP socket connection failed', [
|
||||
'endpoint' => $config->endpoint(),
|
||||
'code' => $errorCode,
|
||||
'message' => $errorMessage ?: 'unknown error',
|
||||
]);
|
||||
|
||||
throw new SmtpException(sprintf(
|
||||
'Unable to connect to %s (%d: %s)',
|
||||
$config->endpoint(),
|
||||
$errorCode,
|
||||
$errorMessage ?: 'unknown error',
|
||||
));
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, (int) $config->timeout());
|
||||
$this->stream = $stream;
|
||||
|
||||
$this->logger?->info('SMTP socket connected to {endpoint} (timeout={timeout})', [
|
||||
'endpoint' => $config->endpoint(),
|
||||
'timeout' => $config->timeout(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
if (!is_resource($this->stream)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fclose($this->stream);
|
||||
$this->stream = null;
|
||||
|
||||
$this->logger?->info('SMTP socket disconnected');
|
||||
}
|
||||
|
||||
public function isConnected(): bool
|
||||
{
|
||||
return is_resource($this->stream);
|
||||
}
|
||||
|
||||
public function write(string $payload): void
|
||||
{
|
||||
$stream = $this->stream();
|
||||
$written = fwrite($stream, $payload);
|
||||
|
||||
if ($written === false || $written !== strlen($payload)) {
|
||||
$this->logger?->error('SMTP socket write failed (bytes={bytes})', [
|
||||
'bytes' => strlen($payload),
|
||||
]);
|
||||
|
||||
throw new SmtpException('Failed to write complete payload to SMTP socket.');
|
||||
}
|
||||
}
|
||||
|
||||
public function readLine(): string
|
||||
{
|
||||
$stream = $this->stream();
|
||||
$line = fgets($stream);
|
||||
|
||||
if ($line === false) {
|
||||
$this->logger?->error('SMTP socket read failed');
|
||||
throw new SmtpException('Failed to read line from SMTP socket.');
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
public function upgradeToTls(): void
|
||||
{
|
||||
$stream = $this->stream();
|
||||
$result = stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||
|
||||
if ($result !== true) {
|
||||
$this->logger?->error('SMTP TLS upgrade failed');
|
||||
throw new SmtpException('Failed to enable TLS on SMTP socket.');
|
||||
}
|
||||
|
||||
$this->logger?->info('SMTP socket upgraded to TLS');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource
|
||||
*/
|
||||
private function stream()
|
||||
{
|
||||
if (!is_resource($this->stream)) {
|
||||
throw new SmtpException('SMTP socket is not connected.');
|
||||
}
|
||||
|
||||
return $this->stream;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class SocketConnectionFactory implements ConnectionFactoryInterface
|
||||
{
|
||||
public function create(?LoggerInterface $logger = null): ConnectionInterface
|
||||
{
|
||||
return new SocketConnection($logger);
|
||||
}
|
||||
}
|
||||
Generated
+1748
-418
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -11,7 +11,11 @@
|
||||
"dev": "vite build --mode development --config vite.config.ts",
|
||||
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"test": "vitest run --config tests/js/vitest.config.ts",
|
||||
"test:unit": "vitest run --config tests/js/vitest.config.ts",
|
||||
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
|
||||
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.0",
|
||||
@@ -24,6 +28,10 @@
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
"vue-tsc": "^3.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-account"
|
||||
class="mb-4"
|
||||
autocomplete="username"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
@@ -130,7 +130,7 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
class="mb-4"
|
||||
autocomplete="current-password"
|
||||
autocomplete="new-password"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||
import { ServiceAddressObject } from '@KTXM/MailManager/models/address'
|
||||
|
||||
type AuxiliaryTab = 'addresses' | 'messages' | 'sync'
|
||||
type DeleteMode = 'soft' | 'hard'
|
||||
@@ -16,8 +17,8 @@ const emit = defineEmits<{
|
||||
const activeTab = ref<AuxiliaryTab>('addresses')
|
||||
const deleteMode = ref<DeleteMode>('soft')
|
||||
const deleteDestination = ref('Trash')
|
||||
const primaryAddress = ref('')
|
||||
const secondaryAddresses = ref('')
|
||||
const primaryAddress = ref<ServiceAddressObject>(new ServiceAddressObject())
|
||||
const secondaryAddresses = ref<ServiceAddressObject[]>([])
|
||||
|
||||
const settingGroups = [
|
||||
{
|
||||
@@ -61,10 +62,6 @@ const destinationHint = computed(() => {
|
||||
return 'Mailbox identifier or well-known role target, for example Trash.'
|
||||
})
|
||||
|
||||
const secondaryAddressesHint = computed(() => {
|
||||
return 'Use one address per line. Commas are also accepted.'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.service,
|
||||
service => {
|
||||
@@ -86,17 +83,17 @@ watch(
|
||||
}
|
||||
|
||||
if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) {
|
||||
if (sameAddresses(nextService, primaryAddress.value, secondaryAddresses.value)) {
|
||||
if (sameAddresses(nextService)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
nextService.primaryAddress = normalizePrimaryAddress(primaryAddress.value)
|
||||
nextService.secondaryAddresses = normalizeSecondaryAddresses(secondaryAddresses.value)
|
||||
nextService.primaryAddress = primaryAddress.value.empty ? null : primaryAddress.value
|
||||
nextService.secondaryAddresses = dedupeAddresses(secondaryAddresses.value)
|
||||
nextService.auxiliary = nextAuxiliary
|
||||
emit('update:service', nextService)
|
||||
},
|
||||
{ immediate: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
function syncFromService(service?: ServiceObject) {
|
||||
@@ -105,8 +102,8 @@ function syncFromService(service?: ServiceObject) {
|
||||
deleteDestination.value = typeof auxiliary.deleteDestination === 'string' && auxiliary.deleteDestination.length > 0
|
||||
? auxiliary.deleteDestination
|
||||
: 'Trash'
|
||||
primaryAddress.value = service?.primaryAddress ?? ''
|
||||
secondaryAddresses.value = (service?.secondaryAddresses ?? []).join('\n')
|
||||
primaryAddress.value = service?.primaryAddress ?? new ServiceAddressObject()
|
||||
secondaryAddresses.value = service?.secondaryAddresses ?? []
|
||||
}
|
||||
|
||||
function normalizeDeleteDestination(value: string): string {
|
||||
@@ -114,16 +111,23 @@ function normalizeDeleteDestination(value: string): string {
|
||||
return trimmedValue.length > 0 ? trimmedValue : 'Trash'
|
||||
}
|
||||
|
||||
function normalizePrimaryAddress(value: string): string | null {
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue.length > 0 ? trimmedValue : null
|
||||
function dedupeAddresses(entries: ServiceAddressObject[]): ServiceAddressObject[] {
|
||||
const populated = entries.filter(entry => !entry.empty)
|
||||
return populated.filter((entry, index) =>
|
||||
populated.findIndex(candidate => candidate.matches(entry.address)) === index)
|
||||
}
|
||||
|
||||
function normalizeSecondaryAddresses(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map(entry => entry.trim())
|
||||
.filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index)
|
||||
function addSecondaryAddress() {
|
||||
secondaryAddresses.value.push(new ServiceAddressObject())
|
||||
}
|
||||
|
||||
function removeSecondaryAddress(index: number) {
|
||||
secondaryAddresses.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function validAddress(value: string): boolean | string {
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedValue) || 'Invalid email address'
|
||||
}
|
||||
|
||||
function sameAuxiliary(current: Record<string, any>, next: Record<string, any>): boolean {
|
||||
@@ -131,9 +135,19 @@ function sameAuxiliary(current: Record<string, any>, next: Record<string, any>):
|
||||
&& (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined)
|
||||
}
|
||||
|
||||
function sameAddresses(service: ServiceObject, nextPrimaryAddress: string, nextSecondaryAddresses: string): boolean {
|
||||
return (service.primaryAddress ?? null) === normalizePrimaryAddress(nextPrimaryAddress)
|
||||
&& JSON.stringify(service.secondaryAddresses) === JSON.stringify(normalizeSecondaryAddresses(nextSecondaryAddresses))
|
||||
function sameAddresses(service: ServiceObject): boolean {
|
||||
const nextPrimary = primaryAddress.value.empty ? null : primaryAddress.value
|
||||
const nextSecondary = dedupeAddresses(secondaryAddresses.value)
|
||||
return sameAddress(service.primaryAddress, nextPrimary)
|
||||
&& service.secondaryAddresses.length === nextSecondary.length
|
||||
&& service.secondaryAddresses.every((entry, index) => sameAddress(entry, nextSecondary[index]))
|
||||
}
|
||||
|
||||
function sameAddress(current: ServiceAddressObject | null, next: ServiceAddressObject | null): boolean {
|
||||
if (current === null || next === null) {
|
||||
return current === next
|
||||
}
|
||||
return current.equals(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -165,23 +179,66 @@ function sameAddresses(service: ServiceObject, nextPrimaryAddress: string, nextS
|
||||
Configure the primary mailbox identity and any additional sender aliases exposed by this service.
|
||||
</p>
|
||||
|
||||
<v-text-field
|
||||
v-model="primaryAddress"
|
||||
label="Primary Address"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="text-subtitle-2 mb-2">Primary Address</div>
|
||||
<div class="d-flex ga-2 mb-6">
|
||||
<v-text-field
|
||||
v-model="primaryAddress.label"
|
||||
label="Display Name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="primaryAddress.address"
|
||||
label="Email Address"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
:rules="[validAddress]"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-textarea
|
||||
v-model="secondaryAddresses"
|
||||
label="Secondary Addresses"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-email-multiple-outline"
|
||||
rows="4"
|
||||
:hint="secondaryAddressesHint"
|
||||
persistent-hint
|
||||
/>
|
||||
<div class="text-subtitle-2 mb-2">Secondary Addresses</div>
|
||||
<div
|
||||
v-for="(entry, index) in secondaryAddresses"
|
||||
:key="index"
|
||||
class="d-flex ga-2 mb-2"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="entry.label"
|
||||
label="Display Name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="entry.address"
|
||||
label="Email Address"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-email-multiple-outline"
|
||||
:rules="[validAddress]"
|
||||
hide-details="auto"
|
||||
/>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="removeSecondaryAddress(index)"
|
||||
>
|
||||
<v-icon>mdi-delete-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="bottom">Remove Alias</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="addSecondaryAddress"
|
||||
>
|
||||
Add Alias
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-window-item>
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { LocationSocketSole } from '@KTXM/MailManager/models/location'
|
||||
import { LocationSocketSplit } from '@KTXM/MailManager/models/location'
|
||||
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||
import type {
|
||||
ServiceLocation,
|
||||
ServiceLocationSocketSole,
|
||||
ServiceLocationSocketSplit,
|
||||
} from '@KTXM/MailManager/types/service'
|
||||
import type {
|
||||
ProviderProtocolPanelProps,
|
||||
ProviderProtocolPanelEmits,
|
||||
} from '@KTXM/MailManager/types/integration'
|
||||
|
||||
type ImapEncryption = 'none' | 'ssl' | 'tls' | 'starttls'
|
||||
type Encryption = 'none' | 'ssl' | 'tls' | 'starttls'
|
||||
|
||||
const props = defineProps<ProviderProtocolPanelProps>()
|
||||
const emit = defineEmits<ProviderProtocolPanelEmits>()
|
||||
|
||||
const host = ref('')
|
||||
const encryption = ref<ImapEncryption>('ssl')
|
||||
const port = ref('993')
|
||||
const verifyPeer = ref(true)
|
||||
const verifyHost = ref(true)
|
||||
// ── Inbound (IMAP) ──────────────────────────────────────────────────────────
|
||||
const inboundHost = ref('')
|
||||
const inboundEncryption = ref<Encryption>('ssl')
|
||||
const inboundPort = ref('993')
|
||||
const inboundVerifyPeer = ref(true)
|
||||
const inboundVerifyHost = ref(true)
|
||||
|
||||
// ── Outbound (SMTP) ─────────────────────────────────────────────────────────
|
||||
const outboundHost = ref('')
|
||||
const outboundEncryption = ref<Encryption>('starttls')
|
||||
const outboundPort = ref('587')
|
||||
const outboundVerifyPeer = ref(true)
|
||||
const outboundVerifyHost = ref(true)
|
||||
|
||||
const encryptionOptions = [
|
||||
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
|
||||
@@ -36,23 +44,30 @@ const rules = {
|
||||
return Number.isInteger(numericValue) && numericValue >= 1 && numericValue <= 65535
|
||||
? true
|
||||
: 'Port must be between 1 and 65535'
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const isValid = computed(() => !!host.value && rules.port(port.value) === true)
|
||||
const inboundValid = computed(() => !!inboundHost.value && rules.port(inboundPort.value) === true)
|
||||
const outboundValid = computed(() => !!outboundHost.value && rules.port(outboundPort.value) === true)
|
||||
const isValid = computed(() => inboundValid.value && outboundValid.value)
|
||||
|
||||
const currentLocation = computed((): ServiceLocationSocketSole | null => {
|
||||
const currentLocation = computed((): ServiceLocationSocketSplit | null => {
|
||||
if (!isValid.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'SOCKET_SOLE',
|
||||
host: host.value,
|
||||
port: Number(port.value),
|
||||
encryption: encryption.value,
|
||||
verifyPeer: verifyPeer.value,
|
||||
verifyHost: verifyHost.value,
|
||||
type: 'SOCKET_SPLIT',
|
||||
inboundHost: inboundHost.value,
|
||||
inboundPort: Number(inboundPort.value),
|
||||
inboundEncryption: inboundEncryption.value,
|
||||
inboundVerifyPeer: inboundVerifyPeer.value,
|
||||
inboundVerifyHost: inboundVerifyHost.value,
|
||||
outboundHost: outboundHost.value,
|
||||
outboundPort: Number(outboundPort.value),
|
||||
outboundEncryption: outboundEncryption.value,
|
||||
outboundVerifyPeer: outboundVerifyPeer.value,
|
||||
outboundVerifyHost: outboundVerifyHost.value,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -80,14 +95,20 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
if (nextService.location instanceof LocationSocketSole) {
|
||||
nextService.location.host = location.host
|
||||
nextService.location.port = location.port
|
||||
nextService.location.encryption = location.encryption
|
||||
nextService.location.verifyPeer = location.verifyPeer ?? true
|
||||
nextService.location.verifyHost = location.verifyHost ?? true
|
||||
if (nextService.location instanceof LocationSocketSplit) {
|
||||
const target = nextService.location
|
||||
target.inboundHost = location.inboundHost
|
||||
target.inboundPort = location.inboundPort
|
||||
target.inboundEncryption = location.inboundEncryption
|
||||
target.inboundVerifyPeer = location.inboundVerifyPeer ?? true
|
||||
target.inboundVerifyHost = location.inboundVerifyHost ?? true
|
||||
target.outboundHost = location.outboundHost
|
||||
target.outboundPort = location.outboundPort
|
||||
target.outboundEncryption = location.outboundEncryption
|
||||
target.outboundVerifyPeer = location.outboundVerifyPeer ?? true
|
||||
target.outboundVerifyHost = location.outboundVerifyHost ?? true
|
||||
} else {
|
||||
nextService.location = LocationSocketSole.fromJson(location)
|
||||
nextService.location = LocationSocketSplit.fromJson(location)
|
||||
}
|
||||
|
||||
emit('update:service', nextService)
|
||||
@@ -95,25 +116,34 @@ watch(
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(encryption, (next, previous) => {
|
||||
const previousDefault = defaultPortFor(previous)
|
||||
if (!port.value || Number(port.value) === previousDefault) {
|
||||
port.value = String(defaultPortFor(next))
|
||||
watch(inboundEncryption, (next, previous) => {
|
||||
const previousDefault = defaultInboundPort(previous)
|
||||
if (!inboundPort.value || Number(inboundPort.value) === previousDefault) {
|
||||
inboundPort.value = String(defaultInboundPort(next))
|
||||
}
|
||||
})
|
||||
|
||||
watch(outboundEncryption, (next, previous) => {
|
||||
const previousDefault = defaultOutboundPort(previous)
|
||||
if (!outboundPort.value || Number(outboundPort.value) === previousDefault) {
|
||||
outboundPort.value = String(defaultOutboundPort(next))
|
||||
}
|
||||
})
|
||||
|
||||
function syncFromLocation(location: ServiceLocation | null) {
|
||||
const socketLocation = getSocketLocation(location)
|
||||
const split = location?.type === 'SOCKET_SPLIT' ? location : null
|
||||
|
||||
host.value = socketLocation?.host ?? ''
|
||||
encryption.value = socketLocation?.encryption ?? 'ssl'
|
||||
port.value = String(socketLocation?.port ?? defaultPortFor(encryption.value))
|
||||
verifyPeer.value = socketLocation?.verifyPeer ?? true
|
||||
verifyHost.value = socketLocation?.verifyHost ?? true
|
||||
}
|
||||
inboundHost.value = split?.inboundHost ?? ''
|
||||
inboundEncryption.value = split?.inboundEncryption ?? 'ssl'
|
||||
inboundPort.value = String(split?.inboundPort ?? defaultInboundPort(inboundEncryption.value))
|
||||
inboundVerifyPeer.value = split?.inboundVerifyPeer ?? true
|
||||
inboundVerifyHost.value = split?.inboundVerifyHost ?? true
|
||||
|
||||
function getSocketLocation(location?: ServiceLocation | null): ServiceLocationSocketSole | null {
|
||||
return location?.type === 'SOCKET_SOLE' ? location : null
|
||||
outboundHost.value = split?.outboundHost ?? ''
|
||||
outboundEncryption.value = split?.outboundEncryption ?? 'starttls'
|
||||
outboundPort.value = String(split?.outboundPort ?? defaultOutboundPort(outboundEncryption.value))
|
||||
outboundVerifyPeer.value = split?.outboundVerifyPeer ?? true
|
||||
outboundVerifyHost.value = split?.outboundVerifyHost ?? true
|
||||
}
|
||||
|
||||
function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boolean {
|
||||
@@ -121,30 +151,51 @@ function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boo
|
||||
return a === b
|
||||
}
|
||||
|
||||
if (a.type !== 'SOCKET_SOLE' || b.type !== 'SOCKET_SOLE') {
|
||||
if (a.type !== 'SOCKET_SPLIT' || b.type !== 'SOCKET_SPLIT') {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.host === b.host
|
||||
&& a.port === b.port
|
||||
&& a.encryption === b.encryption
|
||||
&& (a.verifyPeer ?? true) === (b.verifyPeer ?? true)
|
||||
&& (a.verifyHost ?? true) === (b.verifyHost ?? true)
|
||||
return a.inboundHost === b.inboundHost
|
||||
&& a.inboundPort === b.inboundPort
|
||||
&& a.inboundEncryption === b.inboundEncryption
|
||||
&& (a.inboundVerifyPeer ?? true) === (b.inboundVerifyPeer ?? true)
|
||||
&& (a.inboundVerifyHost ?? true) === (b.inboundVerifyHost ?? true)
|
||||
&& a.outboundHost === b.outboundHost
|
||||
&& a.outboundPort === b.outboundPort
|
||||
&& a.outboundEncryption === b.outboundEncryption
|
||||
&& (a.outboundVerifyPeer ?? true) === (b.outboundVerifyPeer ?? true)
|
||||
&& (a.outboundVerifyHost ?? true) === (b.outboundVerifyHost ?? true)
|
||||
}
|
||||
|
||||
function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
return nextEncryption === 'ssl' || nextEncryption === 'tls' ? 993 : 143
|
||||
function defaultInboundPort(encryption: Encryption): number {
|
||||
return encryption === 'ssl' || encryption === 'tls' ? 993 : 143
|
||||
}
|
||||
|
||||
function defaultOutboundPort(encryption: Encryption): number {
|
||||
if (encryption === 'ssl') {
|
||||
return 465
|
||||
}
|
||||
if (encryption === 'none') {
|
||||
return 25
|
||||
}
|
||||
return 587
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="imap-protocol-panel">
|
||||
<h3 class="text-h6 mb-4">IMAP Connection Settings</h3>
|
||||
<p class="text-body-2 mb-6">Configure the server address, transport security, and certificate verification for your IMAP mailbox.</p>
|
||||
<h3 class="text-h6 mb-4">Connection Settings</h3>
|
||||
<p class="text-body-2 mb-6">Configure the incoming (IMAP) and outgoing (SMTP) servers, transport security, and certificate verification for this mailbox.</p>
|
||||
|
||||
<!-- ── Incoming (IMAP) ─────────────────────────────────────────────────── -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-icon start color="primary">mdi-email-arrow-left</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-medium">Incoming mail (IMAP)</span>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="host"
|
||||
label="Server Host"
|
||||
v-model="inboundHost"
|
||||
label="IMAP Server Host"
|
||||
hint="For example: imap.example.com"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
@@ -157,7 +208,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="encryption"
|
||||
v-model="inboundEncryption"
|
||||
:items="encryptionOptions"
|
||||
label="Security"
|
||||
variant="outlined"
|
||||
@@ -166,7 +217,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="port"
|
||||
v-model="inboundPort"
|
||||
label="Port"
|
||||
hint="Defaults to 993 for TLS/SSL and 143 for plain or STARTTLS"
|
||||
persistent-hint
|
||||
@@ -179,7 +230,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
:rules="[rules.required, rules.port]"
|
||||
/>
|
||||
|
||||
<v-expansion-panels class="mt-4">
|
||||
<v-expansion-panels class="mb-6">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon start>mdi-cog</v-icon>
|
||||
@@ -187,7 +238,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-switch
|
||||
v-model="verifyPeer"
|
||||
v-model="inboundVerifyPeer"
|
||||
label="Verify TLS certificate"
|
||||
color="primary"
|
||||
hint="Disable only for trusted internal or test environments"
|
||||
@@ -196,7 +247,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="verifyHost"
|
||||
v-model="inboundVerifyHost"
|
||||
label="Verify certificate hostname"
|
||||
color="primary"
|
||||
hint="Checks that the certificate matches the IMAP host"
|
||||
@@ -207,9 +258,78 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<v-alert type="info" variant="tonal">
|
||||
STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available.
|
||||
</v-alert>
|
||||
<v-divider class="mb-6" />
|
||||
|
||||
<!-- ── Outgoing (SMTP) ─────────────────────────────────────────────────── -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-icon start color="primary">mdi-email-arrow-right</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-medium">Outgoing mail (SMTP)</span>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="outboundHost"
|
||||
label="SMTP Server Host"
|
||||
hint="For example: smtp.example.com"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
class="mb-4"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
:rules="[rules.required]"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="outboundEncryption"
|
||||
:items="encryptionOptions"
|
||||
label="Security"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-lock"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="outboundPort"
|
||||
label="Port"
|
||||
hint="Defaults to 465 for implicit TLS, 587 for STARTTLS, and 25 for plain"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
class="mb-4"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
:rules="[rules.required, rules.port]"
|
||||
/>
|
||||
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon start>mdi-cog</v-icon>
|
||||
Security Options
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-switch
|
||||
v-model="outboundVerifyPeer"
|
||||
label="Verify TLS certificate"
|
||||
color="primary"
|
||||
hint="Disable only for trusted internal or test environments"
|
||||
persistent-hint
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-switch
|
||||
v-model="outboundVerifyHost"
|
||||
label="Verify certificate hostname"
|
||||
color="primary"
|
||||
hint="Checks that the certificate matches the SMTP host"
|
||||
persistent-hint
|
||||
class="mb-4"
|
||||
/>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -232,4 +352,4 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
||||
letter-spacing: 0.0178571429em;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('Basic Tests', () => {
|
||||
it('should perform basic assertion', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should test array operations', () => {
|
||||
const array = ['foo', 'bar', 'baz']
|
||||
|
||||
expect(array).toHaveLength(3)
|
||||
expect(array).toContain('bar')
|
||||
expect(array[0]).toBe('foo')
|
||||
})
|
||||
|
||||
it('should test string operations', () => {
|
||||
const string = 'Hello, World!'
|
||||
|
||||
expect(string).toContain('World')
|
||||
expect(string.length).toBe(13)
|
||||
})
|
||||
|
||||
it('should test object operations', () => {
|
||||
const obj = { foo: 'bar', count: 42 }
|
||||
|
||||
expect(obj).toHaveProperty('foo')
|
||||
expect(obj.foo).toBe('bar')
|
||||
expect(obj.count).toBeGreaterThan(40)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, configDefaults } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '../../src'),
|
||||
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'tests/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/dist/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace KTXT\ProviderImap\Tests\Integration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BaseTest extends TestCase
|
||||
{
|
||||
public function testBasicAssertion(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testArrayOperations(): void
|
||||
{
|
||||
$array = ['foo' => 'bar'];
|
||||
|
||||
$this->assertArrayHasKey('foo', $array);
|
||||
$this->assertEquals('bar', $array['foo']);
|
||||
}
|
||||
|
||||
public function testStringOperations(): void
|
||||
{
|
||||
$string = 'Hello, World!';
|
||||
|
||||
$this->assertStringContainsString('World', $string);
|
||||
$this->assertEquals(13, strlen($string));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace KTXT\ProviderImap\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BaseTest extends TestCase
|
||||
{
|
||||
public function testBasicAssertion(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testArrayOperations(): void
|
||||
{
|
||||
$array = ['foo' => 'bar'];
|
||||
|
||||
$this->assertArrayHasKey('foo', $array);
|
||||
$this->assertEquals('bar', $array['foo']);
|
||||
}
|
||||
|
||||
public function testStringOperations(): void
|
||||
{
|
||||
$string = 'Hello, World!';
|
||||
|
||||
$this->assertStringContainsString('World', $string);
|
||||
$this->assertEquals(13, strlen($string));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__, 2).'/lib/vendor/autoload.php';
|
||||
|
||||
// When this module is checked out inside a full server (server/modules/<handle>,
|
||||
// as it is in CI and in this monorepo checkout), also load the server's own
|
||||
// core/shared autoloader so tests can reference framework (KTXC/KTXF) types.
|
||||
// Standalone module checkouts without a server alongside them skip this.
|
||||
define('SERVER_ROOT', dirname(__DIR__, 4));
|
||||
$serverAutoload = SERVER_ROOT . '/vendor/autoload.php';
|
||||
if (is_file($serverAutoload)) {
|
||||
require $serverAutoload;
|
||||
}
|
||||
|
||||
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
|
||||
umask(0000);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="../../lib/vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="bootstrap.php"
|
||||
cacheDirectory="../../.phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Unit Tests">
|
||||
<directory>Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Integration Tests">
|
||||
<directory>Integration</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreSuppressionOfDeprecations="true"
|
||||
ignoreIndirectDeprecations="true"
|
||||
restrictNotices="true"
|
||||
restrictWarnings="true"
|
||||
>
|
||||
<include>
|
||||
<directory>../../lib</directory>
|
||||
</include>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
Reference in New Issue
Block a user