feat: message send
Build Test / test (pull_request) Successful in 37s
JS Unit Tests / test (pull_request) Successful in 36s
PHP Unit Tests / test (pull_request) Successful in 59s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-16 13:34:11 -04:00
parent 55b9369710
commit a6d1ba5f10
9 changed files with 612 additions and 435 deletions
+254 -71
View File
@@ -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;
}
}