Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2daf1f7de | |||
| a5eb104bd7 | |||
| 03862ed0cf | |||
| 0c7f103353 | |||
| 684d50062c | |||
| 67024b5bfd | |||
| bd5965f600 | |||
| 8fb3086792 | |||
| c95e503251 | |||
| 4839f822fa | |||
| 8a18494883 | |||
| 0a2a87acfa | |||
| 436197fbfe |
@@ -22,6 +22,7 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.ideas/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+604
-96
@@ -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,23 +113,36 @@ 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) {
|
if ($composed === null) {
|
||||||
$result['disposition'] = 'error';
|
$result['disposition'] = 'error';
|
||||||
$result['error'] = [
|
$result['error'] = [
|
||||||
'type' => 'composition_not_found',
|
'type' => 'composition_not_found',
|
||||||
'message' => 'Composition not found',
|
'message' => 'Composition not found',
|
||||||
];
|
];
|
||||||
return $result;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
|
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
|
||||||
@@ -114,46 +152,260 @@ class CompositionManager {
|
|||||||
'message' => 'Revision mismatch',
|
'message' => 'Revision mismatch',
|
||||||
'currentRevision' => $composed['revision'],
|
'currentRevision' => $composed['revision'],
|
||||||
];
|
];
|
||||||
return $result;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the patch data to the composed message
|
|
||||||
$composed['revision'] = $data['revision'];
|
$composed['revision'] = $data['revision'];
|
||||||
$composed['sender'] = $data['sender'] ?? [];
|
$composed['sender'] = $data['sender'] ?? [];
|
||||||
$composed['message'] = $data['message'] ?? [];
|
$composed['message'] = $data['message'] ?? [];
|
||||||
|
$composed = $this->markRemoteDirty($composed);
|
||||||
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
|
|
||||||
|
|
||||||
$result = [
|
$result = [
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
'disposition' => 'patched',
|
'disposition' => 'patched',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
$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 [
|
return [
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $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(),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
|
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||||
|
}
|
||||||
|
|
||||||
if ($composed === null) {
|
public function synchronize(string $tenantId, string $userId, string $identifier): array {
|
||||||
$result['disposition'] = 'error';
|
$result = [
|
||||||
$result['error'] = [
|
'identifier' => $identifier,
|
||||||
'type' => 'composition_not_found',
|
'disposition' => 'staged',
|
||||||
'message' => 'Composition not found',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// store the message and sender information before sending
|
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
|
||||||
|
// 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['sender'] = $sender;
|
||||||
$composed['message'] = $message;
|
$composed['message'] = $message;
|
||||||
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
|
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 [
|
||||||
|
'disposition' => 'error',
|
||||||
|
'error' => [
|
||||||
|
'type' => 'composition_not_found',
|
||||||
|
'message' => 'Composition not found',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// 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)) {
|
||||||
@@ -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,
|
||||||
@@ -293,6 +554,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
|
||||||
* @return array<int,Address>
|
* @return array<int,Address>
|
||||||
@@ -320,77 +885,20 @@ class CompositionManager {
|
|||||||
return $addresses;
|
return $addresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
|
/**
|
||||||
$result = [
|
* Wrap an in-memory byte string in a single-chunk Generator.
|
||||||
'composition' => $composition,
|
*/
|
||||||
'attachments' => [],
|
private function stringToGenerator(string $bytes): \Generator {
|
||||||
];
|
yield $bytes;
|
||||||
|
|
||||||
$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 a raw PHP stream resource in a Generator yielding chunks, closing it once exhausted.
|
||||||
$identifier = UUID::v4();
|
*/
|
||||||
} else {
|
private function streamToGenerator($resource): \Generator {
|
||||||
$identifier = $attachment['identifier'];
|
while (!feof($resource)) {
|
||||||
|
yield fread($resource, 65536);
|
||||||
}
|
}
|
||||||
|
fclose($resource);
|
||||||
$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 = [
|
|
||||||
'composition' => $composition,
|
|
||||||
'identifier' => $identifier,
|
|
||||||
];
|
|
||||||
|
|
||||||
$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])) {
|
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
@@ -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');
|
||||||
|
|||||||
+16
-2
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace KTXM\Mail;
|
namespace KTXM\Mail;
|
||||||
|
|
||||||
|
use KTXF\Event\DeliveryMode;
|
||||||
|
use KTXF\Event\EventListenerRegistrarInterface;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
@@ -11,8 +13,20 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||||
{
|
{
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|||||||
+103
-55
@@ -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,31 +22,73 @@ 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,
|
||||||
|
$userId,
|
||||||
|
$draftId,
|
||||||
|
LOCK_SH,
|
||||||
|
fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically read, mutate, and replace a composition snapshot.
|
||||||
|
*
|
||||||
|
* 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!is_array($snapshot)) {
|
||||||
$messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME;
|
throw new UnexpectedValueException('Composition save callback must return an array or null');
|
||||||
if (!file_exists($messagePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$decoded = json_decode((string)file_get_contents($messagePath), true);
|
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
return is_array($decoded) ? $decoded : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function compositionSave(string $tenantId, string $userId, string $draftId, array $snapshot): array {
|
|
||||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||||
if (!is_dir($draftDir)) {
|
$this->ensureDirectory($draftDir);
|
||||||
mkdir($draftDir, 0755, true);
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
||||||
return $snapshot;
|
return $snapshot;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
|
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
|
||||||
|
return $this->withCompositionLock(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$draftId,
|
||||||
|
LOCK_EX,
|
||||||
|
function () use ($tenantId, $userId, $draftId): bool {
|
||||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||||
if (!is_dir($draftDir)) {
|
if (!is_dir($draftDir)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -52,15 +96,47 @@ final class CompositionStore {
|
|||||||
|
|
||||||
$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;
|
||||||
|
|||||||
Generated
+320
-225
@@ -15,7 +15,7 @@
|
|||||||
"@tiptap/extension-underline": "^3.0.0",
|
"@tiptap/extension-underline": "^3.0.0",
|
||||||
"@tiptap/starter-kit": "^3.0.0",
|
"@tiptap/starter-kit": "^3.0.0",
|
||||||
"@tiptap/vue-3": "^3.0.0",
|
"@tiptap/vue-3": "^3.0.0",
|
||||||
"pinia": "^4.0.0",
|
"pinia": "^3.0.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^5.0.0",
|
"vue-router": "^5.0.0",
|
||||||
"vuetify": "^4.0.0"
|
"vuetify": "^4.0.0"
|
||||||
@@ -795,49 +795,49 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.3.tgz",
|
||||||
"integrity": "sha512-A/lrhKpOYtl0V5pmPS00Zps8pgBe1qDOoD9fzsumDSZ3HP8W398C959Jgru75PNFokAya9COPD3iaKP3NWF25g==",
|
"integrity": "sha512-kDD8KY99lBCKntCqTBE9eNR1ul/i/wPFw2METWT+LYZvifljXq2oiX6JaGF1Sk59efe7+sq9IISxOk47YlBiWQ==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-blockquote": {
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-k8NKHLEYOqre7guObZBeFM04CaTwmEceCmdIrIjd7H9KoHSbRODaBNved6j5G33x/FWMwIF/RQbE2BjWaFSgzA==",
|
"integrity": "sha512-Dh8yfEqBKTqEdBKZ4Ta3DkTecP0VJxvRNA5b1LCF7gBbe54Lm3A28UuVvK/Sdhh9FJDrIVCQTDS0yHGbi6G9ew==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-bold": {
|
"node_modules/@tiptap/extension-bold": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-BPUJvJ9sCsU3fxao5UJfDJrDuHhMn2hczZwZ9Qs4w1vkAeLGyOYbSejUTJdBYHLoexGiEwgnEeVeZN3C8GaI5g==",
|
"integrity": "sha512-a4BSAjWRN4mWklRTdDCIaG9R+PqyUxXNYGQ2CLtOhO51Wq6daw2qFTHR+cTqAjaiiUSuDYII86UChb2qQRq8Lg==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-bubble-menu": {
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-aBNI8ebX76ObderRmu/PJ9Q2AGVEuhpg0TBvv2SNbHtmU5kNUMHQI8HxrxKFblJwETAmG6EJ3Ed/5jRxpJmrsg==",
|
"integrity": "sha512-YryCf9fq+9n1XIW6f5weSCD2MbL/LhRQ7jArRToQQ/oNxc422LqYVjijQQTNuk2cZEqE9fdO3Hw27tmwEEs9UQ==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.3.tgz",
|
||||||
|
"integrity": "sha512-Z/ZqUfrd3Fd8hHpHEPv3XRzqwqVLD4CP67xFMyjy0cqEgnjgvd1iR6QzJsKD79V9zyu0Agu7ab9ZBf4NktpWcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-code": {
|
"node_modules/@tiptap/extension-code": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-Um0BlyunRJ8Fal288Jn7I2n15FRwQcTy3NnKBBrPz3ikwx+yZZ0n9xTozpLMSxXrTq6cRlxLkASp/9FFx9sX9A==",
|
"integrity": "sha512-mOV4Fg+ji6uXmFxTMuug9WK6zk0ksNQepnuvoDR4g4d+tnz2y9iEbgOXFohVxhOXOLDLdNeKdwHxbHQdi5Ivbw==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-code-block": {
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-nKm1YSEiPVosjm4qeg6m+6SAKq82rKukAzE/g3M5eVs2OgEb4QIKQvQOmJ+zgGAnpb9Q/JIvcKsGGU1rn9Vnyw==",
|
"integrity": "sha512-xT2cDil/ipy/LklPgv/JqSsXys1mjcqlIPDUetK3llB2DT7XZssYPV3qv278tqRO4pJDbrXk82I6Lh7vLwmIAg==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-document": {
|
"node_modules/@tiptap/extension-document": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-folTlrwmUL+WSnojvfjZ951Gb25Pw8BxjXwhvXFuZXJ92qMfOcezA480kwFuu5nG8pPc/76ezQlz9a86Y5uQJw==",
|
"integrity": "sha512-B9gqrgM1uHjCKr/PSnnSl+bS+YQb5EN/PUW9KWp9nSgmIaqxXws2ufqowS65uTLJLqd7pwF4l5lUrsfhq0EKzw==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-dropcursor": {
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-IAY21q7KSXyCXN3u13kfpq0E8D2i3r9nSbsNYlLzkEv0Bpn+pWShZi05A0ubuothDNjdaMa96xaRY0f1y9gDZw==",
|
"integrity": "sha512-xdY66lcQakBLvWbJQHeJy/Td0f10YRXLyFeOFzt9PODsX4nqwJMju9tpqcLIGp0waaztu8PGwZ81oSCdQldIHQ==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-floating-menu": {
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-/rFlN6T70HuT34kFvftKm56UX0IPff7vlII7Sqgs7DprKVYkItiU3lM7+AoeDRzCsWMg4z1yNHj70BPx7+EJ6Q==",
|
"integrity": "sha512-4l5Qee1wBk2YBFEFbWHFhUt++6hDY7Yp81BkO3wY6KFToxs+cm9JIm3xr3KF9UJ/vnUpIhBWIbZl3uKf37SJLA==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-gapcursor": {
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-Jz0zwriGxPMuRjKo6x7WhPa/7pZn0dovyJu/GLarN4T+mMseGNXWwEBsLEKkuumsJVPDEPPFjX+rzEeaFKInPQ==",
|
"integrity": "sha512-+mwm64+RiArd1G8xSgtaTrvx1X5Lzz6sJ93zTmaiMVNc2N29AN77BVPTJ0e+853+/F6Bec/vOnVkIjTLg/ZBRw==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-hard-break": {
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-8qKj0oeuU7IYoG73Lj3W4EC/JS/dFjN5iHmc4yodqppP6qs0o2zsF/2ipceGN/REZq5megPtxRgXu/nxQZ1NYg==",
|
"integrity": "sha512-/tX+IFW2C4RJltbwcn8/4zoHwj5YycRNOugb4Ul9Fl1AOJqjNwRx8xc0NpaOjuADhlMYMczEtSaOiDh5ZEOmjg==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-heading": {
|
"node_modules/@tiptap/extension-heading": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-xWb3QEKo7cp9u6Vzi6oYKODra6jPwziHBv3fJAnxWpRldxtJqfyOVMikWJJFuDjmr+K0qyegzf206pMk+y0bDg==",
|
"integrity": "sha512-y+0u9qdrUAOGNrP52hAEiH1mvr1ZmzmkCBICICiXRH4+QNXpKkiBK3zlbDOPVQj0g0YIg6XIIU0vIaUBDPIaMQ==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-u1OgncXkokIuUJQIh4jVQfYHL/6I8VnrokUcCud4eErWvgeLnCbNmVdih4kxIdfmvEyAxXjDwcqf4AB79TxuNg==",
|
"integrity": "sha512-J/ioKlXu5oJ+pPtybFonByx1LusbR0PUd1nkx6XsZqY9wyW6uecZQgAyI8KUWIXqOfhnAW38UTsHMBRdm2XupA==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-italic": {
|
"node_modules/@tiptap/extension-italic": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-c8gF+zM7yXB9iGxtVCioYecjQ+A2fLlNa1XYBYbTobNhbUo8a11akbTkGNcCIgttwnzwEoI4mB6/PK4yjaQvVg==",
|
"integrity": "sha512-dVWqJ/kDXdPHRBp4nU7bwi2G+c3oNruIgQHYVRgYlWkwW/opvnlACZLBUgEhihH7ANiSaGW1zLQ7HuUDSL3vog==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-link": {
|
"node_modules/@tiptap/extension-link": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-FuLDgE0k1dB4WCBTelw3re9BLmiurgXVSj3d3J4l3sunljyOs941RUU62b7pWP+ePNdGF7RgUMmMAB+58VZCIA==",
|
"integrity": "sha512-MAAXfLJNf6ZFiFK3w51yiLKc31HAQWJWNV+nQ1dAdRpa6WiXdjpkcrWTuxmPd/o6Qt+yNv+hoItAVxDPEox8qQ==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.3.tgz",
|
||||||
|
"integrity": "sha512-cCX99WoVb4UyXkamSh0vlXiRFhlZnwk3WXWdnXpR1E9+auxsQewk2Q9NDW1Ra0vwgzer1gkFhlOl9TCVzH2VVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.30.3",
|
||||||
|
"@tiptap/pm": "3.30.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.3.tgz",
|
||||||
|
"integrity": "sha512-RhSyDlAATvXDTg/1VOw5aFTaewdCm+BAU3EG3eUCw4e5eoZ5pbhPOdOPTtaO45UR4i/AvjQNAZQ9LMDWX5WZFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.30.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.3.tgz",
|
||||||
|
"integrity": "sha512-u6tOpQFnE1pOyvnzI2MvhW/Yw0omAIMlMfkykYx4N4zB1JDi+16OeOuKf3rX6G3V9VUu+qeOIGpSkifkME6H9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.30.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.30.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.3.tgz",
|
||||||
|
"integrity": "sha512-lct/FV8vm9Y5kZFcF5TXIM18opi6m7AAkGoChgGP/A7LYalaniX1X6Ja2j13Y22ZdGaOoPTgd8oKRZXP1/miaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-paragraph": {
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-OtGrkwzqlW+ehW+d2qrQ7VVcP9PbDeQmuT4Ec4yFjf3vR4QO8OVdVfeOcUq52PtkT3kYRxz9fzEiEvjqRm72yA==",
|
"integrity": "sha512-Z9OHCX9b2bDcMDeR3ODCFRAyHT5eNGO4AsDFeAJS/XSitffMyjFHtOkXlyyZ3+pIHxxX6xkUXAwaLMjyATRo1A==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-placeholder": {
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-V2jb0kL/k3rygAtKmSPYxoyBMCQJtJikPYnxiCYuY7FI1ohmCrmhyhkVqJv0b0EPMNW8BMIqf6DgpKJ/ODmXgA==",
|
"integrity": "sha512-B7LwMzeQajQ18FWPGYv2UDh64HSUfXxh/mdIhS6ZQEDJS9NyUD0hPPnwaMQ1w870L/APgJdb2/EGqIkZqjJRSg==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-strike": {
|
"node_modules/@tiptap/extension-strike": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-VzasZkckrEXzmFQT9z3O95rUWy1b0RLFHAC6GUSQHxKk4TpP+Ujfn5+rqmPukMn0eScJyw1Y36jwfJc0VuNR+Q==",
|
"integrity": "sha512-e9BI8Hzei23GjmYysXxL+CHq3fIgOm29BbSUMbYXxT2cJ+TkkgJp7EvHf74gSABNME0gUN+mG0pRR5wTFoOCcg==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-text": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-AMsf2V7IiGvzbl+LqLLfyWzufazZxHuid1gOnEt3KwolQ9AO7p8WJgHld0PZLxNyEh4SaSqK7b3d3SM9sqrlTw==",
|
"integrity": "sha512-Jh8ZyI0HLOJOIPZl5a5XRJbTs4pAUakYIWCZ3Hv3lF9ouXW/C0bY67XQU7bv6Rvu8AHzDszhKklnOZ6z1NQPXQ==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-text-align": {
|
"node_modules/@tiptap/extension-text-align": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-/1htV3teqRJ7i7trAuORDg62LV0CQ1qskbMcP3SB7grsw6H1G54zwdaJVXeiIXmQKDF7FoZgY7u/v/TJckthEw==",
|
"integrity": "sha512-pwIZMn/PYoWZiUXrrD9oomV+Oh/GGhlIpa7Hr2WxdI8k3HZraweHa8S3ygvCUbodo9BWSI4zBs4KM3ic50cwlg==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-underline": {
|
"node_modules/@tiptap/extension-underline": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-DSPTogdvxmoX/L0U5KgBC9jWffRvYkGiyh1tp28gn+QaARh4iJn79pAZpi1m1bEhal/0Mq5m4ukbiO7FWInwaA==",
|
"integrity": "sha512-TiZ+b524Ee4PNlW+HNmkg3x4btR1K6btJy9pEjMuDf0Rx01UaTBvHpHmABR/DbbcC1CuZiQ3ylJwLL+90PaEoA==",
|
||||||
"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.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extensions": {
|
"node_modules/@tiptap/extensions": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.3.tgz",
|
||||||
"integrity": "sha512-ltlrm8dDHIgeNj3cOLEdLFMPPVy3TYvWA8ftrrJ44C/L01MBmgFB1f/vkPFYYnAasb2BYyVG6HxAGcTQHo5jHw==",
|
"integrity": "sha512-vig8ZjUL/NFnniEVbYDYj2UOIdkwZVha+9myFZkanShedviE3kl7c4uCY3fMl5EnqgyFIy2TQSKbg/i99xpztA==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/pm": "3.29.0"
|
"@tiptap/pm": "3.30.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/pm": {
|
"node_modules/@tiptap/pm": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.3.tgz",
|
||||||
"integrity": "sha512-4rr3HiZ8kbSNINuWXqKQJLv9fFMypCBN5gOwxoU+D4lEYVblkoM60fA1SlrI2BZVlZQmw2U38XKl5XMtUr55XQ==",
|
"integrity": "sha512-VheaqLAFUe+PCYEgHubM96Z1OAiluFhijQ2Cy1Ghiozoxm5OzevSaKhqm8SQ1VGfzZZko59oUjRzOmu8tB8yfA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.4.1",
|
"prosemirror-changeset": "^2.4.1",
|
||||||
@@ -1122,124 +1188,58 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/starter-kit": {
|
"node_modules/@tiptap/starter-kit": {
|
||||||
"version": "3.29.0",
|
"version": "3.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-J3jTp3/WXnnL58TCtdMsfeQ2BeycFYrAib6Nbpjd9G0OLUBE7AILiH2HrobRQpvRbAUgD5SW+sBZYOonbWWqNg==",
|
"integrity": "sha512-hj4rAhAoQm+wRk8eLNTyK+wK81+epbuo4O9PvPRv7GJGwYycej+xUN7jGM7Tney7GuTZpHoDCuwLKD2n1oG+pg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "^3.29.0",
|
"@tiptap/core": "3.30.3",
|
||||||
"@tiptap/extension-blockquote": "^3.29.0",
|
"@tiptap/extension-blockquote": "3.30.3",
|
||||||
"@tiptap/extension-bold": "^3.29.0",
|
"@tiptap/extension-bold": "3.30.3",
|
||||||
"@tiptap/extension-bullet-list": "^3.29.0",
|
"@tiptap/extension-bullet-list": "3.30.3",
|
||||||
"@tiptap/extension-code": "^3.29.0",
|
"@tiptap/extension-code": "3.30.3",
|
||||||
"@tiptap/extension-code-block": "^3.29.0",
|
"@tiptap/extension-code-block": "3.30.3",
|
||||||
"@tiptap/extension-document": "^3.29.0",
|
"@tiptap/extension-document": "3.30.3",
|
||||||
"@tiptap/extension-dropcursor": "^3.29.0",
|
"@tiptap/extension-dropcursor": "3.30.3",
|
||||||
"@tiptap/extension-gapcursor": "^3.29.0",
|
"@tiptap/extension-gapcursor": "3.30.3",
|
||||||
"@tiptap/extension-hard-break": "^3.29.0",
|
"@tiptap/extension-hard-break": "3.30.3",
|
||||||
"@tiptap/extension-heading": "^3.29.0",
|
"@tiptap/extension-heading": "3.30.3",
|
||||||
"@tiptap/extension-horizontal-rule": "^3.29.0",
|
"@tiptap/extension-horizontal-rule": "3.30.3",
|
||||||
"@tiptap/extension-italic": "^3.29.0",
|
"@tiptap/extension-italic": "3.30.3",
|
||||||
"@tiptap/extension-link": "^3.29.0",
|
"@tiptap/extension-link": "3.30.3",
|
||||||
"@tiptap/extension-list": "^3.29.0",
|
"@tiptap/extension-list": "3.30.3",
|
||||||
"@tiptap/extension-list-item": "^3.29.0",
|
"@tiptap/extension-list-item": "3.30.3",
|
||||||
"@tiptap/extension-list-keymap": "^3.29.0",
|
"@tiptap/extension-list-keymap": "3.30.3",
|
||||||
"@tiptap/extension-ordered-list": "^3.29.0",
|
"@tiptap/extension-ordered-list": "3.30.3",
|
||||||
"@tiptap/extension-paragraph": "^3.29.0",
|
"@tiptap/extension-paragraph": "3.30.3",
|
||||||
"@tiptap/extension-strike": "^3.29.0",
|
"@tiptap/extension-strike": "3.30.3",
|
||||||
"@tiptap/extension-text": "^3.29.0",
|
"@tiptap/extension-text": "3.30.3",
|
||||||
"@tiptap/extension-underline": "^3.29.0",
|
"@tiptap/extension-underline": "3.30.3",
|
||||||
"@tiptap/extensions": "^3.29.0",
|
"@tiptap/extensions": "3.30.3",
|
||||||
"@tiptap/pm": "^3.29.0"
|
"@tiptap/pm": "3.30.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.30.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.30.3.tgz",
|
||||||
"integrity": "sha512-OFESbEFlmDYPns5LrkyeKggis6tYICBvz+jrlVwxICCWJqWZJJvYb7y/uMtMCxFF1nAeGb8AFr8UhFrLaX5aiA==",
|
"integrity": "sha512-lmGTJgHd1CuYjRmedtKPHRFvOPGWmC/sqBPr9DVCq6Te4tECdi6q7/EiYrhfiTkCptvPxub/06JQJVnvP+lytg==",
|
||||||
"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.30.3",
|
||||||
"@tiptap/extension-floating-menu": "^3.29.0"
|
"@tiptap/extension-floating-menu": "^3.30.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@floating-ui/dom": "^1.0.0",
|
"@floating-ui/dom": "^1.0.0",
|
||||||
"@tiptap/core": "3.29.0",
|
"@tiptap/core": "3.30.3",
|
||||||
"@tiptap/pm": "3.29.0",
|
"@tiptap/pm": "3.30.3",
|
||||||
"vue": "^3.0.0"
|
"vue": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1562,31 +1562,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
"version": "8.2.1",
|
"version": "7.7.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz",
|
||||||
"integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==",
|
"integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-kit": "^8.2.1"
|
"@vue/devtools-kit": "^7.7.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-kit": {
|
"node_modules/@vue/devtools-kit": {
|
||||||
"version": "8.2.1",
|
"version": "7.7.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz",
|
||||||
"integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==",
|
"integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-shared": "^8.2.1",
|
"@vue/devtools-shared": "^7.7.10",
|
||||||
"birpc": "^2.6.1",
|
"birpc": "^2.3.0",
|
||||||
"hookable": "^5.5.3",
|
"hookable": "^5.5.3",
|
||||||
"perfect-debounce": "^2.0.0"
|
"mitt": "^3.0.1",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"speakingurl": "^14.0.1",
|
||||||
|
"superjson": "^2.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-shared": {
|
"node_modules/@vue/devtools-shared": {
|
||||||
"version": "8.2.1",
|
"version": "7.7.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz",
|
||||||
"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
|
"integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rfdc": "^1.4.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/language-core": {
|
"node_modules/@vue/language-core": {
|
||||||
"version": "3.3.8",
|
"version": "3.3.8",
|
||||||
@@ -1927,6 +1933,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/copy-anything": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-what": "^5.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2197,6 +2218,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-what": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -2728,6 +2761,12 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mlly": {
|
"node_modules/mlly": {
|
||||||
"version": "1.8.2",
|
"version": "1.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||||
@@ -2904,9 +2943,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "2.1.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -2928,25 +2967,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pinia": {
|
"node_modules/pinia": {
|
||||||
"version": "4.0.2",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
"integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==",
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nostics": "^1.1.4"
|
"@vue/devtools-api": "^7.7.7"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/posva"
|
"url": "https://github.com/sponsors/posva"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vue/devtools-api": "^8.1.5",
|
"typescript": ">=4.5.0",
|
||||||
"typescript": ">=5.6.0",
|
|
||||||
"vue": "^3.5.11"
|
"vue": "^3.5.11"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@vue/devtools-api": {
|
|
||||||
"optional": false
|
|
||||||
},
|
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
@@ -3186,6 +3221,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rfdc": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||||
@@ -3310,6 +3351,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/speakingurl": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
@@ -3428,6 +3478,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superjson": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"copy-anything": "^4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -3897,6 +3959,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-router/node_modules/@vue/devtools-api": {
|
||||||
|
"version": "8.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz",
|
||||||
|
"integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-kit": "^8.1.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-router/node_modules/@vue/devtools-kit": {
|
||||||
|
"version": "8.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
|
||||||
|
"integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-shared": "^8.1.5",
|
||||||
|
"birpc": "^2.6.1",
|
||||||
|
"hookable": "^5.5.3",
|
||||||
|
"perfect-debounce": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-router/node_modules/@vue/devtools-shared": {
|
||||||
|
"version": "8.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
|
||||||
|
"integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/vue-router/node_modules/perfect-debounce": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.3.8",
|
"version": "3.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.8.tgz",
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
"@tiptap/extension-underline": "^3.0.0",
|
"@tiptap/extension-underline": "^3.0.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^5.0.0",
|
"vue-router": "^5.0.0",
|
||||||
"pinia": "^4.0.0",
|
"pinia": "^3.0.0",
|
||||||
"vuetify": "^4.0.0"
|
"vuetify": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { 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'
|
||||||
@@ -48,6 +49,10 @@ const {
|
|||||||
const showCc = ref(false)
|
const showCc = ref(false)
|
||||||
const showBcc = ref(false)
|
const showBcc = 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,
|
||||||
@@ -157,17 +162,55 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleClose = async () => {
|
const handleClose = () => {
|
||||||
await compositionStore.closeDraft()
|
showCloseDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
closeAction.value = 'save'
|
||||||
|
try {
|
||||||
|
if (await compositionStore.saveDraft()) {
|
||||||
|
showCloseDialog.value = false
|
||||||
emit('close')
|
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) => {
|
||||||
@@ -215,7 +258,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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -264,9 +309,50 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
@bullet-list="toggleBulletList"
|
@bullet-list="toggleBulletList"
|
||||||
@ordered-list="toggleOrderedList"
|
@ordered-list="toggleOrderedList"
|
||||||
@link="toggleLink"
|
@link="toggleLink"
|
||||||
@attach="handleAttach"
|
@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>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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: {
|
||||||
@@ -41,7 +42,8 @@ const emit = defineEmits<{
|
|||||||
bulletList: []
|
bulletList: []
|
||||||
orderedList: []
|
orderedList: []
|
||||||
link: []
|
link: []
|
||||||
attach: [files: File[]]
|
attachFromDevice: [files: File[]]
|
||||||
|
attachFromDocuments: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
@@ -54,7 +56,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 = ''
|
||||||
@@ -142,6 +144,11 @@ 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 />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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', () => {
|
||||||
@@ -122,10 +124,6 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
stageDraft()
|
stageDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDraft() {
|
|
||||||
return discardDraft()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stageDraft() {
|
async function stageDraft() {
|
||||||
if (!activeDraft.value) {
|
if (!activeDraft.value) {
|
||||||
return null
|
return null
|
||||||
@@ -188,16 +186,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 +329,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 +343,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 +380,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) {
|
||||||
@@ -349,6 +472,9 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,23 +544,21 @@ 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): 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)
|
||||||
const freshAttachments = buildAttachments(composition, mode, source)
|
const freshAttachments = buildAttachments(composition, mode, source)
|
||||||
@@ -452,10 +576,7 @@ function buildDraft(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessage(
|
function buildMessage(mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined): ComposerDraftMessage {
|
||||||
mode: ComposerMode,
|
|
||||||
source: EntityObject | MessageAddressObject | null | undefined,
|
|
||||||
): ComposerDraftMessage {
|
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return emptyMessage()
|
return emptyMessage()
|
||||||
@@ -521,7 +642,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,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export enum ComposerMode {
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user