Files
minmal-file-cloud-email-pim…/frontend/src/views/EmailView.vue
T
Stefan Hacker 82f3091f2e feat: Papierkorb + Bestaetigungsdialoge bei allen Loeschaktionen
Papierkorb:
- Dateien/Ordner werden beim Loeschen in den Papierkorb verschoben
  (Soft-Delete) statt sofort geloescht
- Papierkorb-Seite in der Sidebar mit Tabelle aller geloeschten Elemente
- Pro Element: Wiederherstellen (am Originalort) oder endgueltig loeschen
- "Papierkorb leeren" Button loescht alles unwiderruflich
- Backend: is_trashed, trashed_at, original_parent_id Felder im File-Model
- Getrashte Dateien erscheinen nicht in der normalen Dateiliste

Bestaetigungsdialoge (vorher fehlend):
- Kontakte: "Moechtest du XY wirklich loeschen?"
- Kalender Events: Bestaetigung vor dem Loeschen
- Kalender: Bestaetigung vor dem Loeschen (mit Hinweis auf Events)
- E-Mail Nachrichten: Bestaetigung mit Betreff-Vorschau
- Share-Link Dateien: Bestaetigung beim Loeschen aus geteiltem Ordner
- Admin SFTP-Backup-Ziele: Bestaetigung
- Admin Email-Konten: Bestaetigung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:50:19 +02:00

311 lines
13 KiB
Vue

