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:
@@ -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) => ({ '&': '&', '<': '<', '>': '>' })[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"><{message.from_addr}></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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user