From 72d72b29c57261d11196a0c7f2f6ad3a3a33998c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 19 Aug 2026 21:49:56 -0400 Subject: [PATCH] feat: implement entityCreate and entityModify Signed-off-by: Sebastian Krupinski --- lib/Providers/Mail/Service.php | 135 +++++++++---- lib/Service/Remote/RemoteMailService.php | 238 +++++++++++++++++------ 2 files changed, 274 insertions(+), 99 deletions(-) diff --git a/lib/Providers/Mail/Service.php b/lib/Providers/Mail/Service.php index 028f8c5..e3db676 100644 --- a/lib/Providers/Mail/Service.php +++ b/lib/Providers/Mail/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, @@ -638,14 +638,42 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource { - // TODO: Implement entity create - return $this->entityFresh(); + 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, $nativeAttachments] = $this->messagePayload($properties); + + $created = $this->mailService->entityCreate((string)$target->collection(), $nativeMessage, $nativeAttachments); + if ($created === null || !isset($created['id'])) { + throw new \RuntimeException('JMAP entity creation did not return an identifier'); + } + $created['mailboxIds'] ??= [(string)$target->collection() => true]; + + return $this->entityFresh()->fromJmap($created); } - public function entityModify(EntityIdentifier $identifier, MessagePropertiesMutableInterface $properties): EntityResource + public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource { - // TODO: Implement entity modify - return $this->entityFresh(); + 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, $nativeAttachments] = $this->messagePayload($properties); + $nativeMessage['id'] = (string)$target->entity(); + $nativeMessage['mailboxIds'] = [(string)$target->collection() => true]; + + // JMAP replaces immutable Email content and returns the new authoritative ID. + $modified = $this->mailService->entityReplace($nativeMessage, $nativeAttachments); + if ($modified === null) { + throw new \RuntimeException('JMAP entity modification failed'); + } + + return $this->entityFresh()->fromJmap($modified); } public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array @@ -897,32 +925,14 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC ); } - $nativeProperties = $this->normalizeMessageProperties($message); - $attachments = $nativeProperties->getAttachments(); - foreach ($attachments as $attachment) { - if ($attachment->getBlobId() !== null) { - continue; - } - - $content = $attachment->getContent(); - if ($content === null) { - throw new \InvalidArgumentException(sprintf( - 'Attachment "%s" has neither content nor a JMAP blob identifier', - $attachment->getName() ?? 'unnamed', - )); - } - - $attachment->setBlobId($this->mailService->blobDeposit( - $attachment->getType() ?? 'application/octet-stream', - $content, - )); - } - if ($attachments !== []) { - $nativeProperties->setAttachments(...$attachments); - } - - $nativeMessage = $nativeProperties->toJmap(); - $transportId = $this->mailService->entitySubmitFresh($identityId, $nativeMessage, $preSendTarget->collection(), $postSentTarget->collection()); + [$nativeMessage, $nativeAttachments] = $this->messagePayload($message); + $transportId = $this->mailService->entitySubmitFresh( + $identityId, + $nativeMessage, + $preSendTarget->collection(), + $postSentTarget->collection(), + $nativeAttachments, + ); return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_SENT, @@ -940,12 +950,19 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC ); } - $patch = null; if ($message !== null) { - $patch = $this->normalizeMessageProperties($message)->toJmap(); + [$nativeMessage, $nativeAttachments] = $this->messagePayload($message); + } else { + $nativeMessage = null; + $nativeAttachments = []; } - $transportId = $this->mailService->entitySubmitDraft($identityId, (string)$source->entity(), $patch); + $transportId = $this->mailService->entitySubmitDraft( + $identityId, + (string)$source->entity(), + $nativeMessage, + $nativeAttachments, + ); return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_SENT, @@ -954,16 +971,50 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC ); } - private function normalizeMessageProperties(MessagePropertiesMutableInterface $message): MessageProperties + /** + * Convert message properties and describe attachment blobs for the remote + * service. Uploading and request batching remain remote-layer concerns. + * + * @return array{0: array, 1: array} + */ + private function messagePayload(MessagePropertiesMutableInterface $message): array { - if ($message instanceof MessageProperties) { - return $message; + if ($message instanceof MessageProperties === false) { + $native = new MessageProperties([]); + $native->jsonDeserialize($message->jsonSerialize()); + } else { + $native = $message; } - $native = new MessageProperties([]); - $native->jsonDeserialize($message->jsonSerialize()); + $attachments = $native->getAttachments(); + $uploads = []; - return $native; + foreach ($attachments as $attachment) { + if ($attachment->getBlobId() !== null) { + continue; + } + + $content = $attachment->getContent(); + if ($content === null) { + throw new \InvalidArgumentException(sprintf( + 'Attachment "%s" has neither content nor a JMAP blob identifier', + $attachment->getName() ?? 'unnamed', + )); + } + + $creationId = 'attachment_' . count($uploads); + $attachment->setBlobId('#' . $creationId); + $uploads[$creationId] = [ + 'type' => $attachment->getType() ?? 'application/octet-stream', + 'content' => $content, + ]; + } + + if ($attachments !== []) { + $native->setAttachments(...$attachments); + } + + return [$native->toJmap(), $uploads]; } private function resolveCollectionByRole(string $role): ?CollectionIdentifier diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 626d885..73610a0 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -11,6 +11,7 @@ namespace KTXM\ProviderJmapc\Service\Remote; use Exception; use JmapClient\Client; +use JmapClient\Requests\Blob\BlobSet; use JmapClient\Requests\Mail\MailboxGet; use JmapClient\Requests\Mail\MailboxParameters as MailboxParametersRequest; use JmapClient\Requests\Mail\MailboxQuery; @@ -727,7 +728,8 @@ class RemoteMailService { * * @since Release 1.0.0 */ - public function entityCreate(string $location, array $so): ?array { + public function entityCreate(string $location, array $so, array $attachments = []): ?array { + $blobRequest = $this->prepareAttachmentRequest($so, $attachments); // convert entity $to = new MailParametersRequest(); $to->parametersRaw($so); @@ -737,9 +739,11 @@ class RemoteMailService { $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->create($id, $to); // transceive - $bundle = $this->dataStore->perform([$r0]); + $requests = $blobRequest !== null ? [$blobRequest, $r0] : [$r0]; + $bundle = $this->dataStore->perform($requests); + $this->assertAttachmentUploads($bundle, $blobRequest, $attachments, $so); // extract response - $response = $bundle->response(0); + $response = $bundle->response($blobRequest !== null ? 1 : 0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { @@ -769,27 +773,64 @@ class RemoteMailService { * * @since Release 1.0.0 */ - public function entityModify(array $so): ?array { - // extract entity id - $id = $so['id']; - // convert entity - $to = new MailParametersRequest(); - $to->parametersRaw($so); - // construct request - $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); - $r0->update($id, $to); - // transceive - $bundle = $this->dataStore->perform([$r0]); - // extract response - $response = $bundle->response(0); - // determine if command succeeded - if (array_key_exists($id, $response->updated())) { - // update entity - $ro = $response->updated()[$id]; - $so = array_merge($so, $ro); - return $so; + public function entityReplace(array $so, array $attachments = []): ?array { + // JMAP Email content is immutable after creation. Replacing a draft must + // therefore create a new Email and remove the superseded Email together. + $id = (string)($so['id'] ?? ''); + unset($so['id']); + if ($id === '') { + throw new Exception('Draft identifier is required for replacement', 1); } - return null; + + $location = null; + foreach (($so['mailboxIds'] ?? []) as $mailboxId => $included) { + if ($included === true) { + $location = (string)$mailboxId; + break; + } + } + if ($location === null || $location === '') { + throw new Exception('Draft mailbox is required for replacement', 1); + } + + $blobRequest = $this->prepareAttachmentRequest($so, $attachments); + $replacement = new MailParametersRequest(); + $replacement->parametersRaw($so); + $replacement->in($location); + $creationId = uniqid(); + + // Email/set supports create and destroy in one method call. When attachment + // content is present, Blob/upload is included in the same HTTP request. + $request = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $request->create($creationId, $replacement); + $request->delete($id); + $requests = $blobRequest !== null ? [$blobRequest, $request] : [$request]; + $bundle = $this->dataStore->perform($requests); + $this->assertAttachmentUploads($bundle, $blobRequest, $attachments, $so); + $response = $bundle->response($blobRequest !== null ? 1 : 0); + + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + + $created = $response->createSuccess($creationId); + if ($created === null) { + $failure = $response->createFailure($creationId); + $type = $failure['type'] ?? 'unknownError'; + $description = $failure['description'] ?? 'JMAP draft replacement failed.'; + throw new Exception("$type: $description", 1); + } + if ($response->deleteSuccess($id) === null) { + $failure = $response->deleteFailure($id); + $type = $failure['type'] ?? 'unknownError'; + $description = $failure['description'] ?? 'The superseded JMAP draft could not be deleted.'; + throw new Exception("$type: $description", 1); + } + + return array_merge($so, $created); } public function entityPatch(MailParametersRequest $properties, string ...$identifiers): ?array { @@ -912,7 +953,8 @@ class RemoteMailService { * * @since Release 2.0.0 */ - public function entitySubmitFresh(string $identityId, array $data, string $preSendTarget, string $postSentTarget): string { + public function entitySubmitFresh(string $identityId, array $data, string $preSendTarget, string $postSentTarget, array $attachments = []): string { + $blobRequest = $this->prepareAttachmentRequest($data, $attachments); $message = new MailParametersRequest(); $message->parametersRaw($data); @@ -936,8 +978,11 @@ class RemoteMailService { $s0->identity($identityId); $s0->message('#' . $messageId); - $bundle = $this->dataStore->perform([$r0, $r1]); - $messageResponse = $bundle->response(0); + $requests = $blobRequest !== null ? [$blobRequest, $r0, $r1] : [$r0, $r1]; + $bundle = $this->dataStore->perform($requests); + $this->assertAttachmentUploads($bundle, $blobRequest, $attachments, $data); + $responseOffset = $blobRequest !== null ? 1 : 0; + $messageResponse = $bundle->response($responseOffset); if ($messageResponse instanceof ResponseException) { throw new Exception($messageResponse->type() . ': ' . $messageResponse->description(), 1); } @@ -950,7 +995,51 @@ class RemoteMailService { } } - $response = $bundle->response(1); + $response = $bundle->response($responseOffset + 1); + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + + return $this->extractSubmissionIdentifier($response, $submissionId); + } + + /** + * Submit an existing draft message. + * + * @since Release 2.0.0 + */ + public function entitySubmitDraft(string $identityId, string $draftId, ?array $patch = null, array $attachments = []): string { + if ($draftId === '') { + throw new Exception('Draft identifier is required for draft submission', 1); + } + + $blobRequest = $patch !== null ? $this->prepareAttachmentRequest($patch, $attachments) : null; + $requests = $blobRequest !== null ? [$blobRequest] : []; + + if ($patch !== null && $patch !== []) { + $m0 = new MailParametersRequest(); + $m0->parametersRaw($patch); + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($draftId, $m0); + $requests[] = $r0; + } + + $submissionId = 's_' . uniqid(); + $r1 = new MailSubmissionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $s0 = $r1->create($submissionId); + $s0->identity($identityId); + $s0->message($draftId); + $requests[] = $r1; + + $bundle = $this->dataStore->perform($requests); + if ($patch !== null) { + $this->assertAttachmentUploads($bundle, $blobRequest, $attachments, $patch); + } + $responseIndex = count($requests) - 1; + $response = $bundle->response($responseIndex); if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); @@ -974,43 +1063,78 @@ class RemoteMailService { } /** - * Submit an existing draft message. + * Build one Blob/upload method call for all new attachment content. Servers + * without the Blob capability fall back to the standard upload endpoint. * - * @since Release 2.0.0 + * @param array $attachments */ - public function entitySubmitDraft(string $identityId, string $draftId, ?array $patch = null): string { - if ($draftId === '') { - throw new Exception('Draft identifier is required for draft submission', 1); + private function prepareAttachmentRequest(array &$data, array $attachments): ?BlobSet { + if ($attachments === []) { + return null; } - $requests = []; - - if ($patch !== null && $patch !== []) { - $m0 = new MailParametersRequest(); - $m0->parametersRaw($patch); - $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); - $r0->update($draftId, $m0); - $requests[] = $r0; - } - - $submissionId = 's_' . uniqid(); - $r1 = new MailSubmissionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); - $s0 = $r1->create($submissionId); - $s0->identity($identityId); - $s0->message($draftId); - $requests[] = $r1; - - $bundle = $this->dataStore->perform($requests); - $responseIndex = count($requests) - 1; - $response = $bundle->response($responseIndex); - if ($response instanceof ResponseException) { - if ($response->type() === 'unknownMethod') { - throw new JmapUnknownMethod($response->description(), 1); + if (!$this->dataStore->sessionCapable('blob')) { + foreach ($attachments as $creationId => $attachment) { + $blobId = $this->blobDeposit($attachment['type'], $attachment['content']); + $this->replaceBlobReference($data, '#' . $creationId, $blobId); } - throw new Exception($response->type() . ': ' . $response->description(), 1); + return null; } - return $this->extractSubmissionIdentifier($response, $submissionId); + $request = new BlobSet($this->dataAccount); + foreach ($attachments as $creationId => $attachment) { + $request->create($creationId) + ->type($attachment['type']) + ->dataEncoded(base64_encode($attachment['content'])); + } + + return $request; + } + + /** + * Validate a batched Blob/upload response and replace creation references + * in the returned local representation with actual server blob IDs. + * + * @param array $attachments + */ + private function assertAttachmentUploads(object $bundle, ?BlobSet $request, array $attachments, array &$data): void { + if ($request === null) { + return; + } + + $response = $bundle->response(0); + if ($response instanceof ResponseException) { + throw new Exception('Attachment upload failed: ' . $response->type() . ': ' . $response->description(), 1); + } + + foreach (array_keys($attachments) as $creationId) { + $failure = $response->createFailure($creationId); + if ($failure !== null) { + $type = $failure['type'] ?? 'unknownError'; + $description = $failure['description'] ?? 'Attachment upload failed.'; + throw new Exception("$type: $description", 1); + } + + $success = $response->createSuccess($creationId); + $blobId = is_array($success) ? ($success['id'] ?? null) : null; + if (!is_string($blobId) || $blobId === '') { + throw new Exception('Attachment upload did not return a blob identifier', 1); + } + $this->replaceBlobReference($data, '#' . $creationId, $blobId); + } + } + + private function replaceBlobReference(array &$data, string $reference, string $blobId): void { + foreach ($data as &$value) { + if (is_array($value)) { + $this->replaceBlobReference($value, $reference, $blobId); + continue; + } + if ($value === $reference) { + $value = $blobId; + } + } + unset($value); } private function extractSubmissionIdentifier(object $response, string $createId): string {