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.
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Plus, Trash2, X } from 'lucide-react'
|
|
import { api } from '../lib/api'
|
|
import type { Account, Folder, MatchType, NewRule, Rule, RuleAction, RuleCondition } from '../types'
|
|
|
|
const inputClass =
|
|
'rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px] text-zinc-800 outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:ring-indigo-500/20'
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
|
|
{children}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function conditionsSummary(conditions: RuleCondition[], matchType: MatchType) {
|
|
const parts = conditions.map((c) => `${c.field === 'subject' ? 'Betreff' : 'Absender'} enthält „${c.contains}“`)
|
|
const joiner = matchType === 'all' ? ' UND ' : ' ODER '
|
|
return parts.join(joiner)
|
|
}
|
|
|
|
function actionLabel(r: Rule, accountFolders: Record<string, Folder[]>) {
|
|
switch (r.action) {
|
|
case 'move_to': {
|
|
const folders = r.account_id ? accountFolders[r.account_id] ?? [] : []
|
|
const folder = folders.find((f) => f.name === r.action_value)
|
|
return `verschieben nach „${folder?.display_name ?? r.action_value}“`
|
|
}
|
|
case 'mark_read':
|
|
return 'als gelesen markieren'
|
|
case 'flag':
|
|
return 'markieren'
|
|
case 'delete':
|
|
return 'löschen'
|
|
}
|
|
}
|
|
|
|
const emptyCondition = (): RuleCondition => ({ field: 'from', contains: '' })
|
|
|
|
export function RulesModal({
|
|
open,
|
|
onClose,
|
|
accounts,
|
|
accountFolders,
|
|
onToast,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
accounts: Account[]
|
|
accountFolders: Record<string, Folder[]>
|
|
onToast: (msg: string) => void
|
|
}) {
|
|
const [rules, setRules] = useState<Rule[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const [accountId, setAccountId] = useState('')
|
|
const [conditions, setConditions] = useState<RuleCondition[]>([emptyCondition()])
|
|
const [matchType, setMatchType] = useState<MatchType>('all')
|
|
const [action, setAction] = useState<RuleAction>('move_to')
|
|
const [actionValue, setActionValue] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const ruleAccounts = accounts.filter((a) => a.protocol === 'imap' || a.protocol === 'pop3')
|
|
const targetFolders = accountId ? accountFolders[accountId] ?? [] : []
|
|
const validConditions = conditions.filter((c) => c.contains.trim())
|
|
const isPop3 = accounts.find((a) => a.id === accountId)?.protocol === 'pop3'
|
|
|
|
function refresh() {
|
|
setLoading(true)
|
|
api
|
|
.listRules()
|
|
.then(setRules)
|
|
.catch((e) => onToast(`Regeln konnten nicht geladen werden: ${e}`))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (open) refresh()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open])
|
|
|
|
if (!open) return null
|
|
|
|
function updateCondition(index: number, patch: Partial<RuleCondition>) {
|
|
setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c)))
|
|
}
|
|
|
|
function addCondition() {
|
|
setConditions((prev) => [...prev, emptyCondition()])
|
|
}
|
|
|
|
function removeCondition(index: number) {
|
|
setConditions((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev))
|
|
}
|
|
|
|
async function handleAdd(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (validConditions.length === 0) return
|
|
if (action === 'move_to' && !actionValue) return
|
|
setSaving(true)
|
|
try {
|
|
const newRule: NewRule = {
|
|
account_id: accountId || null,
|
|
conditions: validConditions.map((c) => ({ field: c.field, contains: c.contains.trim() })),
|
|
match_type: matchType,
|
|
action,
|
|
action_value: action === 'move_to' ? actionValue : undefined,
|
|
}
|
|
await api.createRule(newRule)
|
|
setConditions([emptyCondition()])
|
|
setMatchType('all')
|
|
setActionValue('')
|
|
refresh()
|
|
onToast('Regel erstellt')
|
|
} catch (err) {
|
|
onToast(`Fehlgeschlagen: ${err}`)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleToggle(r: Rule) {
|
|
setRules((prev) => prev.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))
|
|
try {
|
|
await api.setRuleEnabled(r.id, !r.enabled)
|
|
} catch (err) {
|
|
onToast(String(err))
|
|
}
|
|
}
|
|
|
|
async function handleDelete(r: Rule) {
|
|
setRules((prev) => prev.filter((x) => x.id !== r.id))
|
|
try {
|
|
await api.deleteRule(r.id)
|
|
} catch (err) {
|
|
onToast(String(err))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="animate-overlay-in fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-6 backdrop-blur-sm dark:bg-black/60">
|
|
<div className="animate-panel-in flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-[28px] border border-white/70 bg-white/95 shadow-[0_24px_70px_-12px_rgba(15,15,25,0.35)] ring-1 ring-black/[0.03] backdrop-blur-2xl dark:border-white/10 dark:bg-zinc-900/90 dark:shadow-[0_24px_70px_-12px_rgba(0,0,0,0.6)]">
|
|
<div className="flex items-center justify-between border-b border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
|
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
|
Regeln verwalten
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
|
<form onSubmit={handleAdd} className="space-y-2.5 rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-950/40">
|
|
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Neue Regel</p>
|
|
<Field label="Konto">
|
|
<select
|
|
className={inputClass}
|
|
value={accountId}
|
|
onChange={(e) => {
|
|
setAccountId(e.target.value)
|
|
const nowPop3 = accounts.find((a) => a.id === e.target.value)?.protocol === 'pop3'
|
|
if (nowPop3 && action === 'move_to') setAction('mark_read')
|
|
}}
|
|
>
|
|
<option value="">Alle Konten</option>
|
|
{ruleAccounts.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.label}
|
|
{a.protocol === 'pop3' ? ' (POP3)' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Bedingungen</span>
|
|
{conditions.length > 1 && (
|
|
<div className="flex rounded-lg bg-zinc-200/60 p-0.5 text-[11px] dark:bg-zinc-800/60">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMatchType('all')}
|
|
className={`rounded-md px-2 py-0.5 transition ${matchType === 'all' ? 'bg-white font-medium text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500'}`}
|
|
>
|
|
Alle (UND)
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMatchType('any')}
|
|
className={`rounded-md px-2 py-0.5 transition ${matchType === 'any' ? 'bg-white font-medium text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500'}`}
|
|
>
|
|
Eine (ODER)
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{conditions.map((c, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<select
|
|
className={`${inputClass} w-28 shrink-0`}
|
|
value={c.field}
|
|
onChange={(e) => updateCondition(i, { field: e.target.value })}
|
|
>
|
|
<option value="from">Absender</option>
|
|
<option value="subject">Betreff</option>
|
|
</select>
|
|
<input
|
|
className={`${inputClass} flex-1`}
|
|
value={c.contains}
|
|
onChange={(e) => updateCondition(i, { contains: e.target.value })}
|
|
placeholder="z. B. newsletter@ oder Rechnung"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeCondition(i)}
|
|
disabled={conditions.length === 1}
|
|
className="shrink-0 rounded-lg p-1.5 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-30 dark:hover:bg-red-950/40"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addCondition}
|
|
className="flex items-center gap-1 text-[11.5px] font-medium text-indigo-600 transition hover:text-indigo-700 dark:text-indigo-400"
|
|
>
|
|
<Plus size={12} /> Bedingung hinzufügen
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
<Field label="Aktion">
|
|
<select className={inputClass} value={action} onChange={(e) => setAction(e.target.value as RuleAction)}>
|
|
{!isPop3 && <option value="move_to">In Ordner verschieben</option>}
|
|
<option value="mark_read">Als gelesen markieren</option>
|
|
<option value="flag">Markieren</option>
|
|
<option value="delete">Löschen</option>
|
|
</select>
|
|
{isPop3 && (
|
|
<p className="mt-1 text-[10.5px] text-zinc-400">
|
|
POP3 hat keine Server-Ordner, „Verschieben“ ist daher nicht verfügbar.
|
|
</p>
|
|
)}
|
|
</Field>
|
|
{action === 'move_to' && (
|
|
<Field label="Zielordner">
|
|
<select
|
|
className={inputClass}
|
|
value={actionValue}
|
|
onChange={(e) => setActionValue(e.target.value)}
|
|
disabled={!accountId}
|
|
>
|
|
<option value="">{accountId ? 'Ordner wählen…' : 'Erst Konto wählen'}</option>
|
|
{targetFolders.map((f) => (
|
|
<option key={f.name} value={f.name}>
|
|
{f.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={saving || validConditions.length === 0 || (action === 'move_to' && !actionValue)}
|
|
className="w-full rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
|
|
>
|
|
{saving ? 'Speichere…' : 'Regel hinzufügen'}
|
|
</button>
|
|
</form>
|
|
|
|
<div>
|
|
<p className="mb-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
|
Aktive Regeln
|
|
</p>
|
|
{loading ? (
|
|
<p className="text-xs text-zinc-400">Lade…</p>
|
|
) : rules.length === 0 ? (
|
|
<p className="text-xs text-zinc-400">Noch keine Regeln angelegt.</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{rules.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={r.enabled}
|
|
onChange={() => handleToggle(r)}
|
|
className="rounded border-zinc-300"
|
|
/>
|
|
<div className="min-w-0 flex-1 text-[12.5px] text-zinc-700 dark:text-zinc-300">
|
|
<span className="font-medium">{conditionsSummary(r.conditions, r.match_type)}</span>
|
|
{' → '}
|
|
{actionLabel(r, accountFolders)}
|
|
{r.account_id && (
|
|
<span className="text-zinc-400">
|
|
{' '}
|
|
({accounts.find((a) => a.id === r.account_id)?.label ?? '?'})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => handleDelete(r)}
|
|
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/40"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|