b419af09eb
Signed-off-by: Sebastian <krupinski01@gmail.com>
563 lines
16 KiB
TypeScript
563 lines
16 KiB
TypeScript
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<ComposerDraft | null>(null)
|
|
const saving = ref(false)
|
|
|
|
let autoSaveTimer: ReturnType<typeof setTimeout> | 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: `<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
|
text: '',
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
to: [],
|
|
cc: [],
|
|
bcc: [],
|
|
subject: /^Fwd:/i.test(originalSubject) ? originalSubject : `Fwd: ${originalSubject}`,
|
|
body: {
|
|
html: `<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
|
text: '',
|
|
},
|
|
}
|
|
}
|
|
|
|
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
|
|
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) {
|
|
return {}
|
|
}
|
|
|
|
const sourceAttachments = source.properties.attachments || []
|
|
const attachments: Record<string, ComposerDraftAttachment> = {}
|
|
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<string> {
|
|
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)
|
|
} |