From ab5871aad7e102a96650ef70495e659d3c669e66 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Tue, 25 Aug 2026 21:45:32 -0400 Subject: [PATCH] feat: implement message create and update Signed-off-by: Sebastian Krupinski --- lib/Providers/EntityResource.php | 21 ++++++ lib/Providers/Service.php | 82 ++++++++++++++++++++++-- lib/Service/Remote/RemoteMailService.php | 40 +++++++++++- tests/php/Unit/RemoteMailServiceTest.php | 68 ++++++++++++++++++++ 4 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 tests/php/Unit/RemoteMailServiceTest.php diff --git a/lib/Providers/EntityResource.php b/lib/Providers/EntityResource.php index e18c597..089e7ef 100644 --- a/lib/Providers/EntityResource.php +++ b/lib/Providers/EntityResource.php @@ -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 */ diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index 0195bc0..41e3ffa 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -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, @@ -701,12 +701,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 +924,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]; + } } diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 08c8b18..7129a2b 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -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. * diff --git a/tests/php/Unit/RemoteMailServiceTest.php b/tests/php/Unit/RemoteMailServiceTest.php new file mode 100644 index 0000000..4f66b68 --- /dev/null +++ b/tests/php/Unit/RemoteMailServiceTest.php @@ -0,0 +1,68 @@ +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) ?? []; + } +}