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,