Compare commits
5 Commits
8aab394672
...
e24d08595d
| Author | SHA1 | Date | |
|---|---|---|---|
| e24d08595d | |||
| 684d50062c | |||
| 67024b5bfd | |||
| bd5965f600 | |||
| 8fb3086792 |
+432
-99
@@ -6,13 +6,19 @@ namespace KTXM\Mail;
|
||||
|
||||
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\AddressInterface;
|
||||
use KTXF\Mail\Object\MessagePart;
|
||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||
use KTXF\Utile\UUID;
|
||||
@@ -20,6 +26,8 @@ use KTXM\Mail\Stores\CompositionStore;
|
||||
use KTXM\MailManager\Manager as MailManager;
|
||||
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CompositionManager {
|
||||
|
||||
@@ -30,6 +38,7 @@ class CompositionManager {
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly CompositionStore $compositionStore,
|
||||
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 {
|
||||
@@ -46,11 +55,19 @@ class CompositionManager {
|
||||
'sender' => $sender,
|
||||
'message' => $message,
|
||||
'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);
|
||||
if ($sourceIndentifier === null) {
|
||||
throw new InvalidArgumentException('Invalid source identifier');
|
||||
@@ -96,73 +113,300 @@ class CompositionManager {
|
||||
$meta['size'] = $attachment->getSize() ?? $meta['size'];
|
||||
$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 {
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
$result = [];
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$patchComposition = function (?array $composed) use ($data, $identifier, &$result): ?array {
|
||||
if ($composed === null) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
];
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($composed === null) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'revision_mismatch',
|
||||
'message' => 'Revision mismatch',
|
||||
'currentRevision' => $composed['revision'],
|
||||
];
|
||||
return null;
|
||||
}
|
||||
|
||||
$composed['revision'] = $data['revision'];
|
||||
$composed['sender'] = $data['sender'] ?? [];
|
||||
$composed['message'] = $data['message'] ?? [];
|
||||
$composed = $this->markRemoteDirty($composed);
|
||||
$result = [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'patched',
|
||||
];
|
||||
return $result;
|
||||
|
||||
return $composed;
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$patchComposition,
|
||||
);
|
||||
if (($result['disposition'] ?? null) === 'patched' && $composed !== null) {
|
||||
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)($composed['revision'] ?? 0));
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'revision_mismatch',
|
||||
'message' => 'Revision mismatch',
|
||||
'currentRevision' => $composed['revision'],
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Apply the patch data to the composed message
|
||||
$composed['revision'] = $data['revision'];
|
||||
$composed['sender'] = $data['sender'] ?? [];
|
||||
$composed['message'] = $data['message'] ?? [];
|
||||
|
||||
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
|
||||
|
||||
$result = [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'patched',
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
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 {
|
||||
return [
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
if ($composed === null) {
|
||||
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||
}
|
||||
|
||||
try {
|
||||
$remoteEntity = $this->remoteEntityIdentifier($composed['remote']['entity'] ?? null);
|
||||
if ($remoteEntity !== null) {
|
||||
$outcomes = $this->mailManager->entityDelete($tenantId, $userId, $remoteEntity);
|
||||
$outcome = $outcomes[(string)$remoteEntity] ?? reset($outcomes);
|
||||
if (!is_array($outcome) || ($outcome['disposition'] ?? 'error') === 'error') {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_discard_failed',
|
||||
'message' => is_array($outcome)
|
||||
? (string)($outcome['error'] ?? 'Remote draft could not be discarded')
|
||||
: 'Remote draft could not be discarded',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
return [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_discard_failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$this->compositionStore->compositionDiscard($tenantId, $userId, $identifier);
|
||||
return ['identifier' => $identifier, 'disposition' => 'discarded'];
|
||||
}
|
||||
|
||||
public function synchronize(string $tenantId, string $userId, string $identifier): array {
|
||||
$result = [
|
||||
'identifier' => $identifier,
|
||||
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier),
|
||||
'disposition' => 'staged',
|
||||
];
|
||||
|
||||
// Construct the synchronization operation that will run while the composition is locked.
|
||||
$synchronizeComposition = function (?array $composed) use ($tenantId, $userId, $identifier, &$result): ?array {
|
||||
if ($composed === null) {
|
||||
$result['error'] = 'Composition not found';
|
||||
return null;
|
||||
}
|
||||
|
||||
$remote = isset($composed['remote']) && is_array($composed['remote'])
|
||||
? $composed['remote']
|
||||
: ['status' => 'dirty', 'entity' => null, 'error' => null];
|
||||
|
||||
if (($remote['status'] ?? 'dirty') === 'synced') {
|
||||
$result['disposition'] = 'saved';
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$sender = isset($composed['sender']) && is_array($composed['sender'])
|
||||
? $composed['sender']
|
||||
: [];
|
||||
$senderAddress = (string)($sender['address'] ?? '');
|
||||
if ($senderAddress === '' || !filter_var($senderAddress, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Composition sender is invalid');
|
||||
}
|
||||
|
||||
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
|
||||
if ($service === null || $service->getEnabled() === false) {
|
||||
throw new RuntimeException("No enabled mail service handles '$senderAddress'");
|
||||
}
|
||||
if (!$service instanceof ServiceEntityMutableInterface) {
|
||||
throw new RuntimeException("Mail service '{$service->identifier()}' does not support draft mutations");
|
||||
}
|
||||
|
||||
$target = $this->resolveDraftCollection($service);
|
||||
$attachments = isset($composed['attachments']) && is_array($composed['attachments'])
|
||||
? array_values($composed['attachments'])
|
||||
: [];
|
||||
$properties = $this->buildMessageProperties(
|
||||
service: $service,
|
||||
sender: Address::fromArray($sender),
|
||||
message: is_array($composed['message'] ?? null) ? $composed['message'] : [],
|
||||
attachments: $attachments,
|
||||
composed: $composed,
|
||||
tenantId: $tenantId,
|
||||
userId: $userId,
|
||||
compositionId: $identifier,
|
||||
);
|
||||
$properties->setFlag('draft', true);
|
||||
$properties->setFlag('seen', true);
|
||||
|
||||
$remoteEntity = $this->remoteEntityIdentifier($remote['entity'] ?? null);
|
||||
if ($remoteEntity === null) {
|
||||
$entity = $this->mailManager->entityCreate($tenantId, $userId, $target, $properties);
|
||||
} else {
|
||||
if ($remoteEntity->provider() !== $service->provider()
|
||||
|| (string)$remoteEntity->service() !== (string)$service->identifier()) {
|
||||
throw new RuntimeException('Changing the draft service is not supported yet');
|
||||
}
|
||||
$entity = $this->mailManager->entityModify($tenantId, $userId, $remoteEntity, $properties);
|
||||
}
|
||||
|
||||
if ($entity->collection() === null || $entity->identifier() === null) {
|
||||
throw new RuntimeException('Provider returned an incomplete draft identifier');
|
||||
}
|
||||
|
||||
$composed['remote'] = [
|
||||
...$remote,
|
||||
'status' => 'synced',
|
||||
'entity' => (string)new EntityIdentifier(
|
||||
$entity->provider(),
|
||||
(string)$entity->service(),
|
||||
(string)$entity->collection(),
|
||||
(string)$entity->identifier(),
|
||||
),
|
||||
'error' => null,
|
||||
];
|
||||
$result['disposition'] = 'saved';
|
||||
} catch (Throwable $throwable) {
|
||||
$composed['remote'] = [
|
||||
...$remote,
|
||||
'status' => 'failed',
|
||||
'entity' => $remote['entity'] ?? null,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
$result['error'] = $throwable->getMessage();
|
||||
}
|
||||
|
||||
return $composed;
|
||||
};
|
||||
|
||||
// Synchronize and atomically persist the resulting remote state.
|
||||
$this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$synchronizeComposition,
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
||||
// Construct the snapshot mutation that will run while the composition is locked.
|
||||
$saveComposition = function (?array $composed) use ($sender, $message): ?array {
|
||||
if ($composed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$composed['sender'] = $sender;
|
||||
$composed['message'] = $message;
|
||||
return $this->markRemoteDirty($composed);
|
||||
};
|
||||
|
||||
// Execute the mutation and atomically persist the snapshot it returns.
|
||||
$composed = $this->compositionStore->compositionSave(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$identifier,
|
||||
$saveComposition,
|
||||
);
|
||||
|
||||
if ($composed === null) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
return [
|
||||
'disposition' => 'error',
|
||||
'error' => [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
],
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// store the message and sender information before sending
|
||||
$composed['sender'] = $sender;
|
||||
$composed['message'] = $message;
|
||||
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
|
||||
|
||||
// validate the sender address before attempting to send the message
|
||||
if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) {
|
||||
$result['disposition'] = 'error';
|
||||
@@ -228,7 +472,7 @@ class CompositionManager {
|
||||
}
|
||||
|
||||
private function buildMessageProperties(
|
||||
ServiceEntitySubmitInterface $service,
|
||||
ServiceEntitySubmitInterface|ServiceEntityMutableInterface $service,
|
||||
AddressInterface $sender,
|
||||
array $message,
|
||||
array $attachments,
|
||||
@@ -306,38 +550,57 @@ class CompositionManager {
|
||||
'composition' => $composition,
|
||||
'attachments' => [],
|
||||
];
|
||||
// fetch the composition to ensure it exists
|
||||
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
|
||||
$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 = $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) {
|
||||
$result['disposition'] = 'error';
|
||||
$result['error'] = [
|
||||
'type' => 'composition_not_found',
|
||||
'message' => 'Composition not found',
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
// separate attachments by type
|
||||
$uploads = [];
|
||||
$documents = [];
|
||||
foreach ($attachments as $attachment) {
|
||||
if (isset($attachment['origin']) && $attachment['origin'] === 'documents') {
|
||||
$documents[] = $attachment;
|
||||
} elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') {
|
||||
$uploads[] = $attachment;
|
||||
}
|
||||
}
|
||||
// process uploaded attachments
|
||||
if ($uploads !== []) {
|
||||
$this->attachmentAddFromDevice($tenantId, $userId, $composition, $uploads, $composed);
|
||||
}
|
||||
// process attachments from the documents module
|
||||
if ($documents !== []) {
|
||||
$failed = $documents !== []
|
||||
? $this->attachmentAddFromDocuments($tenantId, $userId, $composition, $documents, $composed)
|
||||
: [];
|
||||
}
|
||||
// save the updated composition with new attachments
|
||||
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
|
||||
|
||||
// construct the result
|
||||
$result['attachments'] = $composed['attachments'];
|
||||
$result['failed'] = $failed;
|
||||
@@ -352,6 +615,10 @@ class CompositionManager {
|
||||
'message' => 'None of the requested attachments could be added',
|
||||
];
|
||||
}
|
||||
if ($compositionChanged) {
|
||||
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -486,32 +753,98 @@ class CompositionManager {
|
||||
'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;
|
||||
|
||||
// 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 = $this->markRemoteDirty($composed);
|
||||
$result['disposition'] = 'removed';
|
||||
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);
|
||||
}
|
||||
|
||||
unset($composed['attachments'][$identifier]);
|
||||
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
|
||||
|
||||
$result['disposition'] = 'removed';
|
||||
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
|
||||
* @return array<int,Address>
|
||||
@@ -555,4 +888,4 @@ class CompositionManager {
|
||||
}
|
||||
fclose($resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ final class CompositionController extends ControllerAbstract {
|
||||
$result = match ($operation) {
|
||||
'stage' => $this->stage($tenantId, $userId, $data),
|
||||
'patch' => $this->patch($tenantId, $userId, $data),
|
||||
'save' => $this->save($tenantId, $userId, $data),
|
||||
'discard' => $this->discard($tenantId, $userId, $data),
|
||||
'send' => $this->send($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);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') {
|
||||
throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string');
|
||||
|
||||
+16
-2
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace KTXM\Mail;
|
||||
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
@@ -11,8 +13,20 @@ use KTXF\Module\ModuleInstanceAbstract;
|
||||
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
|
||||
{
|
||||
|
||||
+113
-25
@@ -6,6 +6,8 @@ namespace KTXM\Mail\Stores;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class CompositionStore {
|
||||
|
||||
@@ -20,38 +22,118 @@ final class CompositionStore {
|
||||
}
|
||||
|
||||
public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME;
|
||||
if (!file_exists($messagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($messagePath), true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_SH,
|
||||
fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
|
||||
);
|
||||
}
|
||||
|
||||
public function compositionSave(string $tenantId, string $userId, string $draftId, array $snapshot): array {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
mkdir($draftDir, 0755, true);
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
if (!is_array($snapshot)) {
|
||||
throw new UnexpectedValueException('Composition save callback must return an array or null');
|
||||
}
|
||||
|
||||
file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return $snapshot;
|
||||
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
$this->ensureDirectory($draftDir);
|
||||
|
||||
$temporaryPath = tempnam($draftDir, '.composition-');
|
||||
if ($temporaryPath === false) {
|
||||
throw new RuntimeException("Unable to create a temporary composition file for '$draftId'");
|
||||
}
|
||||
|
||||
try {
|
||||
$written = file_put_contents($temporaryPath, $json);
|
||||
if ($written !== strlen($json)) {
|
||||
throw new RuntimeException("Unable to write composition '$draftId'");
|
||||
}
|
||||
if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) {
|
||||
throw new RuntimeException("Unable to replace composition '$draftId'");
|
||||
}
|
||||
} finally {
|
||||
if (is_file($temporaryPath)) {
|
||||
unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return false;
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_EX,
|
||||
function () use ($tenantId, $userId, $draftId): bool {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->deleteDir($draftDir);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function compositionRead(string $tenantId, string $userId, string $draftId): ?array {
|
||||
$messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME;
|
||||
if (!is_file($messagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->deleteDir($draftDir);
|
||||
return true;
|
||||
$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 {
|
||||
@@ -112,6 +194,12 @@ final class CompositionStore {
|
||||
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 {
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
@@ -133,4 +221,4 @@ final class CompositionStore {
|
||||
|
||||
rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+198
-198
@@ -795,49 +795,49 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.0.tgz",
|
||||
"integrity": "sha512-A/lrhKpOYtl0V5pmPS00Zps8pgBe1qDOoD9fzsumDSZ3HP8W398C959Jgru75PNFokAya9COPD3iaKP3NWF25g==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.2.tgz",
|
||||
"integrity": "sha512-QbZC/s1OOqcoUdkhIY16TjR/gCtR0qAk9e4bJwUqOJqZuv5ozqCL5hzWm22jjTPp6c6Ei2tPd6t30VwfIKW4lQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.0.tgz",
|
||||
"integrity": "sha512-k8NKHLEYOqre7guObZBeFM04CaTwmEceCmdIrIjd7H9KoHSbRODaBNved6j5G33x/FWMwIF/RQbE2BjWaFSgzA==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.30.2.tgz",
|
||||
"integrity": "sha512-BOkwhZenek7vzXBOgKppSrlx4YryBdAYu1p1MXKn0R9A9eNmE2HVhmm0gG49+E8BhsE/TG8wKVclwET42JJiIg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.0.tgz",
|
||||
"integrity": "sha512-BPUJvJ9sCsU3fxao5UJfDJrDuHhMn2hczZwZ9Qs4w1vkAeLGyOYbSejUTJdBYHLoexGiEwgnEeVeZN3C8GaI5g==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.30.2.tgz",
|
||||
"integrity": "sha512-MsvJhPgYejY2D9MhwYJv8AmscozvLBI8qtJ7YLdYZBWkMR4bgmxHq5+xqEfBsao9bOMMwBon9p3+P+/Tq5ReWA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.29.0.tgz",
|
||||
"integrity": "sha512-aBNI8ebX76ObderRmu/PJ9Q2AGVEuhpg0TBvv2SNbHtmU5kNUMHQI8HxrxKFblJwETAmG6EJ3Ed/5jRxpJmrsg==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.2.tgz",
|
||||
"integrity": "sha512-oS0WiWNXHKpiPYMkcnHm1j7iEvufTGGtLFtcJJd3olb5OS6V2acoXVDL0nNJDmRQ32K3QXut3fbRcMseMxT+lw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -848,67 +848,80 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.2.tgz",
|
||||
"integrity": "sha512-+awIL/TUz4aB3rL68igU1rWfaaoBIAkPcakkktkRq8gYf0bd9eSb48P6kHkpx/3q+JyK7g9vsnltLNHNh6twnA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.0.tgz",
|
||||
"integrity": "sha512-Um0BlyunRJ8Fal288Jn7I2n15FRwQcTy3NnKBBrPz3ikwx+yZZ0n9xTozpLMSxXrTq6cRlxLkASp/9FFx9sX9A==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.30.2.tgz",
|
||||
"integrity": "sha512-r8EZk3R9yGpF6v5xxafAU1HwrD/e+RpbfnmVi2TeB/ZHAsVO62fW96E32G0t6IdaCtOFtAd85hkDAaOfCT4yGg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.0.tgz",
|
||||
"integrity": "sha512-nKm1YSEiPVosjm4qeg6m+6SAKq82rKukAzE/g3M5eVs2OgEb4QIKQvQOmJ+zgGAnpb9Q/JIvcKsGGU1rn9Vnyw==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.2.tgz",
|
||||
"integrity": "sha512-9otGKaQZmePHrLXFtCtz+BYDn5z4sSumTkUqQIQHz0gVxwPoTi7g51RedwxvViTb/zu2XV5ROXYLHIxKxypMPg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.0.tgz",
|
||||
"integrity": "sha512-folTlrwmUL+WSnojvfjZ951Gb25Pw8BxjXwhvXFuZXJ92qMfOcezA480kwFuu5nG8pPc/76ezQlz9a86Y5uQJw==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.2.tgz",
|
||||
"integrity": "sha512-+xIv67V+/2L1uvz98FAT5W7kWEfHwfNV3MD7b4UsKPU0lhcCWuVOXy0JB8yYmdNExqpI7xT9g3MWzREoBvBQSg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.0.tgz",
|
||||
"integrity": "sha512-IAY21q7KSXyCXN3u13kfpq0E8D2i3r9nSbsNYlLzkEv0Bpn+pWShZi05A0ubuothDNjdaMa96xaRY0f1y9gDZw==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.2.tgz",
|
||||
"integrity": "sha512-nyRKUmItATnKI9AiRChmjhcBbCEsNxRu+AaCz+cx8EvnAcNHsVRdNYL5PmBs3WlNA/Et4Eb2DG0hVQDnNF61eg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.29.0"
|
||||
"@tiptap/extensions": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.29.0.tgz",
|
||||
"integrity": "sha512-/rFlN6T70HuT34kFvftKm56UX0IPff7vlII7Sqgs7DprKVYkItiU3lM7+AoeDRzCsWMg4z1yNHj70BPx7+EJ6Q==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.2.tgz",
|
||||
"integrity": "sha512-A8PLvvh8W6PUMrqh+EpBerxm+Ucr0irGxJvwAnzYQmNGNIJ9U4OVgw4OcEU+9JH0gMmEzDcHzMPP2s/s1lIcyw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
@@ -917,80 +930,80 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.0.tgz",
|
||||
"integrity": "sha512-Jz0zwriGxPMuRjKo6x7WhPa/7pZn0dovyJu/GLarN4T+mMseGNXWwEBsLEKkuumsJVPDEPPFjX+rzEeaFKInPQ==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.2.tgz",
|
||||
"integrity": "sha512-7Xk0ut6FM+RAsvKxDN3bAtk7zvYZ6Aa8pawJ6s7dLAmLR9JwrZevlcL4FSrj4bR7rqKOj92RYCFxzgTEbWimag==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.29.0"
|
||||
"@tiptap/extensions": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.0.tgz",
|
||||
"integrity": "sha512-8qKj0oeuU7IYoG73Lj3W4EC/JS/dFjN5iHmc4yodqppP6qs0o2zsF/2ipceGN/REZq5megPtxRgXu/nxQZ1NYg==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.30.2.tgz",
|
||||
"integrity": "sha512-IxSNgmG3d4OZdUTeebrOI7SxdIWXXJqlcGiSNDabWqxipUitfy3mZ3gDDE6G01koKxZRbhz4KIplAZlpxnTFSg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.0.tgz",
|
||||
"integrity": "sha512-xWb3QEKo7cp9u6Vzi6oYKODra6jPwziHBv3fJAnxWpRldxtJqfyOVMikWJJFuDjmr+K0qyegzf206pMk+y0bDg==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.30.2.tgz",
|
||||
"integrity": "sha512-PblDvgSJ05p1t6hzyPi02xeiBjB0M2abReoGEImqSWCy79UqnAGacgsZo4EeEawtJV1NEP8chhvmX+nRtzdT1A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.0.tgz",
|
||||
"integrity": "sha512-u1OgncXkokIuUJQIh4jVQfYHL/6I8VnrokUcCud4eErWvgeLnCbNmVdih4kxIdfmvEyAxXjDwcqf4AB79TxuNg==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.2.tgz",
|
||||
"integrity": "sha512-j8aswLTsuEdJKC62DF+kw0EgvIRL7QMUyAVp2fdjR0qgM0ZVlEwCC4qIEq3kK9tFVU4kRtQ5BSj/jn6QwrlbCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.0.tgz",
|
||||
"integrity": "sha512-c8gF+zM7yXB9iGxtVCioYecjQ+A2fLlNa1XYBYbTobNhbUo8a11akbTkGNcCIgttwnzwEoI4mB6/PK4yjaQvVg==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.30.2.tgz",
|
||||
"integrity": "sha512-pp8uaiuXsUbLm5rYzR1jlWbwm1mAahRajdHwAKBtthFRB2rDvC7ZWhKaCSoKhZvfIDRmu9/B67+uAHoutL0dCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.0.tgz",
|
||||
"integrity": "sha512-FuLDgE0k1dB4WCBTelw3re9BLmiurgXVSj3d3J4l3sunljyOs941RUU62b7pWP+ePNdGF7RgUMmMAB+58VZCIA==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.30.2.tgz",
|
||||
"integrity": "sha512-jwdcymKcrbFpj5hRAuGVLCq8FieVkGFnENyroYmvkad+XAt8ZLy/MTFYRN6SK3ukH6PZMY7H4iObGtciQaC5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
@@ -1000,106 +1013,159 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.2.tgz",
|
||||
"integrity": "sha512-MIUpo1Bd9Rf1Qg+TNYNwDZ4xsfFeQahjU9Xhy6UcaszKQzbAM7KCzn5BObNytK1NdcqNHsC8Wj5vFvMKEzrXdw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.2.tgz",
|
||||
"integrity": "sha512-HWgRCRlGxulE+hN1VUcnWD6P2NE08VBgGtcaxOfdXVqaI93BCK6AhRQZGpLsfKgajLk+5DXTBraaitwnBqzCxg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.2.tgz",
|
||||
"integrity": "sha512-TTve3WOlQaYu1ahMqsQ/T0wzaxfgZvcOl3/OuPyInOi8QtxXhqGhFjmYe5jOr56G9W2QDuFWVsZecVwfDte9zg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.2.tgz",
|
||||
"integrity": "sha512-Z7OO1HcF0idda1n6vodXeQ3h2ylN9JR4IfIGUYkar5Xl9JusK8PDETTBQZQn//96p49I2d+GoWsD2LXPtjHXXg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.0.tgz",
|
||||
"integrity": "sha512-OtGrkwzqlW+ehW+d2qrQ7VVcP9PbDeQmuT4Ec4yFjf3vR4QO8OVdVfeOcUq52PtkT3kYRxz9fzEiEvjqRm72yA==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.30.2.tgz",
|
||||
"integrity": "sha512-ulEu3LNt+kPVAWEnrhoz13Fs8Q/v/8NUxQbAeteuBchQ8joxJXuWExhpy1fUfZir5+b+W5z7/NesgPjZQfv47w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.29.0.tgz",
|
||||
"integrity": "sha512-V2jb0kL/k3rygAtKmSPYxoyBMCQJtJikPYnxiCYuY7FI1ohmCrmhyhkVqJv0b0EPMNW8BMIqf6DgpKJ/ODmXgA==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.30.2.tgz",
|
||||
"integrity": "sha512-Bj1seUvPCoRrD/LpzMoKD+jQIjxuc+oq931GpPq4wobSUUbD4pF/0NMwpCLpiHO19QnTQz8+9p2dqPdlc44LHA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.29.0"
|
||||
"@tiptap/extensions": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.0.tgz",
|
||||
"integrity": "sha512-VzasZkckrEXzmFQT9z3O95rUWy1b0RLFHAC6GUSQHxKk4TpP+Ujfn5+rqmPukMn0eScJyw1Y36jwfJc0VuNR+Q==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.30.2.tgz",
|
||||
"integrity": "sha512-fBLxMXz6hYIURzLOD+/L6aVATztsKham00ANWmGi13vN0hx2lQMYZffN+gR+QqiCDfMQxBXzrf5a7tJuDiQHLQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.0.tgz",
|
||||
"integrity": "sha512-AMsf2V7IiGvzbl+LqLLfyWzufazZxHuid1gOnEt3KwolQ9AO7p8WJgHld0PZLxNyEh4SaSqK7b3d3SM9sqrlTw==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.2.tgz",
|
||||
"integrity": "sha512-n/iZnirgRmXet6f97kolAnP3j8DsgLSiTbz/KLWc8eBYiFmkjRzkuisOm5xuGdfGIxwpB4x3tlSF4ef4DLnbRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.29.0.tgz",
|
||||
"integrity": "sha512-/1htV3teqRJ7i7trAuORDg62LV0CQ1qskbMcP3SB7grsw6H1G54zwdaJVXeiIXmQKDF7FoZgY7u/v/TJckthEw==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.30.2.tgz",
|
||||
"integrity": "sha512-zexiz0uJlX2KzeAyYI/uEoBsl0Zw3Ua0CTV/kRKTnk7K9vCOnwvJXtXdkHA4WC12ACqUsPnyc4yMTwDR22fKFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.0.tgz",
|
||||
"integrity": "sha512-DSPTogdvxmoX/L0U5KgBC9jWffRvYkGiyh1tp28gn+QaARh4iJn79pAZpi1m1bEhal/0Mq5m4ukbiO7FWInwaA==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.30.2.tgz",
|
||||
"integrity": "sha512-SZiTMnvqXcnrtJX+X25ZbYsuDO83haGOVMBD/O+mAWYNYXhaSc5Rkph5czzItxrd+Yyp/vs4PiwD7XTNbfqmpA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0"
|
||||
"@tiptap/core": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.0.tgz",
|
||||
"integrity": "sha512-ltlrm8dDHIgeNj3cOLEdLFMPPVy3TYvWA8ftrrJ44C/L01MBmgFB1f/vkPFYYnAasb2BYyVG6HxAGcTQHo5jHw==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.2.tgz",
|
||||
"integrity": "sha512-2LqAHXk26QDsryW+beECxYeBzv5Ylk4GuB3cOmfghS7/G37R2W+Te3TkUK7BT0EWoDryvBT57/5q0DEFIhfZZg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.0.tgz",
|
||||
"integrity": "sha512-4rr3HiZ8kbSNINuWXqKQJLv9fFMypCBN5gOwxoU+D4lEYVblkoM60fA1SlrI2BZVlZQmw2U38XKl5XMtUr55XQ==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.2.tgz",
|
||||
"integrity": "sha512-BJN8tUx4ppFN3R3cV/FJfrJbJkvo1lj4uciq+nwpjwzdRvFzqIuglWf+HLcJ6CwlYpLOHp7ArgkBg4Q5e60Gog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
@@ -1122,124 +1188,58 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.0.tgz",
|
||||
"integrity": "sha512-J3jTp3/WXnnL58TCtdMsfeQ2BeycFYrAib6Nbpjd9G0OLUBE7AILiH2HrobRQpvRbAUgD5SW+sBZYOonbWWqNg==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.30.2.tgz",
|
||||
"integrity": "sha512-fJSrhW1CyD4sjYA20evSP4Cp13B/HhbxCdM974K0xpHOVqvCtNU9w2s9hfq9mg2yGoU7MSNHKNYMkJjIi2/Xyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.29.0",
|
||||
"@tiptap/extension-blockquote": "^3.29.0",
|
||||
"@tiptap/extension-bold": "^3.29.0",
|
||||
"@tiptap/extension-bullet-list": "^3.29.0",
|
||||
"@tiptap/extension-code": "^3.29.0",
|
||||
"@tiptap/extension-code-block": "^3.29.0",
|
||||
"@tiptap/extension-document": "^3.29.0",
|
||||
"@tiptap/extension-dropcursor": "^3.29.0",
|
||||
"@tiptap/extension-gapcursor": "^3.29.0",
|
||||
"@tiptap/extension-hard-break": "^3.29.0",
|
||||
"@tiptap/extension-heading": "^3.29.0",
|
||||
"@tiptap/extension-horizontal-rule": "^3.29.0",
|
||||
"@tiptap/extension-italic": "^3.29.0",
|
||||
"@tiptap/extension-link": "^3.29.0",
|
||||
"@tiptap/extension-list": "^3.29.0",
|
||||
"@tiptap/extension-list-item": "^3.29.0",
|
||||
"@tiptap/extension-list-keymap": "^3.29.0",
|
||||
"@tiptap/extension-ordered-list": "^3.29.0",
|
||||
"@tiptap/extension-paragraph": "^3.29.0",
|
||||
"@tiptap/extension-strike": "^3.29.0",
|
||||
"@tiptap/extension-text": "^3.29.0",
|
||||
"@tiptap/extension-underline": "^3.29.0",
|
||||
"@tiptap/extensions": "^3.29.0",
|
||||
"@tiptap/pm": "^3.29.0"
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/extension-blockquote": "3.30.2",
|
||||
"@tiptap/extension-bold": "3.30.2",
|
||||
"@tiptap/extension-bullet-list": "3.30.2",
|
||||
"@tiptap/extension-code": "3.30.2",
|
||||
"@tiptap/extension-code-block": "3.30.2",
|
||||
"@tiptap/extension-document": "3.30.2",
|
||||
"@tiptap/extension-dropcursor": "3.30.2",
|
||||
"@tiptap/extension-gapcursor": "3.30.2",
|
||||
"@tiptap/extension-hard-break": "3.30.2",
|
||||
"@tiptap/extension-heading": "3.30.2",
|
||||
"@tiptap/extension-horizontal-rule": "3.30.2",
|
||||
"@tiptap/extension-italic": "3.30.2",
|
||||
"@tiptap/extension-link": "3.30.2",
|
||||
"@tiptap/extension-list": "3.30.2",
|
||||
"@tiptap/extension-list-item": "3.30.2",
|
||||
"@tiptap/extension-list-keymap": "3.30.2",
|
||||
"@tiptap/extension-ordered-list": "3.30.2",
|
||||
"@tiptap/extension-paragraph": "3.30.2",
|
||||
"@tiptap/extension-strike": "3.30.2",
|
||||
"@tiptap/extension-text": "3.30.2",
|
||||
"@tiptap/extension-underline": "3.30.2",
|
||||
"@tiptap/extensions": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"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": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.29.0.tgz",
|
||||
"integrity": "sha512-OFESbEFlmDYPns5LrkyeKggis6tYICBvz+jrlVwxICCWJqWZJJvYb7y/uMtMCxFF1nAeGb8AFr8UhFrLaX5aiA==",
|
||||
"version": "3.30.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.30.2.tgz",
|
||||
"integrity": "sha512-7TCahsK19ChY60FvgseTuzaz67yR1tkzTbKW6s3iXx9NTiU411C6WEQAS/EtPt4dsGDv/esFjSc9EMKilRhY5g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.29.0",
|
||||
"@tiptap/extension-floating-menu": "^3.29.0"
|
||||
"@tiptap/extension-bubble-menu": "^3.30.2",
|
||||
"@tiptap/extension-floating-menu": "^3.30.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.29.0",
|
||||
"@tiptap/pm": "3.29.0",
|
||||
"@tiptap/core": "3.30.2",
|
||||
"@tiptap/pm": "3.30.2",
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,6 +50,8 @@ const showCc = ref(false)
|
||||
const showBcc = 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({
|
||||
@@ -160,9 +162,32 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleClose = async () => {
|
||||
await compositionStore.closeDraft()
|
||||
emit('close')
|
||||
const handleClose = () => {
|
||||
showCloseDialog.value = true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
closeAction.value = 'save'
|
||||
try {
|
||||
if (await compositionStore.saveDraft()) {
|
||||
showCloseDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
} finally {
|
||||
closeAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscard = async () => {
|
||||
closeAction.value = 'discard'
|
||||
try {
|
||||
if (await compositionStore.discardDraft()) {
|
||||
showCloseDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
} finally {
|
||||
closeAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
@@ -233,7 +258,9 @@ const isActive = (name: string, attrs?: any) => {
|
||||
:status="stageStatus"
|
||||
:can-send="canSend"
|
||||
:sending="sending"
|
||||
:saving="saving"
|
||||
@close="handleClose"
|
||||
@save="handleSave"
|
||||
@send="handleSend"
|
||||
/>
|
||||
|
||||
@@ -294,6 +321,38 @@ const isActive = (name: string, attrs?: any) => {
|
||||
: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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ interface Props {
|
||||
status: string
|
||||
canSend: boolean
|
||||
sending: boolean
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
send: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -37,9 +39,20 @@ defineEmits<{
|
||||
{{ status }}
|
||||
</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
|
||||
color="primary"
|
||||
:disabled="!canSend"
|
||||
:disabled="!canSend || saving"
|
||||
:loading="sending"
|
||||
prepend-icon="mdi-send"
|
||||
@click="$emit('send')"
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
CompositionDiscardResponse,
|
||||
CompositionPatchRequest,
|
||||
CompositionPatchResponse,
|
||||
CompositionSaveRequest,
|
||||
CompositionSaveResponse,
|
||||
CompositionSendRequest,
|
||||
CompositionSendResponse,
|
||||
CompositionStageRequest,
|
||||
@@ -50,6 +52,10 @@ const compositionService = {
|
||||
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> {
|
||||
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CompositionAttachmentInterface,
|
||||
CompositionAttachmentRemoveRequest,
|
||||
CompositionPatchRequest,
|
||||
CompositionSaveRequest,
|
||||
CompositionStageRequest,
|
||||
CompositionStageResponse,
|
||||
} from '@/types/composition'
|
||||
@@ -123,10 +124,6 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
stageDraft()
|
||||
}
|
||||
|
||||
function closeDraft() {
|
||||
return discardDraft()
|
||||
}
|
||||
|
||||
async function stageDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return null
|
||||
@@ -189,16 +186,73 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
|
||||
async function discardDraft() {
|
||||
if (!activeDraft.value) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = null
|
||||
}
|
||||
|
||||
const draftId = activeDraft.value.identifier
|
||||
saving.value = true
|
||||
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) {
|
||||
console.error('[Mail][Composer] Failed to discard staged draft:', error)
|
||||
mailUiStore.notify('Failed to discard draft', 'error')
|
||||
return false
|
||||
} 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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +533,8 @@ export const useMailCompositionStore = defineStore('mailCompositionStore', () =>
|
||||
stageStatus,
|
||||
senderIdentities,
|
||||
openDraft,
|
||||
closeDraft,
|
||||
saveDraft,
|
||||
discardDraft,
|
||||
sendDraft,
|
||||
updateSender,
|
||||
updateRecipients,
|
||||
|
||||
@@ -58,6 +58,17 @@ export interface CompositionPatchResponse {
|
||||
message: CompositionMessageInterface
|
||||
}
|
||||
|
||||
export interface CompositionSaveRequest extends CompositionPatchRequest {}
|
||||
|
||||
export interface CompositionSaveResponse {
|
||||
identifier: string
|
||||
disposition: 'saved' | 'error'
|
||||
error?: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompositionDiscardRequest {
|
||||
identifier: string
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ 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;
|
||||
@@ -52,6 +54,115 @@ final class CompositionManagerTest extends TestCase {
|
||||
$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);
|
||||
@@ -60,7 +171,12 @@ final class CompositionManagerTest extends TestCase {
|
||||
]);
|
||||
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
||||
|
||||
$manager = $this->makeManager($documentsManager);
|
||||
$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',
|
||||
@@ -78,6 +194,11 @@ final class CompositionManagerTest extends TestCase {
|
||||
$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('dirty', $snapshot['remote']['status']);
|
||||
$this->assertNull($snapshot['remote']['entity']);
|
||||
$this->assertNull($snapshot['remote']['error']);
|
||||
}
|
||||
|
||||
public function testAttachmentAddFromDocumentsPartialFailure(): void {
|
||||
@@ -164,7 +285,11 @@ final class CompositionManagerTest extends TestCase {
|
||||
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
|
||||
}
|
||||
|
||||
private function makeManager(Stub $documentsManager, bool $documentsModuleEnabled = true): CompositionManager {
|
||||
private function makeManager(
|
||||
Stub $documentsManager,
|
||||
bool $documentsModuleEnabled = true,
|
||||
?EventDispatcherInterface $events = null,
|
||||
): CompositionManager {
|
||||
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||
|
||||
$moduleManager = $this->createStub(ModuleManager::class);
|
||||
@@ -185,15 +310,23 @@ final class CompositionManagerTest extends TestCase {
|
||||
moduleManager: $moduleManager,
|
||||
compositionStore: $this->compositionStore,
|
||||
mailManager: $mailManager,
|
||||
events: $events ?? $this->createStub(EventDispatcherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function stageEmptyComposition(): string {
|
||||
$compositionId = 'draft-' . uniqid();
|
||||
$this->compositionStore->compositionSave(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||
$saveComposition = static fn(): array => [
|
||||
'identifier' => $compositionId,
|
||||
'attachments' => [],
|
||||
]);
|
||||
];
|
||||
|
||||
$this->compositionStore->compositionSave(
|
||||
self::TENANT_ID,
|
||||
self::USER_ID,
|
||||
$compositionId,
|
||||
$saveComposition,
|
||||
);
|
||||
return $compositionId;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,331 @@
|
||||
<?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\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,
|
||||
));
|
||||
}
|
||||
|
||||
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 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