From 684d50062cd38e27ab3d6c173c83e813a811d946 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 18 Aug 2026 22:30:42 -0400 Subject: [PATCH] feat: implement save mechanisim Signed-off-by: Sebastian --- lib/CompositionManager.php | 89 ++++++++++++++- lib/Controllers/CompositionController.php | 16 +++ src/components/MessageComposer.vue | 65 ++++++++++- src/components/composer/ComposerToolbar.vue | 15 ++- src/services/compositionService.ts | 6 + src/stores/mailCompositionStore.ts | 69 ++++++++++-- src/types/composition.ts | 11 ++ .../Unit/CompositionSynchronizationTest.php | 103 ++++++++++++++++++ 8 files changed, 359 insertions(+), 15 deletions(-) diff --git a/lib/CompositionManager.php b/lib/CompositionManager.php index c69badc..ea9a3b3 100644 --- a/lib/CompositionManager.php +++ b/lib/CompositionManager.php @@ -182,11 +182,92 @@ class CompositionManager { 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 [ - 'identifier' => $identifier, - 'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier), - ]; + $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 { diff --git a/lib/Controllers/CompositionController.php b/lib/Controllers/CompositionController.php index c056bd4..073750a 100644 --- a/lib/Controllers/CompositionController.php +++ b/lib/Controllers/CompositionController.php @@ -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'); diff --git a/src/components/MessageComposer.vue b/src/components/MessageComposer.vue index 90efb7c..c92e1e8 100644 --- a/src/components/MessageComposer.vue +++ b/src/components/MessageComposer.vue @@ -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" /> + + + + Save this draft? + + Save the draft to your mail server, or discard it permanently. + + + + + Cancel + + + Discard + + + Save and close + + + + diff --git a/src/components/composer/ComposerToolbar.vue b/src/components/composer/ComposerToolbar.vue index f0010a1..1279639 100644 --- a/src/components/composer/ComposerToolbar.vue +++ b/src/components/composer/ComposerToolbar.vue @@ -6,12 +6,14 @@ interface Props { status: string canSend: boolean sending: boolean + saving: boolean } defineProps() defineEmits<{ close: [] + save: [] send: [] }>() @@ -37,9 +39,20 @@ defineEmits<{ {{ status }} + + Save + + ('patch', request) }, + async save(request: CompositionSaveRequest): Promise { + return await post('save', request) + }, + async discard(request: CompositionDiscardRequest): Promise { return await post('discard', request) }, diff --git a/src/stores/mailCompositionStore.ts b/src/stores/mailCompositionStore.ts index 080f9db..17ea95d 100644 --- a/src/stores/mailCompositionStore.ts +++ b/src/stores/mailCompositionStore.ts @@ -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, diff --git a/src/types/composition.ts b/src/types/composition.ts index b310df3..c311fa2 100644 --- a/src/types/composition.ts +++ b/src/types/composition.ts @@ -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 } diff --git a/tests/php/Unit/CompositionSynchronizationTest.php b/tests/php/Unit/CompositionSynchronizationTest.php index 05c4cb6..76daa8f 100644 --- a/tests/php/Unit/CompositionSynchronizationTest.php +++ b/tests/php/Unit/CompositionSynchronizationTest.php @@ -154,6 +154,109 @@ final class CompositionSynchronizationTest extends TestCase { $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,