feat: mail composition
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -8,18 +8,20 @@ import Underline from '@tiptap/extension-underline'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { EntityObject } from '@MailManager/models/entity'
|
||||
import type { CollectionObject } from '@MailManager/models'
|
||||
import type { CollectionObject, MessageAddressObject } from '@MailManager/models'
|
||||
import { useMailStore } from '@/stores/mailStore'
|
||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
||||
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
||||
import { ComposerMode } from '@/types/composer'
|
||||
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
||||
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||
import ComposerAttachments from '@/components/composer/ComposerAttachments.vue'
|
||||
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
mode: ComposerMode
|
||||
source?: EntityObject | MessageAddressInterface | null
|
||||
source?: EntityObject | MessageAddressObject | null
|
||||
folder?: CollectionObject | null
|
||||
}
|
||||
|
||||
@@ -31,22 +33,52 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const compositionStore = useMailCompositionStore()
|
||||
const {
|
||||
composerSending: sending,
|
||||
composerSaving: saving,
|
||||
composerLastSaved: lastSaved,
|
||||
} = storeToRefs(mailStore)
|
||||
|
||||
const {
|
||||
activeDraft,
|
||||
saving,
|
||||
stageStatus,
|
||||
} = storeToRefs(compositionStore)
|
||||
|
||||
// State
|
||||
const to = ref<string[]>([])
|
||||
const cc = ref<string[]>([])
|
||||
const bcc = ref<string[]>([])
|
||||
const subject = ref('')
|
||||
const showCc = ref(false)
|
||||
const showBcc = ref(false)
|
||||
const applyingDraftToEditor = ref(false)
|
||||
|
||||
// Auto-save timer
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const sender = computed({
|
||||
get: () => activeDraft.value?.sender ?? null,
|
||||
set: value => {
|
||||
if (value) {
|
||||
compositionStore.updateSender(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const to = computed({
|
||||
get: () => activeDraft.value?.message.to ?? [],
|
||||
set: value => compositionStore.updateRecipients('to', value),
|
||||
})
|
||||
|
||||
const cc = computed({
|
||||
get: () => activeDraft.value?.message.cc ?? [],
|
||||
set: value => compositionStore.updateRecipients('cc', value),
|
||||
})
|
||||
|
||||
const bcc = computed({
|
||||
get: () => activeDraft.value?.message.bcc ?? [],
|
||||
set: value => compositionStore.updateRecipients('bcc', value),
|
||||
})
|
||||
|
||||
const subject = computed({
|
||||
get: () => activeDraft.value?.message.subject ?? '',
|
||||
set: value => compositionStore.updateSubject(value),
|
||||
})
|
||||
|
||||
const attachments = computed(() => activeDraft.value?.attachments ?? {})
|
||||
|
||||
// Initialize Tiptap editor
|
||||
const editor = useEditor({
|
||||
@@ -71,61 +103,6 @@ const editor = useEditor({
|
||||
},
|
||||
})
|
||||
|
||||
function resetComposerFields() {
|
||||
to.value = []
|
||||
cc.value = []
|
||||
bcc.value = []
|
||||
subject.value = ''
|
||||
showCc.value = false
|
||||
showBcc.value = false
|
||||
editor.value?.commands.setContent('')
|
||||
}
|
||||
|
||||
function initializeComposerFromProps() {
|
||||
mailStore.resetComposerState()
|
||||
resetComposerFields()
|
||||
|
||||
if (props.mode === ComposerMode.Fresh) {
|
||||
if (props.source && 'address' in props.source) {
|
||||
// If source is an email address, pre-fill the "To" field
|
||||
to.value = [props.source.address]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.source instanceof EntityObject == false) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceMessage = props.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 || props.source.created || ''
|
||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||
|
||||
if (props.mode === ComposerMode.Reply) {
|
||||
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
|
||||
to.value = fromEmail ? [fromEmail] : []
|
||||
subject.value = /^Re:/i.test(originalSubject)
|
||||
? originalSubject
|
||||
: `Re: ${originalSubject}`
|
||||
editor.value?.commands.setContent(
|
||||
`<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.mode === ComposerMode.Forward) {
|
||||
subject.value = /^Fwd:/i.test(originalSubject)
|
||||
? originalSubject
|
||||
: `Fwd: ${originalSubject}`
|
||||
editor.value?.commands.setContent(
|
||||
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.mode, () => props.source, () => editor.value],
|
||||
([, , currentEditor]) => {
|
||||
@@ -133,7 +110,13 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
initializeComposerFromProps()
|
||||
compositionStore.openDraft(props.mode, props.source)
|
||||
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
||||
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
||||
|
||||
applyingDraftToEditor.value = true
|
||||
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||
applyingDraftToEditor.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -143,97 +126,52 @@ const canSend = computed(() => {
|
||||
return to.value.length > 0 && subject.value.trim().length > 0
|
||||
})
|
||||
|
||||
const saveStatus = computed(() => {
|
||||
if (saving.value) return 'Saving...'
|
||||
if (lastSaved.value) {
|
||||
const seconds = Math.floor((Date.now() - lastSaved.value.getTime()) / 1000)
|
||||
if (seconds < 60) return 'Saved just now'
|
||||
if (seconds < 3600) return `Saved ${Math.floor(seconds / 60)} min ago`
|
||||
return `Saved at ${lastSaved.value.toLocaleTimeString()}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// Auto-save function
|
||||
const saveDraft = async () => {
|
||||
if (saving.value || sending.value) return
|
||||
if (!props.folder) return
|
||||
|
||||
// Don't save if completely empty
|
||||
if (to.value.length === 0 && subject.value.trim().length === 0 && !editor.value?.getText().trim()) {
|
||||
// Watch editor content changes
|
||||
watch(editor, currentEditor => {
|
||||
if (!currentEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await mailStore.saveComposerDraft(props.folder, {
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
body: {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (applyingDraftToEditor.value) {
|
||||
return
|
||||
}
|
||||
|
||||
compositionStore.updateBody({
|
||||
html: currentEditor.getHTML(),
|
||||
text: currentEditor.getText(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to save draft:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes and trigger auto-save
|
||||
const scheduleAutoSave = () => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
currentEditor.on('update', handleUpdate)
|
||||
|
||||
return () => {
|
||||
currentEditor.off('update', handleUpdate)
|
||||
}
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
saveDraft()
|
||||
}, 30000) // 30 seconds
|
||||
}
|
||||
|
||||
watch([to, cc, bcc, subject], () => {
|
||||
scheduleAutoSave()
|
||||
}, { deep: true })
|
||||
|
||||
// Watch editor content changes
|
||||
if (editor.value) {
|
||||
editor.value.on('update', () => {
|
||||
scheduleAutoSave()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
onBeforeUnmount(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
}
|
||||
mailStore.resetComposerState()
|
||||
void compositionStore.flushSave()
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
// Handlers
|
||||
const handleClose = () => {
|
||||
mailStore.resetComposerState()
|
||||
const handleClose = async () => {
|
||||
await compositionStore.closeDraft()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canSend.value || sending.value) return
|
||||
await compositionStore.sendDraft()
|
||||
}
|
||||
|
||||
try {
|
||||
await mailStore.sendComposerMessage({
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
body: {
|
||||
html: editor.value?.getHTML() || '',
|
||||
text: editor.value?.getText() || '',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Mail][Composer] Failed to send message:', error)
|
||||
}
|
||||
const handleAttach = async (files: File[]) => {
|
||||
await compositionStore.addAttachments(files)
|
||||
}
|
||||
|
||||
const handleDetach = async (identifier: string) => {
|
||||
await compositionStore.removeAttachment(identifier)
|
||||
}
|
||||
|
||||
const toggleCc = () => {
|
||||
@@ -257,11 +195,6 @@ const setLink = () => {
|
||||
}
|
||||
}
|
||||
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
||||
|
||||
const isActive = (name: string, attrs?: any) => {
|
||||
return editor.value?.isActive(name, attrs) || false
|
||||
}
|
||||
|
||||
const toggleLink = () => {
|
||||
if (isActive('link')) {
|
||||
removeLink()
|
||||
@@ -270,13 +203,16 @@ const toggleLink = () => {
|
||||
|
||||
setLink()
|
||||
}
|
||||
const isActive = (name: string, attrs?: any) => {
|
||||
return editor.value?.isActive(name, attrs) || false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-composer">
|
||||
<ComposerToolbar
|
||||
:mode="mode"
|
||||
:save-status="saveStatus"
|
||||
:status="stageStatus"
|
||||
:can-send="canSend"
|
||||
:sending="sending"
|
||||
@close="handleClose"
|
||||
@@ -284,6 +220,11 @@ const toggleLink = () => {
|
||||
/>
|
||||
|
||||
<div class="composer-content">
|
||||
<ComposerSender
|
||||
v-model="sender"
|
||||
:options="compositionStore.senderIdentities"
|
||||
/>
|
||||
|
||||
<ComposerRecipients
|
||||
:to="to"
|
||||
:cc="cc"
|
||||
@@ -301,6 +242,14 @@ const toggleLink = () => {
|
||||
|
||||
<v-divider />
|
||||
|
||||
<ComposerAttachments
|
||||
v-if="Object.keys(attachments).length > 0"
|
||||
:attachments="attachments"
|
||||
@remove="handleDetach"
|
||||
/>
|
||||
|
||||
<v-divider v-if="Object.keys(attachments).length > 0" />
|
||||
|
||||
<ComposerEditor
|
||||
:editor="editor"
|
||||
:is-bold-active="isActive('bold')"
|
||||
@@ -315,6 +264,7 @@ const toggleLink = () => {
|
||||
@bullet-list="toggleBulletList"
|
||||
@ordered-list="toggleOrderedList"
|
||||
@link="toggleLink"
|
||||
@attach="handleAttach"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,4 +289,5 @@ const toggleLink = () => {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user