import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { useServicesStore } from '@MailManager/stores/servicesStore' import { useMailUiStore } from './mailUiStore' import compositionService from '@/services/compositionService' import type { CompositionAttachmentAddRequest, CompositionAttachmentInterface, CompositionAttachmentRemoveRequest, CompositionPatchRequest, CompositionStageRequest, CompositionStageResponse, } from '@/types/composition' import { ComposerMode } from '@/types/composer' import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer' import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models' import type { MessageAddressInterface } from '@MailManager/types/message' export const useMailCompositionStore = defineStore('mailCompositionStore', () => { const servicesStore = useServicesStore() const mailUiStore = useMailUiStore() const activeDraft = ref(null) const saving = ref(false) let autoSaveTimer: ReturnType | null = null const stageStatus = computed(() => { if (!activeDraft.value) { return '' } if (saving.value) { return 'Saving...' } if (activeDraft.value.stageStatus === 'local') { return 'Local draft only' } if (!activeDraft.value.stagedAt) { return '' } const seconds = Math.floor((Date.now() - activeDraft.value.stagedAt.getTime()) / 1000) if (seconds < 60) { return 'Staged just now' } if (seconds < 3600) { return `Staged ${Math.floor(seconds / 60)} min ago` } return `Staged at ${activeDraft.value.stagedAt.toLocaleTimeString()}` }) const senderIdentities = computed(() => { const identities: ComposerSenderIdentity[] = [] servicesStore.servicesEnabled.forEach(service => { if (!service.capable('EntityTransmit') || service.identifier === null) { return } if (service.primaryAddress && !service.primaryAddress.empty) { identities.push({ service: service, address: service.primaryAddress.address, label: service.primaryAddress.label ?? null, }) } service.secondaryAddresses.forEach(addr => { identities.push({ service: service, address: addr.address, label: addr.label, }) }) }) return identities }) function openDraft(mode: ComposerMode, source?: EntityObject | MessageAddressObject | null) { let sender = null // find sending identity from source message recipients if possible if (source instanceof EntityObject) { const sourceMessage = source.properties const recipients = [...(sourceMessage.to || []), ...(sourceMessage.cc || []), ...(sourceMessage.bcc || [])] const matchingRecipient = recipients.find(recipient => { return senderIdentities.value.some(identity => identity.address === recipient.address) }) if (matchingRecipient) { sender = senderIdentities.value.find(identity => identity.address === matchingRecipient.address) ?? null } } // find sending identity from currently selected folder service if possible if (sender === null) { const serviceId = mailUiStore.selectedFolder?.service sender = senderIdentities.value.find(identity => serviceId === `${identity.service.provider}:${identity.service.identifier}` ) ?? null } // fallback to first available identity if (sender === null) { sender = senderIdentities.value[0] } if (sender === null) { return } activeDraft.value = buildDraft(mode, sender, source) activeDraft.value.action = mode stageDraft() } function closeDraft() { return discardDraft() } async function stageDraft() { if (!activeDraft.value) { return null } saving.value = true try { const request: CompositionStageRequest = { identifier: activeDraft.value.identifier, action: activeDraft.value.action, sender: { provider: activeDraft.value.sender.service.provider, service: activeDraft.value.sender.service.identifier, address: activeDraft.value.sender.address, name: activeDraft.value.sender.label || null, }, source: activeDraft.value.source, message: activeDraft.value.message, } const response = await compositionService.stage(request) applyStageResponse(response) } catch (error) { console.error('[Mail][Composer] Failed to stage draft:', error) throw error } finally { saving.value = false } } async function patchDraft() { if (!activeDraft.value) { return null } saving.value = true try { const request: CompositionPatchRequest = { identifier: activeDraft.value.identifier, revision: activeDraft.value.revision, sender: { provider: activeDraft.value.sender.service.provider, service: activeDraft.value.sender.service.identifier, address: activeDraft.value.sender.address, name: activeDraft.value.sender.label || null, }, message: activeDraft.value.message, } const response = await compositionService.patch(request) return response } catch (error) { console.error('[Mail][Composer] Failed to patch draft:', error) throw error } finally { saving.value = false } } async function discardDraft() { if (!activeDraft.value) { return } const draftId = activeDraft.value.identifier try { await compositionService.discard({ identifier: draftId }) } catch (error) { console.error('[Mail][Composer] Failed to discard staged draft:', error) } finally { activeDraft.value = null } } async function sendDraft() { if (!activeDraft.value) { return } try { const response = await compositionService.send({ identifier: activeDraft.value.identifier, revision: activeDraft.value.revision, sender: { provider: activeDraft.value.sender.service.provider, service: activeDraft.value.sender.service.identifier, address: activeDraft.value.sender.address, name: activeDraft.value.sender.label || null, }, message: activeDraft.value.message, attachments: activeDraft.value.attachments, }) if (response.disposition === 'error') { console.error('[Mail][Composer] Failed to send message:', response.error) mailUiStore.notify(`Failed to send message: ${response.error?.message || 'Unknown error'}`, 'error') return } activeDraft.value = null mailUiStore.notify('Message sent successfully', 'success') mailUiStore.closeComposer() } catch (error) { console.error('[Mail][Composer] Failed to send draft:', error) throw error } } function updateRecipients(field: 'to' | 'cc' | 'bcc', values: MessageAddressInterface[]) { if (!activeDraft.value) { return } activeDraft.value.message[field] = [...values] queueSave(false) } function updateSubject(subject: string) { if (!activeDraft.value) { return } activeDraft.value.message.subject = subject queueSave(false) } function updateBody(body: ComposerDraftMessage['body']) { if (!activeDraft.value) { return } activeDraft.value.message.body = { html: body.html, text: body.text, } queueSave(false) } function updateSender(sender: ComposerSenderIdentity) { if (!activeDraft.value) { return } activeDraft.value.sender = sender queueSave(false) } async function addAttachments(files: File[]) { if (!activeDraft.value || files.length === 0) { return } const composition = activeDraft.value.identifier const attachments = Object.fromEntries( await Promise.all(files.map(async file => { const identifier = createIdentifier() const data = await fileToBase64(file) return [identifier, { identifier, composition, origin: 'upload', name: file.name, type: file.type || 'application/octet-stream', size: file.size, source: null, data, } satisfies CompositionAttachmentInterface] })), ) const request: CompositionAttachmentAddRequest = { composition, attachments, } const response = await compositionService.attachmentAdd(request) if (!activeDraft.value || response.composition !== activeDraft.value.identifier) { return } if (response.disposition === 'error') { console.error('[Mail][Composer] Failed to add attachments:', response.error) return } Object.values(response.attachments).forEach(attachment => { activeDraft.value!.attachments[attachment.identifier] = { identifier: attachment.identifier, composition: attachment.composition, origin: attachment.origin, name: attachment.name, type: attachment.type, size: attachment.size, source: attachment.source, } }) } async function removeAttachment(identifier: string) { if (!activeDraft.value) { return } const attachment = activeDraft.value.attachments[identifier] if (!attachment) { return } const request: CompositionAttachmentRemoveRequest = { composition: attachment.composition, identifier: attachment.identifier, } const response = await compositionService.attachmentRemove(request) if (response.disposition === 'error') { console.error('[Mail][Composer] Failed to remove attachment:', response.error) } delete activeDraft.value.attachments[attachment.identifier] } function queueSave(immediate: boolean) { if (!activeDraft.value) { return } activeDraft.value.revision += 1 if (autoSaveTimer) { clearTimeout(autoSaveTimer) } autoSaveTimer = setTimeout(() => { void flushSave() }, immediate ? 0 : 15000) } async function flushSave() { if (autoSaveTimer) { clearTimeout(autoSaveTimer) autoSaveTimer = null } if (!activeDraft.value) { return } await patchDraft() } function applyStageResponse(response: CompositionStageResponse) { const draft = activeDraft.value if (!draft) { return } if (response.identifier !== draft.identifier) { console.warn('[Mail][Composer] Stage response identifier mismatch:', response.identifier, draft.identifier) return } if (response.revision < draft.revision) { console.warn('[Mail][Composer] Stage response revision is older than current draft:', response.revision, draft.revision) return } draft.attachments = {} Object.values(response.attachments).forEach(attachment => { draft.attachments[attachment.identifier] = { identifier: attachment.identifier, composition: attachment.composition, origin: attachment.origin, name: attachment.name, type: attachment.type, size: attachment.size, source: attachment.source, } }) draft.revision = response.revision draft.stageStatus = response.disposition draft.stagedAt = new Date() } return { activeDraft, saving, stageStatus, senderIdentities, openDraft, closeDraft, sendDraft, updateSender, updateRecipients, updateSubject, updateBody, addAttachments, removeAttachment, flushSave, } }) function buildDraft( mode: ComposerMode, sender: ComposerSenderIdentity, source?: EntityObject | MessageAddressObject | null, ): ComposerDraft { const composition = createIdentifier() const freshMessage = buildMessage(mode, source) const freshAttachments = buildAttachments(composition, mode, source) const sourceIdentifier = source instanceof EntityObject ? source.identifier : null return { action: mode, identifier: composition, revision: 1, sender: sender, source: sourceIdentifier, stageStatus: 'local', stagedAt: null, message: freshMessage, attachments: freshAttachments, } } function buildMessage( mode: ComposerMode, source: EntityObject | MessageAddressObject | null | undefined, ): ComposerDraftMessage { if (!source) { return emptyMessage() } if (mode === ComposerMode.Fresh) { const freshMessage = emptyMessage() if (source && source instanceof MessageAddressObject) { freshMessage.to = [{ address: source.address, label: source.label }] } return freshMessage } if (!(source instanceof EntityObject)) { return emptyMessage() } const sourceMessage = source.properties const originalSubject = sourceMessage.subject || '' const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || '' const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown' const sentAt = sourceMessage.sent || source.created || '' const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time' if (mode === ComposerMode.Reply) { const replyToAddr = sourceMessage.replyTo?.[0] ?? sourceMessage.from ?? null const replyToAddress = replyToAddr?.address || '' return { to: replyToAddress ? [{ address: replyToAddress, label: replyToAddr?.label }] : [], cc: [], bcc: [], subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`, body: { html: `


---------- Original message ---------

From: ${senderName}

Date: ${sentLabel}

Subject: ${originalSubject}

${originalBody}
`, text: '', }, } } return { to: [], cc: [], bcc: [], subject: /^Fwd:/i.test(originalSubject) ? originalSubject : `Fwd: ${originalSubject}`, body: { html: `


---------- Forwarded message ---------

From: ${senderName}

Date: ${sentLabel}

Subject: ${originalSubject}

${originalBody}
`, text: '', }, } } function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record { if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) { return {} } const sourceAttachments = source.properties.attachments || [] const attachments: Record = {} sourceAttachments.forEach(attachment => { const id = createIdentifier() attachments[id] = { identifier: id, composition: composition, origin: 'source', name: attachment.name || '', type: attachment.type || 'application/octet-stream', size: attachment.size || 0, source: source.identifier, } }) return attachments } function emptyMessage(): ComposerDraftMessage { return { to: [], cc: [], bcc: [], subject: '', body: { html: '', text: '', }, } } function createIdentifier(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } async function fileToBase64(file: File): Promise { const buffer = await file.arrayBuffer() let binary = '' const bytes = new Uint8Array(buffer) for (let index = 0; index < bytes.byteLength; index += 1) { binary += String.fromCharCode(bytes[index]) } return btoa(binary) }