feat: message and attachment download

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-05-23 23:09:40 -04:00
parent cdff4d0d3f
commit b66ebbd078
4 changed files with 114 additions and 19 deletions

View File

@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC' import { useUser } from '@KTXC'
import type { EntityObject, MessageObject } from '@MailManager/models' import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
import { useMailStore } from '@/stores/mailStore'
import ReaderEmpty from './reader/ReaderEmpty.vue' import ReaderEmpty from './reader/ReaderEmpty.vue'
import ReaderToolbar from './reader/ReaderToolbar.vue' import ReaderToolbar from './reader/ReaderToolbar.vue'
import ReaderHeader from './reader/ReaderHeader.vue' import ReaderHeader from './reader/ReaderHeader.vue'
@@ -25,6 +26,7 @@ const emit = defineEmits<{
// User settings // User settings
const { getSetting } = useUser() const { getSetting } = useUser()
const mailStore = useMailStore()
// Per-message overrides // Per-message overrides
const allowImages = ref(false) const allowImages = ref(false)
@@ -94,6 +96,14 @@ const handleMove = () => {
} }
} }
const handleDownload = async (partIndex?: number) => {
if (!props.entity) {
return
}
await mailStore.downloadMessage(props.entity, partIndex)
}
const handleFlag = () => { const handleFlag = () => {
if (props.entity) { if (props.entity) {
emit('flag', props.entity) emit('flag', props.entity)
@@ -125,13 +135,17 @@ const handleCompose = () => {
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
@flag="handleFlag" @flag="handleFlag"
@download="handleDownload()"
@toggle-images="toggleImages" @toggle-images="toggleImages"
@set-security-level="setSecurityLevel" @set-security-level="setSecurityLevel"
/> />
<!-- Message content --> <!-- Message content -->
<div class="message-content"> <div class="message-content">
<ReaderHeader :message="message!" /> <ReaderHeader
:entity="props.entity"
@download-attachment="handleDownload"
/>
<v-divider /> <v-divider />

View File

@@ -1,16 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { MessageObject } from '@MailManager/models/message' import { computed } from 'vue'
import RecipientDetails from '@/components/common/RecipientDetails.vue' import RecipientDetails from '@/components/common/RecipientDetails.vue'
import type { EntityObject } from '@MailManager/models';
interface Props { interface Props {
message: MessageObject entity: EntityObject | null
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const addressKey = (address: { address?: string | null; label?: string | null } | null | undefined, index: number): string => { const emit = defineEmits<{
return `${address?.address || address?.label || 'address'}-${index}` downloadAttachment: [index: number]
} }>()
const message = computed(() => {
return props.entity?.properties ?? null
})
const randomKey = computed(() => {
return Math.random().toString(36).substring(2, 15)
})
// Format date for display // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
@@ -36,6 +45,10 @@ const formatFileSize = (bytes: number | undefined): string => {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB' return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
} }
const download = async (index: number): Promise<void> => {
emit('downloadAttachment', index)
}
</script> </script>
<template> <template>
@@ -66,7 +79,7 @@ const formatFileSize = (bytes: number | undefined): string => {
<!-- Recipients --> <!-- Recipients -->
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1"> <div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">To:</span> <span class="text-medium-emphasis">To:</span>
<template v-for="(recipient, index) in message.to" :key="addressKey(recipient, index)"> <template v-for="(recipient, index) in message.to" :key="randomKey">
<RecipientDetails :address="recipient"> <RecipientDetails :address="recipient">
<template #default="{ label }"> <template #default="{ label }">
<span class="contact-link">{{ label }}</span> <span class="contact-link">{{ label }}</span>
@@ -78,7 +91,7 @@ const formatFileSize = (bytes: number | undefined): string => {
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1"> <div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">Cc:</span> <span class="text-medium-emphasis">Cc:</span>
<template v-for="(recipient, index) in message.cc" :key="addressKey(recipient, index)"> <template v-for="(recipient, index) in message.cc" :key="randomKey">
<RecipientDetails :address="recipient"> <RecipientDetails :address="recipient">
<template #default="{ label }"> <template #default="{ label }">
<span class="contact-link">{{ label }}</span> <span class="contact-link">{{ label }}</span>
@@ -94,19 +107,24 @@ const formatFileSize = (bytes: number | undefined): string => {
Attachments ({{ message?.attachments.length }}) Attachments ({{ message?.attachments.length }})
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<v-chip <div
v-for="(attachment, index) in message?.attachments" v-for="(attachment, index) in message?.attachments"
:key="index" :key="randomKey"
prepend-icon="mdi-paperclip" class="attachment-item"
size="small"
variant="outlined"
class="attachment-chip"
> >
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span> <v-chip
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1"> prepend-icon="mdi-paperclip"
({{ formatFileSize(attachment.size) }}) size="small"
</span> variant="outlined"
</v-chip> class="attachment-chip"
@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-chip>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -121,8 +139,15 @@ const formatFileSize = (bytes: number | undefined): string => {
gap: 0.5rem; gap: 0.5rem;
} }
.attachment-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.attachment-chip { .attachment-chip {
max-width: 300px; max-width: 300px;
cursor: pointer;
.attachment-name { .attachment-name {
overflow: hidden; overflow: hidden;
@@ -131,6 +156,11 @@ const formatFileSize = (bytes: number | undefined): string => {
} }
} }
.attachment-error {
color: rgb(var(--v-theme-error));
margin-top: 0.25rem;
}
.contact-link { .contact-link {
display: inline-block; display: inline-block;
border-radius: 4px; border-radius: 4px;

View File

@@ -17,6 +17,7 @@ const emit = defineEmits<{
move: [] move: []
delete: [] delete: []
flag: [] flag: []
download: []
toggleImages: [] toggleImages: []
setSecurityLevel: [level: SecurityLevel] setSecurityLevel: [level: SecurityLevel]
}>() }>()
@@ -161,6 +162,28 @@ const currentSecurityLevel = computed(() => {
<v-icon>mdi-delete-outline</v-icon> <v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip> <v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
</v-btn> </v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
variant="text"
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-download"
@click="emit('download')"
>
<v-list-item-title>Download</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar> </v-toolbar>
</template> </template>

View File

@@ -633,6 +633,33 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function downloadMessage(entity: EntityObject, index?: number) {
const target = entity.identifier
let part = null
if (index !== undefined) {
part = entity.properties.attachments?.[Number(index)] ?? null
}
try {
await entitiesStore.download(target, part)
} catch (error) {
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
console.error(
index === undefined
? '[Mail][Operations] Failed to download message:'
: '[Mail][Operations] Failed to download attachment:',
error,
)
notify(messageText, 'error')
throw error
}
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') { function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color }) showSnackbar({ message, color })
} }
@@ -672,6 +699,7 @@ export const useMailStore = defineStore('mailStore', () => {
deleteMessages, deleteMessages,
deleteFolder, deleteFolder,
moveMessages, moveMessages,
downloadMessage,
moveFolder, moveFolder,
renameFolder, renameFolder,
notify, notify,