E-Mail Aktionen und Anhang-Download ergänzen
KI-AGENT: Ergänzt IMAP-basierte Aktionen zum Verschieben, Archivieren und Löschen von E-Mails sowie den Download von Anhängen aus der Originalmail. Die E-Mail Oberfläche bietet dafür Zielordner-Auswahl, Aktionsbuttons und Anhang-Downloads.
This commit is contained in:
@@ -479,6 +479,198 @@ export function emailSyncService(server: FastifyInstance) {
|
|||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadMessageForAction = async (tenantId: number, userId: string, messageId: string) => {
|
||||||
|
const rows = await server.db
|
||||||
|
.select()
|
||||||
|
.from(emailMessages)
|
||||||
|
.where(and(
|
||||||
|
eq(emailMessages.tenantId, tenantId),
|
||||||
|
eq(emailMessages.userId, userId),
|
||||||
|
eq(emailMessages.id, messageId),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCachedMessage = async (messageId: string) => {
|
||||||
|
await server.db
|
||||||
|
.delete(emailMessages)
|
||||||
|
.where(eq(emailMessages.id, messageId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveMessage = async (
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
messageId: string,
|
||||||
|
destinationMailboxPath: string,
|
||||||
|
) => {
|
||||||
|
const message = await loadMessageForAction(tenantId, userId, messageId)
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
if (message.mailboxPath === destinationMailboxPath) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await getAccount(tenantId, userId, message.accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("E-Mail Konto nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRows = await server.db
|
||||||
|
.select({ path: emailMailboxes.path })
|
||||||
|
.from(emailMailboxes)
|
||||||
|
.where(and(
|
||||||
|
eq(emailMailboxes.tenantId, tenantId),
|
||||||
|
eq(emailMailboxes.userId, userId),
|
||||||
|
eq(emailMailboxes.accountId, message.accountId),
|
||||||
|
eq(emailMailboxes.path, destinationMailboxPath),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!targetRows[0]) {
|
||||||
|
throw new Error("Zielordner nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(account)
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lock = await client.getMailboxLock(message.mailboxPath)
|
||||||
|
try {
|
||||||
|
await client.mailboxOpen(message.mailboxPath)
|
||||||
|
await client.messageMove(message.uid, destinationMailboxPath, { uid: true })
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout().catch(() => client.close())
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteCachedMessage(messageId)
|
||||||
|
return { success: true, destinationMailboxPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveMessage = async (tenantId: number, userId: string, messageId: string) => {
|
||||||
|
const message = await loadMessageForAction(tenantId, userId, messageId)
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
const mailboxes = await server.db
|
||||||
|
.select()
|
||||||
|
.from(emailMailboxes)
|
||||||
|
.where(and(
|
||||||
|
eq(emailMailboxes.tenantId, tenantId),
|
||||||
|
eq(emailMailboxes.userId, userId),
|
||||||
|
eq(emailMailboxes.accountId, message.accountId),
|
||||||
|
))
|
||||||
|
|
||||||
|
const archiveMailbox = mailboxes.find((mailbox) => mailbox.specialUse === "\\Archive")
|
||||||
|
|| mailboxes.find((mailbox) => ["archive", "archiv"].includes(mailbox.name.toLowerCase()))
|
||||||
|
|| mailboxes.find((mailbox) => ["archive", "archiv"].includes(mailbox.path.toLowerCase()))
|
||||||
|
|
||||||
|
if (!archiveMailbox) {
|
||||||
|
throw new Error("Kein Archivordner gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await moveMessage(tenantId, userId, messageId, archiveMailbox.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMessage = async (tenantId: number, userId: string, messageId: string) => {
|
||||||
|
const message = await loadMessageForAction(tenantId, userId, messageId)
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
const account = await getAccount(tenantId, userId, message.accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("E-Mail Konto nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(account)
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lock = await client.getMailboxLock(message.mailboxPath)
|
||||||
|
try {
|
||||||
|
await client.mailboxOpen(message.mailboxPath)
|
||||||
|
await client.messageDelete(message.uid, { uid: true })
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout().catch(() => client.close())
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteCachedMessage(messageId)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAttachmentContent = async (
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
) => {
|
||||||
|
const rows = await server.db
|
||||||
|
.select({
|
||||||
|
attachment: emailAttachments,
|
||||||
|
message: emailMessages,
|
||||||
|
})
|
||||||
|
.from(emailAttachments)
|
||||||
|
.innerJoin(emailMessages, eq(emailMessages.id, emailAttachments.messageId))
|
||||||
|
.where(and(
|
||||||
|
eq(emailAttachments.id, attachmentId),
|
||||||
|
eq(emailMessages.tenantId, tenantId),
|
||||||
|
eq(emailMessages.userId, userId),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
const account = await getAccount(tenantId, userId, row.message.accountId)
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("E-Mail Konto nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(account)
|
||||||
|
await client.connect()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lock = await client.getMailboxLock(row.message.mailboxPath)
|
||||||
|
try {
|
||||||
|
await client.mailboxOpen(row.message.mailboxPath)
|
||||||
|
|
||||||
|
for await (const fetched of client.fetch([row.message.uid], {
|
||||||
|
source: true,
|
||||||
|
}, { uid: true })) {
|
||||||
|
if (!fetched.source) break
|
||||||
|
|
||||||
|
const parsed = await simpleParser(fetched.source)
|
||||||
|
const attachment = parsed.attachments.find((item) =>
|
||||||
|
(row.attachment.checksum && item.checksum === row.attachment.checksum)
|
||||||
|
|| (
|
||||||
|
item.filename === row.attachment.filename
|
||||||
|
&& item.contentType === row.attachment.contentType
|
||||||
|
&& Number(item.size || 0) === Number(row.attachment.size || 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!attachment) break
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: attachment.filename || row.attachment.filename || "anhang",
|
||||||
|
contentType: attachment.contentType || row.attachment.contentType || "application/octet-stream",
|
||||||
|
content: attachment.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout().catch(() => client.close())
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const listMailboxes = async (tenantId: number, userId: string, accountId: string) => {
|
const listMailboxes = async (tenantId: number, userId: string, accountId: string) => {
|
||||||
return await server.db
|
return await server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -546,5 +738,9 @@ export function emailSyncService(server: FastifyInstance) {
|
|||||||
listMessages,
|
listMessages,
|
||||||
getMessage,
|
getMessage,
|
||||||
setMessageSeen,
|
setMessageSeen,
|
||||||
|
moveMessage,
|
||||||
|
archiveMessage,
|
||||||
|
deleteMessage,
|
||||||
|
getAttachmentContent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,4 +393,86 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post("/email/messages/:id/move", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const body = (req.body || {}) as { mailbox?: string }
|
||||||
|
|
||||||
|
if (!body.mailbox) {
|
||||||
|
return reply.code(400).send({ error: "Zielordner fehlt" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await emailSync.moveMessage(
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
id,
|
||||||
|
body.mailbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
return reply.send({ success: true, ...result })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verschoben werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.post("/email/messages/:id/archive", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const result = await emailSync.archiveMessage(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
|
||||||
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
return reply.send({ success: true, ...result })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht archiviert werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.delete("/email/messages/:id", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const result = await emailSync.deleteMessage(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
|
||||||
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
||||||
|
return reply.send({ success: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht gelöscht werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.get("/email/attachments/:id/download", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user?.tenant_id) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const attachment = await emailSync.getAttachmentContent(req.user.tenant_id, req.user.user_id, id)
|
||||||
|
|
||||||
|
if (!attachment) return reply.code(404).send({ error: "Anhang nicht gefunden" })
|
||||||
|
|
||||||
|
reply.header("Content-Type", attachment.contentType)
|
||||||
|
reply.header("Content-Disposition", `attachment; filename="${attachment.filename.replace(/"/g, "")}"`)
|
||||||
|
return reply.send(attachment.content)
|
||||||
|
} catch (err: any) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message || "Anhang konnte nicht geladen werden" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type EmailMessage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const accounts = ref<EmailAccount[]>([])
|
const accounts = ref<EmailAccount[]>([])
|
||||||
@@ -71,6 +72,8 @@ const loadingMessage = ref(false)
|
|||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const expandedMailboxPaths = ref<string[]>([])
|
const expandedMailboxPaths = ref<string[]>([])
|
||||||
const syncedMailboxPaths = ref<string[]>([])
|
const syncedMailboxPaths = ref<string[]>([])
|
||||||
|
const actionLoading = ref("")
|
||||||
|
const moveTargetMailboxPath = ref("")
|
||||||
|
|
||||||
const selectedAccount = computed(() =>
|
const selectedAccount = computed(() =>
|
||||||
accounts.value.find((account) => account.id === selectedAccountId.value) || null
|
accounts.value.find((account) => account.id === selectedAccountId.value) || null
|
||||||
@@ -171,6 +174,15 @@ const filteredMessages = computed(() => {
|
|||||||
].some((value) => String(value || "").toLowerCase().includes(needle)))
|
].some((value) => String(value || "").toLowerCase().includes(needle)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const moveMailboxOptions = computed(() =>
|
||||||
|
mailboxes.value
|
||||||
|
.filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath)
|
||||||
|
.map((mailbox) => ({
|
||||||
|
label: mailboxLabel(mailbox),
|
||||||
|
value: mailbox.path,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
const mailboxIcon = (mailbox: EmailMailbox) => {
|
const mailboxIcon = (mailbox: EmailMailbox) => {
|
||||||
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "i-heroicons-inbox"
|
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "i-heroicons-inbox"
|
||||||
if (mailbox.specialUse === "\\Sent") return "i-heroicons-paper-airplane"
|
if (mailbox.specialUse === "\\Sent") return "i-heroicons-paper-airplane"
|
||||||
@@ -354,6 +366,7 @@ async function selectMessage(message: EmailMessage) {
|
|||||||
loadingMessage.value = true
|
loadingMessage.value = true
|
||||||
try {
|
try {
|
||||||
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
||||||
|
moveTargetMailboxPath.value = ""
|
||||||
if (!message.seen) {
|
if (!message.seen) {
|
||||||
await setMessageSeen(message.id, true)
|
await setMessageSeen(message.id, true)
|
||||||
}
|
}
|
||||||
@@ -362,6 +375,89 @@ async function selectMessage(message: EmailMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeMessageFromCurrentList(messageId: string) {
|
||||||
|
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
|
||||||
|
messages.value = messages.value.filter((message) => message.id !== messageId)
|
||||||
|
|
||||||
|
const nextMessage = messages.value[currentIndex] || messages.value[currentIndex - 1] || null
|
||||||
|
selectedMessage.value = null
|
||||||
|
|
||||||
|
if (nextMessage) {
|
||||||
|
selectMessage(nextMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveSelectedMessage() {
|
||||||
|
if (!selectedMessage.value) return
|
||||||
|
|
||||||
|
const messageId = selectedMessage.value.id
|
||||||
|
actionLoading.value = "archive"
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $api(`/api/email/messages/${messageId}/archive`, { method: "POST" })
|
||||||
|
removeMessageFromCurrentList(messageId)
|
||||||
|
toast.add({ title: "E-Mail archiviert", color: "success" })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Archivieren fehlgeschlagen",
|
||||||
|
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht archiviert werden.",
|
||||||
|
color: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedMessage() {
|
||||||
|
if (!selectedMessage.value) return
|
||||||
|
|
||||||
|
const messageId = selectedMessage.value.id
|
||||||
|
actionLoading.value = "delete"
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $api(`/api/email/messages/${messageId}`, { method: "DELETE" })
|
||||||
|
removeMessageFromCurrentList(messageId)
|
||||||
|
toast.add({ title: "E-Mail gelöscht", color: "success" })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Löschen fehlgeschlagen",
|
||||||
|
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht gelöscht werden.",
|
||||||
|
color: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSelectedMessage() {
|
||||||
|
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
|
||||||
|
|
||||||
|
const messageId = selectedMessage.value.id
|
||||||
|
actionLoading.value = "move"
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $api(`/api/email/messages/${messageId}/move`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { mailbox: moveTargetMailboxPath.value },
|
||||||
|
})
|
||||||
|
removeMessageFromCurrentList(messageId)
|
||||||
|
toast.add({ title: "E-Mail verschoben", color: "success" })
|
||||||
|
moveTargetMailboxPath.value = ""
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Verschieben fehlgeschlagen",
|
||||||
|
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht verschoben werden.",
|
||||||
|
color: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAttachment(attachmentId: string) {
|
||||||
|
window.open(`${config.public.apiBase}/api/email/attachments/${attachmentId}/download`, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
async function setMessageSeen(messageId: string, seen: boolean) {
|
async function setMessageSeen(messageId: string, seen: boolean) {
|
||||||
const previousMessage = messages.value.find((message) => message.id === messageId)
|
const previousMessage = messages.value.find((message) => message.id === messageId)
|
||||||
|
|
||||||
@@ -634,6 +730,40 @@ onMounted(loadAccounts)
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="moveTargetMailboxPath"
|
||||||
|
:items="moveMailboxOptions"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
size="sm"
|
||||||
|
class="w-48"
|
||||||
|
placeholder="Verschieben nach"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-folder-arrow-down"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
:loading="actionLoading === 'move'"
|
||||||
|
:disabled="!moveTargetMailboxPath"
|
||||||
|
@click="moveSelectedMessage"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-archive-box-arrow-down"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:loading="actionLoading === 'archive'"
|
||||||
|
@click="archiveSelectedMessage"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:loading="actionLoading === 'delete'"
|
||||||
|
@click="deleteSelectedMessage"
|
||||||
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
:icon="selectedMessage.seen ? 'i-heroicons-envelope' : 'i-heroicons-envelope-open'"
|
:icon="selectedMessage.seen ? 'i-heroicons-envelope' : 'i-heroicons-envelope-open'"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
@@ -680,15 +810,17 @@ onMounted(loadAccounts)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
<div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
||||||
<div
|
<button
|
||||||
v-for="attachment in selectedMessage.attachments"
|
v-for="attachment in selectedMessage.attachments"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
class="flex items-center gap-2 rounded-md border border-(--ui-border) px-3 py-2 text-sm"
|
class="flex items-center gap-2 rounded-md border border-(--ui-border) px-3 py-2 text-left text-sm hover:bg-(--ui-bg-muted)"
|
||||||
|
@click="downloadAttachment(attachment.id)"
|
||||||
>
|
>
|
||||||
<UIcon name="i-heroicons-paper-clip" class="size-4 text-dimmed" />
|
<UIcon name="i-heroicons-paper-clip" class="size-4 text-dimmed" />
|
||||||
<span>{{ attachment.filename || 'Anhang' }}</span>
|
<span>{{ attachment.filename || 'Anhang' }}</span>
|
||||||
<span class="text-xs text-dimmed">{{ formatAttachmentSize(attachment.size) }}</span>
|
<span class="text-xs text-dimmed">{{ formatAttachmentSize(attachment.size) }}</span>
|
||||||
</div>
|
<UIcon name="i-heroicons-arrow-down-tray" class="size-4 text-dimmed" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user