From a6d1ba5f10d171decbdde5e733b47ab6949c9cce Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Tue, 16 Jun 2026 13:34:11 -0400 Subject: [PATCH] feat: message send Signed-off-by: Sebastian Krupinski --- lib/Providers/Document/EntityProperties.php | 14 +- lib/Providers/Document/Provider.php | 8 +- lib/Providers/Document/Service.php | 38 +-- lib/Providers/Mail/MessageAttachment.php | 186 ----------- lib/Providers/Mail/MessageProperties.php | 178 +++++++++-- lib/Providers/Mail/Provider.php | 8 +- lib/Providers/Mail/Service.php | 325 +++++++++++++++----- lib/Service/Remote/RemoteMailService.php | 155 +++++----- src/components/JmapAuxiliaryPanel.vue | 135 +++++--- 9 files changed, 612 insertions(+), 435 deletions(-) delete mode 100644 lib/Providers/Mail/MessageAttachment.php diff --git a/lib/Providers/Document/EntityProperties.php b/lib/Providers/Document/EntityProperties.php index 945dc61..fdfe3e6 100644 --- a/lib/Providers/Document/EntityProperties.php +++ b/lib/Providers/Document/EntityProperties.php @@ -24,23 +24,23 @@ class EntityProperties extends EntityPropertiesMutableAbstract { public function fromJmap(array $parameters): static { if (isset($data['size'])) { - $this->data[self::JSON_PROPERTY_SIZE] = (int) $data['size']; + $this->data[self::PROPERTY_SIZE] = (int) $data['size']; } if (isset($data['label'])) { - $this->data[self::JSON_PROPERTY_LABEL] = (string) $data['name']; + $this->data[self::PROPERTY_LABEL] = (string) $data['name']; } if (isset($data['mime'])) { - $this->data[self::JSON_PROPERTY_MIME] = (string) $data['type']; + $this->data[self::PROPERTY_MIME] = (string) $data['type']; } if (isset($data['format'])) { - $this->data[self::JSON_PROPERTY_FORMAT] = null; + $this->data[self::PROPERTY_FORMAT] = null; } if (isset($data['encoding'])) { - $this->data[self::JSON_PROPERTY_ENCODING] = null; + $this->data[self::PROPERTY_ENCODING] = null; } return $this; @@ -52,8 +52,8 @@ class EntityProperties extends EntityPropertiesMutableAbstract { public function toJmap(): array { $parameters = array_filter([ - 'name' => $this->data[self::JSON_PROPERTY_LABEL], - 'type' => $this->data[self::JSON_PROPERTY_MIME] ?? 'application/octet-stream' + 'name' => $this->data[self::PROPERTY_LABEL], + 'type' => $this->data[self::PROPERTY_MIME] ?? 'application/octet-stream' ], static fn($value) => $value !== null); return $parameters; diff --git a/lib/Providers/Document/Provider.php b/lib/Providers/Document/Provider.php index a5577d8..13c5265 100644 --- a/lib/Providers/Document/Provider.php +++ b/lib/Providers/Document/Provider.php @@ -49,10 +49,10 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceTestInt 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, ]; } diff --git a/lib/Providers/Document/Service.php b/lib/Providers/Document/Service.php index adaff01..fd8ba33 100644 --- a/lib/Providers/Document/Service.php +++ b/lib/Providers/Document/Service.php @@ -125,15 +125,15 @@ 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_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_LOCATION => $this->location?->jsonSerialize(), + self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), + self::PROPERTY_AUXILIARY => $this->auxiliary, ], fn($v) => $v !== null); } @@ -142,20 +142,20 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } - 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_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_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_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; diff --git a/lib/Providers/Mail/MessageAttachment.php b/lib/Providers/Mail/MessageAttachment.php deleted file mode 100644 index 90c712a..0000000 --- a/lib/Providers/Mail/MessageAttachment.php +++ /dev/null @@ -1,186 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderJmapc\Providers\Mail; - -/** - * Mail Attachment Object - * - * @since 30.0.0 - */ -class MessageAttachment implements MessagePart { - protected MessagePart $_meta; - protected ?string $_contents = null; - - public function __construct(?MessagePart $meta = null, ?string $contents = null) { - // determine if meta data exists - // if meta data is missing create new - if ($meta === null) { - $meta = new MessagePart(); - $meta->setDisposition('attachment'); - $meta->setType('application/octet-stream'); - } - $this->setParameters($meta); - // determine if attachment contents exists - // if contents exists set the contents - if ($contents !== null) { - $this->setContents($contents); - } - } - - /** - * sets the attachments parameters - * - * @since 1.0.0 - * - * @param MessagePart|null $meta collection of all message parameters - * - * @return self return this object for command chaining - */ - public function setParameters(?MessagePart $meta): self { - - // replace meta data store - $this->_meta = $meta; - // return this object for command chaining - return $this; - } - - /** - * gets the attachments of this message - * - * @since 1.0.0 - * - * @return array collection of all message parameters - */ - public function getParameters(): MessagePart { - // evaluate if data store field exists and return value(s) or null otherwise - return $this->_meta; - } - - /** - * arbitrary unique text string identifying this message - * - * @since 1.0.0 - * - * @return string id of this message - */ - public function id(): string { - // return id of message - return $this->_meta->getBlobId(); - } - - /** - * sets the attachment file name - * - * @since 30.0.0 - * - * @param string $value file name (e.g example.txt) - * - * @return self return this object for command chaining - */ - public function setName(string $value): self { - $this->_meta->setName($value); - return $this; - } - - /** - * gets the attachment file name - * - * @since 30.0.0 - * - * @return string | null returns the attachment file name or null if not set - */ - public function getName(): ?string { - return $this->_meta->getName(); - } - - /** - * sets the attachment mime type - * - * @since 30.0.0 - * - * @param string $value mime type (e.g. text/plain) - * - * @return self return this object for command chaining - */ - public function setType(string $value): self { - $this->_meta->setType($value); - return $this; - } - - /** - * gets the attachment mime type - * - * @since 30.0.0 - * - * @return string | null returns the attachment mime type or null if not set - */ - public function getType(): ?string { - return $this->_meta->getType(); - } - - /** - * sets the attachment contents (actual data) - * - * @since 30.0.0 - * - * @param string $value binary contents of file - * - * @return self return this object for command chaining - */ - public function setContents(string $value): self { - $this->_contents = $value; - return $this; - } - - /** - * gets the attachment contents (actual data) - * - * @since 30.0.0 - * - * @return string | null returns the attachment contents or null if not set - */ - public function getContents(): ?string { - return $this->_contents; - } - - /** - * sets the embedded status of the attachment - * - * @since 30.0.0 - * - * @param bool $value true - embedded / false - not embedded - * - * @return self return this object for command chaining - */ - public function setEmbedded(bool $value): self { - if ($value) { - $this->_meta->setDisposition('inline'); - } else { - $this->_meta->setDisposition('attachment'); - } - return $this; - } - - /** - * gets the embedded status of the attachment - * - * @since 30.0.0 - * - * @return bool embedded status of the attachment - */ - public function getEmbedded(): bool { - if ($this->_meta->getDisposition() === 'inline') { - return true; - } else { - return false; - } - } - -} diff --git a/lib/Providers/Mail/MessageProperties.php b/lib/Providers/Mail/MessageProperties.php index e883244..7076513 100644 --- a/lib/Providers/Mail/MessageProperties.php +++ b/lib/Providers/Mail/MessageProperties.php @@ -16,6 +16,142 @@ use KTXF\Mail\Object\MessagePropertiesMutableAbstract; */ class MessageProperties extends MessagePropertiesMutableAbstract { + private function buildJmapBodyStructure(array &$bodyValues): ?array { + $counter = 0; + $body = isset($this->data[static::PROPERTY_BODY]) && is_array($this->data[static::PROPERTY_BODY]) + ? $this->serializeBodyPartForJmap($this->data[static::PROPERTY_BODY], $bodyValues, $counter) + : null; + + $attachments = []; + if (isset($this->data[static::PROPERTY_ATTACHMENTS]) && is_array($this->data[static::PROPERTY_ATTACHMENTS])) { + foreach ($this->data[static::PROPERTY_ATTACHMENTS] as $attachment) { + if (!is_array($attachment)) { + continue; + } + + $serialized = $this->serializeBodyPartForJmap($attachment, $bodyValues, $counter); + if ($serialized !== null) { + $attachments[] = $serialized; + } + } + } + + if ($body === null) { + if ($attachments === []) { + return null; + } + + if (count($attachments) === 1) { + return $attachments[0]; + } + + return [ + 'type' => 'multipart/mixed', + 'subParts' => $attachments, + ]; + } + + if ($attachments === []) { + return $body; + } + + return [ + 'type' => 'multipart/mixed', + 'subParts' => array_merge([$body], $attachments), + ]; + } + + private function serializeBodyPartForJmap(array $part, array &$bodyValues, int &$counter): ?array { + $type = isset($part['type']) && is_string($part['type']) && $part['type'] !== '' + ? strtolower($part['type']) + : null; + + if ($type === null) { + return null; + } + + $isMultipart = str_starts_with($type, 'multipart/'); + $serialized = [ + 'type' => $type, + ]; + + if (isset($part['name']) && is_string($part['name']) && $part['name'] !== '') { + $serialized['name'] = $part['name']; + } + + if (isset($part['disposition']) && is_string($part['disposition']) && $part['disposition'] !== '') { + $serialized['disposition'] = $part['disposition']; + } + + if (isset($part['cid']) && is_string($part['cid']) && $part['cid'] !== '') { + $serialized['cid'] = $part['cid']; + } + + if (isset($part['language']) && is_array($part['language']) && $part['language'] !== []) { + $serialized['language'] = $part['language']; + } + + if (isset($part['location']) && is_string($part['location']) && $part['location'] !== '') { + $serialized['location'] = $part['location']; + } + + if ($isMultipart) { + $subParts = []; + foreach ($part['subParts'] ?? [] as $subPart) { + if (!is_array($subPart)) { + continue; + } + + $serializedSubPart = $this->serializeBodyPartForJmap($subPart, $bodyValues, $counter); + if ($serializedSubPart !== null) { + $subParts[] = $serializedSubPart; + } + } + + if ($subParts !== []) { + $serialized['subParts'] = $subParts; + } + + return $serialized; + } + + $blobId = isset($part['blobId']) && is_string($part['blobId']) && $part['blobId'] !== '' + ? $part['blobId'] + : null; + $content = isset($part['content']) && is_string($part['content']) ? $part['content'] : null; + + if ($blobId !== null) { + $serialized['blobId'] = $blobId; + + if (isset($part['charset']) && is_string($part['charset']) && $part['charset'] !== '' && str_starts_with($type, 'text/')) { + $serialized['charset'] = $part['charset']; + } + + if (isset($part['size']) && is_int($part['size'])) { + $serialized['size'] = $part['size']; + } + + return $serialized; + } + + if ($content !== null && str_starts_with($type, 'text/')) { + $partId = isset($part['partId']) && is_string($part['partId']) && $part['partId'] !== '' + ? $part['partId'] + : 'part-' . (++$counter); + + $serialized['partId'] = $partId; + $bodyValues[$partId] = [ + 'value' => $content, + 'isEncodingProblem' => false, + 'isTruncated' => false, + ]; + + return $serialized; + } + + return $serialized; + } + /** * Convert JMAP parameters array to mail message properties object * @@ -162,11 +298,11 @@ class MessageProperties extends MessagePropertiesMutableAbstract { if (isset($this->data['size'])) { $parameters['size'] = $this->data['size']; } - if (isset($this->data['receivedDate'])) { - $parameters['receivedAt'] = $this->data['receivedDate']; + if (isset($this->data['received'])) { + $parameters['receivedAt'] = $this->data['received']; } - if (isset($this->data['date'])) { - $parameters['sentAt'] = $this->data['date']; + if (isset($this->data['sent'])) { + $parameters['sentAt'] = $this->data['sent']; } if (isset($this->data['inReplyTo'])) { $parameters['inReplyTo'] = $this->data['inReplyTo']; @@ -239,37 +375,17 @@ class MessageProperties extends MessagePropertiesMutableAbstract { $parameters['keywords'][$keyword] = $value; } } - if (isset($this->data['bodyStructure'])) { - $parameters['bodyStructure'] = $this->data['bodyStructure']; - } - if (isset($this->data['body'])) { - $parameters['bodyValues'] = []; - - if (isset($this->data['body']['text']['content'])) { - $parameters['bodyValues']['0'] = [ - 'value' => $this->data['body']['text']['content'], - 'isEncodingProblem' => false, - 'isTruncated' => false - ]; - } - - if (isset($this->data['body']['html']['content'])) { - $parameters['bodyValues']['1'] = [ - 'value' => $this->data['body']['html']['content'], - 'isEncodingProblem' => false, - 'isTruncated' => false - ]; - } + + $bodyValues = []; + $bodyStructure = $this->buildJmapBodyStructure($bodyValues); + if ($bodyStructure !== null) { + $parameters['bodyStructure'] = $bodyStructure; + $parameters['bodyValues'] = $bodyValues; } + if (isset($this->data['headers'])) { $parameters['headers'] = $this->data['headers']; } - if (isset($this->data['attachments'])) { - $parameters['attachments'] = $this->data['attachments']; - $parameters['hasAttachment'] = !empty($this->data['attachments']); - } else { - $parameters['hasAttachment'] = false; - } return $parameters; } diff --git a/lib/Providers/Mail/Provider.php b/lib/Providers/Mail/Provider.php index 322cd50..8f0bd0f 100644 --- a/lib/Providers/Mail/Provider.php +++ b/lib/Providers/Mail/Provider.php @@ -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, ]; } diff --git a/lib/Providers/Mail/Service.php b/lib/Providers/Mail/Service.php index 21aac5e..8693176 100644 --- a/lib/Providers/Mail/Service.php +++ b/lib/Providers/Mail/Service.php @@ -12,7 +12,6 @@ namespace KTXM\ProviderJmapc\Providers\Mail; use Generator; use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionRoles; -use KTXF\Mail\Collection\CollectionMutableInterface; use KTXF\Mail\Collection\CollectionPropertiesBaseInterface; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; @@ -21,7 +20,9 @@ use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; +use KTXF\Mail\Service\ServiceEntitySubmitInterface; use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Mail\Submission\EntitySubmitResult; use KTXF\Resource\BinaryResource; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; @@ -41,7 +42,7 @@ use KTXM\ProviderJmapc\Providers\ServiceLocation; use KTXM\ProviderJmapc\Service\Remote\RemoteMailService; use KTXM\ProviderJmapc\Service\Remote\RemoteService; -class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface { private const PROVIDER_IDENTIFIER = 'jmap'; @@ -51,7 +52,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; @@ -107,6 +108,9 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC self::CAPABILITY_ENTITY_DELETE => true, self::CAPABILITY_ENTITY_MOVE => true, self::CAPABILITY_ENTITY_COPY => false, + self::CAPABILITY_ENTITY_SUBMIT_FRESH => true, + self::CAPABILITY_ENTITY_SUBMIT_DRAFT => true, + 'EntityTransmit' => true, ]; private readonly RemoteMailService $mailService; @@ -170,17 +174,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); } @@ -190,29 +194,29 @@ 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]) && is_string($data[self::PROPERTY_PRIMARY_ADDRESS])) { + $this->setPrimaryAddress(new Address($data[self::PROPERTY_PRIMARY_ADDRESS])); } - 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; @@ -262,41 +266,47 @@ 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; } public function hasAddress(string $address): bool { $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 $secondaryAddress) { - if (strtolower($secondaryAddress->getAddress()) === $address) { + foreach ($this->secondaryAddresses as $secondary) { + $secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary; + if (strtolower($secondaryAddr) === $address) { return true; } } - + return false; + } public function getLocation(): ServiceLocation @@ -463,31 +473,24 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); } - // Move to target collection (e.g. Trash) instead of deleting - if ($deleteMode === 'soft' && $deleteTarget !== null) { - return $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target); - } - - if ($deleteMode === 'soft' && $deleteTarget === null) { - $filter = $this->collectionListFilter(); - $filter->condition('role', CollectionRoles::Trash->value); - - $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); - if (empty($mailboxes)) { + $deleteTargetIdentifier = null; + if ($deleteMode === 'soft') { + $deleteTargetIdentifier = $deleteTarget !== null && (string) $deleteTarget !== '' + ? new CollectionIdentifier($this->provider(), (string) $this->identifier(), (string) $deleteTarget) + : $this->resolveCollectionByRole(CollectionRoles::Trash->value); + if ($deleteTargetIdentifier === null) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } - - $deleteTarget = key($mailboxes); } // we need to determine if the folder being deleted is already in the trash - if (str_starts_with((string) $target->collection(), (string) $deleteTarget)) { + if (str_starts_with((string) $target->collection(), (string) $deleteTargetIdentifier?->collection())) { // if so, we should hard delete instead of moving to avoid duplicates in the trash $deleteMode = 'hard'; } $result = match ($deleteMode) { - 'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target), + 'soft' => $this->collectionMove($deleteTargetIdentifier, $target), 'hard' => $this->mailService->collectionDestroy($target->collection(), $force), }; return $result; @@ -688,28 +691,18 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // connect to remote store $this->initialize(); - // attempt to find a target collection for soft deletion if none was specified - if ($deleteMode === 'soft' && $deleteTarget === null) { - $filter = $this->collectionListFilter(); - $filter->condition('role', CollectionRoles::Trash->value); - - $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); - if (empty($mailboxes)) { + $deleteTargetIdentifier = null; + if ($deleteMode === 'soft') { + $deleteTargetIdentifier = $deleteTarget !== null && (string) $deleteTarget !== '' + ? new CollectionIdentifier($this->provider(), (string) $this->identifier(), (string) $deleteTarget) + : $this->resolveCollectionByRole(CollectionRoles::Trash->value); + if ($deleteTargetIdentifier === null) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } - - $targetMailbox = reset($mailboxes); - if ($targetMailbox === false) { - throw new \RuntimeException('No Trash collection configured or found for deletion'); - } - - $deleteTargetNative = $targetMailbox->id(); - $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); - } else { - $deleteTargetNative = $deleteTarget; - $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); } + $deleteTargetNative = $deleteTargetIdentifier?->collection(); + // if all targets are already in the delete target collection, we should hard delete instead of moving to avoid duplicates in the trash $targetCollections = []; foreach ($targets as $target) { @@ -814,6 +807,195 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return []; } + public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult + { + try { + $this->initialize(); + + if ($source === null && $message === null) { + throw new \InvalidArgumentException('At least one of source or message must be provided for submission'); + } + + if ($source !== null) { + if ($source->provider() !== $this->provider() || (string)$source->service() !== (string)$this->identifier()) { + throw new \InvalidArgumentException('Source entity does not belong to this service'); + } + + return $this->entityTransmitDraft($sender, $source, $message); + } + + if ($message === null) { + throw new \InvalidArgumentException('Message properties are required for fresh submission'); + } + + return $this->entityTransmitFresh($sender, $message); + } catch (\Throwable $e) { + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_ERROR, + errorCode: 'submit_failed', + errorMessage: $e->getMessage(), + ); + } + } + + public function entityTransmitFresh(AddressInterface $sender, MessagePropertiesMutableInterface $message): EntitySubmitResult + { + // determine the drafts folder + // if no explicit drafts folder is configured, we will attempt to find a folder with the "drafts" role. + $preSendTarget = $this->auxiliary['draftTarget'] ?? null; + $preSendTarget = $preSendTarget !== null && (string) $preSendTarget !== '' + ? new CollectionIdentifier($this->provider(), (string) $this->identifier(), (string) $preSendTarget) + : $this->resolveCollectionByRole(CollectionRoles::Drafts->value); + + if ($preSendTarget->provider() !== $this->provider() || $preSendTarget->service() !== (string)$this->identifier()) { + throw new \InvalidArgumentException('Drafts target collection does not belong to this service'); + } + // determine the sent folder + // if no explicit sent folder is configured, we will attempt to find a folder with the "sent" role. + $postSentTarget = $this->auxiliary['sentTarget'] ?? null; + $postSentTarget = $postSentTarget !== null && (string) $postSentTarget !== '' + ? new CollectionIdentifier($this->provider(), (string) $this->identifier(), (string) $postSentTarget) + : $this->resolveCollectionByRole(CollectionRoles::Sent->value); + + if ($postSentTarget->provider() !== $this->provider() || $postSentTarget->service() !== (string)$this->identifier()) { + throw new \InvalidArgumentException('Sent target collection does not belong to this service'); + } + // determine the identity to submit with based on the sender address + $identityId = $this->resolveIdentityId($sender); + if ($identityId === null) { + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_ERROR, + errorCode: 'identity_not_found', + errorMessage: sprintf('No matching JMAP identity found for sender %s', $sender->getAddress()), + ); + } + + $nativeMessage = $this->normalizeMessageProperties($message)->toJmap(); + $transportId = $this->mailService->entitySubmitFresh($identityId, $nativeMessage, $preSendTarget->collection(), $postSentTarget->collection()); + + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_SENT, + transportId: $transportId, + ); + } + + public function entityTransmitDraft(AddressInterface $sender, EntityIdentifierInterface $source, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult { + $identityId = $this->resolveIdentityId($sender); + if ($identityId === null) { + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_ERROR, + errorCode: 'identity_not_found', + errorMessage: sprintf('No matching JMAP identity found for sender %s', $sender->getAddress()), + ); + } + + $patch = null; + if ($message !== null) { + $patch = $this->normalizeMessageProperties($message)->toJmap(); + } + + $transportId = $this->mailService->entitySubmitDraft($identityId, (string)$source->entity(), $patch); + + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_SENT, + transportId: $transportId, + sourceDraft: new EntityIdentifier($this->provider(), $this->identifier(), $source->collection(), $source->entity()), + ); + } + + private function normalizeMessageProperties(MessagePropertiesMutableInterface $message): MessageProperties + { + if ($message instanceof MessageProperties) { + return $message; + } + + $native = new MessageProperties([]); + $native->jsonDeserialize($message->jsonSerialize()); + + return $native; + } + + private function resolveCollectionByRole(string $role): ?CollectionIdentifier + { + $filter = $this->collectionListFilter(); + $filter->condition('role', $role); + + $collections = iterator_to_array($this->mailService->collectionList(null, $filter, null)); + if (empty($collections)) { + return null; + } + + foreach ($collections as $key => $collection) { + $collectionId = $this->extractCollectionId($collection, $key); + if ($collectionId !== null) { + return new CollectionIdentifier($this->provider(), (string) $this->identifier(), $collectionId); + } + } + + return null; + } + + private function extractCollectionId(mixed $collection, string|int|null $fallback = null): ?string + { + if (is_object($collection) && method_exists($collection, 'id')) { + $collectionId = $collection->id(); + if (is_string($collectionId) || is_int($collectionId)) { + return (string) $collectionId; + } + } + + if (is_array($collection) && isset($collection['id']) && (is_string($collection['id']) || is_int($collection['id']))) { + return (string) $collection['id']; + } + + if (is_string($fallback) || is_int($fallback)) { + $fallback = (string) $fallback; + return $fallback !== '' ? $fallback : null; + } + + return null; + } + + private function resolveIdentityId(AddressInterface $sender): ?string + { + $identities = $this->mailService->identityFetch(); + $senderAddress = strtolower(trim($sender->getAddress())); + $fallbackIdentity = null; + + foreach ($identities as $identity) { + $identityId = null; + $identityAddress = null; + + if (is_object($identity)) { + if (method_exists($identity, 'id')) { + $identityId = (string)$identity->id(); + } + if (method_exists($identity, 'email')) { + $email = $identity->email(); + $identityAddress = is_string($email) ? $email : null; + } + } + + if (is_array($identity)) { + $identityId = isset($identity['id']) ? (string)$identity['id'] : $identityId; + $identityAddress = isset($identity['email']) && is_string($identity['email']) ? $identity['email'] : $identityAddress; + } + + if ($identityId === null || $identityId === '') { + continue; + } + + $fallbackIdentity ??= $identityId; + + if ($identityAddress !== null && strtolower(trim($identityAddress)) === $senderAddress) { + return $identityId; + } + } + + return $fallbackIdentity; + + } + private function mapEntities(EntityIdentifier ...$identifiers): array { $list = []; @@ -825,4 +1007,5 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } return $list; } + } diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 96be585..b9a81a6 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -908,99 +908,106 @@ class RemoteMailService { } /** - * send entity - * - * @since Release 1.0.0 + * Submit a fresh message by creating it as a draft and then creating a submission. * + * @since Release 2.0.0 */ - public function entitySend(string $identity, MailMessageObject $message, ?string $presendLocation = null, ?string $postsendLocation = null): string { - // determine if pre-send location is present - if ($presendLocation === null || empty($presendLocation)) { - throw new Exception('Pre-Send Location is missing', 1); - } - // determine if post-send location is present - if ($postsendLocation === null || empty($postsendLocation)) { - throw new Exception('Post-Send Location is missing', 1); - } - // determine if we have the basic required data and fail otherwise - if (empty($message->getFrom())) { - throw new Exception('Missing Requirements: Message MUST have a From address', 1); - } - if (empty($message->getTo())) { - throw new Exception('Missing Requirements: Message MUST have a To address(es)', 1); - } - // determine if message has attachments - if (count($message->getAttachments()) > 0) { - // process attachments first - $message = $this->depositAttachmentsFromMessage($message); - } - // convert from address object to string - $from = $message->getFrom()->getAddress(); - // convert to, cc and bcc address object arrays to single strings array - $to = array_map( - function ($entry) { return $entry->getAddress(); }, - array_merge($message->getTo(), $message->getCc(), $message->getBcc()) - ); - unset($cc, $bcc); - // construct set request + public function entitySubmitFresh(string $identityId, array $message, string $preSendTarget, string $postSentTarget): string { + + $mail = new MailParametersRequest(); + $mail->parametersRaw($message); + + $messageCreateId = 'm_' . uniqid(); + $submissionCreateId = 's_' . uniqid(); + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); - $r0->create('1', $message)->in($presendLocation); - // construct set request + $createdMessage = $r0->create($messageCreateId, $mail); + $createdMessage->in($preSendTarget); $r1 = new MailSubmissionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); - // construct envelope - $e1 = $r1->create('2'); - $e1->identity($identity); - $e1->message('#1'); - $e1->from($from); - $e1->to($to); - // transceive + $submission = $r1->create($submissionCreateId); + $submission->identity($identityId); + $submission->message('#' . $messageCreateId); + $bundle = $this->dataStore->perform([$r0, $r1]); - // extract response $response = $bundle->response(1); - // return collection information - return (string)$response->created()['2']['id']; + 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, $submissionCreateId); } /** - * retrieve collection entity attachment from remote storage - * - * @since Release 1.0.0 + * Submit an existing draft message. * + * @since Release 2.0.0 */ - public function depositAttachmentsFromMessage(MailMessageObject $message): MailMessageObject { + public function entitySubmitDraft(string $identityId, string $draftId, ?array $patch = null): string { + if ($draftId === '') { + throw new Exception('Draft identifier is required for draft submission', 1); + } - $parameters = $message->toJmap(); - $attachments = $message->getAttachments(); - $matches = []; + $requests = []; - $this->findAttachmentParts($parameters['bodyStructure'], $matches); + if ($patch !== null && $patch !== []) { + $draftMutation = new MailParametersRequest(); + $draftMutation->parametersRaw($patch); + $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $r0->update($draftId, $draftMutation); + $requests[] = $r0; + } - foreach ($attachments as $attachment) { - $part = $attachment->toJmap(); - if (isset($matches[$part->getId()])) { - // deposit attachment in data store - $response = $this->blobDeposit($account, $part->getType(), $attachment->getContents()); - // transfer blobId and size to mail part - $matches[$part->getId()]->blobId = $response['blobId']; - $matches[$part->getId()]->size = $response['size']; - unset($matches[$part->getId()]->partId); + $submissionCreateId = 's_' . uniqid(); + $r1 = new MailSubmissionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + $submission = $r1->create($submissionCreateId); + $submission->identity($identityId); + $submission->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); + } + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + + return $this->extractSubmissionIdentifier($response, $submissionCreateId); + } + + private function collectionByRole(string $role): ?string { + $filter = $this->collectionListFilter(); + $filter->condition('role', $role); + + $collections = $this->collectionList(null, $filter, null); + if ($collections === []) { + return null; + } + + return (string)array_key_first($collections); + } + + private function extractSubmissionIdentifier(object $response, string $createId): string { + if (method_exists($response, 'createSuccess')) { + $success = $response->createSuccess($createId); + if (is_array($success) && isset($success['id'])) { + return (string)$success['id']; } } - return (new MailMessageObject())->fromJmap($parameters); - - } - - protected function findAttachmentParts(object &$part, array &$matches) { - - if ($part->disposition === 'attachment' || $part->disposition === 'inline') { - $matches[$part->partId] = $part; - } - - foreach ($part->subParts as $entry) { - $this->findAttachmentParts($entry, $matches); + if (method_exists($response, 'created')) { + $created = $response->created(); + if (is_array($created) && isset($created[$createId]['id'])) { + return (string)$created[$createId]['id']; + } } + throw new Exception('Submission succeeded without a returned identifier', 1); } /** diff --git a/src/components/JmapAuxiliaryPanel.vue b/src/components/JmapAuxiliaryPanel.vue index 9f232ac..1f4862b 100644 --- a/src/components/JmapAuxiliaryPanel.vue +++ b/src/components/JmapAuxiliaryPanel.vue @@ -1,6 +1,7 @@ @@ -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.

- +
Primary Address
+
+ + +
- +
Secondary Addresses
+
+ + + + mdi-delete-outline + Remove Alias + +
+ + + Add Alias +