Initial commit: Outlook-competitor desktop mail client

Tauri v2 (Rust backend, React/TypeScript frontend). IMAP/POP3/SMTP core,
Google OAuth (mail/calendar/contacts sync), CalDAV, local calendar with
recurrence, rules engine, contacts, tasks, unified inbox, context menus,
drag & drop, snooze, templates, and more.
This commit is contained in:
2026-07-28 20:56:05 +02:00
commit 20bfd2cf6b
107 changed files with 25728 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
import { useEffect, useMemo, useState } from 'react'
import { save } from '@tauri-apps/plugin-dialog'
import {
CalendarCheck,
CalendarClock,
CalendarPlus,
CalendarX,
CheckSquare,
Download,
ListPlus,
Mail,
Paperclip,
} from 'lucide-react'
import type { AttachmentMeta, InviteInfo, InviteResponse, MessageFull } from '../types'
import { Avatar } from './Avatar'
import { api } from '../lib/api'
import { formatBytes, formatFullDate } from '../lib/format'
import { AttachmentPreviewModal, isPreviewable } from './AttachmentPreviewModal'
function isIcsAttachment(att: AttachmentMeta) {
return att.mime_type.toLowerCase() === 'text/calendar' || att.filename.toLowerCase().endsWith('.ics')
}
function InviteBanner({
info,
onRespond,
}: {
info: InviteInfo
onRespond: (response: InviteResponse) => Promise<void>
}) {
const [responding, setResponding] = useState<InviteResponse | null>(null)
const [responded, setResponded] = useState<InviteResponse | null>(null)
async function respond(r: InviteResponse) {
setResponding(r)
try {
await onRespond(r)
setResponded(r)
} finally {
setResponding(null)
}
}
const dateLabel = `${new Date(info.start_ts * 1000).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}${
info.all_day ? '' : `, ${new Date(info.start_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ${new Date(info.end_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
}`
return (
<div className="border-b border-indigo-100 bg-indigo-50/60 px-7 py-3.5 dark:border-indigo-950 dark:bg-indigo-950/30">
<p className="text-[12.5px] font-medium text-indigo-800 dark:text-indigo-200">
Termineinladung: {info.summary}
</p>
<p className="text-[11.5px] text-indigo-500 dark:text-indigo-400">
{dateLabel}
{info.organizer_email && ` · von ${info.organizer_name || info.organizer_email}`}
</p>
{responded ? (
<p className="mt-2 text-[12px] font-medium text-indigo-700 dark:text-indigo-300">
{responded === 'accepted' && 'Angenommen — Antwort wurde gesendet.'}
{responded === 'declined' && 'Abgelehnt — Antwort wurde gesendet.'}
{responded === 'tentative' && 'Mit Vorbehalt beantwortet — Antwort wurde gesendet.'}
</p>
) : (
<div className="mt-2 flex gap-2">
<button
onClick={() => respond('accepted')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-green-600 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-green-500 disabled:opacity-60"
>
<CalendarCheck size={13} /> {responding === 'accepted' ? 'Sende…' : 'Annehmen'}
</button>
<button
onClick={() => respond('tentative')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-amber-500 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:opacity-60"
>
<CalendarClock size={13} /> {responding === 'tentative' ? 'Sende…' : 'Vorbehalt'}
</button>
<button
onClick={() => respond('declined')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-zinc-500 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-zinc-400 disabled:opacity-60"
>
<CalendarX size={13} /> {responding === 'declined' ? 'Sende…' : 'Ablehnen'}
</button>
</div>
)}
</div>
)
}
function downloadAttachment(filename: string, mimeType: string, base64: string) {
const byteChars = atob(base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function ReadingPane({
message,
loading,
selectionCount = 0,
onImportIcsAttachment,
onCreateTask,
onReplyToInvite,
}: {
message: MessageFull | null
loading: boolean
selectionCount?: number
onImportIcsAttachment?: (base64: string) => Promise<void>
onCreateTask?: (message: MessageFull) => void
onReplyToInvite?: (accountId: string, icsBase64: string, response: InviteResponse) => Promise<void>
}) {
const [importingIcs, setImportingIcs] = useState<number | null>(null)
const [previewAttachment, setPreviewAttachment] = useState<AttachmentMeta | null>(null)
const [invite, setInvite] = useState<{ base64: string; info: InviteInfo } | null>(null)
useEffect(() => {
setInvite(null)
if (!message || !onReplyToInvite) return
const icsAtt = message.attachments.find(isIcsAttachment)
if (!icsAtt) return
let cancelled = false
api
.previewInvite(icsAtt.content_base64)
.then((info) => {
if (!cancelled && info) setInvite({ base64: icsAtt.content_base64, info })
})
.catch(() => {})
return () => {
cancelled = true
}
}, [message, onReplyToInvite])
const srcDoc = useMemo(() => {
if (!message) return ''
if (message.body_html) {
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.55;color:#18181b;margin:0;padding:0;word-wrap:break-word;}
img{max-width:100%;height:auto;}
a{color:#4f46e5;}
@media (prefers-color-scheme: dark){ body{color:#e4e4e7;} a{color:#a5b4fc;} }
</style></head><body>${message.body_html}</body></html>`
}
const escaped = (message.body_text || '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
return `<!doctype html><html><head><meta charset="utf-8"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.55;color:#18181b;margin:0;white-space:pre-wrap;word-wrap:break-word;}
@media (prefers-color-scheme: dark){ body{color:#e4e4e7;} }
</style></head><body>${escaped}</body></html>`
}, [message])
async function handleExportEml(m: MessageFull) {
const suggested = `${m.subject.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60) || 'nachricht'}.eml`
const path = await save({ defaultPath: suggested, filters: [{ name: 'E-Mail', extensions: ['eml'] }] })
if (!path) return
await api.exportMessageEml(m.account_id, m.folder, m.uid, path)
}
if (loading) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-zinc-400">
Lade Nachricht
</div>
)
}
if (selectionCount > 1) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-indigo-50 text-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300">
<CheckSquare size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">{selectionCount} Nachrichten ausgewählt</p>
</div>
)
}
if (!message) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 text-zinc-300 dark:bg-zinc-900 dark:text-zinc-700">
<Mail size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">Wähle eine Nachricht aus</p>
</div>
)
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-4 border-b border-zinc-200/80 px-7 py-5 dark:border-zinc-800/80">
<div className="min-w-0">
<h1 className="truncate text-[18px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{message.subject || '(kein Betreff)'}
</h1>
<div className="mt-2.5 flex items-center gap-3">
<Avatar name={message.from_name || message.from_addr} size={38} />
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-200">
{message.from_name || message.from_addr}
<span className="ml-1.5 font-normal text-zinc-400">&lt;{message.from_addr}&gt;</span>
</p>
<p className="truncate text-[12px] text-zinc-400">
an {message.to || 'mich'} · {formatFullDate(message.date)}
</p>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{onCreateTask && (
<button
onClick={() => onCreateTask(message)}
title="Als Aufgabe markieren"
className="rounded-full border border-zinc-200 p-1.5 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<ListPlus size={14} />
</button>
)}
<button
onClick={() => handleExportEml(message)}
title="Als .eml exportieren"
className="rounded-full border border-zinc-200 p-1.5 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<Download size={14} />
</button>
</div>
</div>
{invite && onReplyToInvite && (
<InviteBanner
info={invite.info}
onRespond={(response) => onReplyToInvite(message.account_id, invite.base64, response)}
/>
)}
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-b border-zinc-200/80 px-7 py-3.5 dark:border-zinc-800/80">
{message.attachments.map((att, i) => (
<div key={i} className="flex items-center gap-1">
<button
onClick={() =>
isPreviewable(att) ? setPreviewAttachment(att) : downloadAttachment(att.filename, att.mime_type, att.content_base64)
}
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 text-[12px] text-zinc-600 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
>
<Paperclip size={12} />
<span className="max-w-40 truncate">{att.filename}</span>
<span className="text-zinc-400">{formatBytes(att.size)}</span>
{isPreviewable(att) ? (
<span
onClick={(e) => {
e.stopPropagation()
downloadAttachment(att.filename, att.mime_type, att.content_base64)
}}
title="Herunterladen"
className="text-zinc-400 hover:text-indigo-600"
>
<Download size={11} />
</span>
) : (
<Download size={11} className="text-zinc-400" />
)}
</button>
{isIcsAttachment(att) && onImportIcsAttachment && (
<button
onClick={async () => {
setImportingIcs(i)
try {
await onImportIcsAttachment(att.content_base64)
} finally {
setImportingIcs(null)
}
}}
disabled={importingIcs === i}
title="Zum Kalender hinzufügen"
className="flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-[12px] font-medium text-indigo-700 shadow-sm transition hover:bg-indigo-100 disabled:opacity-60 dark:border-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-300"
>
<CalendarPlus size={12} />
{importingIcs === i ? 'Füge hinzu…' : 'Zum Kalender'}
</button>
)}
</div>
))}
</div>
)}
<iframe
title="message-body"
srcDoc={srcDoc}
sandbox=""
className="w-full flex-1 bg-white dark:bg-zinc-950"
/>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
onDownload={(att) => downloadAttachment(att.filename, att.mime_type, att.content_base64)}
/>
</div>
)
}