20 Commits

Author SHA1 Message Date
Sebastian 6c6c6cef92 chore(deps): update tiptap monorepo to v3.31.3
Build Test / test (pull_request) Successful in 50s
JS Unit Tests / test (pull_request) Successful in 51s
PHP Integration Tests / Integration Tests (pull_request) Failing after 59s
PHP Unit Tests / test (pull_request) Failing after 1m8s
2026-09-05 03:05:19 +00:00
Sebastian 5c9e13f87b refactor(module): register browser integration through context
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian 9508106d4f feat(preview): disable attachment previews when unavailable
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian da1e8e52b0 feat: use new variant type
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-29 19:13:11 -04:00
Sebastian 74d3654515 feat: implement attachment preview
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-29 16:21:18 -04:00
Sebastian 704e01fece feat: implement format toggle
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 22:29:22 -04:00
Sebastian 45cb0bd817 feat: wire mailbox search to server-side filtering
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 21:36:55 -04:00
Sebastian 33c44d62db feat(core): add CLI management for tenant system stores
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-28 21:23:50 -04:00
Sebastian a5eb104bd7 fix: reuse synced remote draft when sending a composition
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:39:33 -04:00
Sebastian 03862ed0cf fix: purge local composition after successful send
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:14:57 -04:00
Sebastian 0c7f103353 fix: increment composition revision on attachment add and remove
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-25 22:10:19 -04:00
Sebastian 684d50062c feat: implement save mechanisim
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-18 22:30:42 -04:00
Sebastian 67024b5bfd feat: add logic to sync drafts to server
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-18 21:57:38 -04:00
Sebastian bd5965f600 feat: add remote sync state
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-17 23:39:09 -04:00
Sebastian 8fb3086792 feat: Atomically read, mutate, and replace a composition snapshot.
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-17 23:20:35 -04:00
Sebastian c95e503251 chore: update git ignore list
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-17 21:57:28 -04:00
Sebastian 4839f822fa feat: add attach from documents
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-08-15 12:55:59 -04:00
Sebastian 8a18494883 feat: show disabled accounts
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-28 20:52:13 -04:00
Sebastian 0a2a87acfa fix: auto selected sending account when only once available
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-28 13:10:01 -04:00
Sebastian 436197fbfe refactor: migrate to scoped execution contexts
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-07-27 00:52:28 -04:00
29 changed files with 2844 additions and 553 deletions
+1
View File
@@ -22,6 +22,7 @@ coverage/
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
.ideas/
# Logs # Logs
*.log *.log
+629 -121
View File
@@ -5,23 +5,40 @@ declare(strict_types=1);
namespace KTXM\Mail; namespace KTXM\Mail;
use InvalidArgumentException; use InvalidArgumentException;
use KTXC\Module\ModuleManager;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Mail\Object\Address; use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePart; use KTXF\Mail\Object\MessagePart;
use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface; use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult; use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXF\Resource\BinaryResource;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier; use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Utile\UUID; use KTXF\Utile\UUID;
use KTXM\Mail\Stores\CompositionStore; use KTXM\Mail\Stores\CompositionStore;
use KTXM\MailManager\Manager as MailManager; use KTXM\MailManager\Manager as MailManager;
use KTXM\DocumentsManager\Manager as DocumentsManager;
use Psr\Container\ContainerInterface;
use RuntimeException;
use Throwable;
class CompositionManager { class CompositionManager {
private const DOCUMENTS_MODULE_HANDLE = 'documents_manager';
public function __construct( public function __construct(
private readonly ContainerInterface $container,
private readonly ModuleManager $moduleManager,
private readonly CompositionStore $compositionStore, private readonly CompositionStore $compositionStore,
private readonly MailManager $mailManager, private readonly MailManager $mailManager,
private readonly EventDispatcherInterface $events,
) {} ) {}
public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array { public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array {
@@ -38,11 +55,19 @@ class CompositionManager {
'sender' => $sender, 'sender' => $sender,
'message' => $message, 'message' => $message,
'attachments' => [], 'attachments' => [],
'remote' => [
'status' => 'dirty',
'entity' => null,
'error' => null,
],
]; ];
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot); // Construct the snapshot mutation that will run while the composition is locked.
$saveComposition = function () use ($tenantId, $userId, $identifier, $action, $source, $snapshot): array {
if ($action !== 'forward') {
return $snapshot;
}
if ($action === 'forward') {
$sourceIndentifier = ResourceIdentifier::fromString($source); $sourceIndentifier = ResourceIdentifier::fromString($source);
if ($sourceIndentifier === null) { if ($sourceIndentifier === null) {
throw new InvalidArgumentException('Invalid source identifier'); throw new InvalidArgumentException('Invalid source identifier');
@@ -75,7 +100,7 @@ class CompositionManager {
data: $data, data: $data,
); );
// supplement the attachment metadata with source information // supplement the attachment metadata with source information
$meta['origin'] = 'source'; $meta['origin'] = 'message';
$meta['source'] = $source; $meta['source'] = $source;
$meta['partId'] = $attachment->getId(); $meta['partId'] = $attachment->getId();
$meta['blobId'] = $attachment->getBlobId(); $meta['blobId'] = $attachment->getBlobId();
@@ -88,73 +113,300 @@ class CompositionManager {
$meta['size'] = $attachment->getSize() ?? $meta['size']; $meta['size'] = $attachment->getSize() ?? $meta['size'];
$snapshot['attachments'][$meta['identifier']] = $meta; $snapshot['attachments'][$meta['identifier']] = $meta;
} }
// update the snapshot with the staged attachments
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
}
return $snapshot; return $snapshot;
};
// Execute the mutation and atomically persist the snapshot it returns.
$snapshot = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$saveComposition,
) ?? $snapshot;
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)$snapshot['revision']);
$this->events->dispatch($event);
$response = $snapshot;
unset($response['remote']);
return $response;
} }
public function patch(string $tenantId, string $userId, string $identifier, array $data): array { public function patch(string $tenantId, string $userId, string $identifier, array $data): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier); $result = [];
// Construct the snapshot mutation that will run while the composition is locked.
$patchComposition = function (?array $composed) use ($data, $identifier, &$result): ?array {
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return null;
}
if ($composed === null) { if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
$result['disposition'] = 'error'; $result['disposition'] = 'error';
$result['error'] = [ $result['error'] = [
'type' => 'composition_not_found', 'type' => 'revision_mismatch',
'message' => 'Composition not found', 'message' => 'Revision mismatch',
'currentRevision' => $composed['revision'],
];
return null;
}
$composed['revision'] = $data['revision'];
$composed['sender'] = $data['sender'] ?? [];
$composed['message'] = $data['message'] ?? [];
$composed = $this->markRemoteDirty($composed);
$result = [
'identifier' => $identifier,
'disposition' => 'patched',
]; ];
return $result;
return $composed;
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$patchComposition,
);
if (($result['disposition'] ?? null) === 'patched' && $composed !== null) {
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event);
} }
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'revision_mismatch',
'message' => 'Revision mismatch',
'currentRevision' => $composed['revision'],
];
return $result;
}
// Apply the patch data to the composed message
$composed['revision'] = $data['revision'];
$composed['sender'] = $data['sender'] ?? [];
$composed['message'] = $data['message'] ?? [];
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
$result = [
'identifier' => $identifier,
'disposition' => 'patched',
];
return $result; return $result;
} }
public function save(string $tenantId, string $userId, string $identifier, array $data): array {
// Construct the snapshot mutation that will run while the composition is locked.
$saveComposition = function (?array $composed) use ($data): ?array {
if ($composed === null) {
return null;
}
if (isset($data['revision']) && is_int($data['revision'])) {
$composed['revision'] = $data['revision'];
}
$composed['sender'] = $data['sender'];
$composed['message'] = $data['message'];
return $this->markRemoteDirty($composed);
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$saveComposition,
);
if ($composed === null) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_not_found',
'message' => 'Composition not found',
],
];
}
$synchronized = $this->synchronize($tenantId, $userId, $identifier);
if ($synchronized['disposition'] !== 'saved') {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_save_failed',
'message' => (string)($synchronized['error'] ?? 'Draft could not be saved to the server'),
],
];
}
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return ['identifier' => $identifier, 'disposition' => 'saved'];
}
public function discard(string $tenantId, string $userId, string $identifier): array { public function discard(string $tenantId, string $userId, string $identifier): array {
return [ $composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
if ($composed === null) {
return ['identifier' => $identifier, 'disposition' => 'discarded'];
}
try {
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
if ($remoteEntity !== null) {
$outcomes = $this->mailManager->entityDelete($tenantId, $userId, $remoteEntity);
$outcome = $outcomes[(string)$remoteEntity] ?? reset($outcomes);
if (!is_array($outcome) || ($outcome['disposition'] ?? 'error') === 'error') {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_discard_failed',
'message' => is_array($outcome)
? (string)($outcome['error'] ?? 'Remote draft could not be discarded')
: 'Remote draft could not be discarded',
],
];
}
}
} catch (Throwable $throwable) {
return [
'identifier' => $identifier,
'disposition' => 'error',
'error' => [
'type' => 'composition_discard_failed',
'message' => $throwable->getMessage(),
],
];
}
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return ['identifier' => $identifier, 'disposition' => 'discarded'];
}
public function synchronize(string $tenantId, string $userId, string $identifier): array {
$result = [
'identifier' => $identifier, 'identifier' => $identifier,
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier), 'disposition' => 'staged',
]; ];
// Construct the synchronization operation that will run while the composition is locked.
$synchronizeComposition = function (?array $composed) use ($tenantId, $userId, $identifier, &$result): ?array {
if ($composed === null) {
$result['error'] = 'Composition not found';
return null;
}
$remote = isset($composed['remote']) && is_array($composed['remote'])
? $composed['remote']
: ['status' => 'dirty', 'entity' => null, 'error' => null];
if (($remote['status'] ?? 'dirty') === 'synced') {
$result['disposition'] = 'saved';
return null;
}
try {
$sender = isset($composed['sender']) && is_array($composed['sender'])
? $composed['sender']
: [];
$senderAddress = (string)($sender['address'] ?? '');
if ($senderAddress === '' || !filter_var($senderAddress, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Composition sender is invalid');
}
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
if ($service === null || $service->getEnabled() === false) {
throw new RuntimeException("No enabled mail service handles '$senderAddress'");
}
if (!$service instanceof ServiceEntityMutableInterface) {
throw new RuntimeException("Mail service '{$service->identifier()}' does not support draft mutations");
}
$target = $this->resolveDraftCollection($service);
$attachments = isset($composed['attachments']) && is_array($composed['attachments'])
? array_values($composed['attachments'])
: [];
$properties = $this->buildMessageProperties(
service: $service,
sender: Address::fromArray($sender),
message: is_array($composed['message'] ?? null) ? $composed['message'] : [],
attachments: $attachments,
composed: $composed,
tenantId: $tenantId,
userId: $userId,
compositionId: $identifier,
);
$properties->setFlag('draft', true);
$properties->setFlag('seen', true);
$remoteEntity = $this->remoteEntityIdentifier($remote['entity'] ?? null);
if ($remoteEntity === null) {
$entity = $this->mailManager->entityCreate($tenantId, $userId, $target, $properties);
} else {
if ($remoteEntity->provider() !== $service->provider()
|| (string)$remoteEntity->service() !== (string)$service->identifier()) {
throw new RuntimeException('Changing the draft service is not supported yet');
}
$entity = $this->mailManager->entityModify($tenantId, $userId, $remoteEntity, $properties);
}
if ($entity->collection() === null || $entity->identifier() === null) {
throw new RuntimeException('Provider returned an incomplete draft identifier');
}
$composed['remote'] = [
...$remote,
'status' => 'synced',
'entity' => (string)new EntityIdentifier(
$entity->provider(),
(string)$entity->service(),
(string)$entity->collection(),
(string)$entity->identifier(),
),
'error' => null,
];
$result['disposition'] = 'saved';
} catch (Throwable $throwable) {
$composed['remote'] = [
...$remote,
'status' => 'failed',
'entity' => $remote['entity'] ?? null,
'error' => $throwable->getMessage(),
];
$result['error'] = $throwable->getMessage();
}
return $composed;
};
// Synchronize and atomically persist the resulting remote state.
$this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$synchronizeComposition,
);
return $result;
} }
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array { public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier); // Construct the snapshot mutation that will run while the composition is locked.
$saveComposition = function (?array $composed) use ($sender, $message): ?array {
if ($composed === null) {
return null;
}
$composed['sender'] = $sender;
$composed['message'] = $message;
return $this->markRemoteDirty($composed);
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$identifier,
$saveComposition,
);
if ($composed === null) { if ($composed === null) {
$result['disposition'] = 'error'; return [
$result['error'] = [ 'disposition' => 'error',
'type' => 'composition_not_found', 'error' => [
'message' => 'Composition not found', 'type' => 'composition_not_found',
'message' => 'Composition not found',
],
]; ];
return $result;
} }
// store the message and sender information before sending
$composed['sender'] = $sender;
$composed['message'] = $message;
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
// validate the sender address before attempting to send the message // validate the sender address before attempting to send the message
if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) { if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) {
$result['disposition'] = 'error'; $result['disposition'] = 'error';
@@ -188,7 +440,14 @@ class CompositionManager {
]; ];
} }
$source = null; // Reuse the synced remote draft (if any) so the provider can natively clean it up
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
$source = ($remoteEntity !== null
&& $remoteEntity->provider() === $service->provider()
&& (string)$remoteEntity->service() === (string)$service->identifier())
? $remoteEntity
: null;
$properties = $this->buildMessageProperties( $properties = $this->buildMessageProperties(
service: $service, service: $service,
sender: $senderObject, sender: $senderObject,
@@ -213,6 +472,8 @@ class CompositionManager {
]; ];
} }
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
return [ return [
'identifier' => $identifier, 'identifier' => $identifier,
'disposition' => 'sent' 'disposition' => 'sent'
@@ -220,7 +481,7 @@ class CompositionManager {
} }
private function buildMessageProperties( private function buildMessageProperties(
ServiceEntitySubmitInterface $service, ServiceEntitySubmitInterface|ServiceEntityMutableInterface $service,
AddressInterface $sender, AddressInterface $sender,
array $message, array $message,
array $attachments, array $attachments,
@@ -292,6 +553,310 @@ class CompositionManager {
return $properties; return $properties;
} }
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
$result = [
'composition' => $composition,
'attachments' => [],
];
$failed = [];
$compositionChanged = false;
// Construct the snapshot mutation that will run while the composition is locked.
$addAttachments = function (?array $composed) use ($tenantId, $userId, $composition, $attachments, &$result, &$failed, &$compositionChanged): ?array {
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return null;
}
$uploads = [];
$documents = [];
foreach ($attachments as $attachment) {
if (isset($attachment['origin']) && $attachment['origin'] === 'documents') {
$documents[] = $attachment;
} elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') {
$uploads[] = $attachment;
}
}
if ($uploads !== []) {
$this->attachmentAddFromDevice($tenantId, $userId, $composition, $uploads, $composed);
}
if ($documents !== []) {
$failed = $this->attachmentAddFromDocuments($tenantId, $userId, $composition, $documents, $composed);
}
$deviceAttachmentsChanged = $uploads !== [];
$documentAttachmentsChanged = count($failed) < count($documents);
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed);
$compositionChanged = true;
}
return $composed;
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$composition,
$addAttachments,
);
if ($composed === null) {
return $result;
}
// construct the result
$result['attachments'] = $composed['attachments'];
$result['failed'] = $failed;
$result['disposition'] = match (true) {
$failed === [] => 'added',
$result['attachments'] !== [] => 'partial',
default => 'error',
};
if ($result['disposition'] === 'error') {
$result['error'] = [
'type' => 'attachment_add_failed',
'message' => 'None of the requested attachments could be added',
];
}
if ($compositionChanged) {
$result['revision'] = (int)($composed['revision'] ?? 0);
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event);
}
return $result;
}
/**
* Stage device-uploaded (base64 payload) attachment entries
*/
private function attachmentAddFromDevice(string $tenantId, string $userId, string $composition, array $uploads, array &$composed): void {
foreach ($uploads as $attachment) {
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
? $attachment['identifier']
: UUID::v4();
$decoded = base64_decode((string) ($attachment['data'] ?? ''), true);
if ($decoded === false) {
throw new InvalidArgumentException('Attachment payload is not valid base64');
}
$data = new BinaryResource(
$attachment['name'] ?? 'unknown.bin',
$attachment['type'] ?? 'application/octet-stream',
$this->stringToGenerator($decoded),
);
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
data: $data,
);
$meta['origin'] = 'device';
$composed['attachments'][$meta['identifier']] = $meta;
}
}
private function resolveDocumentsManager(): ?DocumentsManager {
$module = $this->moduleManager->fetch(self::DOCUMENTS_MODULE_HANDLE);
if ($module === null || !$module->enabled()) {
return null;
}
if (!$this->container->has(DocumentsManager::class)) {
return null;
}
return $this->container->get(DocumentsManager::class);
}
/**
* Stage documents-sourced attachment entries
*/
private function attachmentAddFromDocuments(string $tenantId, string $userId, string $composition, array $sourced, array &$composed): array {
$failed = [];
// The documents module is an optional dependency, so we must resolve it
// lazily and handle the case where it is not installed or enabled.
$documentsManager = $this->resolveDocumentsManager();
if ($documentsManager === null) {
foreach ($sourced as $attachment) {
$failed[] = [
'identifier' => (string) ($attachment['source'] ?? ''),
'message' => 'Documents module is not installed or enabled',
];
}
return $failed;
}
$requested = [];
foreach ($sourced as $attachment) {
$rawIdentifier = (string) $attachment['source'];
try {
$parsed = ResourceIdentifier::fromString($rawIdentifier);
} catch (InvalidArgumentException) {
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
continue;
}
if (!$parsed instanceof EntityIdentifier) {
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
continue;
}
$requested[] = ['attachment' => $attachment, 'identifier' => $parsed];
}
if ($requested === []) {
return $failed;
}
$entities = $documentsManager->entityFetchBulk($tenantId, $userId, ...array_column($requested, 'identifier'));
$entitiesByUrn = [];
foreach ($entities as $entity) {
$entitiesByUrn[$entity->urn()] = $entity;
}
foreach ($requested as ['attachment' => $attachment, 'identifier' => $entityIdentifier]) {
$urn = (string) $entityIdentifier;
$entity = $entitiesByUrn[$urn] ?? null;
if ($entity === null) {
$failed[] = ['identifier' => $urn, 'message' => 'Document not found or access denied'];
continue;
}
$resource = $documentsManager->entityReadStream($tenantId, $userId, $entityIdentifier);
if ($resource === null) {
$failed[] = ['identifier' => $urn, 'message' => 'Document content could not be read'];
continue;
}
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
? $attachment['identifier']
: UUID::v4();
$properties = $entity->getProperties();
$data = new BinaryResource($properties->getLabel(), $properties->getMime(), $this->streamToGenerator($resource));
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
data: $data,
);
$meta['origin'] = 'documents';
$meta['source'] = $urn;
$composed['attachments'][$meta['identifier']] = $meta;
}
return $failed;
}
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
$result = [
'composition' => $composition,
'identifier' => $identifier,
];
// Construct the snapshot mutation that will run while the composition is locked.
$removeAttachment = function (?array $composed) use ($identifier, &$result): ?array {
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return null;
}
if (!isset($composed['attachments'][$identifier])) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'attachment_not_found',
'message' => 'Attachment not found',
];
return null;
}
unset($composed['attachments'][$identifier]);
$composed['revision'] = (int)($composed['revision'] ?? 0) + 1;
$composed = $this->markRemoteDirty($composed);
$result['disposition'] = 'removed';
$result['revision'] = $composed['revision'];
return $composed;
};
// Execute the mutation and atomically persist the snapshot it returns.
$composed = $this->compositionStore->compositionSave(
$tenantId,
$userId,
$composition,
$removeAttachment,
);
if (($result['disposition'] ?? null) === 'removed' && $composed !== null) {
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
$this->events->dispatch($event);
}
return $result;
}
private function markRemoteDirty(array $composed): array {
$remote = isset($composed['remote']) && is_array($composed['remote'])
? $composed['remote']
: [];
$remote['status'] = 'dirty';
$remote['entity'] ??= null;
$remote['error'] = null;
$composed['remote'] = $remote;
return $composed;
}
private function resolveDraftCollection(ServiceBaseInterface $service): CollectionIdentifier {
$configuredTarget = $service->getAuxiliary()['draftTarget'] ?? null;
if ($configuredTarget !== null && (string)$configuredTarget !== '') {
return new CollectionIdentifier(
$service->provider(),
(string)$service->identifier(),
(string)$configuredTarget,
);
}
$filter = $service->collectionListFilter();
$filter->condition('role', CollectionRoles::Drafts->value);
$collections = $service->collectionList('', $filter);
$collection = reset($collections);
if (!$collection instanceof CollectionBaseInterface || $collection->identifier() === null) {
throw new RuntimeException("Mail service '{$service->identifier()}' has no Drafts collection");
}
return new CollectionIdentifier(
$service->provider(),
(string)$service->identifier(),
(string)$collection->identifier(),
);
}
private function remoteEntityIdentifier(mixed $value): ?EntityIdentifier {
if (!is_string($value) || $value === '') {
return null;
}
$identifier = ResourceIdentifier::fromString($value);
if (!$identifier instanceof EntityIdentifier) {
throw new RuntimeException("Invalid remote draft identifier '$value'");
}
return $identifier;
}
/** /**
* @param array<int,array|string> $entries * @param array<int,array|string> $entries
@@ -319,78 +884,21 @@ class CompositionManager {
return $addresses; return $addresses;
} }
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
$result = [
'composition' => $composition,
'attachments' => [],
];
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
}
foreach ($attachments as $attachment) { /**
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') { * Wrap an in-memory byte string in a single-chunk Generator.
$identifier = UUID::v4(); */
} else { private function stringToGenerator(string $bytes): \Generator {
$identifier = $attachment['identifier']; yield $bytes;
}
$meta = $this->compositionStore->attachmentStageFromBase64(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
name: $attachment['name'] ?? 'unknown.bin',
type: $attachment['type'] ?? 'application/octet-stream',
data: $attachment['data'] ?? '',
);
$meta['origin'] = 'upload';
$composed['attachments'][$meta['identifier']] = $meta;
}
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'added';
$result['attachments'] = $composed['attachments'];
return $result;
} }
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array { /**
$result = [ * Wrap a raw PHP stream resource in a Generator yielding chunks, closing it once exhausted.
'composition' => $composition, */
'identifier' => $identifier, private function streamToGenerator($resource): \Generator {
]; while (!feof($resource)) {
yield fread($resource, 65536);
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'composition_not_found',
'message' => 'Composition not found',
];
return $result;
} }
if (!isset($composed['attachments'][$identifier])) { fclose($resource);
$result['disposition'] = 'error';
$result['error'] = [
'type' => 'attachment_not_found',
'message' => 'Attachment not found',
];
return $result;
}
unset($composed['attachments'][$identifier]);
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'removed';
return $result;
} }
} }
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
use InvalidArgumentException;
use KTXF\Event\Event;
final class CompositionSavedEvent extends Event {
public function __construct(
string $tenantId,
private readonly string $userId,
private readonly string $compositionId,
private readonly int $revision,
) {
if ($tenantId === '' || $this->userId === '' || $this->compositionId === '') {
throw new InvalidArgumentException('Composition save events require tenant, user, and composition identifiers');
}
if ($this->revision < 0) {
throw new InvalidArgumentException('Composition save event revision cannot be negative');
}
parent::__construct(
self::class,
[
'userId' => $this->userId,
'compositionId' => $this->compositionId,
'revision' => $this->revision,
],
$tenantId,
$this->userId,
);
}
public function userIdentifier(): string {
return $this->userId;
}
public function compositionIdentifier(): string {
return $this->compositionId;
}
public function revision(): int {
return $this->revision;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace KTXM\Mail;
final class CompositionSyncListener {
public function __construct(
private readonly CompositionManager $compositionManager,
) {}
public function onCompositionSaved(CompositionSavedEvent $event): void {
$this->compositionManager->synchronize(
$event->tenantIdentifier(),
$event->userIdentifier(),
$event->compositionIdentifier(),
);
}
}
+26 -10
View File
@@ -7,8 +7,8 @@ namespace KTXM\Mail\Controllers;
use InvalidArgumentException; use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\Mail\CompositionManager; use KTXM\Mail\CompositionManager;
@@ -18,8 +18,8 @@ use Throwable;
final class CompositionController extends ControllerAbstract { final class CompositionController extends ControllerAbstract {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly CompositionManager $compositionManager, private readonly CompositionManager $compositionManager,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) {} ) {}
@@ -31,8 +31,8 @@ final class CompositionController extends ControllerAbstract {
string|null $operation = null, string|null $operation = null,
array|null $data = null, array|null $data = null,
): Response { ): Response {
$tenantId = $this->tenantIdentity->identifier(); $tenantId = $this->tenantContext->identifier();
$userId = $this->userIdentity->identifier(); $userId = $this->identityContext->identifier();
try { try {
if ($operation === null) { if ($operation === null) {
@@ -42,6 +42,7 @@ final class CompositionController extends ControllerAbstract {
$result = match ($operation) { $result = match ($operation) {
'stage' => $this->stage($tenantId, $userId, $data), 'stage' => $this->stage($tenantId, $userId, $data),
'patch' => $this->patch($tenantId, $userId, $data), 'patch' => $this->patch($tenantId, $userId, $data),
'save' => $this->save($tenantId, $userId, $data),
'discard' => $this->discard($tenantId, $userId, $data), 'discard' => $this->discard($tenantId, $userId, $data),
'send' => $this->send($tenantId, $userId, $data), 'send' => $this->send($tenantId, $userId, $data),
'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data), 'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data),
@@ -79,8 +80,8 @@ final class CompositionController extends ControllerAbstract {
if (!isset($data['action']) || !is_string($data['action'])) { if (!isset($data['action']) || !is_string($data['action'])) {
throw new InvalidArgumentException('Invalid parameter: action must be a string'); throw new InvalidArgumentException('Invalid parameter: action must be a string');
} }
if (!in_array($data['action'], ['fresh', 'reply', 'forward'], true)) { if (!in_array($data['action'], ['fresh', 'reply', 'replyAll', 'forward'], true)) {
throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, forward'); throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, replyAll, forward');
} }
if (!isset($data['sender']) || !is_array($data['sender'])) { if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array'); throw new InvalidArgumentException('Invalid parameter: sender must be an array');
@@ -88,8 +89,8 @@ final class CompositionController extends ControllerAbstract {
if (!isset($data['message']) || !is_array($data['message'])) { if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array'); throw new InvalidArgumentException('Invalid parameter: message must be an array');
} }
if (($data['action'] === 'reply' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) { if (($data['action'] === 'reply' || $data['action'] === 'replyAll' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) {
throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply or forward action'); throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply, replyAll, or forward action');
} }
return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null); return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null);
@@ -108,6 +109,21 @@ final class CompositionController extends ControllerAbstract {
return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data); return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data);
} }
private function save(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
}
if (!isset($data['sender']) || !is_array($data['sender'])) {
throw new InvalidArgumentException('Invalid parameter: sender must be an array');
}
if (!isset($data['message']) || !is_array($data['message'])) {
throw new InvalidArgumentException('Invalid parameter: message must be an array');
}
return $this->compositionManager->save($tenantId, $userId, $data['identifier'], $data);
}
private function discard(string $tenantId, string $userId, array $data): array { private function discard(string $tenantId, string $userId, array $data): array {
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') { if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string'); throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
+23 -14
View File
@@ -2,17 +2,32 @@
namespace KTXM\Mail; namespace KTXM\Mail;
use KTXF\Module\ModuleBrowserInterface; use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract; use KTXF\Module\ModuleInstanceAbstract;
/** /**
* Mail Module - Email Client * Mail Module - Email Client
*/ */
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface class Module extends ModuleInstanceAbstract
{ {
public function __construct() public function __construct(
{ } private readonly EventListenerRegistrarInterface $events,
) {}
public function boot(): void
{
$this->events->listen(
$this->handle(),
CompositionSavedEvent::class,
CompositionSyncListener::class,
'onCompositionSaved',
DeliveryMode::Deferred,
);
}
public function handle(): string public function handle(): string
{ {
@@ -65,16 +80,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
]; ];
} }
public function registerBI(): array public function configure(ModuleContextInterface $context): void
{ {
return [ if ($context instanceof BrowserModuleContextInterface) {
'handle' => $this->handle(), $context->registerModule($this, 'Mail', 'static/module.mjs');
'namespace' => 'Mail', }
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
} }
} }
+115 -67
View File
@@ -6,6 +6,8 @@ namespace KTXM\Mail\Stores;
use DI\Attribute\Inject; use DI\Attribute\Inject;
use KTXF\Resource\BinaryResource; use KTXF\Resource\BinaryResource;
use RuntimeException;
use UnexpectedValueException;
final class CompositionStore { final class CompositionStore {
@@ -20,47 +22,121 @@ final class CompositionStore {
} }
public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array { public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array {
$draftDir = $this->draftDir($tenantId, $userId, $draftId); return $this->withCompositionLock(
if (!is_dir($draftDir)) { $tenantId,
return null; $userId,
} $draftId,
LOCK_SH,
$messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME; fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
if (!file_exists($messagePath)) { );
return null;
}
$decoded = json_decode((string)file_get_contents($messagePath), true);
return is_array($decoded) ? $decoded : null;
} }
public function compositionSave(string $tenantId, string $userId, string $draftId, array $snapshot): array { /**
$draftDir = $this->draftDir($tenantId, $userId, $draftId); * Atomically read, mutate, and replace a composition snapshot.
if (!is_dir($draftDir)) { *
mkdir($draftDir, 0755, true); * Returning null from the callback leaves the stored snapshot unchanged.
} * The callback receives null when the composition does not exist.
*
* @param callable(?array): ?array $update
*/
public function compositionSave(string $tenantId, string $userId, string $draftId, callable $update): ?array {
return $this->withCompositionLock(
$tenantId,
$userId,
$draftId,
LOCK_EX,
function () use ($tenantId, $userId, $draftId, $update): ?array {
$snapshot = $update($this->compositionRead($tenantId, $userId, $draftId));
if ($snapshot === null) {
return null;
}
if (!is_array($snapshot)) {
throw new UnexpectedValueException('Composition save callback must return an array or null');
}
file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
return $snapshot; $draftDir = $this->draftDir($tenantId, $userId, $draftId);
$this->ensureDirectory($draftDir);
$temporaryPath = tempnam($draftDir, '.composition-');
if ($temporaryPath === false) {
throw new RuntimeException("Unable to create a temporary composition file for '$draftId'");
}
try {
$written = file_put_contents($temporaryPath, $json);
if ($written !== strlen($json)) {
throw new RuntimeException("Unable to write composition '$draftId'");
}
if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) {
throw new RuntimeException("Unable to replace composition '$draftId'");
}
} finally {
if (is_file($temporaryPath)) {
unlink($temporaryPath);
}
}
return $snapshot;
},
);
} }
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool { public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
$draftDir = $this->draftDir($tenantId, $userId, $draftId); return $this->withCompositionLock(
if (!is_dir($draftDir)) { $tenantId,
return false; $userId,
} $draftId,
LOCK_EX,
function () use ($tenantId, $userId, $draftId): bool {
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
if (!is_dir($draftDir)) {
return false;
}
$this->deleteDir($draftDir); $this->deleteDir($draftDir);
return true; return true;
},
);
} }
public function attachmentStageFromStream( private function compositionRead(string $tenantId, string $userId, string $draftId): ?array {
string $tenantId, $messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME;
string $userId, if (!is_file($messagePath)) {
string $compositionId, return null;
string $attachmentId, }
BinaryResource $data,
): array { $contents = file_get_contents($messagePath);
if ($contents === false) {
throw new RuntimeException("Unable to read composition '$draftId'");
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : null;
}
private function withCompositionLock(string $tenantId, string $userId, string $draftId, int $operation, callable $callback): mixed {
$lockDir = $this->storagePath . '/.locks';
$this->ensureDirectory($lockDir);
$lockId = hash('sha256', $tenantId . "\0" . $userId . "\0" . $draftId);
$handle = fopen($lockDir . '/' . $lockId . '.lock', 'c+b');
if ($handle === false) {
throw new RuntimeException("Unable to open composition lock for '$draftId'");
}
try {
if (!flock($handle, $operation)) {
throw new RuntimeException("Unable to lock composition '$draftId'");
}
return $callback();
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array {
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId); $attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
if (!is_dir($attachmentDir)) { if (!is_dir($attachmentDir)) {
mkdir($attachmentDir, 0755, true); mkdir($attachmentDir, 0755, true);
@@ -91,40 +167,6 @@ final class CompositionStore {
return $meta; return $meta;
} }
public function attachmentStageFromBase64(
string $tenantId,
string $userId,
string $compositionId,
string $attachmentId,
string $name,
string $type,
string $data,
): array {
$decoded = base64_decode($data, true);
if ($decoded === false) {
throw new \InvalidArgumentException('Attachment payload is not valid base64');
}
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
if (!is_dir($attachmentDir)) {
mkdir($attachmentDir, 0755, true);
}
file_put_contents($attachmentDir . '/' . $attachmentId . '.blob', $decoded);
$metadata = [
'identifier' => $attachmentId,
'composition' => $compositionId,
'name' => $name,
'type' => $type,
'size' => strlen($decoded),
];
file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $metadata;
}
public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string { public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string {
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob'; $path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob';
if (!file_exists($path)) { if (!file_exists($path)) {
@@ -152,6 +194,12 @@ final class CompositionStore {
return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId; return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId;
} }
private function ensureDirectory(string $path): void {
if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) {
throw new RuntimeException("Unable to create directory '$path'");
}
}
private function deleteDir(string $path): void { private function deleteDir(string $path): void {
if (!is_dir($path)) { if (!is_dir($path)) {
return; return;
@@ -173,4 +221,4 @@ final class CompositionStore {
rmdir($path); rmdir($path);
} }
} }
+202 -202
View File
@@ -795,49 +795,49 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tiptap/core": { "node_modules/@tiptap/core": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
"integrity": "sha512-A/lrhKpOYtl0V5pmPS00Zps8pgBe1qDOoD9fzsumDSZ3HP8W398C959Jgru75PNFokAya9COPD3iaKP3NWF25g==", "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-blockquote": { "node_modules/@tiptap/extension-blockquote": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
"integrity": "sha512-k8NKHLEYOqre7guObZBeFM04CaTwmEceCmdIrIjd7H9KoHSbRODaBNved6j5G33x/FWMwIF/RQbE2BjWaFSgzA==", "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-bold": { "node_modules/@tiptap/extension-bold": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
"integrity": "sha512-BPUJvJ9sCsU3fxao5UJfDJrDuHhMn2hczZwZ9Qs4w1vkAeLGyOYbSejUTJdBYHLoexGiEwgnEeVeZN3C8GaI5g==", "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-bubble-menu": { "node_modules/@tiptap/extension-bubble-menu": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
"integrity": "sha512-aBNI8ebX76ObderRmu/PJ9Q2AGVEuhpg0TBvv2SNbHtmU5kNUMHQI8HxrxKFblJwETAmG6EJ3Ed/5jRxpJmrsg==", "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -848,67 +848,80 @@
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-code": { "node_modules/@tiptap/extension-code": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
"integrity": "sha512-Um0BlyunRJ8Fal288Jn7I2n15FRwQcTy3NnKBBrPz3ikwx+yZZ0n9xTozpLMSxXrTq6cRlxLkASp/9FFx9sX9A==", "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-code-block": { "node_modules/@tiptap/extension-code-block": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
"integrity": "sha512-nKm1YSEiPVosjm4qeg6m+6SAKq82rKukAzE/g3M5eVs2OgEb4QIKQvQOmJ+zgGAnpb9Q/JIvcKsGGU1rn9Vnyw==", "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-document": { "node_modules/@tiptap/extension-document": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
"integrity": "sha512-folTlrwmUL+WSnojvfjZ951Gb25Pw8BxjXwhvXFuZXJ92qMfOcezA480kwFuu5nG8pPc/76ezQlz9a86Y5uQJw==", "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-dropcursor": { "node_modules/@tiptap/extension-dropcursor": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
"integrity": "sha512-IAY21q7KSXyCXN3u13kfpq0E8D2i3r9nSbsNYlLzkEv0Bpn+pWShZi05A0ubuothDNjdaMa96xaRY0f1y9gDZw==", "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/extensions": "3.29.0" "@tiptap/extensions": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-floating-menu": { "node_modules/@tiptap/extension-floating-menu": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
"integrity": "sha512-/rFlN6T70HuT34kFvftKm56UX0IPff7vlII7Sqgs7DprKVYkItiU3lM7+AoeDRzCsWMg4z1yNHj70BPx7+EJ6Q==", "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"funding": { "funding": {
@@ -917,80 +930,80 @@
}, },
"peerDependencies": { "peerDependencies": {
"@floating-ui/dom": "^1.0.0", "@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-gapcursor": { "node_modules/@tiptap/extension-gapcursor": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
"integrity": "sha512-Jz0zwriGxPMuRjKo6x7WhPa/7pZn0dovyJu/GLarN4T+mMseGNXWwEBsLEKkuumsJVPDEPPFjX+rzEeaFKInPQ==", "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/extensions": "3.29.0" "@tiptap/extensions": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-hard-break": { "node_modules/@tiptap/extension-hard-break": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
"integrity": "sha512-8qKj0oeuU7IYoG73Lj3W4EC/JS/dFjN5iHmc4yodqppP6qs0o2zsF/2ipceGN/REZq5megPtxRgXu/nxQZ1NYg==", "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-heading": { "node_modules/@tiptap/extension-heading": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
"integrity": "sha512-xWb3QEKo7cp9u6Vzi6oYKODra6jPwziHBv3fJAnxWpRldxtJqfyOVMikWJJFuDjmr+K0qyegzf206pMk+y0bDg==", "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-horizontal-rule": { "node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
"integrity": "sha512-u1OgncXkokIuUJQIh4jVQfYHL/6I8VnrokUcCud4eErWvgeLnCbNmVdih4kxIdfmvEyAxXjDwcqf4AB79TxuNg==", "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-italic": { "node_modules/@tiptap/extension-italic": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
"integrity": "sha512-c8gF+zM7yXB9iGxtVCioYecjQ+A2fLlNa1XYBYbTobNhbUo8a11akbTkGNcCIgttwnzwEoI4mB6/PK4yjaQvVg==", "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-link": { "node_modules/@tiptap/extension-link": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
"integrity": "sha512-FuLDgE0k1dB4WCBTelw3re9BLmiurgXVSj3d3J4l3sunljyOs941RUU62b7pWP+ePNdGF7RgUMmMAB+58VZCIA==", "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"linkifyjs": "^4.3.3" "linkifyjs": "^4.3.3"
@@ -1000,106 +1013,159 @@
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-paragraph": { "node_modules/@tiptap/extension-paragraph": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
"integrity": "sha512-OtGrkwzqlW+ehW+d2qrQ7VVcP9PbDeQmuT4Ec4yFjf3vR4QO8OVdVfeOcUq52PtkT3kYRxz9fzEiEvjqRm72yA==", "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-placeholder": { "node_modules/@tiptap/extension-placeholder": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz",
"integrity": "sha512-V2jb0kL/k3rygAtKmSPYxoyBMCQJtJikPYnxiCYuY7FI1ohmCrmhyhkVqJv0b0EPMNW8BMIqf6DgpKJ/ODmXgA==", "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/extensions": "3.29.0" "@tiptap/extensions": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-strike": { "node_modules/@tiptap/extension-strike": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
"integrity": "sha512-VzasZkckrEXzmFQT9z3O95rUWy1b0RLFHAC6GUSQHxKk4TpP+Ujfn5+rqmPukMn0eScJyw1Y36jwfJc0VuNR+Q==", "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-text": { "node_modules/@tiptap/extension-text": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
"integrity": "sha512-AMsf2V7IiGvzbl+LqLLfyWzufazZxHuid1gOnEt3KwolQ9AO7p8WJgHld0PZLxNyEh4SaSqK7b3d3SM9sqrlTw==", "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-text-align": { "node_modules/@tiptap/extension-text-align": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.31.3.tgz",
"integrity": "sha512-/1htV3teqRJ7i7trAuORDg62LV0CQ1qskbMcP3SB7grsw6H1G54zwdaJVXeiIXmQKDF7FoZgY7u/v/TJckthEw==", "integrity": "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-underline": { "node_modules/@tiptap/extension-underline": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
"integrity": "sha512-DSPTogdvxmoX/L0U5KgBC9jWffRvYkGiyh1tp28gn+QaARh4iJn79pAZpi1m1bEhal/0Mq5m4ukbiO7FWInwaA==", "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extensions": { "node_modules/@tiptap/extensions": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
"integrity": "sha512-ltlrm8dDHIgeNj3cOLEdLFMPPVy3TYvWA8ftrrJ44C/L01MBmgFB1f/vkPFYYnAasb2BYyVG6HxAGcTQHo5jHw==", "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0" "@tiptap/pm": "3.31.3"
} }
}, },
"node_modules/@tiptap/pm": { "node_modules/@tiptap/pm": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
"integrity": "sha512-4rr3HiZ8kbSNINuWXqKQJLv9fFMypCBN5gOwxoU+D4lEYVblkoM60fA1SlrI2BZVlZQmw2U38XKl5XMtUr55XQ==", "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.4.1", "prosemirror-changeset": "^2.4.1",
@@ -1114,7 +1180,7 @@
"prosemirror-state": "^1.4.4", "prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5", "prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0", "prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.9" "prosemirror-view": "^1.42.3"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1122,124 +1188,58 @@
} }
}, },
"node_modules/@tiptap/starter-kit": { "node_modules/@tiptap/starter-kit": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
"integrity": "sha512-J3jTp3/WXnnL58TCtdMsfeQ2BeycFYrAib6Nbpjd9G0OLUBE7AILiH2HrobRQpvRbAUgD5SW+sBZYOonbWWqNg==", "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tiptap/core": "^3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/extension-blockquote": "^3.29.0", "@tiptap/extension-blockquote": "3.31.3",
"@tiptap/extension-bold": "^3.29.0", "@tiptap/extension-bold": "3.31.3",
"@tiptap/extension-bullet-list": "^3.29.0", "@tiptap/extension-bullet-list": "3.31.3",
"@tiptap/extension-code": "^3.29.0", "@tiptap/extension-code": "3.31.3",
"@tiptap/extension-code-block": "^3.29.0", "@tiptap/extension-code-block": "3.31.3",
"@tiptap/extension-document": "^3.29.0", "@tiptap/extension-document": "3.31.3",
"@tiptap/extension-dropcursor": "^3.29.0", "@tiptap/extension-dropcursor": "3.31.3",
"@tiptap/extension-gapcursor": "^3.29.0", "@tiptap/extension-gapcursor": "3.31.3",
"@tiptap/extension-hard-break": "^3.29.0", "@tiptap/extension-hard-break": "3.31.3",
"@tiptap/extension-heading": "^3.29.0", "@tiptap/extension-heading": "3.31.3",
"@tiptap/extension-horizontal-rule": "^3.29.0", "@tiptap/extension-horizontal-rule": "3.31.3",
"@tiptap/extension-italic": "^3.29.0", "@tiptap/extension-italic": "3.31.3",
"@tiptap/extension-link": "^3.29.0", "@tiptap/extension-link": "3.31.3",
"@tiptap/extension-list": "^3.29.0", "@tiptap/extension-list": "3.31.3",
"@tiptap/extension-list-item": "^3.29.0", "@tiptap/extension-list-item": "3.31.3",
"@tiptap/extension-list-keymap": "^3.29.0", "@tiptap/extension-list-keymap": "3.31.3",
"@tiptap/extension-ordered-list": "^3.29.0", "@tiptap/extension-ordered-list": "3.31.3",
"@tiptap/extension-paragraph": "^3.29.0", "@tiptap/extension-paragraph": "3.31.3",
"@tiptap/extension-strike": "^3.29.0", "@tiptap/extension-strike": "3.31.3",
"@tiptap/extension-text": "^3.29.0", "@tiptap/extension-text": "3.31.3",
"@tiptap/extension-underline": "^3.29.0", "@tiptap/extension-underline": "3.31.3",
"@tiptap/extensions": "^3.29.0", "@tiptap/extensions": "3.31.3",
"@tiptap/pm": "^3.29.0" "@tiptap/pm": "3.31.3"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
} }
}, },
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/extension-bullet-list": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.0.tgz",
"integrity": "sha512-1045sC5CRn7KD0wluxZeksBwpm2k3LKBUOM3kNwExHAPSV4in4gdarYJ2/WCmIKsmyj2FzUcF4rbpLLSB/dBSg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.29.0"
}
},
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/extension-list": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.0.tgz",
"integrity": "sha512-k0B/+nIkn4VvHSQ0kP+AzzAmgeOVxKMAdqG4a6qwxp/lR12aJGHlOP92KCjXV2RNPtuwDksJ1RIXrgxdf9WmJg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.29.0",
"@tiptap/pm": "3.29.0"
}
},
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/extension-list-item": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.0.tgz",
"integrity": "sha512-JSz27OIDHWDL7uw28E4W3eaN3H1u+NzJQKZkNXo5Qsvvsx8m/YIQWO9XlhiL3V4TZMjfvlc94lUp5tKW5KwETw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.29.0"
}
},
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/extension-list-keymap": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.0.tgz",
"integrity": "sha512-g14QLZR9fmfJIL+X2R/cVTvQS0Mp7CoenA9PO+8adTjK7hCeB8hA3UAakMB4ekkElOXNOtb16e0wqulDcoqANg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.29.0"
}
},
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/extension-ordered-list": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.0.tgz",
"integrity": "sha512-9TXUDFagkGeu6Bo8L0b2SAVAYrY2Xzd28MXwiDxYvDKMjEGWkkCUpMiEI5Qav3GnfFWLmS3jI8PgUjxIw9wLLQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.29.0"
}
},
"node_modules/@tiptap/vue-3": { "node_modules/@tiptap/vue-3": {
"version": "3.29.0", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.29.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.31.3.tgz",
"integrity": "sha512-OFESbEFlmDYPns5LrkyeKggis6tYICBvz+jrlVwxICCWJqWZJJvYb7y/uMtMCxFF1nAeGb8AFr8UhFrLaX5aiA==", "integrity": "sha512-PNt0+rm7BXzhe/U+xlNnXBeJ6t9x2mE3902VXeIeEBxu5CrYLCJ0a5yHrof1jU3KwJrLOe/BtlGWjbq4qnTTfQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.29.0", "@tiptap/extension-bubble-menu": "^3.31.3",
"@tiptap/extension-floating-menu": "^3.29.0" "@tiptap/extension-floating-menu": "^3.31.3"
}, },
"peerDependencies": { "peerDependencies": {
"@floating-ui/dom": "^1.0.0", "@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.29.0", "@tiptap/core": "3.31.3",
"@tiptap/pm": "3.29.0", "@tiptap/pm": "3.31.3",
"vue": "^3.0.0" "vue": "^3.0.0"
} }
}, },
@@ -3155,9 +3155,9 @@
} }
}, },
"node_modules/prosemirror-view": { "node_modules/prosemirror-view": {
"version": "1.42.1", "version": "1.42.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.1.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
"integrity": "sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==", "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prosemirror-model": "^1.25.8", "prosemirror-model": "^1.25.8",
+145 -6
View File
@@ -12,6 +12,8 @@ import type { CollectionObject, MessageAddressObject } from '@MailManager/models
import { useMailOperationsStore } from '@/stores/mailOperationsStore' import { useMailOperationsStore } from '@/stores/mailOperationsStore'
import { useMailCompositionStore } from '@/stores/mailCompositionStore' import { useMailCompositionStore } from '@/stores/mailCompositionStore'
import { ComposerMode } from '@/types/composer' import { ComposerMode } from '@/types/composer'
import type { ComposerFormat } from '@/types/composer'
import { documentsSelectorComponent } from '@/services/documentsSelector'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue' import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerSender from '@/components/composer/ComposerSender.vue' import ComposerSender from '@/components/composer/ComposerSender.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue' import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
@@ -47,7 +49,13 @@ const {
// State // State
const showCc = ref(false) const showCc = ref(false)
const showBcc = ref(false) const showBcc = ref(false)
const format = ref<ComposerFormat>('rich')
const textEdited = ref(false)
const applyingDraftToEditor = ref(false) const applyingDraftToEditor = ref(false)
const showDocumentSelector = ref(false)
const showCloseDialog = ref(false)
const closeAction = ref<'save' | 'discard' | null>(null)
const documentSelector = computed(() => documentsSelectorComponent())
const sender = computed({ const sender = computed({
get: () => activeDraft.value?.sender ?? null, get: () => activeDraft.value?.sender ?? null,
@@ -80,6 +88,14 @@ const subject = computed({
const attachments = computed(() => activeDraft.value?.attachments ?? {}) const attachments = computed(() => activeDraft.value?.attachments ?? {})
const text = computed({
get: () => activeDraft.value?.message.body.text ?? '',
set: value => {
textEdited.value = true
compositionStore.updateBody({ text: value, html: textToHtml(value) })
},
})
// Initialize Tiptap editor // Initialize Tiptap editor
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -113,6 +129,8 @@ watch(
compositionStore.openDraft(props.mode, props.source) compositionStore.openDraft(props.mode, props.source)
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0 showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0 showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
format.value = 'rich'
textEdited.value = false
applyingDraftToEditor.value = true applyingDraftToEditor.value = true
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '') currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
@@ -157,17 +175,55 @@ onBeforeUnmount(() => {
}) })
// Handlers // Handlers
const handleClose = async () => { const handleClose = () => {
await compositionStore.closeDraft() showCloseDialog.value = true
emit('close') }
const handleSave = async () => {
closeAction.value = 'save'
try {
if (await compositionStore.saveDraft()) {
showCloseDialog.value = false
emit('close')
}
} finally {
closeAction.value = null
}
}
const handleDiscard = async () => {
closeAction.value = 'discard'
try {
if (await compositionStore.discardDraft()) {
showCloseDialog.value = false
emit('close')
}
} finally {
closeAction.value = null
}
} }
const handleSend = async () => { const handleSend = async () => {
await compositionStore.sendDraft() await compositionStore.sendDraft()
} }
const handleAttach = async (files: File[]) => { const handleAttachFromDevice = async (files: File[]) => {
await compositionStore.addAttachments(files) await compositionStore.addAttachmentsFromDevice(files)
}
const handleAttachFromDocuments = () => {
showDocumentSelector.value = true
}
const handleDocumentsSelected = async (items: { kind: 'entity' | 'collection'; identifier: string; name: string; mimeType?: string; size?: number }[]) => {
// The dialog defaults to entities-only (no `selectable` prop passed below), but
// filter defensively since mail can't attach a folder as a single file blob.
const files = items.filter(item => item.kind === 'entity')
if (files.length === 0) {
return
}
await compositionStore.addAttachmentsFromDocuments(files)
showDocumentSelector.value = false
} }
const handleDetach = async (identifier: string) => { const handleDetach = async (identifier: string) => {
@@ -206,6 +262,42 @@ const toggleLink = () => {
const isActive = (name: string, attrs?: any) => { const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false return editor.value?.isActive(name, attrs) || false
} }
const toggleFormat = () => {
if (format.value === 'rich') {
if (editor.value) {
compositionStore.updateBody({
html: activeDraft.value?.message.body.html ?? '',
text: editor.value.getText(),
})
}
textEdited.value = false
format.value = 'plain'
return
}
format.value = 'rich'
if (textEdited.value && editor.value) {
applyingDraftToEditor.value = true
editor.value.commands.setContent(activeDraft.value?.message.body.html || '')
applyingDraftToEditor.value = false
}
}
function textToHtml(text: string): string {
if (!text) {
return ''
}
return text
.split(/\r?\n/)
.map(line => `<p>${escapeHtml(line) || '<br>'}</p>`)
.join('')
}
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
</script> </script>
<template> <template>
@@ -215,7 +307,9 @@ const isActive = (name: string, attrs?: any) => {
:status="stageStatus" :status="stageStatus"
:can-send="canSend" :can-send="canSend"
:sending="sending" :sending="sending"
:saving="saving"
@close="handleClose" @close="handleClose"
@save="handleSave"
@send="handleSend" @send="handleSend"
/> />
@@ -252,6 +346,8 @@ const isActive = (name: string, attrs?: any) => {
<ComposerEditor <ComposerEditor
:editor="editor" :editor="editor"
:format="format"
:text="text"
:is-bold-active="isActive('bold')" :is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')" :is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')" :is-underline-active="isActive('underline')"
@@ -264,9 +360,52 @@ const isActive = (name: string, attrs?: any) => {
@bullet-list="toggleBulletList" @bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList" @ordered-list="toggleOrderedList"
@link="toggleLink" @link="toggleLink"
@attach="handleAttach" @toggle-format="toggleFormat"
@update:text="text = $event"
@attach-from-device="handleAttachFromDevice"
@attach-from-documents="handleAttachFromDocuments"
/> />
</div> </div>
<component
:is="documentSelector"
v-if="documentSelector"
v-model="showDocumentSelector"
:selectable="['entity']"
@select="handleDocumentsSelected"
/>
<v-dialog v-model="showCloseDialog" max-width="480" persistent>
<v-card>
<v-card-title>Save this draft?</v-card-title>
<v-card-text>
Save the draft to your mail server, or discard it permanently.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="closeAction !== null" @click="showCloseDialog = false">
Cancel
</v-btn>
<v-btn
color="error"
variant="text"
:loading="closeAction === 'discard'"
:disabled="closeAction !== null || saving"
@click="handleDiscard"
>
Discard
</v-btn>
<v-btn
color="primary"
:loading="closeAction === 'save'"
:disabled="closeAction !== null || saving"
@click="handleSave"
>
Save and close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
+4 -2
View File
@@ -16,12 +16,14 @@ interface Props {
selectionMode?: boolean selectionMode?: boolean
selectedCollection?: CollectionObject | null selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
searchActive?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
selectionList: () => [], selectionList: () => [],
selectionMode: false, selectionMode: false,
searchActive: false,
}) })
// Emits // Emits
@@ -372,10 +374,10 @@ onBeforeUnmount(() => {
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="messages.length === 0" class="pa-8 text-center"> <div v-else-if="messages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon> <v-icon size="64" color="grey-lighten-1">{{ searchActive ? 'mdi-email-search-outline' : 'mdi-email-outline' }}</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div> <div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
This folder is empty {{ searchActive ? 'No messages match your search' : 'This folder is empty' }}
</div> </div>
</div> </div>
+12 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC' import { usePreviewStore, useUser } from '@KTXC'
import { useMailUiStore } from '@/stores/mailUiStore' import { useMailUiStore } from '@/stores/mailUiStore'
import type { EntityObject, MessageObject } from '@MailManager/models' import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
@@ -19,6 +19,7 @@ const props = defineProps<{
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
reply: [entity: EntityObject] reply: [entity: EntityObject]
replyAll: [entity: EntityObject]
forward: [entity: EntityObject] forward: [entity: EntityObject]
move: [entity: EntityObject] move: [entity: EntityObject]
delete: [entity: EntityObject] delete: [entity: EntityObject]
@@ -28,6 +29,7 @@ const emit = defineEmits<{
// User settings // User settings
const { getSetting } = useUser() const { getSetting } = useUser()
const previewStore = usePreviewStore()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
// Security overrides // Security overrides
@@ -64,6 +66,7 @@ const effectiveSecurityLevel = computed(() => {
// Preview state // Preview state
const previewDialog = computed(() => mediaViewerComponent('dialog')) const previewDialog = computed(() => mediaViewerComponent('dialog'))
const previewItems = computed<MediaViewerItem[]>(() => { const previewItems = computed<MediaViewerItem[]>(() => {
if (!previewStore.availability.storage) return []
const attachments = message.value?.attachments ?? [] const attachments = message.value?.attachments ?? []
const items: MediaViewerItem[] = [] const items: MediaViewerItem[] = []
attachments.forEach((attachment, index) => { attachments.forEach((attachment, index) => {
@@ -107,6 +110,12 @@ const handleReply = () => {
} }
} }
const handleReplyAll = () => {
if (props.entity) {
emit('replyAll', props.entity)
}
}
const handleForward = () => { const handleForward = () => {
if (props.entity) { if (props.entity) {
emit('forward', props.entity) emit('forward', props.entity)
@@ -152,7 +161,7 @@ const handlePreview = (index: number) => {
const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => { const handleViewerRetrieve = (item: MediaViewerItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected')) if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment) return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'fullscreen')
} }
const handleViewerDownload = (item: MediaViewerItem) => { const handleViewerDownload = (item: MediaViewerItem) => {
@@ -176,6 +185,7 @@ const handleViewerDownload = (item: MediaViewerItem) => {
:security-level="effectiveSecurityLevel" :security-level="effectiveSecurityLevel"
:is-security-overridden="overrideSecurityLevel !== null" :is-security-overridden="overrideSecurityLevel !== null"
@reply="handleReply" @reply="handleReply"
@reply-all="handleReplyAll"
@forward="handleForward" @forward="handleForward"
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
+108 -60
View File
@@ -2,12 +2,21 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { EditorContent, type Editor } from '@tiptap/vue-3' import { EditorContent, type Editor } from '@tiptap/vue-3'
import { documentsSelectorAvailable } from '@/services/documentsSelector'
defineProps({ defineProps({
editor: { editor: {
type: Object as PropType<Editor | null>, type: Object as PropType<Editor | null>,
default: null, default: null,
}, },
format: {
type: String as PropType<'rich' | 'plain'>,
required: true,
},
text: {
type: String,
default: '',
},
isBoldActive: { isBoldActive: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -41,7 +50,10 @@ const emit = defineEmits<{
bulletList: [] bulletList: []
orderedList: [] orderedList: []
link: [] link: []
attach: [files: File[]] toggleFormat: []
'update:text': [value: string]
attachFromDevice: [files: File[]]
attachFromDocuments: []
}>() }>()
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
@@ -54,7 +66,7 @@ function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement | null const input = event.target as HTMLInputElement | null
const files = input?.files ? Array.from(input.files) : [] const files = input?.files ? Array.from(input.files) : []
if (files.length > 0) { if (files.length > 0) {
emit('attach', files) emit('attachFromDevice', files)
} }
if (input) { if (input) {
input.value = '' input.value = ''
@@ -64,68 +76,84 @@ function handleFileChange(event: Event) {
<template> <template>
<v-toolbar density="compact" elevation="0" class="editor-toolbar"> <v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn <template v-if="format === 'rich'">
icon <v-btn
size="small" icon
:class="{ 'v-btn--active': isBoldActive }" size="small"
@click="$emit('bold')" :class="{ 'v-btn--active': isBoldActive }"
> @click="$emit('bold')"
<v-icon>mdi-format-bold</v-icon> >
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip> <v-icon>mdi-format-bold</v-icon>
</v-btn> <v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isItalicActive }"
@click="$emit('italic')"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
</template>
<v-btn <v-btn
icon icon
size="small" size="small"
:class="{ 'v-btn--active': isItalicActive }" :class="{ 'v-btn--active': format === 'plain' }"
@click="$emit('italic')" @click="$emit('toggleFormat')"
> >
<v-icon>mdi-format-italic</v-icon> <v-icon>{{ format === 'plain' ? 'mdi-format-text' : 'mdi-code-tags' }}</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip> <v-tooltip activator="parent" location="bottom">
</v-btn> {{ format === 'plain' ? 'Rich Text' : 'Plain Text' }}
</v-tooltip>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn> </v-btn>
<v-spacer /> <v-spacer />
@@ -142,12 +170,27 @@ function handleFileChange(event: Event) {
<v-icon>mdi-paperclip</v-icon> <v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip> <v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn> </v-btn>
<v-btn v-if="documentsSelectorAvailable()" icon size="small" @click="$emit('attachFromDocuments')">
<v-icon>mdi-folder-search-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Attach from Documents</v-tooltip>
</v-btn>
</v-toolbar> </v-toolbar>
<v-divider /> <v-divider />
<div class="editor-container"> <div class="editor-container">
<EditorContent :editor="editor ?? undefined" /> <EditorContent v-if="format === 'rich'" :editor="editor ?? undefined" />
<v-textarea
v-else
:model-value="text"
variant="plain"
auto-grow
hide-details
placeholder="Write your message..."
class="text-editor pa-4"
@update:model-value="$emit('update:text', $event)"
/>
</div> </div>
</template> </template>
@@ -172,6 +215,11 @@ function handleFileChange(event: Event) {
color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-primary));
} }
.text-editor :deep(textarea) {
min-height: 300px;
font-family: inherit;
}
:deep(.tiptap-editor) { :deep(.tiptap-editor) {
outline: none; outline: none;
min-height: 300px; min-height: 300px;
+14 -18
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ComposerSenderIdentity } from '@/types/composer'; import type { ComposerSenderIdentity } from '@/types/composer';
import { computed } from 'vue' import { computed, watch } from 'vue'
interface Props { interface Props {
modelValue: ComposerSenderIdentity | null modelValue: ComposerSenderIdentity | null
@@ -27,11 +27,6 @@ const items = computed<SenderListItem[]>(() => {
const selectedValue = computed(() => props.modelValue?.address ?? null) const selectedValue = computed(() => props.modelValue?.address ?? null)
const singleOptionLabel = computed(() => {
const onlyOption = props.options[0]
return onlyOption ? formatSenderLabel(onlyOption.label, onlyOption.address) : ''
})
const errorMessages = computed(() => { const errorMessages = computed(() => {
if (props.options.length === 0) { if (props.options.length === 0) {
return ['No send-capable account is available.'] return ['No send-capable account is available.']
@@ -40,6 +35,18 @@ const errorMessages = computed(() => {
return [] return []
}) })
// Auto-select (and keep selected) the only send-capable account, since
// there's no "from" UI shown for the user to pick it themselves.
watch(
() => props.options,
options => {
if (options.length === 1 && props.modelValue?.address !== options[0].address) {
emit('update:modelValue', options[0])
}
},
{ immediate: true },
)
function handleUpdate(value: string | null) { function handleUpdate(value: string | null) {
const match = value ? props.options.find(o => o.address === value) ?? null : null const match = value ? props.options.find(o => o.address === value) ?? null : null
emit('update:modelValue', match) emit('update:modelValue', match)
@@ -51,19 +58,8 @@ function formatSenderLabel(label: string | null | undefined, address: string): s
</script> </script>
<template> <template>
<div class="composer-sender px-4 pt-4 pb-0"> <div v-if="options.length !== 1" class="composer-sender px-4 pt-4 pb-0">
<v-text-field
v-if="options.length === 1"
:model-value="singleOptionLabel"
label="From"
variant="outlined"
density="compact"
readonly
class="mb-2"
/>
<v-select <v-select
v-else
:model-value="selectedValue" :model-value="selectedValue"
:items="items" :items="items"
item-title="title" item-title="title"
+15 -2
View File
@@ -6,12 +6,14 @@ interface Props {
status: string status: string
canSend: boolean canSend: boolean
sending: boolean sending: boolean
saving: boolean
} }
defineProps<Props>() defineProps<Props>()
defineEmits<{ defineEmits<{
close: [] close: []
save: []
send: [] send: []
}>() }>()
</script> </script>
@@ -28,7 +30,7 @@ defineEmits<{
</v-btn> </v-btn>
<v-toolbar-title> <v-toolbar-title>
{{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }} {{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.ReplyAll ? 'Reply All' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer /> <v-spacer />
@@ -37,9 +39,20 @@ defineEmits<{
{{ status }} {{ status }}
</span> </span>
<v-btn
class="mr-2"
variant="text"
:loading="saving"
:disabled="sending"
prepend-icon="mdi-content-save-outline"
@click="$emit('save')"
>
Save
</v-btn>
<v-btn <v-btn
color="primary" color="primary"
:disabled="!canSend" :disabled="!canSend || saving"
:loading="sending" :loading="sending"
prepend-icon="mdi-send" prepend-icon="mdi-send"
@click="$emit('send')" @click="$emit('send')"
+6 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { usePreviewStore } from '@KTXC'
import RecipientDetails from '@/components/common/RecipientDetails.vue' import RecipientDetails from '@/components/common/RecipientDetails.vue'
import { formatFileSize } from '@/utile/format' import { formatFileSize } from '@/utile/format'
import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer' import { mediaViewerCanDisplay, mediaViewerComponent } from '@/services/mediaViewer'
@@ -27,7 +28,9 @@ const emit = defineEmits<{
}>() }>()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
const previewPopover = computed(() => mediaViewerComponent('popover')) const previewStore = usePreviewStore()
const previewsAvailable = computed(() => previewStore.availability.storage)
const previewPopover = computed(() => previewsAvailable.value ? mediaViewerComponent('popover') : null)
const message = computed(() => { const message = computed(() => {
return props.entity?.properties ?? null return props.entity?.properties ?? null
@@ -48,7 +51,7 @@ const attachmentItems = computed<AttachmentItem[]>(() =>
) )
const isPreviewable = (attachment: MessagePartInterface): boolean => { const isPreviewable = (attachment: MessagePartInterface): boolean => {
return mediaViewerCanDisplay(attachment.type) return previewsAvailable.value && mediaViewerCanDisplay(attachment.type)
} }
const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => { const handleAttachmentClick = (attachment: MessagePartInterface, index: number): void => {
@@ -61,7 +64,7 @@ const handleAttachmentClick = (attachment: MessagePartInterface, index: number):
const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => { const handleViewerRetrieve = (item: AttachmentItem): Promise<Blob> => {
if (!props.entity) return Promise.reject(new Error('No message selected')) if (!props.entity) return Promise.reject(new Error('No message selected'))
return mailUiStore.attachmentBlob(props.entity, item.meta.attachment) return mailUiStore.attachmentPreviewBlob(props.entity, item.meta.attachment, 'inline')
} }
// Format date for display // Format date for display
+2 -1
View File
@@ -13,6 +13,7 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
reply: [] reply: []
replyAll: []
forward: [] forward: []
move: [] move: []
delete: [] delete: []
@@ -47,7 +48,7 @@ const currentSecurityLevel = computed(() => {
<v-btn <v-btn
icon="mdi-reply-all" icon="mdi-reply-all"
variant="text" variant="text"
@click="emit('reply')" @click="emit('replyAll')"
> >
<v-icon>mdi-reply-all</v-icon> <v-icon>mdi-reply-all</v-icon>
<v-tooltip activator="parent" location="bottom">Reply All</v-tooltip> <v-tooltip activator="parent" location="bottom">Reply All</v-tooltip>
+3 -2
View File
@@ -40,10 +40,11 @@ const handleAccountSaved = async () => {
<v-list-item <v-list-item
v-for="service in servicesStore.services" v-for="service in servicesStore.services"
:key="`${service.provider}:${service.identifier}`" :key="`${service.provider}:${service.identifier}`"
:class="{ 'opacity-60': !service.enabled }"
> >
<template #prepend> <template #prepend>
<v-avatar color="primary"> <v-avatar :color="service.enabled ? 'primary' : 'grey'">
<v-icon>mdi-email</v-icon> <v-icon>{{ service.enabled ? 'mdi-email' : 'mdi-email-off' }}</v-icon>
</v-avatar> </v-avatar>
</template> </template>
+16 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, unref } from 'vue' import { computed, onMounted, ref, unref, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
@@ -41,6 +41,8 @@ const {
loading, loading,
selectedMessage, selectedMessage,
currentMessages, currentMessages,
searchResults,
searching,
} = storeToRefs(mailOperationsStore) } = storeToRefs(mailOperationsStore)
const { const {
@@ -74,21 +76,19 @@ const {
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailOperationsStore const { mailSync, entitiesStore } = mailOperationsStore
// Message search (filters the current message list by subject/sender) // Message search — debounced server-side search of the selected folder
const searchQuery = ref('') const searchQuery = ref('')
const filteredMessages = computed(() => { watch(searchQuery, query => mailOperationsStore.searchMessages(query))
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (!query) return currentMessages.value
return currentMessages.value.filter((message: EntityObject) => { // Reset the search box when the folder changes so it doesn't show a stale
const subject = (message.properties.subject || '').toLowerCase() // query for a folder whose results are no longer being filtered.
const fromLabel = (message.properties.from?.label || '').toLowerCase() watch(selectedFolder, () => {
const fromAddress = (message.properties.from?.address || '').toLowerCase() searchQuery.value = ''
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
})
}) })
const filteredMessages = computed(() => searchResults.value ?? currentMessages.value)
const lastSyncLabel = computed(() => { const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync)) const lastSync = unref(unref(mailSync.lastSync))
@@ -167,6 +167,8 @@ const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply) const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
const handleMessageComposeReplyAll = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.ReplyAll)
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward) const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
const handleMessageComposeClose = () => mailUiStore.closeComposer() const handleMessageComposeClose = () => mailUiStore.closeComposer()
@@ -250,6 +252,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
variant="outlined" variant="outlined"
placeholder="Search messages..." placeholder="Search messages..."
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
:loading="searching"
hide-details hide-details
single-line single-line
clearable clearable
@@ -308,6 +311,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:selection-list="selectionList" :selection-list="selectionList"
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
:search-active="searchResults !== null"
@open="handleMessageOpen" @open="handleMessageOpen"
@reply="handleMessageComposeReply" @reply="handleMessageComposeReply"
@forward="handleMessageComposeForward" @forward="handleMessageComposeForward"
@@ -339,6 +343,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:entity="selectedMessage" :entity="selectedMessage"
@compose="handleMessageComposeFresh" @compose="handleMessageComposeFresh"
@reply="handleMessageComposeReply" @reply="handleMessageComposeReply"
@reply-all="handleMessageComposeReplyAll"
@forward="handleMessageComposeForward" @forward="handleMessageComposeForward"
@move="handleMessageMove" @move="handleMessageMove"
@delete="handleMessageDelete" @delete="handleMessageDelete"
+6
View File
@@ -9,6 +9,8 @@ import type {
CompositionDiscardResponse, CompositionDiscardResponse,
CompositionPatchRequest, CompositionPatchRequest,
CompositionPatchResponse, CompositionPatchResponse,
CompositionSaveRequest,
CompositionSaveResponse,
CompositionSendRequest, CompositionSendRequest,
CompositionSendResponse, CompositionSendResponse,
CompositionStageRequest, CompositionStageRequest,
@@ -50,6 +52,10 @@ const compositionService = {
return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request) return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request)
}, },
async save(request: CompositionSaveRequest): Promise<CompositionSaveResponse> {
return await post<CompositionSaveRequest, CompositionSaveResponse>('save', request)
},
async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> { async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> {
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request) return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
}, },
+27
View File
@@ -0,0 +1,27 @@
/**
* documentsSelector — bridges the Mail composer to documents_manager's
* document/collection picker dialog, resolved at runtime via the integration
* store. documents_manager (not the end-user `documents` app) registers this,
* since it's the module designed to be depended on by other feature modules;
* this mirrors CompositionManager's own ModuleManager-based check on the
* backend, which also targets documents_manager.
*/
import { defineAsyncComponent } from 'vue'
import { useIntegrationStore } from '@KTXC'
const DOCUMENTS_SELECTOR_POINT = 'documents_selector'
/** True if a document selector dialog has been registered (i.e. documents_manager is enabled). */
export function documentsSelectorAvailable(): boolean {
return useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).length > 0
}
export function documentsSelectorComponent() {
// The integration store prefixes ids with the module handle
// (e.g. 'documents_manager.dialog'), so match on the suffix.
const entry = useIntegrationStore().getItems(DOCUMENTS_SELECTOR_POINT).find(i => i.id.endsWith('.dialog'))
return entry?.component
? defineAsyncComponent(entry.component as () => Promise<unknown>)
: null
}
+165 -27
View File
@@ -8,12 +8,14 @@ import type {
CompositionAttachmentInterface, CompositionAttachmentInterface,
CompositionAttachmentRemoveRequest, CompositionAttachmentRemoveRequest,
CompositionPatchRequest, CompositionPatchRequest,
CompositionSaveRequest,
CompositionStageRequest, CompositionStageRequest,
CompositionStageResponse, CompositionStageResponse,
} from '@/types/composition' } from '@/types/composition'
import { ComposerMode } from '@/types/composer' import { ComposerMode } from '@/types/composer'
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer' import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models' import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
import type { EntityIdentifier } from '@MailManager/types/common'
import type { MessageAddressInterface } from '@MailManager/types/message' import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailCompositionStore = defineStore('mailCompositionStore', () => { export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
@@ -116,16 +118,13 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
return return
} }
activeDraft.value = buildDraft(mode, sender, source) const ownAddresses = senderIdentities.value.map(identity => identity.address)
activeDraft.value = buildDraft(mode, sender, source, ownAddresses)
activeDraft.value.action = mode activeDraft.value.action = mode
stageDraft() stageDraft()
} }
function closeDraft() {
return discardDraft()
}
async function stageDraft() { async function stageDraft() {
if (!activeDraft.value) { if (!activeDraft.value) {
return null return null
@@ -188,16 +187,73 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
async function discardDraft() { async function discardDraft() {
if (!activeDraft.value) { if (!activeDraft.value) {
return return true
}
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
autoSaveTimer = null
} }
const draftId = activeDraft.value.identifier const draftId = activeDraft.value.identifier
saving.value = true
try { try {
await compositionService.discard({ identifier: draftId }) const response = await compositionService.discard({ identifier: draftId })
if (response.disposition === 'error') {
mailUiStore.notify(`Failed to discard draft: ${response.error?.message || 'Unknown error'}`, 'error')
return false
}
activeDraft.value = null
return true
} catch (error) { } catch (error) {
console.error('[Mail][Composer] Failed to discard staged draft:', error) console.error('[Mail][Composer] Failed to discard staged draft:', error)
mailUiStore.notify('Failed to discard draft', 'error')
return false
} finally { } finally {
saving.value = false
}
}
async function saveDraft() {
if (!activeDraft.value) {
return true
}
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
autoSaveTimer = null
}
const draft = activeDraft.value
const request: CompositionSaveRequest = {
identifier: draft.identifier,
revision: draft.revision,
sender: {
provider: draft.sender.service.provider,
service: draft.sender.service.identifier,
address: draft.sender.address,
name: draft.sender.label || null,
},
message: draft.message,
}
saving.value = true
try {
const response = await compositionService.save(request)
if (response.disposition === 'error') {
mailUiStore.notify(`Failed to save draft: ${response.error?.message || 'Unknown error'}`, 'error')
return false
}
activeDraft.value = null activeDraft.value = null
mailUiStore.notify('Draft saved', 'success')
return true
} catch (error) {
console.error('[Mail][Composer] Failed to save draft:', error)
mailUiStore.notify('Failed to save draft', 'error')
return false
} finally {
saving.value = false
} }
} }
@@ -274,7 +330,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
queueSave(false) queueSave(false)
} }
async function addAttachments(files: File[]) { async function addAttachmentsFromDevice(files: File[]) {
if (!activeDraft.value || files.length === 0) { if (!activeDraft.value || files.length === 0) {
return return
} }
@@ -288,7 +344,7 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
return [identifier, { return [identifier, {
identifier, identifier,
composition, composition,
origin: 'upload', origin: 'device',
name: file.name, name: file.name,
type: file.type || 'application/octet-stream', type: file.type || 'application/octet-stream',
size: file.size, size: file.size,
@@ -325,6 +381,74 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
source: attachment.source, source: attachment.source,
} }
}) })
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
}
async function addAttachmentsFromDocuments(items: { identifier: string; name: string; mimeType?: string; size?: number }[]) {
if (!activeDraft.value || items.length === 0) {
return
}
const composition = activeDraft.value.identifier
const attachments = Object.fromEntries(
items.map(item => {
const identifier = createIdentifier()
return [identifier, {
identifier,
composition,
origin: 'documents',
name: item.name,
type: item.mimeType || 'application/octet-stream',
size: item.size ?? 0,
source: item.identifier as EntityIdentifier,
} satisfies CompositionAttachmentInterface]
}),
)
const request: CompositionAttachmentAddRequest = {
composition,
attachments,
}
const response = await compositionService.attachmentAdd(request)
if (!activeDraft.value || response.composition !== activeDraft.value.identifier) {
return
}
if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to add attachments from documents:', response.error)
mailUiStore.notify(`Failed to attach documents: ${response.error?.message || 'Unknown error'}`, 'error')
return
}
Object.values(response.attachments).forEach(attachment => {
activeDraft.value!.attachments[attachment.identifier] = {
identifier: attachment.identifier,
composition: attachment.composition,
origin: attachment.origin,
name: attachment.name,
type: attachment.type,
size: attachment.size,
source: attachment.source,
}
})
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
if (response.failed?.length) {
mailUiStore.notify(
response.failed.length === 1
? '1 document could not be attached'
: `${response.failed.length} documents could not be attached`,
'warning',
)
}
} }
async function removeAttachment(identifier: string) { async function removeAttachment(identifier: string) {
@@ -347,8 +471,11 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
if (response.disposition === 'error') { if (response.disposition === 'error') {
console.error('[Mail][Composer] Failed to remove attachment:', response.error) console.error('[Mail][Composer] Failed to remove attachment:', response.error)
} }
delete activeDraft.value.attachments[attachment.identifier] delete activeDraft.value.attachments[attachment.identifier]
if (response.revision !== undefined) {
activeDraft.value.revision = response.revision
}
} }
function queueSave(immediate: boolean) { function queueSave(immediate: boolean) {
@@ -418,25 +545,23 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
stageStatus, stageStatus,
senderIdentities, senderIdentities,
openDraft, openDraft,
closeDraft, saveDraft,
discardDraft,
sendDraft, sendDraft,
updateSender, updateSender,
updateRecipients, updateRecipients,
updateSubject, updateSubject,
updateBody, updateBody,
addAttachments, addAttachmentsFromDevice,
addAttachmentsFromDocuments,
removeAttachment, removeAttachment,
flushSave, flushSave,
} }
}) })
function buildDraft( function buildDraft(mode: ComposerMode, sender: ComposerSenderIdentity, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[]): ComposerDraft {
mode: ComposerMode,
sender: ComposerSenderIdentity,
source?: EntityObject | MessageAddressObject | null,
): ComposerDraft {
const composition = createIdentifier() const composition = createIdentifier()
const freshMessage = buildMessage(mode, source) const freshMessage = buildMessage(mode, source, ownAddresses)
const freshAttachments = buildAttachments(composition, mode, source) const freshAttachments = buildAttachments(composition, mode, source)
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
return { return {
@@ -452,10 +577,7 @@ function buildDraft(
} }
} }
function buildMessage( function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined, ownAddresses: string[] = []): ComposerDraftMessage {
mode: ComposerMode,
source: EntityObject | MessageAddressObject | null | undefined,
): ComposerDraftMessage {
if (!source) { if (!source) {
return emptyMessage() return emptyMessage()
@@ -482,12 +604,28 @@ function buildMessage(
const sentAt = sourceMessage.sent || source.created || '' const sentAt = sourceMessage.sent || source.created || ''
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time' const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (mode === ComposerMode.Reply) { if (mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll) {
const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null
const replyToAddress = replyToAddr?.address || '' const replyToAddress = replyToAddr?.address || ''
const to: MessageAddressInterface[] = replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : []
let cc: MessageAddressInterface[] = []
if (mode === ComposerMode.ReplyAll) {
const excluded = new Set([replyToAddress.toLowerCase(), ...ownAddresses.map(addr => addr.toLowerCase())])
const seen = new Set<string>()
cc = [...(sourceMessage.to || []), ...(sourceMessage.cc || [])].filter(recipient => {
const address = recipient.address?.toLowerCase() || ''
if (!address || excluded.has(address) || seen.has(address)) {
return false
}
seen.add(address)
return true
})
}
return { return {
to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [], to,
cc: [], cc,
bcc: [], bcc: [],
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`, subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
body: { body: {
@@ -510,7 +648,7 @@ function buildMessage(
} }
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> { function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) { if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || mode === ComposerMode.ReplyAll || !(source instanceof EntityObject)) {
return {} return {}
} }
@@ -521,7 +659,7 @@ function buildAttachments(composition: string, mode: ComposerMode, source?: Enti
attachments[id] = { attachments[id] = {
identifier: id, identifier: id,
composition: composition, composition: composition,
origin: 'source', origin: 'message',
name: attachment.name || '', name: attachment.name || '',
type: attachment.type || 'application/octet-stream', type: attachment.type || 'application/octet-stream',
size: attachment.size || 0, size: attachment.size || 0,
+89 -1
View File
@@ -4,7 +4,8 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier, ListFilter } from '@MailManager/types/common'
import { ListFilterComparisonOperator, ListFilterConjunctionOperator } from '@MailManager/types/common'
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity' import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message' import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models' import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
@@ -50,6 +51,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const searchResults = shallowRef<EntityObject[] | null>(null)
const searching = ref(false)
const composerSaving = ref(false) const composerSaving = ref(false)
const composerSending = ref(false) const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null) const composerLastSaved = ref<Date | null>(null)
@@ -293,6 +296,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
} }
async function selectFolder(folder: CollectionObject | null) { async function selectFolder(folder: CollectionObject | null) {
clearSearch()
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
@@ -307,6 +311,67 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
_updateSyncSources() _updateSyncSources()
} }
const SEARCH_DEBOUNCE_MS = 300
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
// Monotonic token so out-of-order/stale search responses are discarded.
let searchToken = 0
/**
* Debounced server-side search of the selected folder. Clearing the query
* (or the folder) reverts the message list back to `currentMessages`.
*/
function searchMessages(query: string) {
const trimmed = (query ?? '').trim()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
if (!trimmed || !selectedFolder.value) {
searchToken++
searchResults.value = null
searching.value = false
return
}
const folder = selectedFolder.value
// 'from'/'to'/'subject'/'body' are the attributes both IMAP and JMAP
// backends whitelist for entity search (see their entityListFilter
// capability); OR them together for a single-box search.
const filter: ListFilter = {
from: [trimmed, ListFilterComparisonOperator.LIKE],
to: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
subject: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
body: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
}
searching.value = true
const token = ++searchToken
searchDebounceTimer = setTimeout(async () => {
try {
const results = await entitiesStore.list([folder.identifier], filter)
if (token !== searchToken) return
searchResults.value = Object.values(results)
} catch (error) {
if (token === searchToken) searchResults.value = []
console.error('[Mail][Operations] Failed to search messages:', error)
} finally {
if (token === searchToken) searching.value = false
}
}, SEARCH_DEBOUNCE_MS)
}
function clearSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
searchToken++
searchResults.value = null
searching.value = false
}
function selectMessage(entity: EntityObject | null) { function selectMessage(entity: EntityObject | null) {
selectedMessage.value = entity selectedMessage.value = entity
} }
@@ -575,6 +640,24 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
return blob return blob
} }
async function attachmentPreviewBlob(
entity: EntityObject,
part: MessagePartInterface,
variant: 'inline' | 'fullscreen' = 'inline',
): Promise<Blob> {
return entitiesStore.preview({
target: entity.identifier,
part: {
blobId: part.blobId ?? undefined,
partId: part.partId ?? undefined,
cid: part.cid ?? undefined,
type: part.type || 'application/octet-stream',
size: part.size ?? null,
},
variant,
})
}
// ── Exports ─────────────────────────────────────────────────────────────── // ── Exports ───────────────────────────────────────────────────────────────
return { return {
@@ -587,6 +670,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
// State // State
loading, loading,
selectedMessage, selectedMessage,
searchResults,
searching,
composerSaving, composerSaving,
composerSending, composerSending,
composerLastSaved, composerLastSaved,
@@ -602,6 +687,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
retrieveService, retrieveService,
selectFolder, selectFolder,
selectMessage, selectMessage,
searchMessages,
clearSearch,
createFolder, createFolder,
saveComposerDraft, saveComposerDraft,
sendComposerMessage, sendComposerMessage,
@@ -612,6 +699,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
moveMessages, moveMessages,
downloadMessage, downloadMessage,
attachmentBlob, attachmentBlob,
attachmentPreviewBlob,
moveFolder, moveFolder,
renameFolder, renameFolder,
isServiceFolderLoading, isServiceFolderLoading,
+10 -1
View File
@@ -714,6 +714,14 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
return mailOperationsStore.attachmentBlob(entity, part) return mailOperationsStore.attachmentBlob(entity, part)
} }
function attachmentPreviewBlob(
entity: EntityObject,
part: Parameters<typeof mailOperationsStore.attachmentPreviewBlob>[1],
variant: 'inline' | 'fullscreen' = 'inline',
) {
return mailOperationsStore.attachmentPreviewBlob(entity, part, variant)
}
return { return {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
@@ -763,6 +771,7 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
moveMessages, moveMessages,
downloadMessage, downloadMessage,
attachmentBlob, attachmentBlob,
attachmentPreviewBlob,
messageSelectionModeActivate, messageSelectionModeActivate,
messageSelectionModeDeactivate, messageSelectionModeDeactivate,
messageSelectionToggleOne, messageSelectionToggleOne,
@@ -788,4 +797,4 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
confirmDeleteFolder, confirmDeleteFolder,
flagSelectedMessages, flagSelectedMessages,
} }
}) })
+5 -1
View File
@@ -5,13 +5,17 @@ import type { MessageAddressInterface } from "@MailManager/types/message"
export enum ComposerMode { export enum ComposerMode {
Fresh = 'fresh', Fresh = 'fresh',
Reply = 'reply', Reply = 'reply',
ReplyAll = 'replyAll',
Forward = 'forward', Forward = 'forward',
} }
/** Body editing format: rich (Tiptap/HTML) or plain text. */
export type ComposerFormat = 'rich' | 'plain'
export interface ComposerDraftAttachment { export interface ComposerDraftAttachment {
identifier: string identifier: string
composition: string composition: string
origin: 'source' | 'upload' origin: 'device' | 'message' | 'documents'
name: string name: string
type: string type: string
size: number size: number
+19 -2
View File
@@ -22,7 +22,7 @@ export interface CompositionMessageInterface {
export interface CompositionAttachmentInterface { export interface CompositionAttachmentInterface {
identifier: string identifier: string
composition: string composition: string
origin: 'source' | 'upload' origin: 'device' | 'message' | 'documents'
name: string name: string
type: string type: string
size: number size: number
@@ -58,6 +58,17 @@ export interface CompositionPatchResponse {
message: CompositionMessageInterface message: CompositionMessageInterface
} }
export interface CompositionSaveRequest extends CompositionPatchRequest {}
export interface CompositionSaveResponse {
identifier: string
disposition: 'saved' | 'error'
error?: {
type: string
message: string
}
}
export interface CompositionDiscardRequest { export interface CompositionDiscardRequest {
identifier: string identifier: string
} }
@@ -94,13 +105,18 @@ export interface CompositionAttachmentAddRequest {
} }
export interface CompositionAttachmentAddResponse { export interface CompositionAttachmentAddResponse {
disposition: 'added' | 'error' disposition: 'added' | 'partial' | 'error'
error?: { error?: {
type: string type: string
message: string message: string
} }
composition: string composition: string
revision?: number
attachments: Record<string, CompositionAttachmentInterface> attachments: Record<string, CompositionAttachmentInterface>
failed?: {
identifier: string
message: string
}[]
} }
export interface CompositionAttachmentRemoveRequest { export interface CompositionAttachmentRemoveRequest {
@@ -116,4 +132,5 @@ export interface CompositionAttachmentRemoveResponse {
} }
composition: string composition: string
identifier: string identifier: string
revision?: number
} }
+462
View File
@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use KTXC\Module\ModuleManager;
use KTXC\Module\ModuleObject;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Documents\Entity\EntityBaseInterface;
use KTXF\Documents\Entity\EntityPropertiesBaseInterface;
use KTXM\Mail\CompositionManager;
use KTXM\Mail\CompositionSavedEvent;
use KTXM\Mail\Stores\CompositionStore;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
// KTXM\MailManager\Manager and KTXM\DocumentsManager\Manager live in sibling
// modules. This module's isolated test bootstrap only autoloads its own PSR-4
// map plus the server's KTXC/KTXF namespaces (see tests/php/bootstrap.php), so
// sibling-module classes are not otherwise resolvable here. Require them
// directly so PHPUnit can reflect on the real classes to build mocks; both
// files only depend on already-autoloadable KTXC/KTXF types. Skipped
// gracefully in setUp() if this module is ever checked out standalone.
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
$documentsManagerFile = dirname(__DIR__, 4) . '/documents_manager/lib/Manager.php';
if (is_file($mailManagerFile)) {
require_once $mailManagerFile;
}
if (is_file($documentsManagerFile)) {
require_once $documentsManagerFile;
}
final class CompositionManagerTest extends TestCase {
private const TENANT_ID = 'tenant-1';
private const USER_ID = 'user-1';
private string $rootDir;
private CompositionStore $compositionStore;
protected function setUp(): void {
if (!class_exists(\KTXM\MailManager\Manager::class) || !class_exists(\KTXM\DocumentsManager\Manager::class)) {
$this->markTestSkipped('mail_manager and documents_manager modules are not available alongside this checkout');
}
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-composition-test-' . uniqid();
mkdir($this->rootDir, 0755, true);
$this->compositionStore = new CompositionStore($this->rootDir);
}
protected function tearDown(): void {
$this->deleteDir($this->rootDir);
}
public function testStageInitializesDirtyRemoteState(): void {
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$response = $manager->stage(
self::TENANT_ID,
self::USER_ID,
'draft-new',
'new',
['address' => 'sender@example.com'],
['subject' => 'Draft'],
);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-new');
$this->assertArrayNotHasKey('remote', $response);
$this->assertSame([
'status' => 'dirty',
'entity' => null,
'error' => null,
], $snapshot['remote']);
}
public function testStageQueuesDraftSynchronizationAfterLocalSave(): void {
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
$event->tenantIdentifier() === self::TENANT_ID
&& $event->userIdentifier() === self::USER_ID
&& $event->compositionIdentifier() === 'draft-event'
&& $event->revision() === 1));
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager, events: $events);
$result = $manager->stage(
self::TENANT_ID,
self::USER_ID,
'draft-event',
'fresh',
['address' => 'sender@example.com'],
['subject' => 'Draft'],
);
$this->assertSame('staged', $result['disposition']);
}
public function testStageSurfacesEventQueueingFailureAfterLocalSave(): void {
$events = $this->createStub(EventDispatcherInterface::class);
$events->method('dispatch')->willThrowException(new \RuntimeException('No active event scope'));
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager, events: $events);
try {
$manager->stage(
self::TENANT_ID,
self::USER_ID,
'draft-event-failure',
'fresh',
['address' => 'sender@example.com'],
['subject' => 'Draft'],
);
$this->fail('Event dispatch failure should be surfaced');
} catch (\RuntimeException $exception) {
$this->assertSame('No active event scope', $exception->getMessage());
}
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-event-failure');
$this->assertSame('dirty', $snapshot['remote']['status']);
}
public function testPatchMarksExistingRemoteStateDirty(): void {
$compositionId = $this->stageEmptyComposition();
$setSynced = static function (?array $snapshot): array {
$snapshot['revision'] = 1;
$snapshot['remote'] = [
'status' => 'synced',
'entity' => 'provider:service:drafts:123',
'error' => 'old error',
];
return $snapshot;
};
$this->compositionStore->compositionSave(
self::TENANT_ID,
self::USER_ID,
$compositionId,
$setSynced,
);
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
$event->compositionIdentifier() === $compositionId
&& $event->revision() === 2));
$manager = $this->makeManager($documentsManager, events: $events);
$result = $manager->patch(self::TENANT_ID, self::USER_ID, $compositionId, [
'revision' => 2,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Changed'],
]);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame('patched', $result['disposition']);
$this->assertSame('dirty', $snapshot['remote']['status']);
$this->assertSame('provider:service:drafts:123', $snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']);
}
public function testAttachmentAddFromDocumentsHappyPath(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
$event->compositionIdentifier() === $compositionId));
$manager = $this->makeManager($documentsManager, events: $events);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('added', $result['disposition']);
$this->assertCount(1, $result['attachments']);
$this->assertSame([], $result['failed']);
$this->assertSame(1, $result['revision']);
$attachment = $result['attachments']['attach-1'];
$this->assertSame('documents', $attachment['origin']);
$this->assertSame('default:personal:root:doc1', $attachment['source']);
$this->assertSame('hello.txt', $attachment['name']);
$this->assertSame('text/plain', $attachment['type']);
$this->assertSame(strlen('hello world'), $attachment['size']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(1, $snapshot['revision']);
$this->assertSame('dirty', $snapshot['remote']['status']);
$this->assertNull($snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']);
}
public function testAttachmentAddIncrementsRevisionOnSubsequentAdd(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
$this->makeEntity('default:personal:root:doc2', 'world.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturnCallback(
fn(): mixed => $this->makeStream('hello world'),
);
$manager = $this->makeManager($documentsManager);
$manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-2',
'origin' => 'documents',
'source' => 'default:personal:root:doc2',
]);
$this->assertSame(2, $result['revision']);
}
public function testAttachmentAddDoesNotChangeRevisionWhenNothingIsAdded(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// documents_manager unavailable means the sourced attachment fails
// entirely, so no attachment is actually added to the composition.
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertArrayNotHasKey('revision', $result);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertArrayNotHasKey('revision', $snapshot);
}
public function testAttachmentAddFromDocumentsPartialFailure(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// only doc1 is returned even though doc1 and doc2 are both requested
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentAdd(
self::TENANT_ID,
self::USER_ID,
$compositionId,
['identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
['identifier' => 'attach-2', 'origin' => 'documents', 'source' => 'default:personal:root:doc2'],
);
$this->assertSame('partial', $result['disposition']);
$this->assertCount(1, $result['attachments']);
$this->assertCount(1, $result['failed']);
$this->assertSame('default:personal:root:doc2', $result['failed'][0]['identifier']);
}
public function testAttachmentAddMixedUploadAndDocuments(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentAdd(
self::TENANT_ID,
self::USER_ID,
$compositionId,
['identifier' => 'attach-upload', 'origin' => 'device', 'name' => 'upload.txt', 'type' => 'text/plain', 'data' => base64_encode('uploaded bytes')],
['identifier' => 'attach-doc', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
);
$this->assertSame('added', $result['disposition']);
$this->assertCount(2, $result['attachments']);
$this->assertSame('device', $result['attachments']['attach-upload']['origin']);
$this->assertSame('documents', $result['attachments']['attach-doc']['origin']);
}
public function testAttachmentAddCompositionNotFound(): void {
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, 'unknown-composition', [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertSame('composition_not_found', $result['error']['type']);
}
public function testAttachmentAddDocumentsModuleUnavailable(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
// documents_manager is not installed/enabled: attachmentAdd must still
// work for the composition itself, just report the sourced entry as failed
// rather than throwing (e.g. from a failed DI resolution).
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame('error', $result['disposition']);
$this->assertCount(0, $result['attachments']);
$this->assertCount(1, $result['failed']);
$this->assertSame('default:personal:root:doc1', $result['failed'][0]['identifier']);
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
}
public function testAttachmentRemoveIncrementsRevision(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$documentsManager->method('entityFetchBulk')->willReturn([
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
]);
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
$manager = $this->makeManager($documentsManager);
$added = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
'identifier' => 'attach-1',
'origin' => 'documents',
'source' => 'default:personal:root:doc1',
]);
$this->assertSame(1, $added['revision']);
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'attach-1');
$this->assertSame('removed', $result['disposition']);
$this->assertSame(2, $result['revision']);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
$this->assertSame(2, $snapshot['revision']);
$this->assertArrayNotHasKey('attach-1', $snapshot['attachments']);
$this->assertSame('dirty', $snapshot['remote']['status']);
}
public function testAttachmentRemoveNotFoundDoesNotChangeRevision(): void {
$compositionId = $this->stageEmptyComposition();
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
$manager = $this->makeManager($documentsManager);
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'missing-attachment');
$this->assertSame('error', $result['disposition']);
$this->assertSame('attachment_not_found', $result['error']['type']);
$this->assertArrayNotHasKey('revision', $result);
}
private function makeManager(
Stub $documentsManager,
bool $documentsModuleEnabled = true,
?EventDispatcherInterface $events = null,
): CompositionManager {
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$moduleManager = $this->createStub(ModuleManager::class);
if ($documentsModuleEnabled) {
$module = $this->createStub(ModuleObject::class);
$module->method('enabled')->willReturn(true);
$moduleManager->method('fetch')->willReturn($module);
} else {
$moduleManager->method('fetch')->willReturn(null);
}
$container = $this->createStub(ContainerInterface::class);
$container->method('has')->willReturn($documentsModuleEnabled);
$container->method('get')->willReturn($documentsManager);
return new CompositionManager(
container: $container,
moduleManager: $moduleManager,
compositionStore: $this->compositionStore,
mailManager: $mailManager,
events: $events ?? $this->createStub(EventDispatcherInterface::class),
);
}
private function stageEmptyComposition(): string {
$compositionId = 'draft-' . uniqid();
$saveComposition = static fn(): array => [
'identifier' => $compositionId,
'attachments' => [],
];
$this->compositionStore->compositionSave(
self::TENANT_ID,
self::USER_ID,
$compositionId,
$saveComposition,
);
return $compositionId;
}
private function makeEntity(string $urn, string $label, string $mime): EntityBaseInterface&Stub {
$properties = $this->createStub(EntityPropertiesBaseInterface::class);
$properties->method('getLabel')->willReturn($label);
$properties->method('getMime')->willReturn($mime);
$properties->method('size')->willReturn(0);
$entity = $this->createStub(EntityBaseInterface::class);
$entity->method('urn')->willReturn($urn);
$entity->method('getProperties')->willReturn($properties);
return $entity;
}
/** @return resource */
private function makeStream(string $contents) {
$resource = fopen('php://memory', 'r+');
fwrite($resource, $contents);
rewind($resource);
return $resource;
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
continue;
}
unlink($child);
}
rmdir($path);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXM\Mail\CompositionSavedEvent;
use KTXM\Mail\CompositionSyncListener;
use KTXM\Mail\Module;
use PHPUnit\Framework\TestCase;
final class CompositionSavedEventTest extends TestCase {
public function testCarriesOnlySynchronizationCoordinates(): void {
$event = new CompositionSavedEvent('tenant-1', 'user-1', 'draft-1', 7);
$this->assertSame(CompositionSavedEvent::class, $event->label());
$this->assertSame('tenant-1', $event->tenantIdentifier());
$this->assertSame('user-1', $event->userIdentifier());
$this->assertSame('draft-1', $event->compositionIdentifier());
$this->assertSame(7, $event->revision());
$this->assertSame([
'userId' => 'user-1',
'compositionId' => 'draft-1',
'revision' => 7,
], $event->context());
}
public function testModuleRegistersDeferredCompositionSyncListener(): void {
$events = $this->createMock(EventListenerRegistrarInterface::class);
$events->expects($this->once())
->method('listen')
->with(
'mail',
CompositionSavedEvent::class,
CompositionSyncListener::class,
'onCompositionSaved',
DeliveryMode::Deferred,
);
(new Module($events))->boot();
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use JsonException;
use KTXM\Mail\Stores\CompositionStore;
use PHPUnit\Framework\TestCase;
final class CompositionStoreTest extends TestCase {
private const TENANT_ID = 'tenant-1';
private const USER_ID = 'user-1';
private const COMPOSITION_ID = 'draft-1';
private string $rootDir;
private CompositionStore $store;
protected function setUp(): void {
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-store-test-' . uniqid('', true);
mkdir($this->rootDir, 0755, true);
$this->store = new CompositionStore($this->rootDir);
}
protected function tearDown(): void {
$this->deleteDir($this->rootDir);
}
public function testFailedReplacementPreservesPreviousSnapshot(): void {
$initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1];
$this->saveSnapshot($initial);
$resource = fopen('php://memory', 'r');
try {
$saveInvalidSnapshot = static fn(): array => [
'identifier' => self::COMPOSITION_ID,
'invalid' => $resource,
];
$this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$saveInvalidSnapshot,
);
$this->fail('Saving a value that cannot be encoded as JSON should fail');
} catch (JsonException) {
$this->assertSame(
$initial,
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID),
);
} finally {
fclose($resource);
}
}
public function testUpdateCanLeaveSnapshotUnchanged(): void {
$initial = ['identifier' => self::COMPOSITION_ID, 'revision' => 1];
$this->saveSnapshot($initial);
$leaveUnchanged = static fn(?array $snapshot): ?array => null;
$updated = $this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$leaveUnchanged,
);
$this->assertNull($updated);
$this->assertSame(
$initial,
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID),
);
}
public function testConcurrentUpdatesDoNotLoseChanges(): void {
if (!function_exists('pcntl_fork')) {
$this->markTestSkipped('The pcntl extension is required for the concurrency test');
}
$this->saveSnapshot([
'identifier' => self::COMPOSITION_ID,
'updates' => 0,
]);
$childPid = pcntl_fork();
if ($childPid === -1) {
$this->fail('Unable to fork the concurrency test process');
}
if ($childPid === 0) {
$this->incrementAfterDelay();
exit(0);
}
$this->incrementAfterDelay();
pcntl_waitpid($childPid, $status);
$this->assertTrue(pcntl_wifexited($status));
$this->assertSame(0, pcntl_wexitstatus($status));
$this->assertSame(
2,
$this->store->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID)['updates'],
);
}
private function incrementAfterDelay(): void {
$incrementSnapshot = static function (?array $snapshot): array {
usleep(200_000);
$snapshot['updates']++;
return $snapshot;
};
$this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$incrementSnapshot,
);
}
private function saveSnapshot(array $snapshot): void {
$saveSnapshot = static fn(): array => $snapshot;
$this->store->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$saveSnapshot,
);
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
continue;
}
unlink($child);
}
rmdir($path);
}
}
@@ -0,0 +1,474 @@
<?php
declare(strict_types=1);
namespace KTXT\Mail\Tests\Unit;
use KTXC\Module\ModuleManager;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Entity\EntityBaseInterface;
use KTXF\Mail\Entity\EntityMutableInterface;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXM\Mail\CompositionManager;
use KTXM\Mail\Stores\CompositionStore;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
if (is_file($mailManagerFile)) {
require_once $mailManagerFile;
}
final class CompositionSynchronizationTest extends TestCase {
private const TENANT_ID = 'tenant-1';
private const USER_ID = 'user-1';
private const COMPOSITION_ID = 'draft-1';
private string $rootDir;
private CompositionStore $compositionStore;
protected function setUp(): void {
if (!class_exists(\KTXM\MailManager\Manager::class)) {
$this->markTestSkipped('mail_manager module is not available alongside this checkout');
}
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-sync-test-' . uniqid('', true);
mkdir($this->rootDir, 0755, true);
$this->compositionStore = new CompositionStore($this->rootDir);
}
protected function tearDown(): void {
$this->deleteDir($this->rootDir);
}
public function testCreatesDraftInConfiguredCollection(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
$created = $this->entity('provider', 'service', 'drafts', 'created-1');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityCreate')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:drafts'),
$properties,
)
->willReturn($created);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$replayed = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('saved', $result['disposition']);
$this->assertSame('saved', $replayed['disposition']);
$this->assertSame('synced', $snapshot['remote']['status']);
$this->assertSame('provider:service:drafts:created-1', $snapshot['remote']['entity']);
$this->assertNull($snapshot['remote']['error']);
}
public function testModifiesExistingDraftAndStoresAuthoritativeIdentifier(): void {
$this->stageComposition('provider:service:drafts:old-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
$modified = $this->entity('provider', 'service', 'drafts', 'replacement-2');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityModify')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === 'provider:service:drafts:old-1'),
$properties,
)
->willReturn($modified);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('saved', $result['disposition']);
$this->assertSame('provider:service:drafts:replacement-2', $snapshot['remote']['entity']);
}
public function testResolvesDraftCollectionByRoleWhenNoTargetIsConfigured(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService([], $properties);
$filter = $this->createMock(IFilter::class);
$filter->expects($this->once())->method('condition')->with('role', 'drafts');
$service->method('collectionListFilter')->willReturn($filter);
$collection = $this->createStub(CollectionBaseInterface::class);
$collection->method('identifier')->willReturn('role-drafts');
$service->method('collectionList')->willReturn([$collection]);
$created = $this->entity('provider', 'service', 'role-drafts', 'created-1');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityCreate')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:role-drafts'),
$properties,
)
->willReturn($created);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('saved', $result['disposition']);
}
public function testRecordsFailureWhileKeepingLocalDraftStaged(): void {
$this->stageComposition();
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn(null);
$compositionManager = $this->compositionManager($mailManager);
$result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('staged', $result['disposition']);
$this->assertArrayHasKey('error', $result);
$this->assertSame('failed', $snapshot['remote']['status']);
$this->assertSame($result['error'], $snapshot['remote']['error']);
$this->assertNull($snapshot['remote']['entity']);
}
public function testInlineSaveSynchronizesAndPurgesLocalComposition(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->mutableService(['draftTarget' => 'drafts'], $properties);
$created = $this->entity('provider', 'service', 'drafts', 'created-inline');
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$mailManager->expects($this->once())
->method('entityCreate')
->willReturn($created);
$result = $this->compositionManager($mailManager)->save(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
[
'revision' => 2,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Saved inline'],
],
);
$this->assertSame('saved', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testInlineSaveFailureKeepsLocalComposition(): void {
$this->stageComposition();
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn(null);
$result = $this->compositionManager($mailManager)->save(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
[
'revision' => 2,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Keep this draft'],
],
);
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID);
$this->assertSame('error', $result['disposition']);
$this->assertSame('composition_save_failed', $result['error']['type']);
$this->assertSame('Keep this draft', $snapshot['message']['subject']);
$this->assertSame('failed', $snapshot['remote']['status']);
}
public function testDiscardDeletesRemoteDraftBeforePurgingLocalComposition(): void {
$remote = 'provider:service:drafts:remote-1';
$this->stageComposition($remote);
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->expects($this->once())
->method('entityDelete')
->with(
self::TENANT_ID,
self::USER_ID,
$this->callback(static fn(EntityIdentifier $target): bool => (string)$target === $remote),
)
->willReturn([$remote => ['disposition' => 'deleted']]);
$result = $this->compositionManager($mailManager)->discard(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
);
$this->assertSame('discarded', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testRemoteDiscardFailureKeepsLocalComposition(): void {
$remote = 'provider:service:drafts:remote-1';
$this->stageComposition($remote);
$mailManager = $this->createMock(\KTXM\MailManager\Manager::class);
$mailManager->method('entityDelete')
->willReturn([$remote => ['disposition' => 'error', 'error' => 'Provider unavailable']]);
$result = $this->compositionManager($mailManager)->discard(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
);
$this->assertSame('error', $result['disposition']);
$this->assertSame('Provider unavailable', $result['error']['message']);
$this->assertNotNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testSendPurgesLocalCompositionOnSuccess(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->submitService($properties);
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
$this->assertNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
public function testSendReusesSyncedRemoteDraftAsSubmissionSource(): void {
$this->stageComposition('provider:service:drafts:remote-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->createMockForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
$service->expects($this->once())
->method('entitySubmit')
->with(
$this->anything(),
$this->callback(static fn(?EntityIdentifier $source): bool => $source !== null
&& (string)$source === 'provider:service:drafts:remote-1'),
$properties,
)
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
}
public function testSendIgnoresRemoteDraftBelongingToAnotherService(): void {
$this->stageComposition('provider:other-service:drafts:remote-1');
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->createMockForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
$service->expects($this->once())
->method('entitySubmit')
->with($this->anything(), $this->isNull(), $properties)
->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Sent draft'],
[],
);
$this->assertSame('sent', $result['disposition']);
}
public function testSendFailureKeepsLocalComposition(): void {
$this->stageComposition();
$properties = $this->createStub(MessagePropertiesMutableInterface::class);
$service = $this->submitService($properties);
$service->method('entitySubmit')->willReturn(new EntitySubmitResult(
EntitySubmitResult::DISPOSITION_ERROR,
errorMessage: 'Transport unavailable',
));
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
$mailManager->method('serviceFindByAddress')->willReturn($service);
$result = $this->compositionManager($mailManager)->send(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
['address' => 'sender@example.com'],
['subject' => 'Keep this draft'],
[],
);
$this->assertSame('error', $result['disposition']);
$this->assertSame('Transport unavailable', $result['error']['message']);
$this->assertNotNull($this->compositionStore->compositionFetch(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
));
}
private function stageComposition(?string $remoteEntity = null): void {
$saveComposition = static fn(): array => [
'identifier' => self::COMPOSITION_ID,
'sender' => ['address' => 'sender@example.com'],
'message' => ['subject' => 'Draft'],
'attachments' => [],
'remote' => [
'status' => 'dirty',
'entity' => $remoteEntity,
'error' => null,
],
];
$this->compositionStore->compositionSave(
self::TENANT_ID,
self::USER_ID,
self::COMPOSITION_ID,
$saveComposition,
);
}
private function mutableService(array $auxiliary, MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntityMutableInterface&Stub {
$service = $this->createStubForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntityMutableInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('getAuxiliary')->willReturn($auxiliary);
$service->method('entityFresh')->willReturn($entity);
return $service;
}
private function submitService(MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntitySubmitInterface&Stub {
$service = $this->createStubForIntersectionOfInterfaces([
ServiceBaseInterface::class,
ServiceEntitySubmitInterface::class,
]);
$entity = $this->createStub(EntityMutableInterface::class);
$entity->method('getProperties')->willReturn($properties);
$service->method('provider')->willReturn('provider');
$service->method('identifier')->willReturn('service');
$service->method('getEnabled')->willReturn(true);
$service->method('entityFresh')->willReturn($entity);
return $service;
}
private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager {
return new CompositionManager(
container: $this->createStub(ContainerInterface::class),
moduleManager: $this->createStub(ModuleManager::class),
compositionStore: $this->compositionStore,
mailManager: $mailManager,
events: $this->createStub(EventDispatcherInterface::class),
);
}
private function entity(string $provider, string $service, string $collection, string $identifier): EntityBaseInterface&Stub {
$entity = $this->createStub(EntityBaseInterface::class);
$entity->method('provider')->willReturn($provider);
$entity->method('service')->willReturn($service);
$entity->method('collection')->willReturn($collection);
$entity->method('identifier')->willReturn($identifier);
return $entity;
}
private function deleteDir(string $path): void {
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$child = $path . '/' . $entry;
if (is_dir($child)) {
$this->deleteDir($child);
} else {
unlink($child);
}
}
rmdir($path);
}
}