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
+