5 Commits

Author SHA1 Message Date
Sebastian 23612dcb3e chore(deps): update dependency phpunit/phpunit to v12.5.35
Build Test / test (pull_request) Successful in 1m1s
JS Unit Tests / test (pull_request) Successful in 1m15s
PHP Unit Tests / test (pull_request) Successful in 1m24s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m25s
2026-09-10 03:05:02 +00:00
Sebastian 3fdb7b7a2e refactor(module): register integrations through runtime contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian d8d5f9661f fix: delete superseded draft UID after sending via SMTP
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-25 22:41:12 -04:00
Sebastian ab5871aad7 feat: implement message create and update
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-25 21:45:32 -04:00
Sebastian 0e99bb0fd2 feat: listen to user life cycle events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-09 22:37:56 -04:00
8 changed files with 349 additions and 66 deletions
Generated
+51 -39
View File
@@ -273,16 +273,16 @@
},
{
"name": "symfony/mime",
"version": "v7.4.16",
"version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5"
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/20094b76a7106dbe978d31cd3bd7aa1ed248d0e5",
"reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5",
"url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770",
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770",
"shasum": ""
},
"require": {
@@ -338,7 +338,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.4.16"
"source": "https://github.com/symfony/mime/tree/v7.4.13"
},
"funding": [
{
@@ -358,7 +358,7 @@
"type": "tidelift"
}
],
"time": "2026-08-07T14:56:57+00:00"
"time": "2026-05-23T16:22:37+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
@@ -621,20 +621,20 @@
"packages-dev": [
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
"version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
"php": "^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
@@ -669,15 +669,15 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
"source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
"url": "https://github.com/mnapoli",
"type": "github"
}
],
"time": "2025-08-01T08:46:24+00:00"
"time": "2026-08-11T10:17:44+00:00"
},
{
"name": "nikic/php-parser",
@@ -944,23 +944,23 @@
},
{
"name": "phpunit/php-file-iterator",
"version": "6.0.1",
"version": "6.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5",
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
"phpunit/phpunit": "^12.5.33"
},
"type": "library",
"extra": {
@@ -993,7 +993,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2"
},
"funding": [
{
@@ -1013,7 +1013,7 @@
"type": "tidelift"
}
],
"time": "2026-02-02T14:04:18+00:00"
"time": "2026-08-25T14:40:53+00:00"
},
{
"name": "phpunit/php-invoker",
@@ -1201,16 +1201,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.31",
"version": "12.5.35",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
"shasum": ""
},
"require": {
@@ -1220,18 +1220,18 @@
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"myclabs/deep-copy": "^1.14.0",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
"phpunit/php-code-coverage": "^12.5.7",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-file-iterator": "^6.0.2",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.1",
"sebastian/comparator": "^7.1.8",
"sebastian/diff": "^7.0.0",
"sebastian/diff": "^7.0.1",
"sebastian/environment": "^8.1.2",
"sebastian/exporter": "^7.0.3",
"sebastian/global-state": "^8.0.3",
@@ -1279,7 +1279,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35"
},
"funding": [
{
@@ -1287,7 +1287,7 @@
"type": "other"
}
],
"time": "2026-07-06T14:54:16+00:00"
"time": "2026-09-09T04:48:50+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -1510,24 +1510,24 @@
},
{
"name": "sebastian/diff",
"version": "7.0.0",
"version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
"reference": "7ab1ea946c012266ca32390913653d844ecd085f"
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
"reference": "7ab1ea946c012266ca32390913653d844ecd085f",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
"symfony/process": "^7.2"
"phpunit/phpunit": "^12.5.33",
"symfony/process": "^7.4.17"
},
"type": "library",
"extra": {
@@ -1565,15 +1565,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
"security": "https://github.com/sebastianbergmann/diff/security/policy",
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
"type": "tidelift"
}
],
"time": "2025-02-07T04:55:46+00:00"
"time": "2026-08-25T15:35:54+00:00"
},
{
"name": "sebastian/environment",
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Listeners;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXM\ProviderImap\Stores\MessageStore;
use KTXM\ProviderImap\Stores\ServiceStore;
final class UserEventListener
{
public function __construct(
private readonly ServiceStore $serviceStore,
private readonly MessageStore $messageStore,
) {
}
public function onUserCreated(UserCreatedEvent $event): void
{
// TODO: implement provisioning configuration
}
public function onUserDeleting(UserDeletingEvent $event): void
{
$tenantId = $event->tenantIdentifier();
$userId = $event->userIdentifier();
foreach (array_keys($this->serviceStore->list($tenantId, $userId)) as $serviceId) {
$this->messageStore->deleteByServiceId((string) $serviceId);
}
$this->serviceStore->deleteByUser($tenantId, $userId);
}
}
+28 -22
View File
@@ -10,14 +10,20 @@ declare(strict_types=1);
namespace KTXM\ProviderImap;
use KTXC\Resource\ProviderManager;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
use KTXF\Resource\Provider\ProviderInterface;
use KTXM\ProviderImap\Console\ConnectCommand;
use KTXM\ProviderImap\Console\DiscoverCommand;
use KTXM\ProviderImap\Console\DisconnectCommand;
use KTXM\ProviderImap\Console\TestCommand;
use KTXM\ProviderImap\Listeners\UserEventListener;
use KTXM\ProviderImap\Providers\Provider as MailProvider;
/**
@@ -25,10 +31,11 @@ use KTXM\ProviderImap\Providers\Provider as MailProvider;
*
* Registers the IMAP mail provider with the Ktrix provider manager.
*/
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
class Module extends ModuleInstanceAbstract
{
public function __construct(
private readonly ProviderManager $providerManager,
private readonly EventListenerRegistrarInterface $events,
) {}
public function handle(): string
@@ -69,29 +76,28 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function boot(): void
{
// Register listeners
$this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred);
$this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred);
// Register providers
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'imap', MailProvider::class);
}
public function registerCI(): array
public function configure(ModuleContextInterface $context): void
{
return [
DiscoverCommand::class,
ConnectCommand::class,
DisconnectCommand::class,
TestCommand::class,
];
}
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'ProviderImap', 'static/module.mjs');
}
public function registerBI(): array
{
return [
'handle' => $this->handle(),
'namespace' => 'ProviderImap',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
if ($context instanceof ConsoleModuleContextInterface) {
foreach ([
DiscoverCommand::class,
ConnectCommand::class,
DisconnectCommand::class,
TestCommand::class,
] as $command) {
$context->registerCommand($command);
}
}
}
}
+21
View File
@@ -11,6 +11,7 @@ namespace KTXM\ProviderImap\Providers;
use KTXM\ProviderImap\Client\Message;
use KTXF\Mail\Entity\EntityMutableAbstract;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
/**
* Mail Entity Resource Implementation
@@ -42,6 +43,26 @@ class EntityResource extends EntityMutableAbstract {
return $this;
}
/**
* Populate an entity returned by a create or replacement operation.
*/
public function fromMutation(
string $mailbox,
string|int $identifier,
MessagePropertiesMutableInterface $properties,
): static {
$this->data['collection'] = $mailbox;
$this->data['identifier'] = $identifier;
if ($properties instanceof MessageProperties) {
$this->setProperties($properties);
} else {
$this->getProperties()->jsonDeserialize($properties->jsonSerialize());
}
return $this;
}
/**
* @inheritDoc
*/
+95 -4
View File
@@ -102,8 +102,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
],
self::CAPABILITY_ENTITY_FETCH => true,
self::CAPABILITY_ENTITY_EXTANT => true,
self::CAPABILITY_ENTITY_CREATE => false,
self::CAPABILITY_ENTITY_MODIFY => false,
self::CAPABILITY_ENTITY_CREATE => true,
self::CAPABILITY_ENTITY_MODIFY => true,
self::CAPABILITY_ENTITY_PATCH => true,
self::CAPABILITY_ENTITY_DELETE => true,
self::CAPABILITY_ENTITY_MOVE => true,
@@ -675,10 +675,27 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// ignore — the message was already delivered
}
// Best-effort: IMAP has no native "submit existing draft" operation, so the
// message above was always sent fresh. If it originated from a synced draft,
// remove the now-superseded draft UID. A failure here must not turn a
// successful delivery into an error; the draft is simply left for a later
// discard or retry.
if ($source !== null
&& $source->provider() === $this->provider()
&& $source->service() === $this->identifier()) {
try {
$this->initialize();
$this->remoteService->entityDestroy($source->collection(), (int) $source->entity());
} catch (\Throwable) {
// ignore — the message was already delivered
}
}
return new EntitySubmitResult(
disposition: EntitySubmitResult::DISPOSITION_SENT,
transportId: $queueId !== '' ? $queueId : null,
sentEntity: $sentEntity,
sourceDraft: $source instanceof EntityIdentifier ? $source : null,
);
}
@@ -701,12 +718,45 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
{
throw new \RuntimeException('Entity creation is not supported in this service');
if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . (string)$target);
}
$this->initialize();
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties, $options);
$created = $this->remoteService->entityCreate(
(string)$target->collection(),
$nativeMessage,
$nativeFlags,
);
if ($created === null || $created <= 0) {
throw new \RuntimeException('IMAP APPEND did not return a valid UID');
}
return $this->entityFresh()->fromMutation((string)$target->collection(), $created, $properties);
}
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource
{
throw new \RuntimeException('Entity modification is not supported in this service');
if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Target entity does not belong to this service: ' . (string)$target);
}
$this->initialize();
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties);
$modified = $this->remoteService->entityReplace(
(string)$target->collection(),
(int)$target->entity(),
$nativeMessage,
$nativeFlags,
);
if ($modified === null || $modified <= 0) {
throw new \RuntimeException('IMAP replacement did not return a valid UID');
}
return $this->entityFresh()->fromMutation((string)$target->collection(), $modified, $properties);
}
public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array
@@ -891,5 +941,46 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
}
return $list;
}
/**
* Convert canonical message flags to IMAP system flags.
*
* @return string[]
*/
private function messageFlags(MessagePropertiesMutableInterface $properties): array
{
$flags = [];
foreach ($properties->getFlags() as $flag => $enabled) {
if ($enabled !== true) {
continue;
}
$flags[] = match (strtolower((string)$flag)) {
'seen' => '\\Seen',
'flagged' => '\\Flagged',
'answered' => '\\Answered',
'draft' => '\\Draft',
'deleted' => '\\Deleted',
default => (string)$flag,
};
}
return array_values(array_unique($flags));
}
/**
* Convert message properties into the native IMAP append payload.
*
* @return array{0: string, 1: string[]}
*/
private function messagePayload(MessagePropertiesMutableInterface $properties, array $options = []): array
{
$flags = $this->messageFlags($properties);
if (isset($options['flags']) && is_array($options['flags'])) {
$flags = array_values(array_unique([...$flags, ...$options['flags']]));
}
return [(new MessageBuilder())->build($properties), $flags];
}
}
+39 -1
View File
@@ -131,7 +131,7 @@ class RemoteMailService
return null;
}
$logDir = dirname(__DIR__, 5) . '/var/log';
$logDir = dirname(__DIR__, 5) . '/var/logs/provider_imap';
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
}
@@ -490,6 +490,44 @@ class RemoteMailService
return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags));
}
/**
* Append a replacement message, then permanently remove the superseded UID.
* The replacement is removed on cleanup failure so the old UID remains
* authoritative for a later retry.
*
* @param string[] $flags
*/
public function entityReplace(
string $collection,
int $identifier,
string $rawMessage,
array $flags = [],
): ?int {
if ($identifier <= 0) {
throw new RuntimeException('A valid IMAP UID is required for replacement');
}
$replacement = $this->entityCreate($collection, $rawMessage, $flags);
if ($replacement === null || $replacement <= 0) {
throw new RuntimeException('IMAP replacement append did not return a valid UID');
}
try {
$deleted = $this->entityDestroy($collection, $identifier);
if (($deleted[$identifier] ?? false) !== true) {
throw new RuntimeException('Failed to delete the superseded IMAP entity');
}
} catch (\Throwable $error) {
try {
$this->entityDestroy($collection, $replacement);
} catch (\Throwable) {
}
throw $error;
}
return $replacement;
}
/**
* Modify message flags for one or more messages.
*
+11
View File
@@ -177,4 +177,15 @@ class ServiceStore
return $result->getDeletedCount() > 0;
}
/**
* Delete every service owned by a user.
*/
public function deleteByUser(string $tenantId, string $userId): void
{
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany([
'tid' => $tenantId,
'uid' => $userId,
]);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class RemoteMailServiceTest extends TestCase {
public function testEntityReplaceReturnsAuthoritativeReplacementUid(): void {
$service = new RemoteMailServiceStub($this->createStub(Service::class));
$service->appendUid = 202;
$service->destroyResults = [[101 => true]];
$result = $service->entityReplace('Drafts', 101, 'raw message', ['\\Draft']);
$this->assertSame(202, $result);
$this->assertSame([
['Drafts', 'raw message', ['\\Draft']],
], $service->createCalls);
$this->assertSame([
['Drafts', [101]],
], $service->destroyCalls);
}
public function testEntityReplaceRemovesReplacementWhenOldUidCannotBeDeleted(): void {
$service = new RemoteMailServiceStub($this->createStub(Service::class));
$service->appendUid = 202;
$service->destroyResults = [
[101 => false],
[202 => true],
];
try {
$service->entityReplace('Drafts', 101, 'raw message', ['\\Draft']);
$this->fail('Expected superseded message cleanup failure');
} catch (RuntimeException $exception) {
$this->assertSame('Failed to delete the superseded IMAP entity', $exception->getMessage());
}
$this->assertSame([
['Drafts', [101]],
['Drafts', [202]],
], $service->destroyCalls);
}
}
final class RemoteMailServiceStub extends RemoteMailService {
public ?int $appendUid = null;
public array $createCalls = [];
public array $destroyCalls = [];
public array $destroyResults = [];
public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int {
$this->createCalls[] = [$collection, $rawMessage, $flags];
return $this->appendUid;
}
public function entityDestroy(string $collection, int ...$uids): array {
$this->destroyCalls[] = [$collection, $uids];
return array_shift($this->destroyResults) ?? [];
}
}