Files
mail-client/src/components/RulesModal.tsx
T
KanjiG 20bfd2cf6b 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.
2026-07-28 20:56:05 +02:00

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>
)
}