feat: mail composition

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-06-16 13:24:54 -04:00
parent 7a3d90d0cd
commit f1cff5441a
22 changed files with 1786 additions and 175 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ const handleCancel = () => {
<div class="mb-4">
<div class="text-caption text-medium-emphasis">Account</div>
<div class="text-body-2">
{{ service.label || service.primaryAddress || 'Mail Account' }}
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
</div>
</div>
+1 -1
View File
@@ -64,7 +64,7 @@ const handleCancel = () => {
<div class="mb-4">
<div class="text-caption text-medium-emphasis">Account</div>
<div class="text-body-2">
{{ service.label || service.primaryAddress || 'Mail Account' }}
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
</div>
</div>
+2 -2
View File
@@ -187,7 +187,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-bind="activatorProps"
class="account-header-item"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined"
:subtitle="group.service.primaryAddress?.address || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
@@ -348,7 +348,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list-item
class="account-header-item"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined"
:subtitle="group.service.primaryAddress?.address || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
+1 -1
View File
@@ -151,7 +151,7 @@ const handleConfirm = () => {
<v-list-item
class="account-header-item account-header-static"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined"
:subtitle="group.service.primaryAddress?.address || undefined"
>
<template #prepend>
<v-icon icon="mdi-email-outline" />
+2 -2
View File
@@ -46,7 +46,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
v-bind="activatorProps"
class="account-header-item"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined"
:subtitle="group.service.primaryAddress?.address || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
@@ -116,7 +116,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
<v-list-item
class="account-header-item account-header-static"
:title="group.service.label || 'Mail Account'"
:subtitle="group.service.primaryAddress || undefined"
:subtitle="group.service.primaryAddress?.address || undefined"
>
<template v-slot:prepend>
<v-icon icon="mdi-email-outline" />
+97 -146
View File
@@ -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>
+1 -1
View File
@@ -91,7 +91,7 @@ const handleCancel = () => {
<div class="mb-4">
<div class="text-caption text-medium-emphasis">Account</div>
<div class="text-body-2">
{{ service.label || service.primaryAddress || 'Mail Account' }}
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
</div>
</div>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { ComposerDraftAttachment } from '@/types/composer'
import { formatFileSize } from '@/utile/format'
interface Props {
attachments: Record<string, ComposerDraftAttachment>
}
defineProps<Props>()
defineEmits<{
remove: [identifier: string]
}>()
</script>
<template>
<div class="composer-attachments px-4 py-3">
<div class="text-caption text-medium-emphasis mb-2">Attachments</div>
<div class="composer-attachments-list">
<v-chip
v-for="attachment in Object.values(attachments)"
:key="attachment.identifier"
size="small"
closable
class="mr-2 mb-2"
@click:close="$emit('remove', attachment.identifier)"
>
{{ attachment.name }} ({{ formatFileSize(attachment.size) }})
</v-chip>
</div>
</div>
</template>
<style scoped lang="scss">
.composer-attachments {
border-bottom: 1px solid rgb(var(--v-border-color));
}
.composer-attachments-list {
display: flex;
flex-wrap: wrap;
}
</style>
+34 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { PropType } from 'vue'
import { EditorContent, type Editor } from '@tiptap/vue-3'
@@ -33,14 +34,32 @@ defineProps({
},
})
defineEmits<{
const emit = defineEmits<{
bold: []
italic: []
underline: []
bulletList: []
orderedList: []
link: []
attach: [files: File[]]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function openFilePicker() {
fileInput.value?.click()
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement | null
const files = input?.files ? Array.from(input.files) : []
if (files.length > 0) {
emit('attach', files)
}
if (input) {
input.value = ''
}
}
</script>
<template>
@@ -111,7 +130,15 @@ defineEmits<{
<v-spacer />
<v-btn icon size="small">
<input
ref="fileInput"
type="file"
multiple
class="sr-only"
@change="handleFileChange"
>
<v-btn icon size="small" @click="openFilePicker">
<v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn>
@@ -120,7 +147,7 @@ defineEmits<{
<v-divider />
<div class="editor-container">
<EditorContent :editor="editor" />
<EditorContent :editor="editor ?? undefined" />
</div>
</template>
@@ -136,6 +163,10 @@ defineEmits<{
background-color: rgb(var(--v-theme-background));
}
.sr-only {
display: none;
}
.v-btn--active {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
@@ -0,0 +1,87 @@
<script setup lang="ts">
import type { ComposerSenderIdentity } from '@/types/composer';
import { computed } from 'vue'
interface Props {
modelValue: ComposerSenderIdentity | null
options: ComposerSenderIdentity[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: ComposerSenderIdentity | null]
}>()
type SenderListItem = {
title: string
value: string
}
const items = computed<SenderListItem[]>(() => {
return props.options.map(option => ({
title: formatSenderLabel(option.label, option.address),
value: option.address,
}))
})
const selectedValue = computed(() => props.modelValue?.address ?? null)
const singleOptionLabel = computed(() => {
const onlyOption = props.options[0]
return onlyOption ? formatSenderLabel(onlyOption.label, onlyOption.address) : ''
})
const errorMessages = computed(() => {
if (props.options.length === 0) {
return ['No send-capable account is available.']
}
return []
})
function handleUpdate(value: string | null) {
const match = value ? props.options.find(o => o.address === value) ?? null : null
emit('update:modelValue', match)
}
function formatSenderLabel(label: string | null | undefined, address: string): string {
return label ? `${label} <${address}>` : address
}
</script>
<template>
<div class="composer-sender px-4 pt-4 pb-0">
<v-text-field
v-if="options.length === 1"
:model-value="singleOptionLabel"
label="From"
variant="outlined"
density="compact"
readonly
class="mb-2"
/>
<v-select
v-else
:model-value="selectedValue"
:items="items"
item-title="title"
item-value="value"
label="From"
variant="outlined"
density="compact"
:error="errorMessages.length > 0"
:error-messages="errorMessages"
:disabled="options.length === 0"
class="mb-2"
@update:model-value="handleUpdate"
/>
</div>
</template>
<style scoped lang="scss">
.composer-sender {
flex-shrink: 0;
}
</style>
+3 -3
View File
@@ -3,7 +3,7 @@ import { ComposerMode } from '@/types/composer'
interface Props {
mode: ComposerMode
saveStatus: string
status: string
canSend: boolean
sending: boolean
}
@@ -33,8 +33,8 @@ defineEmits<{
<v-spacer />
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
{{ saveStatus }}
<span v-if="status" class="text-caption text-medium-emphasis mr-4">
{{ status }}
</span>
<v-btn
+3 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import { formatFileSize } from '@/utile/format'
import type { EntityObject } from '@MailManager/models';
interface Props {
@@ -37,15 +38,6 @@ const formatDate = (date: Date | string | null | undefined): string => {
})
}
// Format file size for display
const formatFileSize = (bytes: number | undefined): string => {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const download = async (index: number): Promise<void> => {
emit('downloadAttachment', index)
}
@@ -120,8 +112,8 @@ const download = async (index: number): Promise<void> => {
@click="download(index)"
>
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size ?? undefined) }})
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size) }})
</span>
</v-chip>
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ const handleAccountSaved = async () => {
</template>
<v-list-item-title>{{ service.label || 'Unnamed Account' }}</v-list-item-title>
<v-list-item-subtitle>{{ service.primaryAddress || service.identifier }}</v-list-item-subtitle>
<v-list-item-subtitle>{{ service.primaryAddress?.address || service.identifier }}</v-list-item-subtitle>
<template #append>
<v-btn