<template>
<div class="view-container email-view">
<div class="email-layout">
<!-- Folder sidebar -->
<aside class="email-sidebar">
<div v-for="account in accounts" :key="account.id" class="account-group">
<div class="account-header">
<i class="pi pi-envelope"></i>
<span>{{ account.display_name }}</span>
</div>
<div v-for="folder in account.folders || []" :key="folder.name"
class="folder-item" :class="{ active: activeFolder === `${account.id}:${folder.name}` }"
@click="selectFolder(account, folder)">
<i :class="folderIcon(folder.name)"></i>
<span>{{ folder.name }}</span>
</div>
</div>
<Button icon="pi pi-cog" label="Konten verwalten" text size="small" class="manage-btn"
@click="$router.push('/settings')" />
</aside>
<!-- Message list -->
<div class="message-list-panel">
<div class="list-header">
<span class="folder-title">{{ currentFolderName }}</span>
<Button icon="pi pi-refresh" text size="small" @click="loadMessages" />
<Button icon="pi pi-pencil" label="Neue E-Mail" size="small" @click="openCompose" />
</div>
<div v-if="loadingMessages" class="loading-center">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else class="message-list">
<div v-for="msg in messages" :key="msg.uid"
class="message-item" :class="{ unread: !msg.seen, active: selectedMessage?.uid === msg.uid }"
@click="selectMessage(msg)">
<div class="msg-from">{{ msg.from }}</div>
<div class="msg-subject">{{ msg.subject || '(Kein Betreff)' }}</div>
<div class="msg-date">{{ formatDate(msg.date) }}</div>
</div>
<div v-if="!messages.length" class="empty">Keine Nachrichten</div>
</div>
</div>
<!-- Message view -->
<div class="message-view-panel">
<div v-if="selectedMessage && messageDetail">
<div class="msg-header">
<h3>{{ messageDetail.subject }}</h3>
<div class="msg-meta">
<div><strong>Von:</strong> {{ messageDetail.from }}</div>
<div><strong>An:</strong> {{ messageDetail.to }}</div>
<div v-if="messageDetail.cc"><strong>CC:</strong> {{ messageDetail.cc }}</div>
<div><strong>Datum:</strong> {{ messageDetail.date }}</div>
</div>
<div class="msg-actions">
<Button icon="pi pi-reply" label="Antworten" size="small" outlined @click="replyTo" />
<Button icon="pi pi-trash" size="small" severity="danger" text @click="confirmDeleteMsg = true" />
</div>
</div>
<div v-if="messageDetail.html_body" class="msg-body" v-html="messageDetail.html_body"></div>
<pre v-else-if="messageDetail.text_body" class="msg-body-text">{{ messageDetail.text_body }}</pre>
<div v-if="messageDetail.attachments?.length" class="msg-attachments">
<strong>Anhaenge:</strong>
<span v-for="a in messageDetail.attachments" :key="a.filename" class="attachment">
<i class="pi pi-paperclip"></i> {{ a.filename }}
</span>
</div>
</div>
<div v-else class="empty-message">
<i class="pi pi-envelope" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Nachricht auswaehlen</p>
</div>
</div>
</div>
<!-- Compose Dialog -->
<Dialog v-model:visible="showCompose" header="Neue E-Mail" modal :style="{ width: '700px' }">
<div v-if="accounts.length > 1" class="field">
<label>Von</label>
<Select v-model="composeForm.account_id" :options="accounts" optionLabel="email_address" optionValue="id" fluid />
</div>
<div class="field">
<label>An</label>
<InputText v-model="composeForm.to" fluid />
</div>
<div class="field">
<label>CC</label>
<InputText v-model="composeForm.cc" fluid />
</div>
<div class="field">
<label>Betreff</label>
<InputText v-model="composeForm.subject" fluid />
</div>
<div class="field">
<label>Nachricht</label>
<Textarea v-model="composeForm.body_text" rows="12" fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showCompose = false" />
<Button label="Senden" icon="pi pi-send" @click="sendEmail" :loading="sending" />
</template>
</Dialog>
<!-- Delete message confirm -->
<Dialog v-model:visible="confirmDeleteMsg" header="Nachricht loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du diese Nachricht wirklich loeschen?</p>
<p v-if="messageDetail" class="msg-subject-preview"><strong>{{ messageDetail.subject }}</strong></p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteMsg = false" />
<Button label="Loeschen" severity="danger" @click="deleteMessage(); confirmDeleteMsg = false" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
const toast = useToast()
const auth = useAuthStore()
const accounts = ref([])
const messages = ref([])
const selectedMessage = ref(null)
const messageDetail = ref(null)
const activeFolder = ref('')
const currentFolderName = ref('INBOX')
const currentAccount = ref(null)
const loadingMessages = ref(false)
const confirmDeleteMsg = ref(false)
const showCompose = ref(false)
const sending = ref(false)
const composeForm = ref({ account_id: null, to: '', cc: '', subject: '', body_text: '' })
function getEncKey() { return auth.masterKeySalt || '' }
function folderIcon(name) {
const n = name.toLowerCase()
if (n === 'inbox') return 'pi pi-inbox'
if (n.includes('sent') || n.includes('gesendet')) return 'pi pi-send'
if (n.includes('draft') || n.includes('entwu')) return 'pi pi-file-edit'
if (n.includes('trash') || n.includes('papier') || n.includes('gelöscht')) return 'pi pi-trash'
if (n.includes('spam') || n.includes('junk')) return 'pi pi-ban'
return 'pi pi-folder'
}
function formatDate(dateStr) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return dateStr }
}
async function loadAccounts() {
const res = await apiClient.get('/email/accounts')
accounts.value = res.data
for (const acc of accounts.value) {
try {
const fRes = await apiClient.get(`/email/accounts/${acc.id}/folders`, {
headers: { 'X-Encryption-Key': getEncKey() }
})
acc.folders = fRes.data
} catch { acc.folders = [{ name: 'INBOX', flags: [], delimiter: '/' }] }
}
if (accounts.value.length) {
selectFolder(accounts.value[0], { name: 'INBOX' })
}
}
function selectFolder(account, folder) {
currentAccount.value = account
activeFolder.value = `${account.id}:${folder.name}`
currentFolderName.value = `${account.display_name} - ${folder.name}`
loadMessages()
}
async function loadMessages() {
if (!currentAccount.value) return
loadingMessages.value = true
selectedMessage.value = null
messageDetail.value = null
try {
const folder = activeFolder.value.split(':')[1]
const res = await apiClient.get(
`/email/accounts/${currentAccount.value.id}/folders/${encodeURIComponent(folder)}/messages`,
{ headers: { 'X-Encryption-Key': getEncKey() } }
)
messages.value = res.data.messages
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
messages.value = []
} finally {
loadingMessages.value = false
}
}
async function selectMessage(msg) {
selectedMessage.value = msg
try {
const folder = activeFolder.value.split(':')[1]
const res = await apiClient.get(
`/email/accounts/${currentAccount.value.id}/messages/${msg.uid}`,
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
)
messageDetail.value = res.data
msg.seen = true
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function openCompose() {
composeForm.value = {
account_id: currentAccount.value?.id || accounts.value[0]?.id,
to: '', cc: '', subject: '', body_text: '',
}
showCompose.value = true
}
function replyTo() {
if (!messageDetail.value) return
composeForm.value = {
account_id: currentAccount.value?.id,
to: messageDetail.value.from,
cc: '',
subject: `Re: ${messageDetail.value.subject}`,
body_text: `\n\n--- Urspruengliche Nachricht ---\n${messageDetail.value.text_body || ''}`,
}
showCompose.value = true
}
async function sendEmail() {
sending.value = true
try {
await apiClient.post('/email/send', composeForm.value, {
headers: { 'X-Encryption-Key': getEncKey() }
})
showCompose.value = false
toast.add({ severity: 'success', summary: 'E-Mail gesendet', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Sendefehler', detail: err.response?.data?.error, life: 5000 })
} finally {
sending.value = false
}
}
async function deleteMessage() {
if (!selectedMessage.value || !currentAccount.value) return
const folder = activeFolder.value.split(':')[1]
try {
await apiClient.delete(
`/email/accounts/${currentAccount.value.id}/messages/${selectedMessage.value.uid}`,
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
)
selectedMessage.value = null
messageDetail.value = null
await loadMessages()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
onMounted(loadAccounts)
</script>
<style scoped>
.view-container { padding: 0; height: calc(100vh - 0px); }
.email-layout { display: flex; height: 100%; }
.email-sidebar { width: 220px; border-right: 1px solid var(--p-surface-200); overflow-y: auto; padding: 0.5rem; flex-shrink: 0; }
.account-group { margin-bottom: 0.5rem; }
.account-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; font-weight: 600; font-size: 0.85rem; }
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem 0.375rem 1.5rem; font-size: 0.825rem; cursor: pointer; border-radius: 4px; }
.folder-item:hover { background: var(--p-surface-100); }
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
.manage-btn { margin-top: 0.5rem; }
.message-list-panel { width: 350px; border-right: 1px solid var(--p-surface-200); display: flex; flex-direction: column; flex-shrink: 0; }
.list-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
.folder-title { font-weight: 600; font-size: 0.875rem; flex: 1; }
.message-list { flex: 1; overflow-y: auto; }
.message-item { padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--p-surface-100); cursor: pointer; }
.message-item:hover { background: var(--p-surface-50); }
.message-item.active { background: var(--p-primary-50); }
.message-item.unread { font-weight: 600; }
.msg-from { font-size: 0.8rem; color: var(--p-text-muted-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-subject { font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-date { font-size: 0.7rem; color: var(--p-text-muted-color); }
.message-view-panel { flex: 1; overflow-y: auto; padding: 1rem; }
.msg-header h3 { margin: 0 0 0.75rem; }
.msg-meta { font-size: 0.85rem; margin-bottom: 0.75rem; line-height: 1.6; }
.msg-actions { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.msg-body { border: 1px solid var(--p-surface-200); border-radius: 6px; padding: 1rem; background: white; }
.msg-body-text { white-space: pre-wrap; font-size: 0.875rem; }
.msg-attachments { margin-top: 1rem; font-size: 0.85rem; }
.attachment { display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.75rem; }
.empty-message { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 0.5rem; color: var(--p-text-muted-color); }
.empty { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
.loading-center { display: flex; justify-content: center; padding: 2rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
</style>