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.
@@ -0,0 +1,33 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Rust / Tauri build output
|
||||||
|
src-tauri/target
|
||||||
|
|
||||||
|
# Local secrets, never commit
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Mail Client
|
||||||
|
|
||||||
|
Ein nativer Desktop-Mail-Client (Tauri + Rust + React/Tailwind) mit eigenem IMAP-, POP3- und SMTP-Backend. Mehrere Konten (Gmail, Outlook, Yahoo, iCloud, beliebige custom IMAP/POP3-Server), Passwörter liegen im System-Keychain (nicht in einer Datei).
|
||||||
|
|
||||||
|
## Setup (einmalig)
|
||||||
|
|
||||||
|
Systempakete für den Tauri-Build unter Linux:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt update && sudo apt install -y libwebkit2gtk-4.1-dev libssl-dev libdbus-1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev build-essential curl wget file pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust-Toolchain: mind. 1.77 (per `rustup`, siehe https://rustup.rs).
|
||||||
|
|
||||||
|
## Entwicklung starten
|
||||||
|
|
||||||
|
```
|
||||||
|
cd mail-client
|
||||||
|
npm install
|
||||||
|
npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Das öffnet das App-Fenster mit Hot-Reload für Frontend und Backend.
|
||||||
|
|
||||||
|
## Produktions-Build
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konto hinzufügen
|
||||||
|
|
||||||
|
Beim Hinzufügen eines Kontos über die Sidebar (`+`) ein Provider-Preset wählen. Für **Gmail** und **Yahoo** wird ein App-Passwort benötigt (nicht das normale Account-Passwort):
|
||||||
|
|
||||||
|
- Gmail: Google-Konto → Sicherheit → 2-Faktor-Authentifizierung aktivieren → App-Passwörter
|
||||||
|
- Yahoo: Account Security → App-Passwörter generieren
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- `src-tauri/src/imap_client.rs` — IMAP (Ordner, Nachrichtenliste, Volltext) via `imap` + `native-tls`
|
||||||
|
- `src-tauri/src/pop3_client.rs` — handgeschriebener minimaler POP3-Client (TLS, USER/PASS/LIST/TOP/RETR/DELE)
|
||||||
|
- `src-tauri/src/smtp_client.rs` — SMTP-Versand via `lettre` (implizites TLS oder STARTTLS)
|
||||||
|
- `src-tauri/src/db.rs` — SQLite-Cache für Nachrichten (Offline-Ansicht, schnelles erstes Laden)
|
||||||
|
- `src-tauri/src/accounts.rs` — Kontoverwaltung, Passwörter im OS-Keychain (`keyring` crate)
|
||||||
|
- `src/` — React/Tailwind-UI: 3-Spalten-Layout (Sidebar, Nachrichtenliste, Lesefenster) + Compose-Modal
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen (MVP)
|
||||||
|
|
||||||
|
- POP3 verwendet die Server-seitige Nachrichtennummer als ID (keine stabile `UIDL`-basierte ID) — für ein Minimal-POP3-Setup ausreichend, aber nicht robust gegenüber Nachrichten-Neuordnung zwischen Sessions.
|
||||||
|
- Gmail/Outlook/Yahoo laufen über klassisches IMAP+Passwort-Login, nicht OAuth2 (einfacher Einstieg, funktioniert mit App-Passwörtern; für einen "Verified by Google"-OAuth-Flow müsste man eine Google-Cloud-App registrieren).
|
||||||
|
- Kein Ordner-Unread-Counter (Sidebar zeigt aktuell 0), keine Volltextsuche, keine Regeln/Filter.
|
||||||
|
- HTML-Mails werden in einem sandboxed `<iframe>` ohne Skriptausführung gerendert; externe Bilder werden aber nachgeladen (kein Tracking-Pixel-Blocker).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mail Client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "mail-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"lucide-react": "^1.27.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"postcss": "^8.5.23",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.6.3", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.11.3", features = [] }
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
imap = "3.0.0-alpha.15"
|
||||||
|
native-tls = "0.2"
|
||||||
|
mailparse = "0.15"
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder", "pool"] }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
keyring = "4"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||||
|
webbrowser = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
quick-xml = "0.41"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main",
|
||||||
|
"compose-*"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:default",
|
||||||
|
"notification:default",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-focus"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
use crate::db::Db;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::models::{Account, AuthMethod, NewAccount};
|
||||||
|
use keyring::Entry;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SERVICE: &str = "com.mailclient.app";
|
||||||
|
|
||||||
|
fn keyring_entry(account_id: &str) -> AppResult<Entry> {
|
||||||
|
Ok(Entry::new(SERVICE, account_id)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_password(account_id: &str, password: &str) -> AppResult<()> {
|
||||||
|
keyring_entry(account_id)?.set_password(password)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_password(account_id: &str) -> AppResult<String> {
|
||||||
|
Ok(keyring_entry(account_id)?.get_password()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_password(account_id: &str) -> AppResult<()> {
|
||||||
|
let _ = keyring_entry(account_id)?.delete_credential();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_account(db: &Db, new: NewAccount) -> AppResult<Account> {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let account = Account {
|
||||||
|
id: id.clone(),
|
||||||
|
label: new.label,
|
||||||
|
email: new.email,
|
||||||
|
username: new.username,
|
||||||
|
protocol: new.protocol,
|
||||||
|
incoming_host: new.incoming_host,
|
||||||
|
incoming_port: new.incoming_port,
|
||||||
|
smtp_host: new.smtp_host,
|
||||||
|
smtp_port: new.smtp_port,
|
||||||
|
smtp_starttls: new.smtp_starttls,
|
||||||
|
auth_method: AuthMethod::Password,
|
||||||
|
};
|
||||||
|
store_password(&id, &new.password)?;
|
||||||
|
db.save_account(&account)?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_google_account(db: &Db, email: String, refresh_token: &str) -> AppResult<Account> {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let account = Account {
|
||||||
|
id: id.clone(),
|
||||||
|
label: email.clone(),
|
||||||
|
email: email.clone(),
|
||||||
|
username: email,
|
||||||
|
protocol: crate::models::Protocol::Imap,
|
||||||
|
incoming_host: "imap.gmail.com".into(),
|
||||||
|
incoming_port: 993,
|
||||||
|
smtp_host: "smtp.gmail.com".into(),
|
||||||
|
smtp_port: 465,
|
||||||
|
smtp_starttls: false,
|
||||||
|
auth_method: AuthMethod::GoogleOauth,
|
||||||
|
};
|
||||||
|
store_password(&id, refresh_token)?;
|
||||||
|
db.save_account(&account)?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_account(db: &Db, account_id: &str) -> AppResult<()> {
|
||||||
|
delete_password(account_id)?;
|
||||||
|
db.delete_account(account_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::ics;
|
||||||
|
use crate::models::Event;
|
||||||
|
use quick_xml::events::Event as XmlEvent;
|
||||||
|
use quick_xml::reader::Reader;
|
||||||
|
|
||||||
|
/// Everything needed to authenticate a request against a CalDAV calendar
|
||||||
|
/// collection. `url` must be the direct collection URL (e.g. Nextcloud's
|
||||||
|
/// `.../remote.php/dav/calendars/<user>/<calendar>/`) — this module does not do
|
||||||
|
/// principal/calendar-home-set auto-discovery, the user supplies it directly.
|
||||||
|
pub struct CaldavCreds {
|
||||||
|
pub url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> reqwest::blocking::Client {
|
||||||
|
reqwest::blocking::Client::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_status(resp: &reqwest::blocking::Response, action: &str) -> AppResult<()> {
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(AppError::Other(format!("CalDAV {action} fehlgeschlagen: HTTP {}", resp.status())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_name(bytes: &[u8]) -> String {
|
||||||
|
// `local_name()` already strips any namespace prefix (e.g. "d:href" -> "href"),
|
||||||
|
// which is what lets this work across servers using different prefixes.
|
||||||
|
String::from_utf8_lossy(bytes).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawResponse {
|
||||||
|
href: String,
|
||||||
|
etag: Option<String>,
|
||||||
|
calendar_data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_field(cur: &mut RawResponse, field: &str, text: &str) {
|
||||||
|
match field {
|
||||||
|
"href" => cur.href.push_str(text),
|
||||||
|
"getetag" => cur.etag = Some(cur.etag.take().unwrap_or_default() + text),
|
||||||
|
"calendar-data" => cur.calendar_data = Some(cur.calendar_data.take().unwrap_or_default() + text),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a CalDAV/WebDAV multistatus (207) XML response into one entry per
|
||||||
|
/// `<d:response>`. Namespace-prefix-agnostic (matches by local element name only),
|
||||||
|
/// since different servers use different prefixes for the same DAV/CalDAV elements.
|
||||||
|
fn parse_multistatus(xml: &str) -> Vec<RawResponse> {
|
||||||
|
let mut reader = Reader::from_str(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut current: Option<RawResponse> = None;
|
||||||
|
let mut field: Option<String> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let event = match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(XmlEvent::Eof) => break,
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
match event {
|
||||||
|
XmlEvent::Start(e) => {
|
||||||
|
let name = local_name(e.local_name().as_ref());
|
||||||
|
match name.as_str() {
|
||||||
|
"response" => current = Some(RawResponse { href: String::new(), etag: None, calendar_data: None }),
|
||||||
|
"href" | "getetag" | "calendar-data" => field = Some(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlEvent::Text(t) => {
|
||||||
|
if let (Some(cur), Some(f)) = (current.as_mut(), field.as_deref()) {
|
||||||
|
let text = t.decode().map(|c| c.into_owned()).unwrap_or_default();
|
||||||
|
apply_field(cur, f, &text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlEvent::CData(t) => {
|
||||||
|
if let (Some(cur), Some(f)) = (current.as_mut(), field.as_deref()) {
|
||||||
|
let text = String::from_utf8_lossy(t.as_ref()).to_string();
|
||||||
|
apply_field(cur, f, &text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XmlEvent::End(e) => {
|
||||||
|
let name = local_name(e.local_name().as_ref());
|
||||||
|
match name.as_str() {
|
||||||
|
"href" | "getetag" | "calendar-data" => field = None,
|
||||||
|
"response" => {
|
||||||
|
if let Some(c) = current.take() {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_href(base_url: &str, href: &str) -> String {
|
||||||
|
if href.starts_with("http://") || href.starts_with("https://") {
|
||||||
|
return href.to_string();
|
||||||
|
}
|
||||||
|
reqwest::Url::parse(base_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|base| base.join(href).ok())
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_else(|| href.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches every VEVENT resource in the calendar collection via a single REPORT
|
||||||
|
/// request. Each `Event` returned has its href/etag as separate return fields
|
||||||
|
/// (the caller decides how to persist them — this app reuses the `google_event_id`
|
||||||
|
/// column to store the href, mirroring how Google Calendar sync already stores its
|
||||||
|
/// own external id there).
|
||||||
|
pub fn list_events(creds: &CaldavCreds) -> AppResult<Vec<(String, Event)>> {
|
||||||
|
let body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<c:calendar-data/>
|
||||||
|
</d:prop>
|
||||||
|
<c:filter>
|
||||||
|
<c:comp-filter name="VCALENDAR">
|
||||||
|
<c:comp-filter name="VEVENT"/>
|
||||||
|
</c:comp-filter>
|
||||||
|
</c:filter>
|
||||||
|
</c:calendar-query>"#;
|
||||||
|
|
||||||
|
let resp = client()
|
||||||
|
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &creds.url)
|
||||||
|
.basic_auth(&creds.username, Some(&creds.password))
|
||||||
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.header("Depth", "1")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if status.as_u16() != 207 && !status.is_success() {
|
||||||
|
let text = resp.text().unwrap_or_default();
|
||||||
|
return Err(AppError::Other(format!(
|
||||||
|
"CalDAV-Abfrage fehlgeschlagen (HTTP {status}). Prüfe Kalender-URL/Zugangsdaten. Server-Antwort: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let text = resp.text().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let responses = parse_multistatus(&text);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in responses {
|
||||||
|
let Some(data) = r.calendar_data else { continue };
|
||||||
|
if let Some(event) = ics::parse(&data).into_iter().next() {
|
||||||
|
out.push((r.href, event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new resource on the server for `event` and returns its href.
|
||||||
|
pub fn create_event(creds: &CaldavCreds, event: &Event) -> AppResult<String> {
|
||||||
|
let filename = format!("{}.ics", event.id);
|
||||||
|
let url = format!("{}/{}", creds.url.trim_end_matches('/'), filename);
|
||||||
|
let ics_body = ics::export(std::slice::from_ref(event));
|
||||||
|
let resp = client()
|
||||||
|
.put(&url)
|
||||||
|
.basic_auth(&creds.username, Some(&creds.password))
|
||||||
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
.body(ics_body)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
check_status(&resp, "Erstellen")?;
|
||||||
|
Ok(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites the resource at `href` with `event`'s current data.
|
||||||
|
pub fn update_event(creds: &CaldavCreds, href: &str, event: &Event) -> AppResult<()> {
|
||||||
|
let url = resolve_href(&creds.url, href);
|
||||||
|
let ics_body = ics::export(std::slice::from_ref(event));
|
||||||
|
let resp = client()
|
||||||
|
.put(&url)
|
||||||
|
.basic_auth(&creds.username, Some(&creds.password))
|
||||||
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
.body(ics_body)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
check_status(&resp, "Aktualisieren")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_event(creds: &CaldavCreds, href: &str) -> AppResult<()> {
|
||||||
|
let url = resolve_href(&creds.url, href);
|
||||||
|
let resp = client()
|
||||||
|
.delete(&url)
|
||||||
|
.basic_auth(&creds.username, Some(&creds.password))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
check_status(&resp, "Löschen")
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("IMAP error: {0}")]
|
||||||
|
Imap(String),
|
||||||
|
#[error("SMTP error: {0}")]
|
||||||
|
Smtp(String),
|
||||||
|
#[error("POP3 error: {0}")]
|
||||||
|
Pop3(String),
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Db(String),
|
||||||
|
#[error("Keychain error: {0}")]
|
||||||
|
Keychain(String),
|
||||||
|
#[error("Parse error: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for AppError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<rusqlite::Error> for AppError {
|
||||||
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
|
AppError::Db(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<keyring::Error> for AppError {
|
||||||
|
fn from(e: keyring::Error) -> Self {
|
||||||
|
AppError::Keychain(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for AppError {
|
||||||
|
fn from(e: anyhow::Error) -> Self {
|
||||||
|
AppError::Other(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::Event;
|
||||||
|
use chrono::{Local, NaiveDate, TimeZone, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const EVENTS_URL: &str = "https://www.googleapis.com/calendar/v3/calendars/primary/events";
|
||||||
|
|
||||||
|
fn client() -> reqwest::blocking::Client {
|
||||||
|
reqwest::blocking::Client::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rfc3339(ts: i64) -> String {
|
||||||
|
Utc.timestamp_opt(ts, 0).single().map(|d| d.to_rfc3339()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GDateTime {
|
||||||
|
#[serde(rename = "dateTime")]
|
||||||
|
date_time: Option<String>,
|
||||||
|
date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GEvent {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: String,
|
||||||
|
#[serde(default)]
|
||||||
|
location: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
status: Option<String>,
|
||||||
|
start: Option<GDateTime>,
|
||||||
|
end: Option<GDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ListResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<GEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_gdatetime(d: &GDateTime) -> Option<(i64, bool)> {
|
||||||
|
if let Some(dt) = &d.date_time {
|
||||||
|
let parsed = chrono::DateTime::parse_from_rfc3339(dt).ok()?;
|
||||||
|
return Some((parsed.timestamp(), false));
|
||||||
|
}
|
||||||
|
if let Some(date) = &d.date {
|
||||||
|
let nd = NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()?;
|
||||||
|
let dt = nd.and_hms_opt(0, 0, 0)?;
|
||||||
|
let local = Local.from_local_datetime(&dt).single()?;
|
||||||
|
return Some((local.timestamp(), true));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_status(resp: reqwest::blocking::Response, action: &str) -> AppResult<reqwest::blocking::Response> {
|
||||||
|
if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
return Err(AppError::Other(format!(
|
||||||
|
"Kalenderzugriff verweigert ({action}, {status}). Entweder fehlt die Berechtigung (Konto neu verbinden) \
|
||||||
|
oder die Google Calendar API ist im Google-Cloud-Projekt nicht aktiviert (APIs & Services → Library). \
|
||||||
|
Google-Antwort: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
return Err(AppError::Other(format!("Google Kalender: {action} fehlgeschlagen ({status}): {body}")));
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_events(access_token: &str, time_min: i64, time_max: i64) -> AppResult<Vec<Event>> {
|
||||||
|
let resp = client()
|
||||||
|
.get(EVENTS_URL)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.query(&[
|
||||||
|
("timeMin", rfc3339(time_min)),
|
||||||
|
("timeMax", rfc3339(time_max)),
|
||||||
|
("singleEvents", "true".to_string()),
|
||||||
|
("maxResults", "2500".to_string()),
|
||||||
|
("orderBy", "startTime".to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let resp = check_status(resp, "Abrufen")?;
|
||||||
|
let parsed: ListResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for g in parsed.items {
|
||||||
|
if g.status.as_deref() == Some("cancelled") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (Some(start), Some(end)) = (g.start.as_ref(), g.end.as_ref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (Some((start_ts, all_day)), Some((end_ts, _))) = (parse_gdatetime(start), parse_gdatetime(end)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
out.push(Event {
|
||||||
|
id: id.clone(),
|
||||||
|
base_id: id,
|
||||||
|
title: if g.summary.is_empty() { "(Ohne Titel)".to_string() } else { g.summary },
|
||||||
|
location: g.location,
|
||||||
|
description: g.description,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
all_day,
|
||||||
|
account_id: None,
|
||||||
|
google_event_id: Some(g.id),
|
||||||
|
rrule: None,
|
||||||
|
reminder_minutes: None,
|
||||||
|
category: None,
|
||||||
|
exdates: None,
|
||||||
|
series_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GDateTimeOut {
|
||||||
|
#[serde(rename = "dateTime", skip_serializing_if = "Option::is_none")]
|
||||||
|
date_time: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_gdatetime(ts: i64, all_day: bool) -> GDateTimeOut {
|
||||||
|
if all_day {
|
||||||
|
let date = Local
|
||||||
|
.timestamp_opt(ts, 0)
|
||||||
|
.single()
|
||||||
|
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
GDateTimeOut { date_time: None, date: Some(date) }
|
||||||
|
} else {
|
||||||
|
GDateTimeOut { date_time: Some(rfc3339(ts)), date: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_body(event: &Event) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"summary": event.title,
|
||||||
|
"location": event.location,
|
||||||
|
"description": event.description,
|
||||||
|
"start": to_gdatetime(event.start_ts, event.all_day),
|
||||||
|
"end": to_gdatetime(event.end_ts, event.all_day),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct InsertResponse {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_event(access_token: &str, event: &Event) -> AppResult<String> {
|
||||||
|
let resp = client()
|
||||||
|
.post(EVENTS_URL)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.json(&event_body(event))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let resp = check_status(resp, "Erstellen")?;
|
||||||
|
let parsed: InsertResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
Ok(parsed.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_event(access_token: &str, google_id: &str, event: &Event) -> AppResult<()> {
|
||||||
|
let url = format!("{EVENTS_URL}/{google_id}");
|
||||||
|
let resp = client()
|
||||||
|
.put(&url)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.json(&event_body(event))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
check_status(resp, "Aktualisieren")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_event(access_token: &str, google_id: &str) -> AppResult<()> {
|
||||||
|
let url = format!("{EVENTS_URL}/{google_id}");
|
||||||
|
let resp = client()
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
// Google returns 410 Gone if the event was already removed server-side — treat as success.
|
||||||
|
if resp.status().as_u16() == 410 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
check_status(resp, "Löschen")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const CONNECTIONS_URL: &str = "https://people.googleapis.com/v1/people/me/connections";
|
||||||
|
const OTHER_CONTACTS_URL: &str = "https://people.googleapis.com/v1/otherContacts";
|
||||||
|
|
||||||
|
fn client() -> reqwest::blocking::Client {
|
||||||
|
reqwest::blocking::Client::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GName {
|
||||||
|
#[serde(rename = "displayName")]
|
||||||
|
display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GEmailAddress {
|
||||||
|
value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GOrganization {
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GPhoneNumber {
|
||||||
|
value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GAddress {
|
||||||
|
#[serde(rename = "formattedValue")]
|
||||||
|
formatted_value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct GPerson {
|
||||||
|
#[serde(rename = "resourceName", default)]
|
||||||
|
resource_name: String,
|
||||||
|
names: Option<Vec<GName>>,
|
||||||
|
#[serde(rename = "emailAddresses")]
|
||||||
|
email_addresses: Option<Vec<GEmailAddress>>,
|
||||||
|
#[serde(default)]
|
||||||
|
organizations: Option<Vec<GOrganization>>,
|
||||||
|
#[serde(rename = "phoneNumbers", default)]
|
||||||
|
phone_numbers: Option<Vec<GPhoneNumber>>,
|
||||||
|
#[serde(default)]
|
||||||
|
addresses: Option<Vec<GAddress>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ConnectionsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
connections: Vec<GPerson>,
|
||||||
|
#[serde(rename = "nextPageToken")]
|
||||||
|
next_page_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OtherContactsResponse {
|
||||||
|
#[serde(default, rename = "otherContacts")]
|
||||||
|
other_contacts: Vec<GPerson>,
|
||||||
|
#[serde(rename = "nextPageToken")]
|
||||||
|
next_page_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParsedContact {
|
||||||
|
pub name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub phone: Option<String>,
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub company: Option<String>,
|
||||||
|
pub resource_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_status(resp: reqwest::blocking::Response) -> AppResult<reqwest::blocking::Response> {
|
||||||
|
if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
return Err(AppError::Other(format!(
|
||||||
|
"Kontaktzugriff verweigert ({status}). Entweder fehlt die Berechtigung (Konto neu verbinden) oder die \
|
||||||
|
People API ist im Google-Cloud-Projekt nicht aktiviert (APIs & Services → Library). Google-Antwort: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
return Err(AppError::Other(format!("Google Kontakte: Abrufen fehlgeschlagen ({status}): {body}")));
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_person(person: GPerson) -> Option<ParsedContact> {
|
||||||
|
let email = person
|
||||||
|
.email_addresses
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|list| list.first())
|
||||||
|
.and_then(|e| e.value.clone())
|
||||||
|
.filter(|e| !e.is_empty());
|
||||||
|
let phone = person
|
||||||
|
.phone_numbers
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|list| list.first())
|
||||||
|
.and_then(|p| p.value.clone())
|
||||||
|
.filter(|p| !p.is_empty());
|
||||||
|
let address = person
|
||||||
|
.addresses
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|list| list.first())
|
||||||
|
.and_then(|a| a.formatted_value.clone())
|
||||||
|
.filter(|a| !a.is_empty());
|
||||||
|
let display_name = person
|
||||||
|
.names
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|list| list.first())
|
||||||
|
.and_then(|n| n.display_name.clone())
|
||||||
|
.filter(|n| !n.is_empty());
|
||||||
|
|
||||||
|
if email.is_none() && phone.is_none() && address.is_none() && display_name.is_none() {
|
||||||
|
eprintln!("People API: Kontakt „{}“ übersprungen (keine verwertbaren Felder)", person.resource_name);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = display_name
|
||||||
|
.or_else(|| email.clone())
|
||||||
|
.or_else(|| phone.clone())
|
||||||
|
.unwrap_or_else(|| "Unbenannt".to_string());
|
||||||
|
let company = person
|
||||||
|
.organizations
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|list| list.first())
|
||||||
|
.and_then(|o| o.name.clone());
|
||||||
|
let resource_name = person.resource_name.clone();
|
||||||
|
Some(ParsedContact { name, email, phone, address, company, resource_name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls every contact explicitly saved to "My Contacts", paginating through
|
||||||
|
/// the People API's connections endpoint.
|
||||||
|
fn list_connections(access_token: &str) -> AppResult<Vec<ParsedContact>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut page_token: Option<String> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut req = client().get(CONNECTIONS_URL).bearer_auth(access_token).query(&[
|
||||||
|
("personFields", "names,emailAddresses,organizations,phoneNumbers,addresses"),
|
||||||
|
("pageSize", "1000"),
|
||||||
|
]);
|
||||||
|
if let Some(token) = &page_token {
|
||||||
|
req = req.query(&[("pageToken", token.as_str())]);
|
||||||
|
}
|
||||||
|
let resp = req.send().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let resp = check_status(resp)?;
|
||||||
|
let parsed: ConnectionsResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
|
||||||
|
out.extend(parsed.connections.into_iter().filter_map(parse_person));
|
||||||
|
|
||||||
|
page_token = parsed.next_page_token;
|
||||||
|
if page_token.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls "Other contacts" — people auto-collected from mail interactions that
|
||||||
|
/// were never explicitly saved. This is where most of a typical Gmail user's
|
||||||
|
/// actual correspondents live, so skipping it would make sync look empty
|
||||||
|
/// even for an account with plenty of real email history.
|
||||||
|
fn list_other_contacts(access_token: &str) -> AppResult<Vec<ParsedContact>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut page_token: Option<String> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut req = client().get(OTHER_CONTACTS_URL).bearer_auth(access_token).query(&[
|
||||||
|
("readMask", "names,emailAddresses,phoneNumbers"),
|
||||||
|
("pageSize", "1000"),
|
||||||
|
]);
|
||||||
|
if let Some(token) = &page_token {
|
||||||
|
req = req.query(&[("pageToken", token.as_str())]);
|
||||||
|
}
|
||||||
|
let resp = req.send().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let resp = check_status(resp)?;
|
||||||
|
let parsed: OtherContactsResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
|
||||||
|
out.extend(parsed.other_contacts.into_iter().filter_map(parse_person));
|
||||||
|
|
||||||
|
page_token = parsed.next_page_token;
|
||||||
|
if page_token.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls both "My Contacts" and auto-collected "Other contacts". Duplicates
|
||||||
|
/// across the two lists are harmless — the caller upserts by email.
|
||||||
|
///
|
||||||
|
/// "Other contacts" needs its own separate scope (`contacts.other.readonly`)
|
||||||
|
/// beyond plain `contacts.readonly` — an account that hasn't reconnected since
|
||||||
|
/// that scope was added will get a 403 here. That must not wipe out the
|
||||||
|
/// "My Contacts" results we already fetched, so it's treated as non-fatal.
|
||||||
|
pub fn list_contacts(access_token: &str) -> AppResult<Vec<ParsedContact>> {
|
||||||
|
let mut out = list_connections(access_token)?;
|
||||||
|
match list_other_contacts(access_token) {
|
||||||
|
Ok(other) => out.extend(other),
|
||||||
|
Err(e) => eprintln!("People API: 'Andere Kontakte' übersprungen ({e})"),
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a contact on the Google side. `resource_name` is the People API
|
||||||
|
/// resource id (e.g. "people/c1234567890123456789"), already URL-path-safe.
|
||||||
|
pub fn delete_contact(access_token: &str, resource_name: &str) -> AppResult<()> {
|
||||||
|
let url = format!("https://people.googleapis.com/v1/{resource_name}:deleteContact");
|
||||||
|
let resp = client()
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
check_status(resp)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
use crate::models::Event;
|
||||||
|
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn escape(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\")
|
||||||
|
.replace(',', "\\,")
|
||||||
|
.replace(';', "\\;")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
s.replace("\\n", "\n")
|
||||||
|
.replace("\\N", "\n")
|
||||||
|
.replace("\\,", ",")
|
||||||
|
.replace("\\;", ";")
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unfold(data: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(data.len());
|
||||||
|
for raw_line in data.split('\n') {
|
||||||
|
let line = raw_line.trim_end_matches('\r');
|
||||||
|
if (line.starts_with(' ') || line.starts_with('\t')) && !out.is_empty() {
|
||||||
|
out.push_str(&line[1..]);
|
||||||
|
} else {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a DTSTART/DTEND property line (with any params) into a unix timestamp
|
||||||
|
/// and whether it represents an all-day (date-only) value.
|
||||||
|
fn parse_dt(line: &str) -> Option<(i64, bool)> {
|
||||||
|
let colon = line.find(':')?;
|
||||||
|
let params = &line[..colon];
|
||||||
|
let value = line[colon + 1..].trim();
|
||||||
|
|
||||||
|
let is_date_only = params.to_uppercase().contains("VALUE=DATE") && !params.to_uppercase().contains("VALUE=DATE-TIME");
|
||||||
|
|
||||||
|
if is_date_only || (value.len() == 8 && !value.contains('T')) {
|
||||||
|
let date = NaiveDate::parse_from_str(value, "%Y%m%d").ok()?;
|
||||||
|
let dt = date.and_hms_opt(0, 0, 0)?;
|
||||||
|
let local = Local.from_local_datetime(&dt).single()?;
|
||||||
|
return Some((local.timestamp(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stripped) = value.strip_suffix('Z') {
|
||||||
|
let dt = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
|
||||||
|
return Some((Utc.from_utc_datetime(&dt).timestamp(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dt = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S").ok()?;
|
||||||
|
let local = Local.from_local_datetime(&dt).single()?;
|
||||||
|
Some((local.timestamp(), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_dt(ts: i64, all_day: bool) -> String {
|
||||||
|
if all_day {
|
||||||
|
Local.timestamp_opt(ts, 0).single().map(|d| d.format("%Y%m%d").to_string()).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Utc.timestamp_opt(ts, 0).single().map(|d| d.format("%Y%m%dT%H%M%SZ").to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export(events: &[Event]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//MailClient//DE\r\nCALSCALE:GREGORIAN\r\n");
|
||||||
|
for e in events {
|
||||||
|
out.push_str("BEGIN:VEVENT\r\n");
|
||||||
|
out.push_str(&format!("UID:{}\r\n", e.id));
|
||||||
|
if e.all_day {
|
||||||
|
out.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_dt(e.start_ts, true)));
|
||||||
|
out.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_dt(e.end_ts, true)));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("DTSTART:{}\r\n", format_dt(e.start_ts, false)));
|
||||||
|
out.push_str(&format!("DTEND:{}\r\n", format_dt(e.end_ts, false)));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("SUMMARY:{}\r\n", escape(&e.title)));
|
||||||
|
if let Some(loc) = e.location.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("LOCATION:{}\r\n", escape(loc)));
|
||||||
|
}
|
||||||
|
if let Some(desc) = e.description.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("DESCRIPTION:{}\r\n", escape(desc)));
|
||||||
|
}
|
||||||
|
if let Some(rrule) = e.rrule.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("RRULE:{rrule}\r\n"));
|
||||||
|
}
|
||||||
|
out.push_str("END:VEVENT\r\n");
|
||||||
|
}
|
||||||
|
out.push_str("END:VCALENDAR\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct InviteInfo {
|
||||||
|
pub uid: String,
|
||||||
|
pub sequence: i32,
|
||||||
|
pub organizer_email: Option<String>,
|
||||||
|
pub organizer_name: Option<String>,
|
||||||
|
pub summary: String,
|
||||||
|
pub start_ts: i64,
|
||||||
|
pub end_ts: i64,
|
||||||
|
pub all_day: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_mailto(line: &str) -> Option<String> {
|
||||||
|
let value = line.rsplit(':').next()?.trim();
|
||||||
|
Some(value.strip_prefix("mailto:").unwrap_or(value).trim().to_lowercase()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cn(line: &str) -> Option<String> {
|
||||||
|
let params = line.split(':').next()?;
|
||||||
|
let idx = params.to_uppercase().find("CN=")?;
|
||||||
|
let rest = ¶ms[idx + 3..];
|
||||||
|
let end = rest.find(';').unwrap_or(rest.len());
|
||||||
|
let name = rest[..end].trim_matches('"').trim();
|
||||||
|
if name.is_empty() { None } else { Some(name.to_string()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts organizer/UID/sequence plus the same title/time fields as `parse`, from
|
||||||
|
/// the first VEVENT in an invite. Used to build an iTIP-style REPLY (Accept/Decline/
|
||||||
|
/// Tentative) — deliberately separate from `parse`/`Event` since the general calendar
|
||||||
|
/// model has no room for iTIP participant metadata and doesn't need it.
|
||||||
|
pub fn parse_invite(data: &str) -> Option<InviteInfo> {
|
||||||
|
let unfolded = unfold(data);
|
||||||
|
let mut uid = String::new();
|
||||||
|
let mut sequence = 0i32;
|
||||||
|
let mut organizer_email = None;
|
||||||
|
let mut organizer_name = None;
|
||||||
|
let mut title = String::new();
|
||||||
|
let mut start_ts: Option<i64> = None;
|
||||||
|
let mut end_ts: Option<i64> = None;
|
||||||
|
let mut all_day = false;
|
||||||
|
let mut in_event = false;
|
||||||
|
|
||||||
|
for line in unfolded.lines() {
|
||||||
|
let upper = line.to_uppercase();
|
||||||
|
if upper == "BEGIN:VEVENT" {
|
||||||
|
in_event = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_event {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if upper == "END:VEVENT" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if upper.starts_with("UID") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
uid = line[idx + 1..].trim().to_string();
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("SEQUENCE") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
sequence = line[idx + 1..].trim().parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("ORGANIZER") {
|
||||||
|
organizer_email = extract_mailto(line);
|
||||||
|
organizer_name = extract_cn(line);
|
||||||
|
} else if upper.starts_with("DTSTART") {
|
||||||
|
if let Some((ts, ad)) = parse_dt(line) {
|
||||||
|
start_ts = Some(ts);
|
||||||
|
all_day = ad;
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("DTEND") {
|
||||||
|
if let Some((ts, _)) = parse_dt(line) {
|
||||||
|
end_ts = Some(ts);
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("SUMMARY") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
title = unescape(&line[idx + 1..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = start_ts?;
|
||||||
|
if uid.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(InviteInfo {
|
||||||
|
uid,
|
||||||
|
sequence,
|
||||||
|
organizer_email,
|
||||||
|
organizer_name,
|
||||||
|
summary: if title.is_empty() { "(Ohne Titel)".to_string() } else { title },
|
||||||
|
start_ts: start,
|
||||||
|
end_ts: end_ts.unwrap_or(start + if all_day { 86400 } else { 3600 }),
|
||||||
|
all_day,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an iTIP METHOD:REPLY calendar object for an invite response
|
||||||
|
/// (PARTSTAT one of ACCEPTED/DECLINED/TENTATIVE).
|
||||||
|
pub fn build_reply(invite: &InviteInfo, attendee_email: &str, partstat: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//MailClient//DE\r\nMETHOD:REPLY\r\n");
|
||||||
|
out.push_str("BEGIN:VEVENT\r\n");
|
||||||
|
out.push_str(&format!("UID:{}\r\n", invite.uid));
|
||||||
|
out.push_str(&format!("SEQUENCE:{}\r\n", invite.sequence));
|
||||||
|
if invite.all_day {
|
||||||
|
out.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_dt(invite.start_ts, true)));
|
||||||
|
out.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_dt(invite.end_ts, true)));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("DTSTART:{}\r\n", format_dt(invite.start_ts, false)));
|
||||||
|
out.push_str(&format!("DTEND:{}\r\n", format_dt(invite.end_ts, false)));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("SUMMARY:{}\r\n", escape(&invite.summary)));
|
||||||
|
if let Some(organizer) = &invite.organizer_email {
|
||||||
|
out.push_str(&format!("ORGANIZER:mailto:{organizer}\r\n"));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("ATTENDEE;PARTSTAT={partstat};ROLE=REQ-PARTICIPANT:mailto:{attendee_email}\r\n"));
|
||||||
|
out.push_str("END:VEVENT\r\n");
|
||||||
|
out.push_str("END:VCALENDAR\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(data: &str) -> Vec<Event> {
|
||||||
|
let unfolded = unfold(data);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
|
||||||
|
let mut title = String::new();
|
||||||
|
let mut location: Option<String> = None;
|
||||||
|
let mut description: Option<String> = None;
|
||||||
|
let mut start_ts: Option<i64> = None;
|
||||||
|
let mut end_ts: Option<i64> = None;
|
||||||
|
let mut all_day = false;
|
||||||
|
let mut in_event = false;
|
||||||
|
let mut rrule: Option<String> = None;
|
||||||
|
|
||||||
|
for line in unfolded.lines() {
|
||||||
|
let upper = line.to_uppercase();
|
||||||
|
if upper == "BEGIN:VEVENT" {
|
||||||
|
in_event = true;
|
||||||
|
title.clear();
|
||||||
|
location = None;
|
||||||
|
description = None;
|
||||||
|
start_ts = None;
|
||||||
|
end_ts = None;
|
||||||
|
all_day = false;
|
||||||
|
rrule = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_event {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if upper == "END:VEVENT" {
|
||||||
|
in_event = false;
|
||||||
|
if let (Some(start), true) = (start_ts, !title.is_empty() || start_ts.is_some()) {
|
||||||
|
let end = end_ts.unwrap_or(start + if all_day { 86400 } else { 3600 });
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
out.push(Event {
|
||||||
|
id: id.clone(),
|
||||||
|
base_id: id,
|
||||||
|
title: if title.is_empty() { "(Ohne Titel)".to_string() } else { title.clone() },
|
||||||
|
location: location.clone(),
|
||||||
|
description: description.clone(),
|
||||||
|
start_ts: start,
|
||||||
|
end_ts: end,
|
||||||
|
all_day,
|
||||||
|
account_id: None,
|
||||||
|
google_event_id: None,
|
||||||
|
rrule: rrule.clone(),
|
||||||
|
reminder_minutes: None,
|
||||||
|
category: None,
|
||||||
|
exdates: None,
|
||||||
|
series_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if upper.starts_with("DTSTART") {
|
||||||
|
if let Some((ts, ad)) = parse_dt(line) {
|
||||||
|
start_ts = Some(ts);
|
||||||
|
all_day = ad;
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("DTEND") {
|
||||||
|
if let Some((ts, _)) = parse_dt(line) {
|
||||||
|
end_ts = Some(ts);
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("SUMMARY") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
title = unescape(&line[idx + 1..]);
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("LOCATION") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
location = Some(unescape(&line[idx + 1..]));
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("DESCRIPTION") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
description = Some(unescape(&line[idx + 1..]));
|
||||||
|
}
|
||||||
|
} else if upper.starts_with("RRULE") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
rrule = Some(line[idx + 1..].trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use crate::db::Db;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::imap_client;
|
||||||
|
use crate::models::{Account, Protocol};
|
||||||
|
use imap::extensions::idle;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
|
/// Last-known unseen count per account, used to detect *new* mail (an
|
||||||
|
/// increase) rather than notifying on every mailbox change IDLE reports
|
||||||
|
/// (read/flag changes on other clients also wake IDLE).
|
||||||
|
pub struct UnseenState(pub Mutex<HashMap<String, u32>>);
|
||||||
|
|
||||||
|
impl UnseenState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
UnseenState(Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-configurable toggle for the native OS desktop notification on new
|
||||||
|
/// mail (the in-app popup is a separate, purely frontend-driven concern).
|
||||||
|
pub struct NotificationSettings(pub AtomicBool);
|
||||||
|
|
||||||
|
impl NotificationSettings {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
NotificationSettings(AtomicBool::new(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_BACKOFF_SECS: u64 = 20;
|
||||||
|
|
||||||
|
/// Spawns an IDLE watcher for every IMAP account currently in the DB.
|
||||||
|
/// Called once at app startup.
|
||||||
|
pub fn spawn_watchers(app: AppHandle) {
|
||||||
|
let accounts = {
|
||||||
|
let db = app.state::<Db>();
|
||||||
|
db.list_accounts().unwrap_or_default()
|
||||||
|
};
|
||||||
|
for account in accounts.into_iter().filter(|a| a.protocol == Protocol::Imap) {
|
||||||
|
spawn_watcher(app.clone(), account.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a persistent IMAP IDLE watcher for one account. Reconnects with a
|
||||||
|
/// backoff on any I/O error, and exits cleanly once the account no longer
|
||||||
|
/// exists in the DB (checked between IDLE cycles) — e.g. after deletion.
|
||||||
|
pub fn spawn_watcher(app: AppHandle, account_id: String) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let account = {
|
||||||
|
let db = app.state::<Db>();
|
||||||
|
db.list_accounts()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.id == account_id && a.protocol == Protocol::Imap)
|
||||||
|
};
|
||||||
|
let Some(account) = account else { break };
|
||||||
|
|
||||||
|
let app_c = app.clone();
|
||||||
|
let result = tauri::async_runtime::spawn_blocking(move || run_idle_loop(&app_c, &account)).await;
|
||||||
|
|
||||||
|
if !matches!(result, Ok(Ok(()))) {
|
||||||
|
tokio::time::sleep(Duration::from_secs(RECONNECT_BACKOFF_SECS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_idle_loop(app: &AppHandle, account: &Account) -> AppResult<()> {
|
||||||
|
let mut session = imap_client::open_session(account)?;
|
||||||
|
session.select("INBOX").map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
{
|
||||||
|
let db = app.state::<Db>();
|
||||||
|
let still_exists = db.list_accounts().unwrap_or_default().iter().any(|a| a.id == account.id);
|
||||||
|
if !still_exists {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session
|
||||||
|
.idle()
|
||||||
|
.wait_while(idle::stop_on_any)
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
|
||||||
|
let Ok(count) = imap_client::unseen_count(&mut session, "INBOX") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let prev = {
|
||||||
|
let state = app.state::<UnseenState>();
|
||||||
|
let mut map = state.0.lock().unwrap();
|
||||||
|
map.insert(account.id.clone(), count)
|
||||||
|
};
|
||||||
|
if let Some(prev) = prev {
|
||||||
|
if count > prev {
|
||||||
|
let delta = count - prev;
|
||||||
|
let body = if delta == 1 {
|
||||||
|
format!("1 neue Nachricht in {}", account.label)
|
||||||
|
} else {
|
||||||
|
format!("{delta} neue Nachrichten in {}", account.label)
|
||||||
|
};
|
||||||
|
let desktop_enabled = app.state::<NotificationSettings>().0.load(Ordering::Relaxed);
|
||||||
|
if desktop_enabled {
|
||||||
|
let _ = app.notification().builder().title("Neue E-Mail").body(body).show();
|
||||||
|
}
|
||||||
|
let _ = app.emit(
|
||||||
|
"new-mail",
|
||||||
|
serde_json::json!({ "accountId": account.id, "folder": "INBOX" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::mailutil::{parse_message_full, split_from, thread_ids};
|
||||||
|
use crate::models::{Account, AuthMethod, Folder, MessageFull, MessageSummary};
|
||||||
|
use crate::{accounts, oauth};
|
||||||
|
use imap::types::Flag;
|
||||||
|
use mailparse::{parse_mail, MailHeaderMap};
|
||||||
|
|
||||||
|
type ImapSession = imap::Session<imap::Connection>;
|
||||||
|
|
||||||
|
const FETCH_LIMIT: u32 = 60;
|
||||||
|
const EXPORT_LIMIT: u32 = 500;
|
||||||
|
|
||||||
|
pub fn connect(host: &str, port: u16, username: &str, password: &str) -> AppResult<ImapSession> {
|
||||||
|
let client = imap::ClientBuilder::new(host, port)
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
client
|
||||||
|
.login(username, password)
|
||||||
|
.map_err(|(e, _)| AppError::Imap(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct XOAuth2 {
|
||||||
|
user: String,
|
||||||
|
access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl imap::Authenticator for XOAuth2 {
|
||||||
|
type Response = String;
|
||||||
|
fn process(&self, _challenge: &[u8]) -> Self::Response {
|
||||||
|
format!("user={}\x01auth=Bearer {}\x01\x01", self.user, self.access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_oauth(host: &str, port: u16, email: &str, access_token: &str) -> AppResult<ImapSession> {
|
||||||
|
let client = imap::ClientBuilder::new(host, port)
|
||||||
|
.connect()
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
let auth = XOAuth2 {
|
||||||
|
user: email.to_string(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
};
|
||||||
|
client
|
||||||
|
.authenticate("XOAUTH2", &auth)
|
||||||
|
.map_err(|(e, _)| AppError::Imap(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens an authenticated IMAP session for the given account, resolving either its
|
||||||
|
/// stored password or (for OAuth accounts) refreshing a fresh access token first.
|
||||||
|
pub fn open_session(account: &Account) -> AppResult<ImapSession> {
|
||||||
|
match account.auth_method {
|
||||||
|
AuthMethod::Password => {
|
||||||
|
let password = accounts::get_password(&account.id)?;
|
||||||
|
connect(&account.incoming_host, account.incoming_port, &account.username, &password)
|
||||||
|
}
|
||||||
|
AuthMethod::GoogleOauth => {
|
||||||
|
let refresh_token = accounts::get_password(&account.id)?;
|
||||||
|
let access_token = oauth::refresh_access_token(&refresh_token)?;
|
||||||
|
connect_oauth(&account.incoming_host, account.incoming_port, &account.email, &access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_folders(session: &mut ImapSession) -> AppResult<Vec<Folder>> {
|
||||||
|
let names = session
|
||||||
|
.list(None, Some("*"))
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for n in names.iter() {
|
||||||
|
let delim = n.delimiter().unwrap_or("/");
|
||||||
|
let display = n.name().rsplit(delim).next().unwrap_or(n.name()).to_string();
|
||||||
|
let unread = session
|
||||||
|
.status(n.name(), "(UNSEEN)")
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.unseen)
|
||||||
|
.unwrap_or(0);
|
||||||
|
out.push(Folder {
|
||||||
|
name: n.name().to_string(),
|
||||||
|
display_name: display,
|
||||||
|
unread,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unseen_count(session: &mut ImapSession, folder: &str) -> AppResult<u32> {
|
||||||
|
let mailbox = session
|
||||||
|
.status(folder, "(UNSEEN)")
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
Ok(mailbox.unseen.unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_messages(
|
||||||
|
session: &mut ImapSession,
|
||||||
|
account_id: &str,
|
||||||
|
folder: &str,
|
||||||
|
) -> AppResult<Vec<MessageSummary>> {
|
||||||
|
let mailbox = session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
if mailbox.exists == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let end = mailbox.exists;
|
||||||
|
let start = if end > FETCH_LIMIT { end - FETCH_LIMIT + 1 } else { 1 };
|
||||||
|
let seq = format!("{}:{}", start, end);
|
||||||
|
let fetches = session
|
||||||
|
.fetch(seq, "(UID FLAGS RFC822.HEADER)")
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for f in fetches.iter() {
|
||||||
|
let uid = match f.uid {
|
||||||
|
Some(u) => u,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let header_bytes = f.header().unwrap_or(&[]);
|
||||||
|
let parsed = parse_mail(header_bytes).ok();
|
||||||
|
let from_raw = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("From"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (from_name, from_addr) = split_from(&from_raw);
|
||||||
|
let subject = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("Subject"))
|
||||||
|
.unwrap_or_else(|| "(kein Betreff)".to_string());
|
||||||
|
let date_raw = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("Date"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let timestamp = mailparse::dateparse(&date_raw).unwrap_or(0);
|
||||||
|
let is_read = f.flags().iter().any(|fl| matches!(fl, Flag::Seen));
|
||||||
|
let flagged = f.flags().iter().any(|fl| matches!(fl, Flag::Flagged));
|
||||||
|
let (message_id, thread_id) = thread_ids(parsed.as_ref(), &format!("{account_id}-{folder}-{uid}"));
|
||||||
|
|
||||||
|
out.push(MessageSummary {
|
||||||
|
uid,
|
||||||
|
account_id: account_id.to_string(),
|
||||||
|
folder: folder.to_string(),
|
||||||
|
from_name,
|
||||||
|
from_addr,
|
||||||
|
subject,
|
||||||
|
date: date_raw,
|
||||||
|
timestamp,
|
||||||
|
snippet: String::new(),
|
||||||
|
is_read,
|
||||||
|
has_attachments: false,
|
||||||
|
flagged,
|
||||||
|
message_id,
|
||||||
|
thread_id,
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_raw(session: &mut ImapSession, folder: &str, uid: u32) -> AppResult<Vec<u8>> {
|
||||||
|
session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
let fetches = session
|
||||||
|
.uid_fetch(uid.to_string(), "(UID RFC822)")
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
let f = fetches
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("message uid {uid} not found")))?;
|
||||||
|
Ok(f.body().unwrap_or(&[]).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_full_message(
|
||||||
|
session: &mut ImapSession,
|
||||||
|
account_id: &str,
|
||||||
|
folder: &str,
|
||||||
|
uid: u32,
|
||||||
|
) -> AppResult<MessageFull> {
|
||||||
|
let raw = fetch_raw(session, folder, uid)?;
|
||||||
|
parse_message_full(account_id, folder, uid, &raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_flag(session: &mut ImapSession, folder: &str, uid: u32, flag: &str, add: bool) -> AppResult<()> {
|
||||||
|
session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
let query = if add {
|
||||||
|
format!("+FLAGS ({flag})")
|
||||||
|
} else {
|
||||||
|
format!("-FLAGS ({flag})")
|
||||||
|
};
|
||||||
|
session
|
||||||
|
.uid_store(uid.to_string(), query)
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_message(
|
||||||
|
session: &mut ImapSession,
|
||||||
|
from_folder: &str,
|
||||||
|
uid: u32,
|
||||||
|
to_folder: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
session.select(from_folder).map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
if session.uid_mv(uid.to_string(), to_folder).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Server doesn't support MOVE (RFC 6851) -> fall back to COPY + delete + expunge.
|
||||||
|
session
|
||||||
|
.uid_copy(uid.to_string(), to_folder)
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.uid_store(uid.to_string(), "+FLAGS (\\Deleted)")
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
session.expunge().map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_message(session: &mut ImapSession, folder: &str, uid: u32) -> AppResult<()> {
|
||||||
|
session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.uid_store(uid.to_string(), "+FLAGS (\\Deleted)")
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
session.expunge().map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_folder(session: &mut ImapSession, name: &str) -> AppResult<()> {
|
||||||
|
session.create(name).map_err(|e| AppError::Imap(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_folder(session: &mut ImapSession, from: &str, to: &str) -> AppResult<()> {
|
||||||
|
session.rename(from, to).map_err(|e| AppError::Imap(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_folder(session: &mut ImapSession, name: &str) -> AppResult<()> {
|
||||||
|
session.delete(name).map_err(|e| AppError::Imap(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_message(session: &mut ImapSession, folder: &str, raw: &[u8]) -> AppResult<()> {
|
||||||
|
session
|
||||||
|
.append(folder, raw)
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches raw RFC822 bytes for up to `EXPORT_LIMIT` of the most recent messages in a folder.
|
||||||
|
pub fn export_folder(session: &mut ImapSession, folder: &str) -> AppResult<Vec<Vec<u8>>> {
|
||||||
|
let mailbox = session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
if mailbox.exists == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let end = mailbox.exists;
|
||||||
|
let start = if end > EXPORT_LIMIT { end - EXPORT_LIMIT + 1 } else { 1 };
|
||||||
|
let seq = format!("{}:{}", start, end);
|
||||||
|
let fetches = session
|
||||||
|
.fetch(seq, "RFC822")
|
||||||
|
.map_err(|e| AppError::Imap(e.to_string()))?;
|
||||||
|
Ok(fetches
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.body())
|
||||||
|
.filter(|b| !b.is_empty())
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
mod accounts;
|
||||||
|
mod caldav;
|
||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod gcal;
|
||||||
|
mod gcontacts;
|
||||||
|
mod ics;
|
||||||
|
mod idle;
|
||||||
|
mod imap_client;
|
||||||
|
mod mailutil;
|
||||||
|
mod mbox;
|
||||||
|
mod models;
|
||||||
|
mod oauth;
|
||||||
|
mod pop3_client;
|
||||||
|
mod recurrence;
|
||||||
|
mod reminders;
|
||||||
|
mod smtp_client;
|
||||||
|
mod vcard;
|
||||||
|
|
||||||
|
use db::Db;
|
||||||
|
use idle::UnseenState;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.setup(|app| {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
app.handle().plugin(
|
||||||
|
tauri_plugin_log::Builder::default()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.build(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let data_dir = app.path().app_data_dir()?;
|
||||||
|
let db = Db::open(&data_dir)?;
|
||||||
|
app.manage(db);
|
||||||
|
app.manage(UnseenState::new());
|
||||||
|
app.manage(idle::NotificationSettings::new());
|
||||||
|
app.manage(commands::PendingSends::new());
|
||||||
|
idle::spawn_watchers(app.handle().clone());
|
||||||
|
reminders::spawn_poller(app.handle().clone());
|
||||||
|
commands::rehydrate_scheduled_sends(app.handle());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::add_account,
|
||||||
|
commands::list_accounts,
|
||||||
|
commands::delete_account,
|
||||||
|
commands::update_account,
|
||||||
|
commands::list_folders,
|
||||||
|
commands::create_folder,
|
||||||
|
commands::rename_folder,
|
||||||
|
commands::delete_folder,
|
||||||
|
commands::mark_folder_read,
|
||||||
|
commands::apply_rules_to_folder,
|
||||||
|
commands::sync_messages,
|
||||||
|
commands::list_cached_messages,
|
||||||
|
commands::get_message,
|
||||||
|
commands::send_message,
|
||||||
|
commands::send_message_at,
|
||||||
|
commands::cancel_scheduled_send,
|
||||||
|
commands::mark_read,
|
||||||
|
commands::delete_message,
|
||||||
|
commands::search_messages,
|
||||||
|
commands::search_all_messages,
|
||||||
|
commands::set_message_category,
|
||||||
|
commands::toggle_flag,
|
||||||
|
commands::move_message,
|
||||||
|
commands::list_contacts,
|
||||||
|
commands::search_contacts,
|
||||||
|
commands::add_contact,
|
||||||
|
commands::update_contact,
|
||||||
|
commands::delete_contact,
|
||||||
|
commands::toggle_contact_favorite,
|
||||||
|
commands::export_contacts_vcard,
|
||||||
|
commands::import_contacts_vcard,
|
||||||
|
commands::export_message_eml,
|
||||||
|
commands::export_folder_mbox,
|
||||||
|
commands::export_full_backup,
|
||||||
|
commands::import_eml_file,
|
||||||
|
commands::import_mbox_file,
|
||||||
|
commands::save_draft,
|
||||||
|
commands::load_draft,
|
||||||
|
commands::google_oauth_add_account,
|
||||||
|
commands::list_rules,
|
||||||
|
commands::create_rule,
|
||||||
|
commands::set_rule_enabled,
|
||||||
|
commands::delete_rule,
|
||||||
|
commands::list_events,
|
||||||
|
commands::create_event,
|
||||||
|
commands::update_event,
|
||||||
|
commands::delete_event,
|
||||||
|
commands::import_ics_file,
|
||||||
|
commands::import_ics_text,
|
||||||
|
commands::reply_to_invite,
|
||||||
|
commands::preview_invite,
|
||||||
|
commands::get_vacation_settings,
|
||||||
|
commands::save_vacation_settings,
|
||||||
|
commands::snooze_message,
|
||||||
|
commands::unsnooze_message,
|
||||||
|
commands::list_snoozed_messages,
|
||||||
|
commands::list_templates,
|
||||||
|
commands::create_template,
|
||||||
|
commands::update_template,
|
||||||
|
commands::delete_template,
|
||||||
|
commands::search_messages_advanced,
|
||||||
|
commands::update_event_occurrence,
|
||||||
|
commands::delete_event_occurrence,
|
||||||
|
commands::list_caldav_accounts,
|
||||||
|
commands::add_caldav_account,
|
||||||
|
commands::delete_caldav_account,
|
||||||
|
commands::sync_caldav_calendar,
|
||||||
|
commands::export_events_ics,
|
||||||
|
commands::get_event,
|
||||||
|
commands::list_upcoming_reminders,
|
||||||
|
commands::sync_google_calendar,
|
||||||
|
commands::sync_google_contacts,
|
||||||
|
commands::reconnect_google_account,
|
||||||
|
commands::set_desktop_notifications_enabled,
|
||||||
|
commands::list_tasks,
|
||||||
|
commands::create_task,
|
||||||
|
commands::update_task,
|
||||||
|
commands::delete_task,
|
||||||
|
commands::set_task_completed,
|
||||||
|
commands::create_task_from_message,
|
||||||
|
commands::list_contact_groups,
|
||||||
|
commands::create_contact_group,
|
||||||
|
commands::update_contact_group,
|
||||||
|
commands::delete_contact_group,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::{AttachmentMeta, MessageFull, MessageSummary};
|
||||||
|
use base64::Engine;
|
||||||
|
use mailparse::{parse_mail, MailHeaderMap, ParsedMail};
|
||||||
|
|
||||||
|
/// Derives (message_id, thread_id) from a parsed message's headers.
|
||||||
|
/// thread_id is the root of the References chain, falling back to
|
||||||
|
/// In-Reply-To, then to the message's own id — so every message always
|
||||||
|
/// lands in a thread, even a singleton one.
|
||||||
|
pub fn thread_ids(parsed: Option<&ParsedMail>, seed: &str) -> (String, String) {
|
||||||
|
let message_id = parsed
|
||||||
|
.and_then(|p| p.headers.get_first_value("Message-ID"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| format!("<local-{seed}>"));
|
||||||
|
|
||||||
|
let references = parsed.and_then(|p| p.headers.get_first_value("References")).unwrap_or_default();
|
||||||
|
let in_reply_to = parsed.and_then(|p| p.headers.get_first_value("In-Reply-To")).unwrap_or_default();
|
||||||
|
|
||||||
|
let thread_id = references
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
let t = in_reply_to.trim().to_string();
|
||||||
|
if t.is_empty() { None } else { Some(t) }
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| message_id.clone());
|
||||||
|
|
||||||
|
(message_id, thread_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn split_from(raw: &str) -> (String, String) {
|
||||||
|
if let Some(idx) = raw.rfind('<') {
|
||||||
|
let name = raw[..idx].trim().trim_matches('"').to_string();
|
||||||
|
let addr = raw[idx + 1..].trim_end_matches('>').trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
(addr.clone(), addr)
|
||||||
|
} else {
|
||||||
|
(name, addr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(raw.trim().to_string(), raw.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_parts(
|
||||||
|
part: &ParsedMail,
|
||||||
|
text: &mut Option<String>,
|
||||||
|
html: &mut Option<String>,
|
||||||
|
attachments: &mut Vec<AttachmentMeta>,
|
||||||
|
) {
|
||||||
|
if !part.subparts.is_empty() {
|
||||||
|
for sp in &part.subparts {
|
||||||
|
walk_parts(sp, text, html, attachments);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mimetype = part.ctype.mimetype.to_lowercase();
|
||||||
|
let disposition = part.get_content_disposition();
|
||||||
|
let filename = disposition
|
||||||
|
.params
|
||||||
|
.get("filename")
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| part.ctype.params.get("name").cloned());
|
||||||
|
let is_attachment = disposition.disposition == mailparse::DispositionType::Attachment
|
||||||
|
|| (filename.is_some() && !mimetype.starts_with("text/"));
|
||||||
|
|
||||||
|
if is_attachment {
|
||||||
|
if let Ok(raw) = part.get_body_raw() {
|
||||||
|
attachments.push(AttachmentMeta {
|
||||||
|
filename: filename.unwrap_or_else(|| "attachment".to_string()),
|
||||||
|
mime_type: mimetype,
|
||||||
|
size: raw.len(),
|
||||||
|
content_base64: base64::engine::general_purpose::STANDARD.encode(&raw),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if mimetype == "text/html" && html.is_none() {
|
||||||
|
*html = part.get_body().ok();
|
||||||
|
} else if mimetype == "text/plain" && text.is_none() {
|
||||||
|
*text = part.get_body().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses full RFC822 bytes into a `MessageFull`, extracting headers, body and attachments.
|
||||||
|
pub fn parse_message_full(account_id: &str, folder: &str, uid: u32, raw: &[u8]) -> AppResult<MessageFull> {
|
||||||
|
let parsed = parse_mail(raw).map_err(|e| AppError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
let from_raw = parsed.headers.get_first_value("From").unwrap_or_default();
|
||||||
|
let (from_name, from_addr) = split_from(&from_raw);
|
||||||
|
let to = parsed.headers.get_first_value("To").unwrap_or_default();
|
||||||
|
let subject = parsed
|
||||||
|
.headers
|
||||||
|
.get_first_value("Subject")
|
||||||
|
.unwrap_or_else(|| "(kein Betreff)".to_string());
|
||||||
|
let date = parsed.headers.get_first_value("Date").unwrap_or_default();
|
||||||
|
|
||||||
|
let mut text = None;
|
||||||
|
let mut html = None;
|
||||||
|
let mut attachments = Vec::new();
|
||||||
|
walk_parts(&parsed, &mut text, &mut html, &mut attachments);
|
||||||
|
|
||||||
|
Ok(MessageFull {
|
||||||
|
uid,
|
||||||
|
account_id: account_id.to_string(),
|
||||||
|
folder: folder.to_string(),
|
||||||
|
from_name,
|
||||||
|
from_addr,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
date,
|
||||||
|
body_html: html,
|
||||||
|
body_text: text,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `MessageSummary` from full RFC822 bytes (used when importing local/appended mail).
|
||||||
|
pub fn parse_message_summary(account_id: &str, folder: &str, uid: u32, raw: &[u8]) -> MessageSummary {
|
||||||
|
let parsed = parse_mail(raw).ok();
|
||||||
|
let from_raw = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("From"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (from_name, from_addr) = split_from(&from_raw);
|
||||||
|
let subject = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("Subject"))
|
||||||
|
.unwrap_or_else(|| "(kein Betreff)".to_string());
|
||||||
|
let date_raw = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("Date"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let timestamp = mailparse::dateparse(&date_raw).unwrap_or(0);
|
||||||
|
let has_attachments = parsed
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.parts().any(|part| part.get_content_disposition().disposition == mailparse::DispositionType::Attachment))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let (message_id, thread_id) = thread_ids(parsed.as_ref(), &format!("{account_id}-{folder}-{uid}"));
|
||||||
|
|
||||||
|
MessageSummary {
|
||||||
|
uid,
|
||||||
|
account_id: account_id.to_string(),
|
||||||
|
folder: folder.to_string(),
|
||||||
|
from_name,
|
||||||
|
from_addr,
|
||||||
|
subject,
|
||||||
|
date: date_raw,
|
||||||
|
timestamp,
|
||||||
|
snippet: String::new(),
|
||||||
|
is_read: true,
|
||||||
|
has_attachments,
|
||||||
|
flagged: false,
|
||||||
|
message_id,
|
||||||
|
thread_id,
|
||||||
|
category: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
app_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/// Minimal mboxrd reader/writer: https://en.wikipedia.org/wiki/Mbox#Modifications
|
||||||
|
/// Lines matching `^>*From ` inside a message body are escaped with a leading `>`
|
||||||
|
/// on write and un-escaped by stripping one leading `>` on read.
|
||||||
|
fn mboxrd_escape(raw: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(raw.len() + 16);
|
||||||
|
let mut first = true;
|
||||||
|
for line in raw.split(|&b| b == b'\n') {
|
||||||
|
if !first {
|
||||||
|
out.push(b'\n');
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
let content = line.strip_suffix(b"\r").unwrap_or(line);
|
||||||
|
let mut rest = content;
|
||||||
|
while let Some(r) = rest.strip_prefix(b">") {
|
||||||
|
rest = r;
|
||||||
|
}
|
||||||
|
if rest.starts_with(b"From ") {
|
||||||
|
out.push(b'>');
|
||||||
|
}
|
||||||
|
out.extend_from_slice(line);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape_line(line: &[u8]) -> &[u8] {
|
||||||
|
if let Some(rest) = line.strip_prefix(b">") {
|
||||||
|
let mut check = rest;
|
||||||
|
loop {
|
||||||
|
if check.starts_with(b"From ") {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
match check.strip_prefix(b">") {
|
||||||
|
Some(r2) => check = r2,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_entry(out: &mut Vec<u8>, from_addr: &str, timestamp: i64, raw: &[u8]) {
|
||||||
|
let date = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
|
||||||
|
let date_str = date.format("%a %b %e %H:%M:%S %Y");
|
||||||
|
let sender = if from_addr.is_empty() { "MAILER-DAEMON" } else { from_addr };
|
||||||
|
out.extend_from_slice(format!("From {sender} {date_str}\n").as_bytes());
|
||||||
|
out.extend_from_slice(&mboxrd_escape(raw));
|
||||||
|
if !out.ends_with(b"\n") {
|
||||||
|
out.push(b'\n');
|
||||||
|
}
|
||||||
|
out.push(b'\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a full mbox file's bytes into individual raw RFC822 messages (CRLF line endings).
|
||||||
|
pub fn split_entries(data: &[u8]) -> Vec<Vec<u8>> {
|
||||||
|
let mut messages: Vec<Vec<&[u8]>> = Vec::new();
|
||||||
|
let mut current: Vec<&[u8]> = Vec::new();
|
||||||
|
let mut prev_blank = true;
|
||||||
|
|
||||||
|
for line in data.split(|&b| b == b'\n') {
|
||||||
|
let content = line.strip_suffix(b"\r").unwrap_or(line);
|
||||||
|
let is_from_line = prev_blank && content.starts_with(b"From ");
|
||||||
|
if is_from_line {
|
||||||
|
if !current.is_empty() {
|
||||||
|
messages.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
prev_blank = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prev_blank = content.is_empty();
|
||||||
|
current.push(line);
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
messages.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|lines| {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (i, l) in lines.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.extend_from_slice(b"\r\n");
|
||||||
|
}
|
||||||
|
let content = l.strip_suffix(b"\r").unwrap_or(l);
|
||||||
|
out.extend_from_slice(unescape_line(content));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
})
|
||||||
|
.filter(|m: &Vec<u8>| !m.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Protocol {
|
||||||
|
Imap,
|
||||||
|
Pop3,
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
#[default]
|
||||||
|
Password,
|
||||||
|
GoogleOauth,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub protocol: Protocol,
|
||||||
|
pub incoming_host: String,
|
||||||
|
pub incoming_port: u16,
|
||||||
|
pub smtp_host: String,
|
||||||
|
pub smtp_port: u16,
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_method: AuthMethod,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewAccount {
|
||||||
|
pub label: String,
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub protocol: Protocol,
|
||||||
|
pub incoming_host: String,
|
||||||
|
pub incoming_port: u16,
|
||||||
|
pub smtp_host: String,
|
||||||
|
pub smtp_port: u16,
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Folder {
|
||||||
|
pub name: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub unread: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MessageSummary {
|
||||||
|
pub uid: u32,
|
||||||
|
pub account_id: String,
|
||||||
|
pub folder: String,
|
||||||
|
pub from_name: String,
|
||||||
|
pub from_addr: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub date: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub snippet: String,
|
||||||
|
pub is_read: bool,
|
||||||
|
pub has_attachments: bool,
|
||||||
|
pub flagged: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub message_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub thread_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AttachmentMeta {
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub size: usize,
|
||||||
|
pub content_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MessageFull {
|
||||||
|
pub uid: u32,
|
||||||
|
pub account_id: String,
|
||||||
|
pub folder: String,
|
||||||
|
pub from_name: String,
|
||||||
|
pub from_addr: String,
|
||||||
|
pub to: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub date: String,
|
||||||
|
pub body_html: Option<String>,
|
||||||
|
pub body_text: Option<String>,
|
||||||
|
pub attachments: Vec<AttachmentMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OutgoingAttachment {
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub content_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub company: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub favorite: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub google_account_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub google_resource_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ContactGroup {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub member_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewContactGroup {
|
||||||
|
pub name: String,
|
||||||
|
pub member_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewContact {
|
||||||
|
pub name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub company: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DraftPayload {
|
||||||
|
pub uid: u32,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub to: String,
|
||||||
|
pub cc: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuleAction {
|
||||||
|
MoveTo,
|
||||||
|
MarkRead,
|
||||||
|
Flag,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MatchType {
|
||||||
|
All,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RuleCondition {
|
||||||
|
pub field: String,
|
||||||
|
pub contains: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Rule {
|
||||||
|
pub id: String,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub conditions: Vec<RuleCondition>,
|
||||||
|
pub match_type: MatchType,
|
||||||
|
pub action: RuleAction,
|
||||||
|
pub action_value: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewRule {
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub conditions: Vec<RuleCondition>,
|
||||||
|
pub match_type: MatchType,
|
||||||
|
pub action: RuleAction,
|
||||||
|
pub action_value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Event {
|
||||||
|
pub id: String,
|
||||||
|
/// The real DB row id. Equals `id` for normal events; for an expanded
|
||||||
|
/// recurring occurrence, `id` is synthetic (`{base_id}#{occurrence_start}`)
|
||||||
|
/// while `base_id` points at the actual stored series row.
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub start_ts: i64,
|
||||||
|
pub end_ts: i64,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub google_event_id: Option<String>,
|
||||||
|
pub rrule: Option<String>,
|
||||||
|
pub reminder_minutes: Option<i32>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
/// Comma-separated occurrence start timestamps excluded from this series'
|
||||||
|
/// expansion (RFC5545 EXDATE equivalent) — only meaningful when `rrule` is set.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exdates: Option<String>,
|
||||||
|
/// Set only on a single-occurrence override row: the id of the recurring
|
||||||
|
/// series it replaces one instance of. Such rows always have `rrule: None`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub series_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewEvent {
|
||||||
|
pub title: String,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub start_ts: i64,
|
||||||
|
pub end_ts: i64,
|
||||||
|
pub all_day: bool,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub rrule: Option<String>,
|
||||||
|
pub reminder_minutes: Option<i32>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ComposeMessage {
|
||||||
|
pub account_id: String,
|
||||||
|
pub to: String,
|
||||||
|
pub cc: Option<String>,
|
||||||
|
pub bcc: Option<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub body_html: String,
|
||||||
|
pub attachments: Vec<OutgoingAttachment>,
|
||||||
|
pub in_reply_to: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub request_read_receipt: bool,
|
||||||
|
/// "high" | "low" | None (normal)
|
||||||
|
#[serde(default)]
|
||||||
|
pub importance: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TaskPriority {
|
||||||
|
Low,
|
||||||
|
Normal,
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub due_ts: Option<i64>,
|
||||||
|
pub priority: TaskPriority,
|
||||||
|
pub completed: bool,
|
||||||
|
pub created_ts: i64,
|
||||||
|
pub source_account_id: Option<String>,
|
||||||
|
pub source_folder: Option<String>,
|
||||||
|
pub source_uid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewTask {
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub due_ts: Option<i64>,
|
||||||
|
pub priority: TaskPriority,
|
||||||
|
pub source_account_id: Option<String>,
|
||||||
|
pub source_folder: Option<String>,
|
||||||
|
pub source_uid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VacationSettings {
|
||||||
|
pub account_id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub start_ts: Option<i64>,
|
||||||
|
pub end_ts: Option<i64>,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A generic CalDAV calendar connection (Nextcloud, iCloud, Radicale, ...) —
|
||||||
|
/// independent of the mail `Account` table, since a CalDAV calendar isn't
|
||||||
|
/// necessarily tied to a mail account the way Google Calendar is bolted onto a
|
||||||
|
/// Google-OAuth mail account. `url` is the direct calendar collection URL (no
|
||||||
|
/// principal/home-set auto-discovery); password is stored via the same OS
|
||||||
|
/// keyring helper as mail account passwords, keyed by this struct's `id`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CaldavAccount {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub url: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewCaldavAccount {
|
||||||
|
pub label: String,
|
||||||
|
pub url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SnoozedMessage {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub message: MessageSummary,
|
||||||
|
pub until_ts: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Template {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewTemplate {
|
||||||
|
pub name: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct NewVacationSettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub start_ts: Option<i64>,
|
||||||
|
pub end_ts: Option<i64>,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Google's "Desktop app" OAuth client type is a public client per RFC 8252 — this
|
||||||
|
// client_secret is not treated as confidential by Google for this client type
|
||||||
|
// (same model used by e.g. `gcloud`), so embedding it here is expected practice.
|
||||||
|
const CLIENT_ID: &str = "838805598613-65t2qpqp0njk7ftn37ousqksqjpvi0ju.apps.googleusercontent.com";
|
||||||
|
const CLIENT_SECRET: &str = "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";
|
||||||
|
const AUTH_URL: &str = "https://accounts.google.com/o/oauth2/auth";
|
||||||
|
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
|
||||||
|
const USERINFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo";
|
||||||
|
const SCOPE: &str = "https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/contacts.other.readonly";
|
||||||
|
|
||||||
|
fn urlencode(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for b in s.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
if let Ok(byte) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) {
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_verifier() -> String {
|
||||||
|
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_challenge(verifier: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(verifier.as_bytes());
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RedirectResult {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_redirect(listener: TcpListener) -> AppResult<RedirectResult> {
|
||||||
|
listener.set_nonblocking(true).ok();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(300);
|
||||||
|
let (mut stream, _) = loop {
|
||||||
|
match listener.accept() {
|
||||||
|
Ok(pair) => break pair,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
if Instant::now() > deadline {
|
||||||
|
return Err(AppError::Other("Zeitüberschreitung beim Google-Login".into()));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(AppError::Other(e.to_string())),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stream.set_nonblocking(false).ok();
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(stream.try_clone().map_err(|e| AppError::Other(e.to_string()))?);
|
||||||
|
let mut request_line = String::new();
|
||||||
|
reader
|
||||||
|
.read_line(&mut request_line)
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let path = request_line.split_whitespace().nth(1).unwrap_or("").to_string();
|
||||||
|
|
||||||
|
let body = "<html><body style=\"font-family:sans-serif\"><h2>Fertig! Du kannst dieses Fenster jetzt schließen.</h2></body></html>";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes());
|
||||||
|
|
||||||
|
let query = path.splitn(2, '?').nth(1).unwrap_or("");
|
||||||
|
let mut code = None;
|
||||||
|
let mut error = None;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let mut it = pair.splitn(2, '=');
|
||||||
|
let key = it.next().unwrap_or("");
|
||||||
|
let value = percent_decode(it.next().unwrap_or(""));
|
||||||
|
match key {
|
||||||
|
"code" => code = Some(value),
|
||||||
|
"error" => error = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(err) = error {
|
||||||
|
return Err(AppError::Other(format!("Google hat den Zugriff verweigert: {err}")));
|
||||||
|
}
|
||||||
|
let code = code.ok_or_else(|| AppError::Other("Kein Autorisierungscode erhalten".into()))?;
|
||||||
|
Ok(RedirectResult { code })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_client() -> reqwest::blocking::Client {
|
||||||
|
reqwest::blocking::Client::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> AppResult<TokenResponse> {
|
||||||
|
let resp = http_client()
|
||||||
|
.post(TOKEN_URL)
|
||||||
|
.form(&[
|
||||||
|
("code", code),
|
||||||
|
("client_id", CLIENT_ID),
|
||||||
|
("client_secret", CLIENT_SECRET),
|
||||||
|
("redirect_uri", redirect_uri),
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("code_verifier", verifier),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(AppError::Other(format!(
|
||||||
|
"Token-Austausch fehlgeschlagen: {}",
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
resp.json().map_err(|e| AppError::Other(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_access_token(refresh_token: &str) -> AppResult<String> {
|
||||||
|
let resp = http_client()
|
||||||
|
.post(TOKEN_URL)
|
||||||
|
.form(&[
|
||||||
|
("client_id", CLIENT_ID),
|
||||||
|
("client_secret", CLIENT_SECRET),
|
||||||
|
("refresh_token", refresh_token),
|
||||||
|
("grant_type", "refresh_token"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(AppError::Other(format!(
|
||||||
|
"Token-Erneuerung fehlgeschlagen: {}",
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let token: TokenResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
Ok(token.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserInfo {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_email(access_token: &str) -> AppResult<String> {
|
||||||
|
let resp = http_client()
|
||||||
|
.get(USERINFO_URL)
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let info: UserInfo = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
Ok(info.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the full desktop OAuth2 + PKCE login flow for Google: opens the system browser,
|
||||||
|
/// waits for the local loopback redirect, exchanges the code for tokens.
|
||||||
|
/// Returns (email, refresh_token).
|
||||||
|
pub fn run_google_login() -> AppResult<(String, String)> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))?
|
||||||
|
.port();
|
||||||
|
let redirect_uri = format!("http://localhost:{port}");
|
||||||
|
|
||||||
|
let verifier = code_verifier();
|
||||||
|
let challenge = code_challenge(&verifier);
|
||||||
|
|
||||||
|
let auth_url = format!(
|
||||||
|
"{AUTH_URL}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&access_type=offline&prompt=consent",
|
||||||
|
urlencode(CLIENT_ID),
|
||||||
|
urlencode(&redirect_uri),
|
||||||
|
urlencode(SCOPE),
|
||||||
|
urlencode(&challenge),
|
||||||
|
);
|
||||||
|
|
||||||
|
webbrowser::open(&auth_url).map_err(|e| AppError::Other(e.to_string()))?;
|
||||||
|
|
||||||
|
let result = wait_for_redirect(listener)?;
|
||||||
|
let token = exchange_code(&result.code, &verifier, &redirect_uri)?;
|
||||||
|
let refresh_token = token.refresh_token.ok_or_else(|| {
|
||||||
|
AppError::Other(
|
||||||
|
"Kein Refresh-Token erhalten. Widerrufe den Zugriff in deinem Google-Konto (myaccount.google.com/permissions) und versuche es erneut.".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let email = fetch_email(&token.access_token)?;
|
||||||
|
Ok((email, refresh_token))
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::mailutil::{parse_message_full, split_from, thread_ids};
|
||||||
|
use crate::models::{MessageFull, MessageSummary};
|
||||||
|
use mailparse::{parse_mail, MailHeaderMap};
|
||||||
|
use native_tls::TlsStream;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
pub struct Pop3Session {
|
||||||
|
stream: BufReader<TlsStream<TcpStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_line(stream: &mut BufReader<TlsStream<TcpStream>>) -> AppResult<String> {
|
||||||
|
let mut line = String::new();
|
||||||
|
stream
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| AppError::Pop3(e.to_string()))?;
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_ok(stream: &mut BufReader<TlsStream<TcpStream>>) -> AppResult<String> {
|
||||||
|
let line = read_line(stream)?;
|
||||||
|
if !line.starts_with("+OK") {
|
||||||
|
return Err(AppError::Pop3(format!("server said: {}", line.trim())));
|
||||||
|
}
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_cmd(stream: &mut BufReader<TlsStream<TcpStream>>, cmd: &str) -> AppResult<()> {
|
||||||
|
stream
|
||||||
|
.get_mut()
|
||||||
|
.write_all(format!("{cmd}\r\n").as_bytes())
|
||||||
|
.map_err(|e| AppError::Pop3(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_multiline(stream: &mut BufReader<TlsStream<TcpStream>>) -> AppResult<Vec<u8>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut line = Vec::new();
|
||||||
|
let n = stream
|
||||||
|
.read_until(b'\n', &mut line)
|
||||||
|
.map_err(|e| AppError::Pop3(e.to_string()))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line == b".\r\n" || line == b".\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line.starts_with(b"..") {
|
||||||
|
out.extend_from_slice(&line[1..]);
|
||||||
|
} else {
|
||||||
|
out.extend_from_slice(&line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect(host: &str, port: u16, username: &str, password: &str) -> AppResult<Pop3Session> {
|
||||||
|
let connector = native_tls::TlsConnector::new().map_err(|e| AppError::Pop3(e.to_string()))?;
|
||||||
|
let tcp = TcpStream::connect((host, port)).map_err(|e| AppError::Pop3(e.to_string()))?;
|
||||||
|
let tls = connector
|
||||||
|
.connect(host, tcp)
|
||||||
|
.map_err(|e| AppError::Pop3(e.to_string()))?;
|
||||||
|
let mut stream = BufReader::new(tls);
|
||||||
|
expect_ok(&mut stream)?;
|
||||||
|
send_cmd(&mut stream, &format!("USER {username}"))?;
|
||||||
|
expect_ok(&mut stream)?;
|
||||||
|
send_cmd(&mut stream, &format!("PASS {password}"))?;
|
||||||
|
expect_ok(&mut stream)?;
|
||||||
|
Ok(Pop3Session { stream })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pop3Session {
|
||||||
|
fn list_ids(&mut self) -> AppResult<Vec<u32>> {
|
||||||
|
send_cmd(&mut self.stream, "LIST")?;
|
||||||
|
expect_ok(&mut self.stream)?;
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
loop {
|
||||||
|
let line = read_line(&mut self.stream)?;
|
||||||
|
if line.trim() == "." {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(num_str) = line.split_whitespace().next() {
|
||||||
|
if let Ok(num) = num_str.parse::<u32>() {
|
||||||
|
ids.push(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_messages(&mut self, account_id: &str) -> AppResult<Vec<MessageSummary>> {
|
||||||
|
let mut ids = self.list_ids()?;
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.reverse();
|
||||||
|
ids.truncate(60);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
send_cmd(&mut self.stream, &format!("TOP {id} 0"))?;
|
||||||
|
let ok = expect_ok(&mut self.stream);
|
||||||
|
if ok.is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let header_bytes = read_multiline(&mut self.stream)?;
|
||||||
|
let parsed = parse_mail(&header_bytes).ok();
|
||||||
|
let from_raw = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("From"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (from_name, from_addr) = split_from(&from_raw);
|
||||||
|
let subject = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("Subject"))
|
||||||
|
.unwrap_or_else(|| "(kein Betreff)".to_string());
|
||||||
|
let date_raw = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.headers.get_first_value("Date"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let timestamp = mailparse::dateparse(&date_raw).unwrap_or(0);
|
||||||
|
let (message_id, thread_id) = thread_ids(parsed.as_ref(), &format!("{account_id}-INBOX-{id}"));
|
||||||
|
|
||||||
|
out.push(MessageSummary {
|
||||||
|
uid: id,
|
||||||
|
account_id: account_id.to_string(),
|
||||||
|
folder: "INBOX".to_string(),
|
||||||
|
from_name,
|
||||||
|
from_addr,
|
||||||
|
subject,
|
||||||
|
date: date_raw,
|
||||||
|
timestamp,
|
||||||
|
snippet: String::new(),
|
||||||
|
is_read: true,
|
||||||
|
has_attachments: false,
|
||||||
|
flagged: false,
|
||||||
|
message_id,
|
||||||
|
thread_id,
|
||||||
|
category: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retr_raw(&mut self, id: u32) -> AppResult<Vec<u8>> {
|
||||||
|
send_cmd(&mut self.stream, &format!("RETR {id}"))?;
|
||||||
|
expect_ok(&mut self.stream)?;
|
||||||
|
read_multiline(&mut self.stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fetch_full(&mut self, account_id: &str, id: u32) -> AppResult<MessageFull> {
|
||||||
|
let raw = self.retr_raw(id)?;
|
||||||
|
parse_message_full(account_id, "INBOX", id, &raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_all(&mut self, account_id: &str) -> AppResult<Vec<Vec<u8>>> {
|
||||||
|
let summaries = self.list_messages(account_id)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for s in summaries {
|
||||||
|
if let Ok(raw) = self.retr_raw(s.uid) {
|
||||||
|
out.push(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&mut self, id: u32) -> AppResult<()> {
|
||||||
|
send_cmd(&mut self.stream, &format!("DELE {id}"))?;
|
||||||
|
expect_ok(&mut self.stream)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quit(&mut self) {
|
||||||
|
let _ = send_cmd(&mut self.stream, "QUIT");
|
||||||
|
let _ = expect_ok(&mut self.stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc};
|
||||||
|
|
||||||
|
/// Safety cap on generated occurrences per expansion call, so an unbounded
|
||||||
|
/// (no UNTIL) daily/weekly series can never spin forever.
|
||||||
|
const MAX_OCCURRENCES: usize = 730;
|
||||||
|
|
||||||
|
enum Freq {
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Rule {
|
||||||
|
freq: Freq,
|
||||||
|
interval: i64,
|
||||||
|
until: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a minimal RFC5545-flavored RRULE string as produced by our own UI:
|
||||||
|
/// `FREQ=DAILY|WEEKLY|MONTHLY|YEARLY[;INTERVAL=n][;UNTIL=YYYYMMDD]`.
|
||||||
|
///
|
||||||
|
/// Deliberately returns `None` (falling back to a single occurrence) for any
|
||||||
|
/// recognized-but-unsupported RRULE part (BYDAY, BYMONTHDAY, COUNT, ...) instead of
|
||||||
|
/// silently ignoring it — this matters once CalDAV brings in RRULEs from other
|
||||||
|
/// clients (a phone calendar app, Nextcloud's web UI) that this hand-rolled expander
|
||||||
|
/// was never built to interpret correctly. Showing just the first occurrence is a
|
||||||
|
/// far safer wrong answer than generating occurrences at the wrong times.
|
||||||
|
fn parse(rrule: &str) -> Option<Rule> {
|
||||||
|
let mut freq = None;
|
||||||
|
let mut interval = 1i64;
|
||||||
|
let mut until = None;
|
||||||
|
for part in rrule.split(';') {
|
||||||
|
let mut it = part.splitn(2, '=');
|
||||||
|
let key = it.next()?.trim();
|
||||||
|
let val = it.next().unwrap_or("").trim();
|
||||||
|
match key {
|
||||||
|
"FREQ" => {
|
||||||
|
freq = Some(match val {
|
||||||
|
"DAILY" => Freq::Daily,
|
||||||
|
"WEEKLY" => Freq::Weekly,
|
||||||
|
"MONTHLY" => Freq::Monthly,
|
||||||
|
"YEARLY" => Freq::Yearly,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"INTERVAL" => interval = val.parse().unwrap_or(1),
|
||||||
|
"UNTIL" => {
|
||||||
|
let date_part = &val[..val.len().min(8)];
|
||||||
|
if let Ok(d) = NaiveDate::parse_from_str(date_part, "%Y%m%d") {
|
||||||
|
if let Some(dt) = d.and_hms_opt(23, 59, 59) {
|
||||||
|
until = Some(Utc.from_utc_datetime(&dt).timestamp());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"BYDAY" | "BYMONTHDAY" | "BYMONTH" | "BYSETPOS" | "BYYEARDAY" | "BYWEEKNO" | "COUNT" | "WKST" => {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Rule {
|
||||||
|
freq: freq?,
|
||||||
|
interval: interval.max(1),
|
||||||
|
until,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_day_of_month(year: i32, month: u32) -> u32 {
|
||||||
|
let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
|
||||||
|
NaiveDate::from_ymd_opt(ny, nm, 1)
|
||||||
|
.and_then(|d| d.pred_opt())
|
||||||
|
.map(|d| d.day())
|
||||||
|
.unwrap_or(28)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_months(dt: DateTime<Utc>, months: i64) -> DateTime<Utc> {
|
||||||
|
let naive = dt.naive_utc();
|
||||||
|
let date = naive.date();
|
||||||
|
let total_months = i64::from(date.year()) * 12 + i64::from(date.month0()) + months;
|
||||||
|
let new_year = total_months.div_euclid(12) as i32;
|
||||||
|
let new_month = total_months.rem_euclid(12) as u32 + 1;
|
||||||
|
let new_day = date.day().min(last_day_of_month(new_year, new_month));
|
||||||
|
let Some(new_date) = NaiveDate::from_ymd_opt(new_year, new_month, new_day) else {
|
||||||
|
return dt;
|
||||||
|
};
|
||||||
|
Utc.from_utc_datetime(&new_date.and_time(naive.time()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands a recurring event's occurrences overlapping `[range_start, range_end)`,
|
||||||
|
/// skipping any occurrence whose start matches an entry in `exdates` (single
|
||||||
|
/// occurrences deleted or overridden out of the series). Falls back to the single
|
||||||
|
/// base occurrence if `rrule` is unparseable.
|
||||||
|
pub fn expand(base_start: i64, base_end: i64, rrule: &str, range_start: i64, range_end: i64, exdates: &[i64]) -> Vec<(i64, i64)> {
|
||||||
|
let Some(rule) = parse(rrule) else {
|
||||||
|
return vec![(base_start, base_end)];
|
||||||
|
};
|
||||||
|
let duration = base_end - base_start;
|
||||||
|
let Some(mut current) = Utc.timestamp_opt(base_start, 0).single() else {
|
||||||
|
return vec![(base_start, base_end)];
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut n = 0;
|
||||||
|
while current.timestamp() < range_end && n < MAX_OCCURRENCES {
|
||||||
|
if let Some(until) = rule.until {
|
||||||
|
if current.timestamp() > until {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let occ_start = current.timestamp();
|
||||||
|
let occ_end = occ_start + duration;
|
||||||
|
if occ_end > range_start && occ_start < range_end && !exdates.contains(&occ_start) {
|
||||||
|
out.push((occ_start, occ_end));
|
||||||
|
}
|
||||||
|
current = match rule.freq {
|
||||||
|
Freq::Daily => current + Duration::days(rule.interval),
|
||||||
|
Freq::Weekly => current + Duration::weeks(rule.interval),
|
||||||
|
Freq::Monthly => add_months(current, rule.interval),
|
||||||
|
Freq::Yearly => add_months(current, rule.interval * 12),
|
||||||
|
};
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use crate::db::Db;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
|
const POLL_INTERVAL_SECS: u64 = 30;
|
||||||
|
/// Only fire a reminder whose trigger moment is this recent — avoids a flood of
|
||||||
|
/// stale notifications for reminders that elapsed while the app was closed.
|
||||||
|
const FRESHNESS_WINDOW_SECS: i64 = 300;
|
||||||
|
const LOOKAHEAD_SECS: i64 = 2 * 86400;
|
||||||
|
|
||||||
|
pub fn spawn_poller(app: AppHandle) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let notified: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
poll_once(&app, ¬ified);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_once(app: &AppHandle, notified: &Mutex<HashSet<String>>) {
|
||||||
|
let db = app.state::<Db>();
|
||||||
|
let Ok(events) = db.list_events_with_reminders() else { return };
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
for base in events {
|
||||||
|
let Some(reminder_min) = base.reminder_minutes else { continue };
|
||||||
|
let occurrences = if let Some(rule) = &base.rrule {
|
||||||
|
let exdates: Vec<i64> = base.exdates.as_deref().unwrap_or("").split(',').filter_map(|s| s.trim().parse().ok()).collect();
|
||||||
|
crate::recurrence::expand(base.start_ts, base.end_ts, rule, now - 3600, now + LOOKAHEAD_SECS, &exdates)
|
||||||
|
} else {
|
||||||
|
vec![(base.start_ts, base.end_ts)]
|
||||||
|
};
|
||||||
|
|
||||||
|
for (occ_start, _) in occurrences {
|
||||||
|
let trigger_ts = occ_start - i64::from(reminder_min) * 60;
|
||||||
|
if trigger_ts > now || now - trigger_ts >= FRESHNESS_WINDOW_SECS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = format!("{}#{}", base.id, occ_start);
|
||||||
|
{
|
||||||
|
let mut set = notified.lock().unwrap();
|
||||||
|
if !set.insert(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let when = chrono::Local
|
||||||
|
.timestamp_opt(occ_start, 0)
|
||||||
|
.single()
|
||||||
|
.map(|d| d.format("um %H:%M").to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let body = if base.all_day {
|
||||||
|
"Heute".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Beginnt {when}")
|
||||||
|
};
|
||||||
|
let _ = app
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title(format!("Erinnerung: {}", base.title))
|
||||||
|
.body(body)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use crate::accounts;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::{Account, AuthMethod, ComposeMessage};
|
||||||
|
use crate::oauth;
|
||||||
|
use base64::Engine;
|
||||||
|
use lettre::message::header::{HeaderName, HeaderValue};
|
||||||
|
use lettre::message::{Attachment, MultiPart};
|
||||||
|
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
|
||||||
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
|
|
||||||
|
fn strip_html(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => in_tag = false,
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_credentials(account: &Account) -> AppResult<(Credentials, Option<Vec<Mechanism>>)> {
|
||||||
|
match account.auth_method {
|
||||||
|
AuthMethod::Password => {
|
||||||
|
let password = accounts::get_password(&account.id)?;
|
||||||
|
Ok((Credentials::new(account.username.clone(), password), None))
|
||||||
|
}
|
||||||
|
AuthMethod::GoogleOauth => {
|
||||||
|
let refresh_token = accounts::get_password(&account.id)?;
|
||||||
|
let access_token = oauth::refresh_access_token(&refresh_token)?;
|
||||||
|
Ok((
|
||||||
|
Credentials::new(account.email.clone(), access_token),
|
||||||
|
Some(vec![Mechanism::Xoauth2]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(account: &Account, msg: ComposeMessage) -> AppResult<()> {
|
||||||
|
let account_owned = account.clone();
|
||||||
|
let (creds, mechanisms) = tauri::async_runtime::spawn_blocking(move || resolve_credentials(&account_owned))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Other(e.to_string()))??;
|
||||||
|
|
||||||
|
let mut builder = if account.smtp_starttls {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&account.smtp_host)
|
||||||
|
.map_err(|e| AppError::Smtp(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&account.smtp_host)
|
||||||
|
.map_err(|e| AppError::Smtp(e.to_string()))?
|
||||||
|
}
|
||||||
|
.port(account.smtp_port)
|
||||||
|
.credentials(creds);
|
||||||
|
if let Some(mechanisms) = mechanisms {
|
||||||
|
builder = builder.authentication(mechanisms);
|
||||||
|
}
|
||||||
|
let mailer: AsyncSmtpTransport<Tokio1Executor> = builder.build();
|
||||||
|
|
||||||
|
let mut builder = Message::builder()
|
||||||
|
.from(account.email.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?)
|
||||||
|
.to(msg.to.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?)
|
||||||
|
.subject(msg.subject.clone());
|
||||||
|
|
||||||
|
if let Some(cc) = msg.cc.filter(|s| !s.trim().is_empty()) {
|
||||||
|
builder = builder.cc(cc.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?);
|
||||||
|
}
|
||||||
|
if let Some(bcc) = msg.bcc.filter(|s| !s.trim().is_empty()) {
|
||||||
|
builder = builder.bcc(bcc.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?);
|
||||||
|
}
|
||||||
|
if let Some(ref_id) = msg.in_reply_to.filter(|s| !s.trim().is_empty()) {
|
||||||
|
builder = builder.in_reply_to(ref_id);
|
||||||
|
}
|
||||||
|
if let Some(reply_to) = msg.reply_to.filter(|s| !s.trim().is_empty()) {
|
||||||
|
builder = builder.reply_to(reply_to.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?);
|
||||||
|
}
|
||||||
|
if msg.request_read_receipt {
|
||||||
|
if let Ok(name) = HeaderName::new_from_ascii("Disposition-Notification-To".to_string()) {
|
||||||
|
builder = builder.raw_header(HeaderValue::new(name, account.email.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(importance) = msg.importance.as_deref() {
|
||||||
|
let (priority, importance_word) = match importance {
|
||||||
|
"high" => ("1", "high"),
|
||||||
|
"low" => ("5", "low"),
|
||||||
|
_ => ("3", "normal"),
|
||||||
|
};
|
||||||
|
if let Ok(name) = HeaderName::new_from_ascii("X-Priority".to_string()) {
|
||||||
|
builder = builder.raw_header(HeaderValue::new(name, priority.to_string()));
|
||||||
|
}
|
||||||
|
if let Ok(name) = HeaderName::new_from_ascii("Importance".to_string()) {
|
||||||
|
builder = builder.raw_header(HeaderValue::new(name, importance_word.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let plain = strip_html(&msg.body_html);
|
||||||
|
let mut mixed = MultiPart::mixed().multipart(MultiPart::alternative_plain_html(plain, msg.body_html.clone()));
|
||||||
|
|
||||||
|
for att in msg.attachments {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(att.content_base64.as_bytes())
|
||||||
|
.map_err(|e| AppError::Smtp(format!("invalid attachment data: {e}")))?;
|
||||||
|
let content_type = att
|
||||||
|
.mime_type
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| AppError::Smtp("invalid attachment mime type".into()))?;
|
||||||
|
mixed = mixed.singlepart(Attachment::new(att.filename).body(bytes, content_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = builder
|
||||||
|
.multipart(mixed)
|
||||||
|
.map_err(|e| AppError::Smtp(e.to_string()))?;
|
||||||
|
|
||||||
|
mailer
|
||||||
|
.send(email)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Smtp(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::models::Contact;
|
||||||
|
|
||||||
|
fn escape(s: &str) -> String {
|
||||||
|
s.replace('\\', "\\\\")
|
||||||
|
.replace(',', "\\,")
|
||||||
|
.replace(';', "\\;")
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export(contacts: &[Contact]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for c in contacts {
|
||||||
|
out.push_str("BEGIN:VCARD\r\nVERSION:3.0\r\n");
|
||||||
|
out.push_str(&format!("FN:{}\r\n", escape(&c.name)));
|
||||||
|
if let Some(email) = c.email.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("EMAIL;TYPE=INTERNET:{}\r\n", escape(email)));
|
||||||
|
}
|
||||||
|
if let Some(phone) = c.phone.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("TEL:{}\r\n", escape(phone)));
|
||||||
|
}
|
||||||
|
if let Some(address) = c.address.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("ADR:;;{};;;;\r\n", escape(address)));
|
||||||
|
}
|
||||||
|
if let Some(company) = c.company.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("ORG:{}\r\n", escape(company)));
|
||||||
|
}
|
||||||
|
if let Some(notes) = c.notes.as_ref().filter(|s| !s.is_empty()) {
|
||||||
|
out.push_str(&format!("NOTE:{}\r\n", escape(notes)));
|
||||||
|
}
|
||||||
|
out.push_str("END:VCARD\r\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParsedCard {
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
pub company: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
s.replace("\\n", "\n")
|
||||||
|
.replace("\\,", ",")
|
||||||
|
.replace("\\;", ";")
|
||||||
|
.replace("\\\\", "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(data: &str) -> Vec<ParsedCard> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut email = String::new();
|
||||||
|
let mut company: Option<String> = None;
|
||||||
|
let mut notes: Option<String> = None;
|
||||||
|
|
||||||
|
for raw_line in data.lines() {
|
||||||
|
let line = raw_line.trim_end_matches('\r');
|
||||||
|
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
|
||||||
|
name.clear();
|
||||||
|
email.clear();
|
||||||
|
company = None;
|
||||||
|
notes = None;
|
||||||
|
} else if let Some(v) = line.strip_prefix("FN:") {
|
||||||
|
name = unescape(v);
|
||||||
|
} else if line.to_uppercase().starts_with("EMAIL") {
|
||||||
|
if let Some(idx) = line.find(':') {
|
||||||
|
email = unescape(&line[idx + 1..]);
|
||||||
|
}
|
||||||
|
} else if let Some(v) = line.strip_prefix("ORG:") {
|
||||||
|
company = Some(unescape(v));
|
||||||
|
} else if let Some(v) = line.strip_prefix("NOTE:") {
|
||||||
|
notes = Some(unescape(v));
|
||||||
|
} else if line.eq_ignore_ascii_case("END:VCARD") {
|
||||||
|
if !email.is_empty() {
|
||||||
|
out.push(ParsedCard {
|
||||||
|
name: if name.is_empty() { email.clone() } else { name.clone() },
|
||||||
|
email: email.clone(),
|
||||||
|
company: company.clone(),
|
||||||
|
notes: notes.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"productName": "mail-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.mailclient.app",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Mail Client",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"android": {
|
||||||
|
"debugApplicationIdSuffix": ".debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { emit, emitTo, listen } from '@tauri-apps/api/event'
|
||||||
|
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||||
|
import { ComposePane, type ComposeInitial } from './components/ComposePane'
|
||||||
|
import { api } from './lib/api'
|
||||||
|
import { applyTheme, loadTheme } from './lib/theme'
|
||||||
|
import type { Account, ComposeMessage } from './types'
|
||||||
|
|
||||||
|
const LOCAL_ACCOUNT_ID = 'local'
|
||||||
|
const DRAFTS_FOLDER = 'Entwürfe'
|
||||||
|
|
||||||
|
/// Root component for a detached "popup" compose/reply window (a real OS
|
||||||
|
/// window, not an overlay) — see main.tsx's `?compose=1` routing. Runs in a
|
||||||
|
/// fully separate JS/React context from the main window, so it fetches its
|
||||||
|
/// own account list and receives its initial content via a `compose-init`
|
||||||
|
/// event emitted by the main window once this window signals `compose-ready`.
|
||||||
|
export function ComposeWindow() {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [initial, setInitial] = useState<ComposeInitial | null>(null)
|
||||||
|
const [accountId, setAccountId] = useState<string | null>(null)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const draftUidRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(loadTheme())
|
||||||
|
api.listAccounts().then(setAccounts).catch(() => {})
|
||||||
|
|
||||||
|
const win = getCurrentWebviewWindow()
|
||||||
|
const unlistenPromise = listen<{ initial: ComposeInitial | null; accountId: string | null }>(
|
||||||
|
'compose-init',
|
||||||
|
(event) => {
|
||||||
|
setInitial(event.payload.initial)
|
||||||
|
setAccountId(event.payload.accountId)
|
||||||
|
draftUidRef.current = event.payload.initial?.draftUid ?? null
|
||||||
|
setReady(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
emit('compose-ready', { label: win.label })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPromise.then((f) => f())
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function cleanupDraft() {
|
||||||
|
const uid = draftUidRef.current
|
||||||
|
if (uid == null) return
|
||||||
|
api.deleteMessage(LOCAL_ACCOUNT_ID, DRAFTS_FOLDER, uid).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend(msg: ComposeMessage, sendAt: number) {
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const id = await api.sendMessageAt(msg, sendAt)
|
||||||
|
// Actual sending (and draft cleanup) happens backend-side on its own
|
||||||
|
// timer, independent of this window — the main window picks it up via
|
||||||
|
// `scheduled-send-completed` and owns the undo banner from here on.
|
||||||
|
await emitTo('main', 'compose-scheduled', {
|
||||||
|
id,
|
||||||
|
deadline: sendAt,
|
||||||
|
draftUid: draftUidRef.current,
|
||||||
|
accountId: msg.account_id,
|
||||||
|
})
|
||||||
|
getCurrentWebviewWindow().close()
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
cleanupDraft()
|
||||||
|
getCurrentWebviewWindow().close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDraftSaved(uid: number) {
|
||||||
|
draftUidRef.current = uid
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = accounts.find((a) => a.id === accountId) ?? null
|
||||||
|
|
||||||
|
if (!ready || accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-white text-sm text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
|
||||||
|
Lade…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen flex-col overflow-hidden bg-white text-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
|
||||||
|
<ComposePane
|
||||||
|
account={account}
|
||||||
|
accounts={accounts}
|
||||||
|
onAccountChange={setAccountId}
|
||||||
|
initial={initial}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
onSend={handleSend}
|
||||||
|
onDraftSaved={handleDraftSaved}
|
||||||
|
sending={sending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Globe, Info, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { providerPresets, type ProviderPreset } from '../lib/providers'
|
||||||
|
import type { NewAccount, Protocol } from '../types'
|
||||||
|
import { ProviderIcon } from './ProviderIcons'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function AccountModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
onGoogleAdd,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAdd: (account: NewAccount) => Promise<void>
|
||||||
|
onGoogleAdd: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false)
|
||||||
|
const [preset, setPreset] = useState<ProviderPreset>(providerPresets[0])
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [protocol, setProtocol] = useState<Protocol>(preset.protocol)
|
||||||
|
const [incomingHost, setIncomingHost] = useState(preset.incoming_host)
|
||||||
|
const [incomingPort, setIncomingPort] = useState(preset.incoming_port)
|
||||||
|
const [smtpHost, setSmtpHost] = useState(preset.smtp_host)
|
||||||
|
const [smtpPort, setSmtpPort] = useState(preset.smtp_port)
|
||||||
|
const [smtpStartTls, setSmtpStartTls] = useState(preset.smtp_starttls)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const first = providerPresets[0]
|
||||||
|
setPreset(first)
|
||||||
|
applyPreset(first)
|
||||||
|
setLabel('')
|
||||||
|
setEmail('')
|
||||||
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function applyPreset(p: ProviderPreset) {
|
||||||
|
setPreset(p)
|
||||||
|
setProtocol(p.protocol)
|
||||||
|
setIncomingHost(p.incoming_host)
|
||||||
|
setIncomingPort(p.incoming_port)
|
||||||
|
setSmtpHost(p.smtp_host)
|
||||||
|
setSmtpPort(p.smtp_port)
|
||||||
|
setSmtpStartTls(p.smtp_starttls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleGoogleLogin() {
|
||||||
|
setError(null)
|
||||||
|
setGoogleLoading(true)
|
||||||
|
try {
|
||||||
|
await onGoogleAdd()
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
} finally {
|
||||||
|
setGoogleLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onAdd({
|
||||||
|
label: label.trim() || email.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
username: username.trim() || email.trim(),
|
||||||
|
password,
|
||||||
|
protocol,
|
||||||
|
incoming_host: incomingHost.trim(),
|
||||||
|
incoming_port: incomingPort,
|
||||||
|
smtp_host: smtpHost.trim(),
|
||||||
|
smtp_port: smtpPort,
|
||||||
|
smtp_starttls: smtpStartTls,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="animate-panel-in flex max-h-[85vh] w-full max-w-md 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">
|
||||||
|
Konto hinzufügen
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="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">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{providerPresets.map((p) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => applyPreset(p)}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-col items-center gap-1.5 rounded-2xl border px-2 py-3 text-center transition',
|
||||||
|
preset.id === p.id
|
||||||
|
? 'border-indigo-300 bg-indigo-50 shadow-[0_0_0_3px] shadow-indigo-100 dark:border-indigo-500 dark:bg-indigo-500/10 dark:shadow-indigo-500/10'
|
||||||
|
: 'border-zinc-200/80 hover:border-zinc-300 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={p.id} />
|
||||||
|
<span className="text-[10.5px] leading-tight font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
{p.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preset.id === 'gmail' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
disabled={googleLoading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-full border border-zinc-200 bg-white px-4 py-2.5 text-[13px] font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Globe size={15} className={clsx(googleLoading && 'animate-spin')} />
|
||||||
|
{googleLoading ? 'Öffne Browser…' : 'Mit Google anmelden'}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-zinc-400">
|
||||||
|
<div className="h-px flex-1 bg-zinc-200 dark:bg-zinc-800" />
|
||||||
|
oder manuell mit App-Passwort
|
||||||
|
<div className="h-px flex-1 bg-zinc-200 dark:bg-zinc-800" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset.hint && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg bg-amber-50 px-3 py-2 text-[11.5px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
|
||||||
|
<Info size={13} className="mt-0.5 shrink-0" />
|
||||||
|
<span>{preset.hint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Anzeigename">
|
||||||
|
<input className={inputClass} value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Privat" />
|
||||||
|
</Field>
|
||||||
|
<Field label="E-Mail-Adresse">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="du@example.com"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Benutzername (optional)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="= E-Mail-Adresse"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Passwort / App-Passwort">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
Server-Einstellungen
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Protokoll">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={protocol}
|
||||||
|
onChange={(e) => setProtocol(e.target.value as Protocol)}
|
||||||
|
>
|
||||||
|
<option value="imap">IMAP</option>
|
||||||
|
<option value="pop3">POP3</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div />
|
||||||
|
<Field label={protocol === 'imap' ? 'IMAP-Server' : 'POP3-Server'}>
|
||||||
|
<input className={inputClass} value={incomingHost} onChange={(e) => setIncomingHost(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Port">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
value={incomingPort}
|
||||||
|
onChange={(e) => setIncomingPort(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SMTP-Server">
|
||||||
|
<input className={inputClass} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="SMTP-Port">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
value={smtpPort}
|
||||||
|
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<label className="mt-2.5 flex items-center gap-2 text-[12px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={smtpStartTls}
|
||||||
|
onChange={(e) => setSmtpStartTls(e.target.checked)}
|
||||||
|
className="rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
SMTP über STARTTLS (Port 587) statt direktem TLS (Port 465)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Verbinde…' : 'Konto hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { AlertTriangle, PlaneTakeoff, Trash2, X } from 'lucide-react'
|
||||||
|
import type { Account, Protocol } from '../types'
|
||||||
|
import { loadSignature, saveSignature } from '../lib/signature'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
function toDateTimeLocal(ts: number | null): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDateTimeLocal(v: string): number | null {
|
||||||
|
if (!v) return null
|
||||||
|
const ts = Math.floor(new Date(v).getTime() / 1000)
|
||||||
|
return Number.isNaN(ts) ? null : ts
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function AccountSettingsModal({
|
||||||
|
account,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
account: Account | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (account: Account, newPassword?: string) => Promise<void>
|
||||||
|
onDelete: (account: Account) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [protocol, setProtocol] = useState<Protocol>('imap')
|
||||||
|
const [incomingHost, setIncomingHost] = useState('')
|
||||||
|
const [incomingPort, setIncomingPort] = useState(0)
|
||||||
|
const [smtpHost, setSmtpHost] = useState('')
|
||||||
|
const [smtpPort, setSmtpPort] = useState(0)
|
||||||
|
const [smtpStartTls, setSmtpStartTls] = useState(false)
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [signature, setSignature] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const [vacationEnabled, setVacationEnabled] = useState(false)
|
||||||
|
const [vacationStart, setVacationStart] = useState('')
|
||||||
|
const [vacationEnd, setVacationEnd] = useState('')
|
||||||
|
const [vacationSubject, setVacationSubject] = useState('')
|
||||||
|
const [vacationBody, setVacationBody] = useState('')
|
||||||
|
const [vacationSaving, setVacationSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) {
|
||||||
|
setLabel(account.label)
|
||||||
|
setUsername(account.username)
|
||||||
|
setProtocol(account.protocol)
|
||||||
|
setIncomingHost(account.incoming_host)
|
||||||
|
setIncomingPort(account.incoming_port)
|
||||||
|
setSmtpHost(account.smtp_host)
|
||||||
|
setSmtpPort(account.smtp_port)
|
||||||
|
setSmtpStartTls(account.smtp_starttls)
|
||||||
|
setNewPassword('')
|
||||||
|
setSignature(loadSignature(account.id))
|
||||||
|
setError(null)
|
||||||
|
setConfirmDelete(false)
|
||||||
|
if (account.protocol === 'imap') {
|
||||||
|
api
|
||||||
|
.getVacationSettings(account.id)
|
||||||
|
.then((s) => {
|
||||||
|
setVacationEnabled(s.enabled)
|
||||||
|
setVacationStart(toDateTimeLocal(s.start_ts))
|
||||||
|
setVacationEnd(toDateTimeLocal(s.end_ts))
|
||||||
|
setVacationSubject(s.subject)
|
||||||
|
setVacationBody(s.body)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [account])
|
||||||
|
|
||||||
|
async function handleSaveVacation() {
|
||||||
|
if (!account) return
|
||||||
|
setVacationSaving(true)
|
||||||
|
try {
|
||||||
|
await api.saveVacationSettings(account.id, {
|
||||||
|
enabled: vacationEnabled,
|
||||||
|
start_ts: fromDateTimeLocal(vacationStart),
|
||||||
|
end_ts: fromDateTimeLocal(vacationEnd),
|
||||||
|
subject: vacationSubject,
|
||||||
|
body: vacationBody,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
} finally {
|
||||||
|
setVacationSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) return null
|
||||||
|
|
||||||
|
const isOAuth = account.auth_method === 'google_oauth'
|
||||||
|
const isLocal = account.protocol === 'local'
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!account) return
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
saveSignature(account.id, signature)
|
||||||
|
await onSave(
|
||||||
|
{
|
||||||
|
...account,
|
||||||
|
label: label.trim() || account.email,
|
||||||
|
username: username.trim(),
|
||||||
|
protocol,
|
||||||
|
incoming_host: incomingHost.trim(),
|
||||||
|
incoming_port: incomingPort,
|
||||||
|
smtp_host: smtpHost.trim(),
|
||||||
|
smtp_port: smtpPort,
|
||||||
|
smtp_starttls: smtpStartTls,
|
||||||
|
},
|
||||||
|
newPassword.trim() || undefined,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!account) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await onDelete(account)
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="animate-panel-in flex max-h-[85vh] w-full max-w-md 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">
|
||||||
|
Kontoeinstellungen
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="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">
|
||||||
|
{isLocal ? (
|
||||||
|
<p className="rounded-lg bg-zinc-50 px-3 py-2.5 text-[12.5px] text-zinc-500 dark:bg-zinc-950/40 dark:text-zinc-400">
|
||||||
|
Die lokale Ablage ist ein Systemkonto und kann nicht bearbeitet oder gelöscht werden.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Anzeigename">
|
||||||
|
<input className={inputClass} value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="E-Mail-Adresse">
|
||||||
|
<input className={inputClass} value={account.email} disabled />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOAuth ? (
|
||||||
|
<div className="rounded-lg bg-indigo-50 px-3 py-2.5 text-[12px] text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||||
|
Dieses Konto verwendet Google-Anmeldung (OAuth). Server-Einstellungen werden automatisch verwaltet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Benutzername">
|
||||||
|
<input className={inputClass} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Neues Passwort (optional)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="unverändert lassen"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
Server-Einstellungen
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Protokoll">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={protocol}
|
||||||
|
onChange={(e) => setProtocol(e.target.value as Protocol)}
|
||||||
|
>
|
||||||
|
<option value="imap">IMAP</option>
|
||||||
|
<option value="pop3">POP3</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div />
|
||||||
|
<Field label={protocol === 'imap' ? 'IMAP-Server' : 'POP3-Server'}>
|
||||||
|
<input className={inputClass} value={incomingHost} onChange={(e) => setIncomingHost(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Port">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
value={incomingPort}
|
||||||
|
onChange={(e) => setIncomingPort(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SMTP-Server">
|
||||||
|
<input className={inputClass} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="SMTP-Port">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
value={smtpPort}
|
||||||
|
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<label className="mt-2.5 flex items-center gap-2 text-[12px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={smtpStartTls}
|
||||||
|
onChange={(e) => setSmtpStartTls(e.target.checked)}
|
||||||
|
className="rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
SMTP über STARTTLS statt direktem TLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Signatur">
|
||||||
|
<textarea
|
||||||
|
value={signature}
|
||||||
|
onChange={(e) => setSignature(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
placeholder="z. B. Viele Grüße Dein Name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{protocol === 'imap' && (
|
||||||
|
<div className="rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-950/40">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
<PlaneTakeoff size={12} /> Abwesenheitsnotiz
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-[12px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={vacationEnabled}
|
||||||
|
onChange={(e) => setVacationEnabled(e.target.checked)}
|
||||||
|
className="rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{vacationEnabled && (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<Field label="Von">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={inputClass}
|
||||||
|
value={vacationStart}
|
||||||
|
onChange={(e) => setVacationStart(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Bis">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={inputClass}
|
||||||
|
value={vacationEnd}
|
||||||
|
onChange={(e) => setVacationEnd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Betreff (optional, sonst „Automatische Antwort: …“)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={vacationSubject}
|
||||||
|
onChange={(e) => setVacationSubject(e.target.value)}
|
||||||
|
placeholder="Automatische Antwort"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Nachricht">
|
||||||
|
<textarea
|
||||||
|
value={vacationBody}
|
||||||
|
onChange={(e) => setVacationBody(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
placeholder="Ich bin bis zum ... abwesend und antworte nach meiner Rückkehr."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="text-[11px] text-zinc-400">
|
||||||
|
Antwortet automatisch einmal pro Absender im gewählten Zeitraum. Reagiert nicht auf
|
||||||
|
No-Reply-Adressen oder erkennbare automatische Antworten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveVacation}
|
||||||
|
disabled={vacationSaving}
|
||||||
|
className="mt-2.5 rounded-full bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-zinc-700 disabled:opacity-60 dark:bg-zinc-200 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||||
|
>
|
||||||
|
{vacationSaving ? 'Speichere…' : 'Abwesenheitsnotiz speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-3.5 dark:border-red-950/60 dark:bg-red-950/20">
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-700 dark:text-red-400">
|
||||||
|
<AlertTriangle size={13} /> Konto „{account.label}“ wirklich löschen?
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-red-600/80 dark:text-red-400/70">
|
||||||
|
Lokal zwischengespeicherte Nachrichten dieses Kontos werden entfernt. Der Server-Posteingang bleibt unberührt.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="rounded-full bg-red-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-red-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{deleting ? 'Lösche…' : 'Endgültig löschen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
className="rounded-full px-3.5 py-1.5 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-600 transition hover:text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Konto löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{isLocal ? 'Schließen' : 'Abbrechen'}
|
||||||
|
</button>
|
||||||
|
{!isLocal && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Download, X } from 'lucide-react'
|
||||||
|
import type { AttachmentMeta } from '../types'
|
||||||
|
import { formatBytes } from '../lib/format'
|
||||||
|
|
||||||
|
export function isPreviewable(att: AttachmentMeta) {
|
||||||
|
const mime = att.mime_type.toLowerCase()
|
||||||
|
return mime.startsWith('image/') || mime === 'application/pdf'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPreviewModal({
|
||||||
|
attachment,
|
||||||
|
onClose,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentMeta | null
|
||||||
|
onClose: () => void
|
||||||
|
onDownload: (att: AttachmentMeta) => void
|
||||||
|
}) {
|
||||||
|
if (!attachment) return null
|
||||||
|
const dataUrl = `data:${attachment.mime_type || 'application/octet-stream'};base64,${attachment.content_base64}`
|
||||||
|
const isImage = attachment.mime_type.toLowerCase().startsWith('image/')
|
||||||
|
const isPdf = attachment.mime_type.toLowerCase() === 'application/pdf'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="animate-overlay-in fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/60 p-6 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="animate-panel-in flex h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-[24px] border border-white/10 bg-zinc-900/95 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[13px] font-medium text-zinc-100">{attachment.filename}</p>
|
||||||
|
<p className="text-[11px] text-zinc-400">{formatBytes(attachment.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onDownload(attachment)}
|
||||||
|
title="Herunterladen"
|
||||||
|
className="rounded-full p-2 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
title="Schließen"
|
||||||
|
className="rounded-full p-2 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center overflow-auto bg-zinc-950/60 p-4">
|
||||||
|
{isImage && <img src={dataUrl} alt={attachment.filename} className="max-h-full max-w-full object-contain" />}
|
||||||
|
{isPdf && <iframe title={attachment.filename} src={dataUrl} className="h-full w-full rounded-lg bg-white" />}
|
||||||
|
{!isImage && !isPdf && <p className="text-sm text-zinc-400">Keine Vorschau verfügbar.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { avatarColor, initials } from '../lib/format'
|
||||||
|
|
||||||
|
export function Avatar({ name, size = 36 }: { name: string; size?: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-full font-medium text-white select-none"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fontSize: size * 0.38,
|
||||||
|
backgroundColor: avatarColor(name || '?'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials(name)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Plus, ShieldOff, Trash2, X } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { NewRule, Rule } from '../types'
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'flex-1 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'
|
||||||
|
|
||||||
|
/// A "blocked sender" is just a global (all-accounts) rule of the shape
|
||||||
|
/// {field: from, action: delete}, so this reuses the existing rules engine
|
||||||
|
/// (already applied automatically on every sync) instead of a parallel mechanism.
|
||||||
|
function isBlockRule(r: Rule): boolean {
|
||||||
|
return (
|
||||||
|
!r.account_id &&
|
||||||
|
r.action === 'delete' &&
|
||||||
|
r.conditions.length === 1 &&
|
||||||
|
r.conditions[0].field === 'from' &&
|
||||||
|
r.conditions[0].contains.trim() !== ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockedSendersModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onToast,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
}) {
|
||||||
|
const [rules, setRules] = useState<Rule[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setLoading(true)
|
||||||
|
api
|
||||||
|
.listRules()
|
||||||
|
.then((all) => setRules(all.filter(isBlockRule)))
|
||||||
|
.catch((e) => onToast(`Liste konnte nicht geladen werden: ${e}`))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) refresh()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const value = email.trim()
|
||||||
|
if (!value) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const newRule: NewRule = {
|
||||||
|
account_id: null,
|
||||||
|
conditions: [{ field: 'from', contains: value }],
|
||||||
|
match_type: 'all',
|
||||||
|
action: 'delete',
|
||||||
|
}
|
||||||
|
await api.createRule(newRule)
|
||||||
|
setEmail('')
|
||||||
|
refresh()
|
||||||
|
onToast(`„${value}“ wird jetzt blockiert`)
|
||||||
|
} catch (err) {
|
||||||
|
onToast(`Fehlgeschlagen: ${err}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(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-[80vh] w-full max-w-md 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="flex items-center gap-2 text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
<ShieldOff size={16} className="text-zinc-400" /> Blockierte Absender
|
||||||
|
</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="flex gap-2">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Adresse oder Domain, z. B. spam@x.com"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !email.trim()}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-2 text-[12.5px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Blockieren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-[11.5px] text-zinc-400">
|
||||||
|
Mails von blockierten Absendern werden bei allen Konten automatisch beim Abrufen gelöscht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-zinc-400">Lade…</p>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-400">Noch niemand blockiert.</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"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-700 dark:text-zinc-300">
|
||||||
|
{r.conditions[0].contains}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Globe, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { CaldavAccount, NewCaldavAccount } from '../types'
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaldavModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onToast,
|
||||||
|
onSynced,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
onSynced: () => void
|
||||||
|
}) {
|
||||||
|
const [accounts, setAccounts] = useState<CaldavAccount[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [syncing, setSyncing] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setLoading(true)
|
||||||
|
api
|
||||||
|
.listCaldavAccounts()
|
||||||
|
.then(setAccounts)
|
||||||
|
.catch((e) => onToast(`Kalender konnten nicht geladen werden: ${e}`))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
refresh()
|
||||||
|
setShowForm(false)
|
||||||
|
setLabel('')
|
||||||
|
setUrl('')
|
||||||
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const newAccount: NewCaldavAccount = { label: label.trim() || url.trim(), url: url.trim(), username: username.trim(), password }
|
||||||
|
const created = await api.addCaldavAccount(newAccount)
|
||||||
|
setShowForm(false)
|
||||||
|
refresh()
|
||||||
|
onToast('Kalender verbunden — synchronisiere…')
|
||||||
|
await handleSync(created.id)
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync(id: string) {
|
||||||
|
setSyncing(id)
|
||||||
|
try {
|
||||||
|
const count = await api.syncCaldavCalendar(id)
|
||||||
|
onToast(`${count} Termine synchronisiert`)
|
||||||
|
onSynced()
|
||||||
|
} catch (e) {
|
||||||
|
onToast(`Synchronisierung fehlgeschlagen: ${e}`)
|
||||||
|
} finally {
|
||||||
|
setSyncing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(a: CaldavAccount) {
|
||||||
|
if (!window.confirm(`Kalender „${a.label}“ trennen? Lokal zwischengespeicherte Termine werden entfernt.`)) return
|
||||||
|
setAccounts((prev) => prev.filter((x) => x.id !== a.id))
|
||||||
|
try {
|
||||||
|
await api.deleteCaldavAccount(a.id)
|
||||||
|
onSynced()
|
||||||
|
} catch (e) {
|
||||||
|
onToast(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-md 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="flex items-center gap-2 text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
<Globe size={16} className="text-zinc-400" /> CalDAV-Kalender
|
||||||
|
</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">
|
||||||
|
<p className="text-[11.5px] text-zinc-400">
|
||||||
|
Für Nextcloud, iCloud, Radicale & Co. — trag die direkte Kalender-Adresse ein (kein automatisches Erkennen
|
||||||
|
der Kalenderliste). Bei iCloud/Nextcloud brauchst du meist ein App-Passwort statt deines normalen Passworts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<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">
|
||||||
|
<Field label="Name">
|
||||||
|
<input className={inputClass} value={label} onChange={(e) => setLabel(e.target.value)} placeholder="z. B. Privat" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Kalender-URL">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://cloud.example.com/remote.php/dav/calendars/user/personal/"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Benutzername">
|
||||||
|
<input className={inputClass} value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
<Field label="Passwort / App-Passwort">
|
||||||
|
<input className={inputClass} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-[11.5px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !url.trim() || !username.trim() || !password}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-1.5 text-[12.5px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Verbinde…' : 'Verbinden'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="rounded-full px-4 py-1.5 text-[12.5px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 rounded-2xl border border-dashed border-zinc-300 py-2.5 text-[12.5px] font-medium text-zinc-500 transition hover:border-indigo-300 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Kalender verbinden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-zinc-400">Lade…</p>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-400">Noch kein CalDAV-Kalender verbunden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[12.5px] font-medium text-zinc-700 dark:text-zinc-300">{a.label}</p>
|
||||||
|
<p className="truncate text-[11px] text-zinc-400">{a.url}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSync(a.id)}
|
||||||
|
disabled={syncing === a.id}
|
||||||
|
title="Jetzt synchronisieren"
|
||||||
|
className="shrink-0 rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={clsx(syncing === a.id && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(a)}
|
||||||
|
className="shrink-0 rounded-full p-1.5 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { Event } from '../types'
|
||||||
|
import { categoryHex } from '../lib/categories'
|
||||||
|
|
||||||
|
const HOUR_HEIGHT = 52
|
||||||
|
|
||||||
|
function startOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesSinceMidnight(ts: number, day: Date) {
|
||||||
|
const dayStart = Math.floor(startOfDay(day).getTime() / 1000)
|
||||||
|
return Math.max(0, Math.min(24 * 60, Math.round((ts - dayStart) / 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarDayView({
|
||||||
|
date,
|
||||||
|
events,
|
||||||
|
onEventClick,
|
||||||
|
onEventContextMenu,
|
||||||
|
onEmptyClick,
|
||||||
|
}: {
|
||||||
|
date: Date
|
||||||
|
events: Event[]
|
||||||
|
onEventClick: (event: Event) => void
|
||||||
|
onEventContextMenu?: (e: React.MouseEvent, event: Event) => void
|
||||||
|
onEmptyClick: () => void
|
||||||
|
}) {
|
||||||
|
const dayStart = Math.floor(startOfDay(date).getTime() / 1000)
|
||||||
|
const dayEnd = dayStart + 86400
|
||||||
|
|
||||||
|
const { allDay, timed } = useMemo(() => {
|
||||||
|
const inDay = events.filter((e) => e.start_ts < dayEnd && e.end_ts > dayStart)
|
||||||
|
return {
|
||||||
|
allDay: inDay.filter((e) => e.all_day),
|
||||||
|
timed: inDay.filter((e) => !e.all_day).sort((a, b) => a.start_ts - b.start_ts),
|
||||||
|
}
|
||||||
|
}, [events, dayStart, dayEnd])
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const showNowLine = startOfDay(now).getTime() === startOfDay(date).getTime()
|
||||||
|
const nowTop = (minutesSinceMidnight(Math.floor(now.getTime() / 1000), date) / 60) * HOUR_HEIGHT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{allDay.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 border-b border-zinc-200/80 px-4 py-2 dark:border-zinc-800/80">
|
||||||
|
{allDay.map((e) => {
|
||||||
|
const hex = categoryHex(e.category)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={e.id}
|
||||||
|
onClick={() => onEventClick(e)}
|
||||||
|
onContextMenu={(ev) => onEventContextMenu?.(ev, e)}
|
||||||
|
className="cursor-default truncate rounded-md px-2 py-0.5 text-[11.5px] font-medium bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25"
|
||||||
|
style={hex ? { backgroundColor: `${hex}26`, color: hex } : undefined}
|
||||||
|
>
|
||||||
|
{e.title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative flex-1 overflow-y-auto">
|
||||||
|
<div className="relative" style={{ height: HOUR_HEIGHT * 24 }}>
|
||||||
|
{Array.from({ length: 24 }, (_, h) => (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
onClick={onEmptyClick}
|
||||||
|
className="absolute inset-x-0 flex cursor-default items-start gap-2 border-t border-zinc-100 hover:bg-zinc-50/60 dark:border-zinc-900 dark:hover:bg-zinc-900/40"
|
||||||
|
style={{ top: h * HOUR_HEIGHT, height: HOUR_HEIGHT }}
|
||||||
|
>
|
||||||
|
<span className="w-12 shrink-0 -translate-y-2 pl-2 text-right text-[10.5px] text-zinc-400 select-none">
|
||||||
|
{h.toString().padStart(2, '0')}:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{showNowLine && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 z-10 flex items-center" style={{ top: nowTop }}>
|
||||||
|
<span className="ml-12 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||||
|
<span className="h-px flex-1 bg-red-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-14 right-2">
|
||||||
|
{timed.map((e) => {
|
||||||
|
const top = (minutesSinceMidnight(e.start_ts, date) / 60) * HOUR_HEIGHT
|
||||||
|
const bottom = (minutesSinceMidnight(e.end_ts, date) / 60) * HOUR_HEIGHT
|
||||||
|
const height = Math.max(20, bottom - top)
|
||||||
|
const hex = categoryHex(e.category)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onEventClick(e)
|
||||||
|
}}
|
||||||
|
onContextMenu={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onEventContextMenu?.(ev, e)
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto absolute left-0 right-0 cursor-default overflow-hidden rounded-lg border-l-2 px-2 py-1 text-[11.5px] font-medium bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25"
|
||||||
|
style={{
|
||||||
|
top,
|
||||||
|
height,
|
||||||
|
borderLeftColor: hex ?? '#6366f1',
|
||||||
|
backgroundColor: hex ? `${hex}1a` : undefined,
|
||||||
|
color: hex ?? undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="truncate">{e.title}</p>
|
||||||
|
{height > 32 && (
|
||||||
|
<p className="truncate text-[10.5px] opacity-70">
|
||||||
|
{new Date(e.start_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' – '}
|
||||||
|
{new Date(e.end_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Event } from '../types'
|
||||||
|
import { categoryHex } from '../lib/categories'
|
||||||
|
import { CalendarDayView } from './CalendarDayView'
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = [
|
||||||
|
'Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag',
|
||||||
|
]
|
||||||
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
]
|
||||||
|
|
||||||
|
function startOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(a: Date, b: Date) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrid(monthDate: Date): Date[] {
|
||||||
|
const first = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1)
|
||||||
|
const mondayOffset = (first.getDay() + 6) % 7
|
||||||
|
const gridStart = new Date(first)
|
||||||
|
gridStart.setDate(first.getDate() - mondayOffset)
|
||||||
|
return Array.from({ length: 42 }, (_, i) => {
|
||||||
|
const d = new Date(gridStart)
|
||||||
|
d.setDate(gridStart.getDate() + i)
|
||||||
|
return d
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarView({
|
||||||
|
monthDate,
|
||||||
|
events,
|
||||||
|
loading,
|
||||||
|
onPrevMonth,
|
||||||
|
onNextMonth,
|
||||||
|
onToday,
|
||||||
|
onDayClick,
|
||||||
|
onEventClick,
|
||||||
|
onEventContextMenu,
|
||||||
|
}: {
|
||||||
|
monthDate: Date
|
||||||
|
events: Event[]
|
||||||
|
loading: boolean
|
||||||
|
onPrevMonth: () => void
|
||||||
|
onNextMonth: () => void
|
||||||
|
onToday: () => void
|
||||||
|
onDayClick: (date: Date) => void
|
||||||
|
onEventClick: (event: Event) => void
|
||||||
|
onEventContextMenu?: (e: React.MouseEvent, event: Event) => void
|
||||||
|
}) {
|
||||||
|
const grid = useMemo(() => buildGrid(monthDate), [monthDate])
|
||||||
|
const today = useMemo(() => new Date(), [])
|
||||||
|
const [mode, setMode] = useState<'month' | 'day'>('month')
|
||||||
|
const [dayDate, setDayDate] = useState(() => new Date())
|
||||||
|
|
||||||
|
function openDay(date: Date) {
|
||||||
|
setDayDate(date)
|
||||||
|
setMode('day')
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsByDay = useMemo(() => {
|
||||||
|
const map = new Map<string, Event[]>()
|
||||||
|
for (const day of grid) {
|
||||||
|
const dayStart = Math.floor(startOfDay(day).getTime() / 1000)
|
||||||
|
const dayEnd = dayStart + 86400
|
||||||
|
const items = events
|
||||||
|
.filter((e) => e.start_ts < dayEnd && e.end_ts > dayStart)
|
||||||
|
.sort((a, b) => a.start_ts - b.start_ts)
|
||||||
|
map.set(day.toDateString(), items)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [grid, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-200/80 px-6 py-3.5 dark:border-zinc-800/80">
|
||||||
|
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
{mode === 'month'
|
||||||
|
? `${MONTH_NAMES[monthDate.getMonth()]} ${monthDate.getFullYear()}`
|
||||||
|
: `${WEEKDAY_NAMES[dayDate.getDay()]}, ${dayDate.getDate()}. ${MONTH_NAMES[dayDate.getMonth()]} ${dayDate.getFullYear()}`}
|
||||||
|
</h2>
|
||||||
|
<div className="flex rounded-lg bg-zinc-100 p-0.5 text-[11.5px] dark:bg-zinc-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('month')}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-md px-2.5 py-1 font-medium transition',
|
||||||
|
mode === 'month' ? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Monat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('day')}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-md px-2.5 py-1 font-medium transition',
|
||||||
|
mode === 'day' ? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (mode === 'day') setDayDate(new Date())
|
||||||
|
else onToday()
|
||||||
|
}}
|
||||||
|
className="rounded-full px-3 py-1 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (mode === 'day') setDayDate((d) => new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1))
|
||||||
|
else onPrevMonth()
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1.5 text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (mode === 'day') setDayDate((d) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1))
|
||||||
|
else onNextMonth()
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1.5 text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'day' ? (
|
||||||
|
<CalendarDayView
|
||||||
|
date={dayDate}
|
||||||
|
events={events}
|
||||||
|
onEventClick={onEventClick}
|
||||||
|
onEventContextMenu={onEventContextMenu}
|
||||||
|
onEmptyClick={() => onDayClick(dayDate)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-7 border-b border-zinc-200/80 dark:border-zinc-800/80">
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<div key={w} className="px-2 py-1.5 text-center text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
{w}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-7 grid-rows-6 overflow-y-auto">
|
||||||
|
{grid.map((day) => {
|
||||||
|
const inMonth = day.getMonth() === monthDate.getMonth()
|
||||||
|
const isToday = isSameDay(day, today)
|
||||||
|
const dayEvents = eventsByDay.get(day.toDateString()) ?? []
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toISOString()}
|
||||||
|
onClick={() => onDayClick(day)}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-col items-stretch gap-1 border-b border-r border-zinc-100 p-1.5 text-left transition hover:bg-zinc-50 dark:border-zinc-900 dark:hover:bg-zinc-900/60',
|
||||||
|
!inMonth && 'bg-zinc-50/50 dark:bg-zinc-950/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
openDay(day)
|
||||||
|
}}
|
||||||
|
title="Tagesansicht öffnen"
|
||||||
|
className={clsx(
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11.5px] font-medium transition hover:ring-2 hover:ring-indigo-300',
|
||||||
|
isToday
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: inMonth
|
||||||
|
? 'text-zinc-700 dark:text-zinc-300'
|
||||||
|
: 'text-zinc-350 dark:text-zinc-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
||||||
|
{dayEvents.slice(0, 3).map((e) => {
|
||||||
|
const hex = categoryHex(e.category)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={e.id}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onEventClick(e)
|
||||||
|
}}
|
||||||
|
onContextMenu={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onEventContextMenu?.(ev, e)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'truncate rounded-md px-1.5 py-0.5 text-[10.5px] font-medium',
|
||||||
|
!hex && 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25',
|
||||||
|
)}
|
||||||
|
style={hex ? { backgroundColor: `${hex}26`, color: hex } : undefined}
|
||||||
|
>
|
||||||
|
{e.all_day ? '' : `${new Date(e.start_ts * 1000).getHours().toString().padStart(2, '0')}:${new Date(e.start_ts * 1000).getMinutes().toString().padStart(2, '0')} `}
|
||||||
|
{e.title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{dayEvents.length > 3 && (
|
||||||
|
<span className="px-1.5 text-[10px] text-zinc-400">+{dayEvents.length - 3} weitere</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<div className="border-t border-zinc-200/80 px-6 py-1.5 text-[11px] text-zinc-400 dark:border-zinc-800/80">
|
||||||
|
Lade Termine…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import { Popover } from './Popover'
|
||||||
|
|
||||||
|
export function ClassicTabBar({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
onChange,
|
||||||
|
rightSlot,
|
||||||
|
}: {
|
||||||
|
tabs: { id: string; label: string }[]
|
||||||
|
active: string
|
||||||
|
onChange: (id: string) => void
|
||||||
|
rightSlot?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 bg-[#1e5aa8] px-2 pt-1.5 dark:bg-zinc-900">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-t-sm px-3 py-1.5 text-[13px] transition',
|
||||||
|
active === t.id
|
||||||
|
? 'bg-[#f3f2f1] font-semibold text-[#1e5aa8] dark:bg-zinc-800 dark:text-blue-300'
|
||||||
|
: 'text-white/90 hover:bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{rightSlot && <div className="ml-auto flex items-center pb-1.5">{rightSlot}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassicRibbonGroup({
|
||||||
|
caption,
|
||||||
|
children,
|
||||||
|
onLauncher,
|
||||||
|
}: {
|
||||||
|
caption: string
|
||||||
|
children: React.ReactNode
|
||||||
|
onLauncher?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[92px] shrink-0 flex-col justify-between border-r border-[#d1d1d1] px-1.5 last:border-r-0 dark:border-zinc-700">
|
||||||
|
<div className="flex flex-1 items-start gap-0.5 pt-1">{children}</div>
|
||||||
|
<div className="relative flex items-center justify-center pb-0.5">
|
||||||
|
<span className="text-[10.5px] text-[#605e5c] dark:text-zinc-400">{caption}</span>
|
||||||
|
{onLauncher && (
|
||||||
|
<button
|
||||||
|
onClick={onLauncher}
|
||||||
|
title={`${caption}: weitere Optionen`}
|
||||||
|
className="absolute right-0 text-[#605e5c] hover:text-[#1e5aa8] dark:text-zinc-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<svg width="8" height="8" viewBox="0 0 9 9">
|
||||||
|
<path d="M0 9L9 0M9 0H3M9 0V6" stroke="currentColor" strokeWidth="1.2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassicButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
active,
|
||||||
|
size = 'large',
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ size?: number }>
|
||||||
|
label: string
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
active?: boolean
|
||||||
|
size?: 'large' | 'small'
|
||||||
|
title?: string
|
||||||
|
}) {
|
||||||
|
if (size === 'small') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title ?? label}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1.5 rounded-sm px-1.5 py-0.5 text-[11.5px] text-[#1f1f1f] transition disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-200',
|
||||||
|
active ? 'bg-[#cce4f7] dark:bg-blue-500/20' : 'hover:bg-[#e6f2fb] dark:hover:bg-zinc-700/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span className="whitespace-nowrap">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title ?? label}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-[64px] shrink-0 flex-col items-center gap-1 rounded-sm px-1 py-1 text-center text-[11px] leading-tight text-[#1f1f1f] transition disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-200',
|
||||||
|
active ? 'bg-[#cce4f7] dark:bg-blue-500/20' : 'hover:bg-[#e6f2fb] dark:hover:bg-zinc-700/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={24} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassicDropdown({
|
||||||
|
button,
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
align = 'left',
|
||||||
|
widthClass = 'w-56',
|
||||||
|
}: {
|
||||||
|
button: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
widthClass?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
trigger={button}
|
||||||
|
align={align === 'right' ? 'end' : 'start'}
|
||||||
|
panelClassName={clsx(
|
||||||
|
'animate-panel-in overflow-hidden rounded-md border border-zinc-300 bg-white py-1 shadow-xl dark:border-zinc-700 dark:bg-zinc-900',
|
||||||
|
widthClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassicMenuItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-[#e6f2fb] disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-200 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClassicRibbonBody({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[100px] items-stretch overflow-x-auto bg-[#f3f2f1] px-1.5 dark:bg-zinc-800/60">{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Check, Pencil, PenLine, Plus, Send, Trash2, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account, ComposeMessage, OutgoingAttachment } from '../types'
|
||||||
|
import { formatBytes } from '../lib/format'
|
||||||
|
import { escapeHtml, htmlIsEmpty } from '../lib/richtext'
|
||||||
|
import { getDefaultSignatureId, listSignatures, loadSignature, saveSignatureList, type Signature } from '../lib/signature'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { RecipientInput } from './RecipientInput'
|
||||||
|
import { RichTextEditor, type RichTextEditorHandle } from './RichTextEditor'
|
||||||
|
import { ComposeRibbon } from './ComposeRibbon'
|
||||||
|
import { TemplatesModal } from './TemplatesModal'
|
||||||
|
|
||||||
|
export interface ComposeInitial {
|
||||||
|
to?: string
|
||||||
|
cc?: string
|
||||||
|
subject?: string
|
||||||
|
body?: string
|
||||||
|
attachments?: OutgoingAttachment[]
|
||||||
|
inReplyTo?: string
|
||||||
|
draftUid?: number
|
||||||
|
skipSignature?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY = 1500
|
||||||
|
const UNDO_WINDOW_SECS = 8
|
||||||
|
|
||||||
|
function fileToAttachment(file: File): Promise<OutgoingAttachment> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
const base64 = result.split(',')[1] ?? ''
|
||||||
|
resolve({ filename: file.name, mime_type: file.type || 'application/octet-stream', content_base64: base64 })
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToPlainText(html: string): string {
|
||||||
|
const tmp = document.createElement('div')
|
||||||
|
tmp.innerHTML = html
|
||||||
|
return tmp.textContent ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposePane({
|
||||||
|
account,
|
||||||
|
accounts,
|
||||||
|
onAccountChange,
|
||||||
|
initial,
|
||||||
|
onDiscard,
|
||||||
|
onSend,
|
||||||
|
onDraftSaved,
|
||||||
|
sending,
|
||||||
|
}: {
|
||||||
|
account: Account | null
|
||||||
|
accounts: Account[]
|
||||||
|
onAccountChange: (accountId: string) => void
|
||||||
|
initial: ComposeInitial | null
|
||||||
|
onDiscard: () => void
|
||||||
|
onSend: (msg: ComposeMessage, sendAt: number) => Promise<void>
|
||||||
|
onDraftSaved: (uid: number) => void
|
||||||
|
sending: boolean
|
||||||
|
}) {
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [cc, setCc] = useState('')
|
||||||
|
const [bcc, setBcc] = useState('')
|
||||||
|
const [showCc, setShowCc] = useState(false)
|
||||||
|
const [showBcc, setShowBcc] = useState(false)
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [attachments, setAttachments] = useState<OutgoingAttachment[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [info, setInfo] = useState<string | null>(null)
|
||||||
|
const [signatureOpen, setSignatureOpen] = useState(false)
|
||||||
|
const [signatures, setSignatures] = useState<Signature[]>([])
|
||||||
|
const [defaultSigId, setDefaultSigId] = useState<string | null>(null)
|
||||||
|
const [editingSigId, setEditingSigId] = useState<string | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const dragCounter = useRef(0)
|
||||||
|
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||||
|
|
||||||
|
const [importance, setImportance] = useState<'high' | 'low' | null>(null)
|
||||||
|
const [requestReadReceipt, setRequestReadReceipt] = useState(false)
|
||||||
|
const [delayAt, setDelayAt] = useState('')
|
||||||
|
const [replyTo, setReplyTo] = useState('')
|
||||||
|
const [plainTextMode, setPlainTextMode] = useState(false)
|
||||||
|
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
const editorRef = useRef<RichTextEditorHandle>(null)
|
||||||
|
const draftUidRef = useRef<number | null>(null)
|
||||||
|
const isFirstRender = useRef(true)
|
||||||
|
|
||||||
|
const sendableAccounts = accounts.filter((a) => a.protocol !== 'local')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const signature = account && !initial?.skipSignature ? loadSignature(account.id) : ''
|
||||||
|
const base = initial?.body ?? ''
|
||||||
|
const sigBlock = signature ? `<br><br>--<br>${signature.replace(/\n/g, '<br>')}` : ''
|
||||||
|
const withSignature = base + sigBlock
|
||||||
|
|
||||||
|
setTo(initial?.to ?? '')
|
||||||
|
setCc(initial?.cc ?? '')
|
||||||
|
setBcc('')
|
||||||
|
setShowCc(!!initial?.cc)
|
||||||
|
setShowBcc(false)
|
||||||
|
setSubject(initial?.subject ?? '')
|
||||||
|
setBody(withSignature)
|
||||||
|
setAttachments(initial?.attachments ?? [])
|
||||||
|
setError(null)
|
||||||
|
setImportance(null)
|
||||||
|
setRequestReadReceipt(false)
|
||||||
|
setDelayAt('')
|
||||||
|
setReplyTo('')
|
||||||
|
setPlainTextMode(false)
|
||||||
|
if (account) {
|
||||||
|
setSignatures(listSignatures(account.id))
|
||||||
|
setDefaultSigId(getDefaultSignatureId(account.id))
|
||||||
|
}
|
||||||
|
setEditingSigId(null)
|
||||||
|
draftUidRef.current = initial?.draftUid ?? null
|
||||||
|
isFirstRender.current = true
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initial])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!account) return
|
||||||
|
const hasContent = to.trim() || cc.trim() || bcc.trim() || subject.trim() || !htmlIsEmpty(body)
|
||||||
|
if (!hasContent) return
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.saveDraft(draftUidRef.current, {
|
||||||
|
account_id: account.id,
|
||||||
|
to: to.trim(),
|
||||||
|
cc: cc.trim() || undefined,
|
||||||
|
bcc: bcc.trim() || undefined,
|
||||||
|
subject: subject.trim(),
|
||||||
|
body_html: body,
|
||||||
|
attachments: [],
|
||||||
|
})
|
||||||
|
.then((uid) => {
|
||||||
|
draftUidRef.current = uid
|
||||||
|
onDraftSaved(uid)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, AUTOSAVE_DELAY)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [to, cc, bcc, subject, body, account])
|
||||||
|
|
||||||
|
async function handleFiles(files: FileList | null) {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const converted = await Promise.all(Array.from(files).map(fileToAttachment))
|
||||||
|
setAttachments((prev) => [...prev, ...converted])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter(e: React.DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return
|
||||||
|
dragCounter.current += 1
|
||||||
|
setDragActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: React.DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragCounter.current -= 1
|
||||||
|
if (dragCounter.current <= 0) {
|
||||||
|
dragCounter.current = 0
|
||||||
|
setDragActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: React.DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragCounter.current = 0
|
||||||
|
setDragActive(false)
|
||||||
|
handleFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInsertTemplate(t: { subject: string; body: string }) {
|
||||||
|
if (t.subject && !subject.trim()) setSubject(t.subject)
|
||||||
|
editorRef.current?.exec('insertHTML', t.body.replace(/\n/g, '<br>'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfo(msg: string) {
|
||||||
|
setInfo(msg)
|
||||||
|
setTimeout(() => setInfo(null), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSignatures(next: Signature[], nextDefaultId: string | null) {
|
||||||
|
if (!account) return
|
||||||
|
setSignatures(next)
|
||||||
|
setDefaultSigId(nextDefaultId)
|
||||||
|
saveSignatureList(account.id, next, nextDefaultId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddSignature() {
|
||||||
|
const sig: Signature = { id: crypto.randomUUID(), name: `Signatur ${signatures.length + 1}`, body: '' }
|
||||||
|
const next = [...signatures, sig]
|
||||||
|
persistSignatures(next, defaultSigId ?? sig.id)
|
||||||
|
setEditingSigId(sig.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRenameSignature(id: string, name: string) {
|
||||||
|
persistSignatures(
|
||||||
|
signatures.map((s) => (s.id === id ? { ...s, name } : s)),
|
||||||
|
defaultSigId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditSignatureBody(id: string, sigBody: string) {
|
||||||
|
persistSignatures(
|
||||||
|
signatures.map((s) => (s.id === id ? { ...s, body: sigBody } : s)),
|
||||||
|
defaultSigId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteSignature(id: string) {
|
||||||
|
const next = signatures.filter((s) => s.id !== id)
|
||||||
|
persistSignatures(next, defaultSigId === id ? null : defaultSigId)
|
||||||
|
if (editingSigId === id) setEditingSigId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!account) return
|
||||||
|
if (!to.trim()) {
|
||||||
|
setError('Bitte gib mindestens einen Empfänger an.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const finalBody = plainTextMode ? `<pre>${escapeHtml(htmlToPlainText(body))}</pre>` : body
|
||||||
|
const sendAt = delayAt ? Math.floor(new Date(delayAt).getTime() / 1000) : Math.floor(Date.now() / 1000) + UNDO_WINDOW_SECS
|
||||||
|
await onSend(
|
||||||
|
{
|
||||||
|
account_id: account.id,
|
||||||
|
to: to.trim(),
|
||||||
|
cc: cc.trim() || undefined,
|
||||||
|
bcc: bcc.trim() || undefined,
|
||||||
|
subject: subject.trim(),
|
||||||
|
body_html: finalBody,
|
||||||
|
attachments,
|
||||||
|
in_reply_to: initial?.inReplyTo,
|
||||||
|
reply_to: replyTo.trim() || undefined,
|
||||||
|
request_read_receipt: requestReadReceipt,
|
||||||
|
importance: importance ?? undefined,
|
||||||
|
},
|
||||||
|
sendAt,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex min-w-0 flex-1 flex-col"
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{dragActive && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center gap-2 border-2 border-dashed border-indigo-400 bg-indigo-50/90 text-[13px] font-medium text-indigo-700 backdrop-blur-sm dark:border-indigo-500 dark:bg-indigo-950/80 dark:text-indigo-300">
|
||||||
|
<Plus size={16} /> Dateien hier ablegen, um sie anzuhängen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ComposeRibbon
|
||||||
|
editor={editorRef}
|
||||||
|
onAttach={() => fileInput.current?.click()}
|
||||||
|
onOpenSignature={() => setSignatureOpen((v) => !v)}
|
||||||
|
onOpenTemplates={() => setTemplatesOpen(true)}
|
||||||
|
importance={importance}
|
||||||
|
onImportanceChange={setImportance}
|
||||||
|
showBcc={showBcc}
|
||||||
|
onToggleBcc={() => setShowBcc((v) => !v)}
|
||||||
|
requestReadReceipt={requestReadReceipt}
|
||||||
|
onToggleReadReceipt={() => setRequestReadReceipt((v) => !v)}
|
||||||
|
delayAt={delayAt}
|
||||||
|
onDelayChange={setDelayAt}
|
||||||
|
replyTo={replyTo}
|
||||||
|
onReplyToChange={setReplyTo}
|
||||||
|
plainTextMode={plainTextMode}
|
||||||
|
onTogglePlainText={() => setPlainTextMode((v) => !v)}
|
||||||
|
onWordCount={(count) => showInfo(`${count} Wörter`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{signatureOpen && account && (
|
||||||
|
<div className="border-b border-zinc-200/80 bg-zinc-50/60 px-7 py-3 dark:border-zinc-800/80 dark:bg-zinc-950/40">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Signaturen für dieses Konto</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setSignatureOpen(false)}
|
||||||
|
className="rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{signatures.length === 0 && (
|
||||||
|
<p className="text-[12px] text-zinc-400">Noch keine Signaturen angelegt.</p>
|
||||||
|
)}
|
||||||
|
{signatures.map((s) => (
|
||||||
|
<div key={s.id} className="rounded-xl border border-zinc-200/80 bg-white/70 dark:border-zinc-800 dark:bg-zinc-900/40">
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => persistSignatures(signatures, s.id)}
|
||||||
|
title={s.id === defaultSigId ? 'Standard-Signatur' : 'Als Standard festlegen'}
|
||||||
|
className={clsx(
|
||||||
|
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition',
|
||||||
|
s.id === defaultSigId
|
||||||
|
? 'border-indigo-500 bg-indigo-500 text-white'
|
||||||
|
: 'border-zinc-300 text-transparent hover:border-indigo-400 dark:border-zinc-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check size={10} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
value={s.name}
|
||||||
|
onChange={(e) => handleRenameSignature(s.id, e.target.value)}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-[12.5px] font-medium text-zinc-700 outline-none dark:text-zinc-200"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSigId(editingSigId === s.id ? null : s.id)}
|
||||||
|
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSignature(s.id)}
|
||||||
|
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={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingSigId === s.id && (
|
||||||
|
<textarea
|
||||||
|
value={s.body}
|
||||||
|
onChange={(e) => handleEditSignatureBody(s.id, e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
className="w-full resize-none border-t border-zinc-100 bg-transparent px-2.5 py-2 text-[12.5px] text-zinc-700 outline-none dark:border-zinc-800 dark:text-zinc-200"
|
||||||
|
placeholder="z. B. Viele Grüße Dein Name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAddSignature}
|
||||||
|
className="mt-2 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} /> Neue Signatur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex border-b border-zinc-200/80 dark:border-zinc-800/80">
|
||||||
|
<div className="flex w-24 shrink-0 items-center justify-center border-r border-zinc-200/80 dark:border-zinc-800/80">
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-col items-center gap-1 rounded-lg px-3 py-2.5 text-indigo-600 transition hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Send size={22} />
|
||||||
|
<span className="text-[11px] font-medium">{sending ? 'Sende…' : 'Senden'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-100/80 px-4 py-1.5 dark:border-zinc-800/50">
|
||||||
|
<select
|
||||||
|
value={account?.id ?? ''}
|
||||||
|
onChange={(e) => onAccountChange(e.target.value)}
|
||||||
|
className="w-28 shrink-0 rounded-md border border-zinc-300 bg-white px-2 py-1 text-[11.5px] font-medium text-zinc-600 outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{sendableAccounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="flex-1 truncate text-[13px] text-zinc-500 dark:text-zinc-400">{account?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-100/80 py-1.5 pr-4 dark:border-zinc-800/50">
|
||||||
|
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-zinc-300 py-1 text-[11.5px] text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||||
|
An
|
||||||
|
</div>
|
||||||
|
<RecipientInput value={to} onChange={setTo} placeholder="empfaenger@example.com" />
|
||||||
|
{!showCc && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCc(true)}
|
||||||
|
className="shrink-0 rounded-full px-2 py-0.5 text-[11px] text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Cc
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showCc && (
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-100/80 py-1.5 pr-4 dark:border-zinc-800/50">
|
||||||
|
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-zinc-300 py-1 text-[11.5px] text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||||
|
Cc
|
||||||
|
</div>
|
||||||
|
<RecipientInput value={cc} onChange={setCc} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showBcc && (
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-100/80 py-1.5 pr-4 dark:border-zinc-800/50">
|
||||||
|
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-zinc-300 py-1 text-[11.5px] text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||||
|
Bcc
|
||||||
|
</div>
|
||||||
|
<RecipientInput value={bcc} onChange={setBcc} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 py-1.5 pr-4">
|
||||||
|
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-transparent py-1 text-[11.5px] text-zinc-400">
|
||||||
|
Betreff
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
className="flex-1 bg-transparent text-[13px] font-medium text-zinc-800 outline-none dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RichTextEditor ref={editorRef} html={body} resetKey={initial} onChange={setBody} placeholder="Schreib etwas Geiles…" />
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 border-t border-zinc-100/80 px-7 py-3 dark:border-zinc-800/50">
|
||||||
|
{attachments.map((att, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{att.filename}
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
{formatBytes(Math.ceil((att.content_base64.length * 3) / 4))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setAttachments((prev) => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="text-zinc-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{info && (
|
||||||
|
<p className="border-t border-indigo-100 bg-indigo-50/80 px-7 py-2 text-[12px] text-indigo-600 dark:border-indigo-950 dark:bg-indigo-950/40 dark:text-indigo-400">
|
||||||
|
{info}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="border-t border-red-100 bg-red-50/80 px-7 py-2.5 text-[12px] text-red-600 dark:border-red-950 dark:bg-red-950/40 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-zinc-200/80 px-7 py-2.5 dark:border-zinc-800/80">
|
||||||
|
<input ref={fileInput} type="file" multiple hidden onChange={(e) => handleFiles(e.target.files)} />
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] text-zinc-400">
|
||||||
|
<PenLine size={12} /> {plainTextMode ? 'Nur-Text-Format' : 'HTML-Format'}
|
||||||
|
{delayAt && ` · Wird gesendet am ${new Date(delayAt).toLocaleString('de-DE')}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onDiscard}
|
||||||
|
title="Verwerfen"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<TemplatesModal
|
||||||
|
open={templatesOpen}
|
||||||
|
onClose={() => setTemplatesOpen(false)}
|
||||||
|
onToast={showInfo}
|
||||||
|
onInsert={handleInsertTemplate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
BookUser,
|
||||||
|
Bold,
|
||||||
|
CalendarClock,
|
||||||
|
CheckCheck,
|
||||||
|
Clipboard,
|
||||||
|
Clock,
|
||||||
|
Code2,
|
||||||
|
Copy,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Italic,
|
||||||
|
Link2,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
Minus,
|
||||||
|
Paintbrush,
|
||||||
|
Palette,
|
||||||
|
Paperclip,
|
||||||
|
PenLine,
|
||||||
|
Quote,
|
||||||
|
RemoveFormatting,
|
||||||
|
Save,
|
||||||
|
Scissors,
|
||||||
|
Search,
|
||||||
|
SpellCheck,
|
||||||
|
Strikethrough,
|
||||||
|
Truck,
|
||||||
|
Type,
|
||||||
|
Underline,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { RichTextEditorHandle } from './RichTextEditor'
|
||||||
|
import { ClassicButton, ClassicDropdown, ClassicRibbonBody, ClassicRibbonGroup, ClassicTabBar } from './ClassicRibbonUI'
|
||||||
|
|
||||||
|
type ComposeTab = 'message' | 'insert' | 'options' | 'format' | 'review'
|
||||||
|
|
||||||
|
function DelayDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<ClassicDropdown open={open} onOpenChange={setOpen} widthClass="w-64" button={<ClassicButton icon={Clock} label="Übermittlung verzögern" active={!!value} />}>
|
||||||
|
<div className="space-y-2 px-3 py-2">
|
||||||
|
<label className="block text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Senden am</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-[12px] text-zinc-800 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button onClick={() => onChange('')} className="text-[11px] text-red-500 hover:text-red-600">
|
||||||
|
Verzögerung entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplyToDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<ClassicDropdown open={open} onOpenChange={setOpen} widthClass="w-64" button={<ClassicButton icon={Mail} label="Antworten richten an" active={!!value} />}>
|
||||||
|
<div className="space-y-2 px-3 py-2">
|
||||||
|
<label className="block text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Reply-To-Adresse</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="antworten@example.com"
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-[12px] text-zinc-800 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposeRibbon({
|
||||||
|
editor,
|
||||||
|
onAttach,
|
||||||
|
onOpenSignature,
|
||||||
|
onOpenTemplates,
|
||||||
|
onOpenContactPicker,
|
||||||
|
importance,
|
||||||
|
onImportanceChange,
|
||||||
|
showBcc,
|
||||||
|
onToggleBcc,
|
||||||
|
requestReadReceipt,
|
||||||
|
onToggleReadReceipt,
|
||||||
|
delayAt,
|
||||||
|
onDelayChange,
|
||||||
|
replyTo,
|
||||||
|
onReplyToChange,
|
||||||
|
plainTextMode,
|
||||||
|
onTogglePlainText,
|
||||||
|
onWordCount,
|
||||||
|
}: {
|
||||||
|
editor: React.RefObject<RichTextEditorHandle | null>
|
||||||
|
onAttach: () => void
|
||||||
|
onOpenSignature: () => void
|
||||||
|
onOpenTemplates: () => void
|
||||||
|
onOpenContactPicker?: () => void
|
||||||
|
importance: 'high' | 'low' | null
|
||||||
|
onImportanceChange: (v: 'high' | 'low' | null) => void
|
||||||
|
showBcc: boolean
|
||||||
|
onToggleBcc: () => void
|
||||||
|
requestReadReceipt: boolean
|
||||||
|
onToggleReadReceipt: () => void
|
||||||
|
delayAt: string
|
||||||
|
onDelayChange: (v: string) => void
|
||||||
|
replyTo: string
|
||||||
|
onReplyToChange: (v: string) => void
|
||||||
|
plainTextMode: boolean
|
||||||
|
onTogglePlainText: () => void
|
||||||
|
onWordCount: (count: number) => void
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<ComposeTab>('message')
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function exec(command: string, value?: string) {
|
||||||
|
editor.current?.exec(command, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImagePick(file: File | undefined) {
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => exec('insertImage', reader.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'message', label: 'Nachricht' },
|
||||||
|
{ id: 'insert', label: 'Einfügen' },
|
||||||
|
{ id: 'options', label: 'Optionen' },
|
||||||
|
{ id: 'format', label: 'Text formatieren' },
|
||||||
|
{ id: 'review', label: 'Überprüfen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full border-b border-[#0d3a6e]">
|
||||||
|
<ClassicTabBar tabs={tabs} active={tab} onChange={(id) => setTab(id as ComposeTab)} />
|
||||||
|
<ClassicRibbonBody>
|
||||||
|
{tab === 'message' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Zwischenablage">
|
||||||
|
<div className="flex flex-col gap-0.5 pt-1">
|
||||||
|
<ClassicButton size="small" icon={Clipboard} label="Einfügen" disabled title="Zwischenablage-Zugriff nicht verfügbar" />
|
||||||
|
<ClassicButton size="small" icon={Scissors} label="Ausschneiden" onClick={() => exec('cut')} />
|
||||||
|
<ClassicButton size="small" icon={Copy} label="Kopieren" onClick={() => exec('copy')} />
|
||||||
|
</div>
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Standardtext">
|
||||||
|
<div className="grid grid-cols-4 gap-0.5 pt-1">
|
||||||
|
<ClassicButton size="small" icon={Bold} label="" title="Fett" onClick={() => exec('bold')} />
|
||||||
|
<ClassicButton size="small" icon={Italic} label="" title="Kursiv" onClick={() => exec('italic')} />
|
||||||
|
<ClassicButton size="small" icon={Underline} label="" title="Unterstrichen" onClick={() => exec('underline')} />
|
||||||
|
<ClassicButton size="small" icon={Strikethrough} label="" title="Durchgestrichen" onClick={() => exec('strikeThrough')} />
|
||||||
|
<ClassicButton size="small" icon={List} label="" title="Liste" onClick={() => exec('insertUnorderedList')} />
|
||||||
|
<ClassicButton size="small" icon={ListOrdered} label="" title="Nummeriert" onClick={() => exec('insertOrderedList')} />
|
||||||
|
<ClassicButton size="small" icon={Quote} label="" title="Zitat" onClick={() => exec('formatBlock', 'blockquote')} />
|
||||||
|
<ClassicButton
|
||||||
|
size="small"
|
||||||
|
icon={Link2}
|
||||||
|
label=""
|
||||||
|
title="Link einfügen"
|
||||||
|
onClick={() => {
|
||||||
|
const url = window.prompt('Link-URL:', 'https://')
|
||||||
|
if (url) exec('createLink', url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ClassicButton size="small" icon={RemoveFormatting} label="" title="Formatierung entfernen" onClick={() => exec('removeFormat')} />
|
||||||
|
</div>
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Namen">
|
||||||
|
<ClassicButton icon={BookUser} label="Adressbuch" onClick={onOpenContactPicker} disabled={!onOpenContactPicker} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Einfügen">
|
||||||
|
<ClassicButton icon={Paperclip} label="Datei anfügen" onClick={onAttach} />
|
||||||
|
<ClassicButton icon={PenLine} label="Signatur" onClick={onOpenSignature} />
|
||||||
|
<ClassicButton icon={FileText} label="Vorlagen" onClick={onOpenTemplates} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Markierungen">
|
||||||
|
<ClassicButton
|
||||||
|
icon={ArrowUp}
|
||||||
|
label="Wichtigkeit: hoch"
|
||||||
|
onClick={() => onImportanceChange(importance === 'high' ? null : 'high')}
|
||||||
|
active={importance === 'high'}
|
||||||
|
/>
|
||||||
|
<ClassicButton
|
||||||
|
icon={ArrowDown}
|
||||||
|
label="Wichtigkeit: niedrig"
|
||||||
|
onClick={() => onImportanceChange(importance === 'low' ? null : 'low')}
|
||||||
|
active={importance === 'low'}
|
||||||
|
/>
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'insert' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Einschließen">
|
||||||
|
<ClassicButton icon={Paperclip} label="Datei anfügen" onClick={onAttach} />
|
||||||
|
<ClassicButton icon={PenLine} label="Signatur" onClick={onOpenSignature} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Illustrationen">
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => handleImagePick(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<ClassicButton icon={ImageIcon} label="Bilder" onClick={() => imageInputRef.current?.click()} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Links">
|
||||||
|
<ClassicButton
|
||||||
|
icon={Link2}
|
||||||
|
label="Link"
|
||||||
|
onClick={() => {
|
||||||
|
const url = window.prompt('Link-URL:', 'https://')
|
||||||
|
if (url) exec('createLink', url)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Text">
|
||||||
|
<ClassicButton icon={Minus} label="Horizontale Linie" onClick={() => exec('insertHorizontalRule')} />
|
||||||
|
<ClassicButton
|
||||||
|
icon={CalendarClock}
|
||||||
|
label="Datum und Uhrzeit"
|
||||||
|
onClick={() => exec('insertText', new Date().toLocaleString('de-DE'))}
|
||||||
|
/>
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'options' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Designs">
|
||||||
|
<ClassicButton icon={Palette} label="Designs" disabled title="Nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Felder anzeigen">
|
||||||
|
<ClassicButton icon={Mail} label="Bcc" onClick={onToggleBcc} active={showBcc} />
|
||||||
|
<ClassicButton icon={User} label="Von" active disabled title="Von-Feld ist immer sichtbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Verschlüsseln">
|
||||||
|
<ClassicButton icon={Lock} label="Verschlüsseln" disabled title="S/MIME: in dieser Version nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Verlauf">
|
||||||
|
<ClassicButton icon={CheckCheck} label="Lesebestät. anfordern" onClick={onToggleReadReceipt} active={requestReadReceipt} />
|
||||||
|
<ClassicButton icon={Truck} label="Zustellungsbestät." disabled title="Nicht zuverlässig standardisiert: nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Weitere Optionen">
|
||||||
|
<DelayDropdown value={delayAt} onChange={onDelayChange} />
|
||||||
|
<ReplyToDropdown value={replyTo} onChange={onReplyToChange} />
|
||||||
|
<ClassicButton icon={Save} label="Speichern unter" disabled title="Nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'format' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Format">
|
||||||
|
<ClassicButton icon={Code2} label={plainTextMode ? 'Nur Text' : 'HTML'} onClick={onTogglePlainText} active={plainTextMode} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Schriftart">
|
||||||
|
<div className="grid grid-cols-3 gap-0.5 pt-1">
|
||||||
|
<ClassicButton size="small" icon={Bold} label="" title="Fett" onClick={() => exec('bold')} />
|
||||||
|
<ClassicButton size="small" icon={Italic} label="" title="Kursiv" onClick={() => exec('italic')} />
|
||||||
|
<ClassicButton size="small" icon={Underline} label="" title="Unterstrichen" onClick={() => exec('underline')} />
|
||||||
|
</div>
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Formatvorlagen">
|
||||||
|
<ClassicButton size="small" icon={Paintbrush} label="Standard" onClick={() => exec('formatBlock', 'P')} />
|
||||||
|
<ClassicButton size="small" icon={Paintbrush} label="Überschrift 1" onClick={() => exec('formatBlock', 'H1')} />
|
||||||
|
<ClassicButton size="small" icon={Paintbrush} label="Überschrift 2" onClick={() => exec('formatBlock', 'H2')} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Bearbeiten">
|
||||||
|
<ClassicButton icon={Search} label="Suchen" disabled title="Nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'review' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Rechtschreibung">
|
||||||
|
<ClassicButton icon={SpellCheck} label="Rechtschreibung und Grammatik" active disabled title="Automatische Prüfung des Browsers ist aktiv" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Sprache">
|
||||||
|
<ClassicButton icon={Type} label="Wörter zählen" onClick={() => onWordCount(editor.current?.wordCount() ?? 0)} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ClassicRibbonBody>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ArrowLeft, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import type { Contact, ContactGroup, NewContactGroup } 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'
|
||||||
|
|
||||||
|
export function ContactGroupModal({
|
||||||
|
open,
|
||||||
|
contacts,
|
||||||
|
groups,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
contacts: Contact[]
|
||||||
|
groups: ContactGroup[]
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (data: NewContactGroup, existingId: string | null) => Promise<void>
|
||||||
|
onDelete: (group: ContactGroup) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<ContactGroup | 'new' | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [memberIds, setMemberIds] = useState<Set<string>>(new Set())
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setEditing(null)
|
||||||
|
setConfirmDeleteId(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
setEditing('new')
|
||||||
|
setName('')
|
||||||
|
setMemberIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(g: ContactGroup) {
|
||||||
|
setEditing(g)
|
||||||
|
setName(g.name)
|
||||||
|
setMemberIds(new Set(g.member_ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMember(id: string) {
|
||||||
|
setMemberIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave({ name: name.trim(), member_ids: Array.from(memberIds) }, editing === 'new' ? null : (editing?.id ?? null))
|
||||||
|
setEditing(null)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-md 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
{editing === 'new' ? 'Neue Gruppe' : editing ? 'Gruppe bearbeiten' : 'Kontaktgruppen'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="space-y-3 px-6 py-4">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Name</span>
|
||||||
|
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} autoFocus required />
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Mitglieder</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-0.5 overflow-y-auto px-6 pb-4">
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<p className="text-[12.5px] text-zinc-400">Keine Kontakte vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
contacts.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-center gap-2.5 rounded-xl px-2 py-1.5 text-[12.5px] text-zinc-700 transition hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800/60"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberIds.has(c.id)}
|
||||||
|
onChange={() => toggleMember(c.id)}
|
||||||
|
className="rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{c.name || c.email}</span>
|
||||||
|
<span className="shrink-0 truncate text-[11px] text-zinc-400">{c.email}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 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…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 space-y-1.5 overflow-y-auto px-6 py-4">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="text-[12.5px] text-zinc-400">Noch keine Gruppen angelegt.</p>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] text-zinc-700 dark:text-zinc-200">
|
||||||
|
{g.name} <span className="text-zinc-400">({g.member_ids.length})</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(g)}
|
||||||
|
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
{confirmDeleteId === g.id ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(g)}
|
||||||
|
className="shrink-0 rounded-full bg-red-600 px-2.5 py-1 text-[11px] font-medium text-white transition hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Wirklich löschen?
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDeleteId(g.id)}
|
||||||
|
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 className="border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
onClick={startNew}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 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"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Neue Gruppe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import type { Contact, NewContact } 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactModal({
|
||||||
|
open,
|
||||||
|
contact,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
contact: Contact | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (data: NewContact, existingId: string | null) => Promise<void>
|
||||||
|
onDelete: (contact: Contact) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [address, setAddress] = useState('')
|
||||||
|
const [company, setCompany] = useState('')
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(contact?.name ?? '')
|
||||||
|
setEmail(contact?.email ?? '')
|
||||||
|
setPhone(contact?.phone ?? '')
|
||||||
|
setAddress(contact?.address ?? '')
|
||||||
|
setCompany(contact?.company ?? '')
|
||||||
|
setNotes(contact?.notes ?? '')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, contact])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(
|
||||||
|
{
|
||||||
|
name: name.trim() || email.trim() || phone.trim() || address.trim() || 'Unbenannt',
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
phone: phone.trim() || undefined,
|
||||||
|
address: address.trim() || undefined,
|
||||||
|
company: company.trim() || undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
contact?.id ?? null,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="animate-panel-in flex w-full max-w-sm 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">
|
||||||
|
{contact ? 'Kontakt bearbeiten' : 'Kontakt hinzufügen'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="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-3.5 px-6 py-5">
|
||||||
|
<Field label="Name">
|
||||||
|
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Ada Lovelace" />
|
||||||
|
</Field>
|
||||||
|
<Field label="E-Mail-Adresse (optional)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="ada@example.com"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Telefon (optional)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+49 30 1234567"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Adresse (optional)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="Musterstraße 1, 12345 Berlin"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Firma (optional)">
|
||||||
|
<input className={inputClass} value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Notizen (optional)">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-16 resize-none`}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
{contact ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(contact)}
|
||||||
|
className="rounded-full px-3 py-2 text-[12.5px] font-medium text-red-500 transition hover:bg-red-50 dark:hover:bg-red-950/40"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { open, save } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { Cloud, Download, HardDrive, Mail, Plus, RefreshCw, Search, Star, Upload, UsersRound, UserRound } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account, Contact, ContactGroup } from '../types'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { GoogleContactsMenu } from './GoogleContactsMenu'
|
||||||
|
|
||||||
|
function contactSourceKey(c: Contact): string {
|
||||||
|
return c.google_account_id ? `google:${c.google_account_id}` : 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactSourceLabel(c: Contact, accounts: Account[]): string {
|
||||||
|
if (!c.google_account_id) return 'Lokal'
|
||||||
|
const acc = accounts.find((a) => a.id === c.google_account_id)
|
||||||
|
return acc ? `Google – ${acc.label}` : 'Google'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsView({
|
||||||
|
contacts,
|
||||||
|
groups,
|
||||||
|
loading,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onToggleFavorite,
|
||||||
|
onCompose,
|
||||||
|
onContextMenu,
|
||||||
|
onComposeToEmails,
|
||||||
|
onManageGroups,
|
||||||
|
onRefresh,
|
||||||
|
onToast,
|
||||||
|
googleAccounts,
|
||||||
|
onSyncGoogleContacts,
|
||||||
|
onReconnectGoogleAccount,
|
||||||
|
}: {
|
||||||
|
contacts: Contact[]
|
||||||
|
groups: ContactGroup[]
|
||||||
|
loading: boolean
|
||||||
|
onAdd: () => void
|
||||||
|
onEdit: (c: Contact) => void
|
||||||
|
onToggleFavorite: (c: Contact) => void
|
||||||
|
onCompose: (c: Contact) => void
|
||||||
|
onContextMenu?: (e: React.MouseEvent, c: Contact) => void
|
||||||
|
onComposeToEmails: (emails: string[]) => void
|
||||||
|
onManageGroups: () => void
|
||||||
|
onRefresh: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
googleAccounts: Account[]
|
||||||
|
onSyncGoogleContacts: (accountId: string) => Promise<void>
|
||||||
|
onReconnectGoogleAccount: (accountId: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)
|
||||||
|
const selectedGroup = groups.find((g) => g.id === selectedGroupId) ?? null
|
||||||
|
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sourceOptions = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const c of contacts) {
|
||||||
|
const key = contactSourceKey(c)
|
||||||
|
if (!map.has(key)) map.set(key, contactSourceLabel(c, googleAccounts))
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort(([a, la], [b, lb]) => (a === 'local' ? -1 : b === 'local' ? 1 : la.localeCompare(lb)))
|
||||||
|
.map(([key, label]) => ({ key, label }))
|
||||||
|
}, [contacts, googleAccounts])
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
const path = await save({ defaultPath: 'kontakte.vcf', filters: [{ name: 'vCard', extensions: ['vcf'] }] })
|
||||||
|
if (!path) return
|
||||||
|
try {
|
||||||
|
const count = await api.exportContactsVcard(path)
|
||||||
|
onToast(`${count} Kontakte exportiert`)
|
||||||
|
} catch (e) {
|
||||||
|
onToast(`Export fehlgeschlagen: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
const path = await open({ multiple: false, filters: [{ name: 'vCard', extensions: ['vcf'] }] })
|
||||||
|
if (!path || Array.isArray(path)) return
|
||||||
|
try {
|
||||||
|
const count = await api.importContactsVcard(path)
|
||||||
|
onToast(`${count} Kontakte importiert`)
|
||||||
|
onRefresh()
|
||||||
|
} catch (e) {
|
||||||
|
onToast(`Import fehlgeschlagen: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let base = contacts
|
||||||
|
if (selectedGroup) {
|
||||||
|
const ids = new Set(selectedGroup.member_ids)
|
||||||
|
base = base.filter((c) => ids.has(c.id))
|
||||||
|
}
|
||||||
|
if (selectedSourceKey) {
|
||||||
|
base = base.filter((c) => contactSourceKey(c) === selectedSourceKey)
|
||||||
|
}
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return base
|
||||||
|
return base.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
(c.email ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.phone ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.address ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.company ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [contacts, query, selectedGroup, selectedSourceKey])
|
||||||
|
|
||||||
|
const groupedBySource = useMemo(() => {
|
||||||
|
const map = new Map<string, Contact[]>()
|
||||||
|
for (const c of filtered) {
|
||||||
|
const key = contactSourceKey(c)
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(c)
|
||||||
|
}
|
||||||
|
return sourceOptions.filter((o) => map.has(o.key)).map((o) => ({ ...o, items: map.get(o.key)! }))
|
||||||
|
}, [filtered, sourceOptions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-200/80 px-7 py-4 dark:border-zinc-800/80">
|
||||||
|
<h1 className="text-[17px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
Kontakte
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<Search size={13} className="shrink-0 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Kontakte durchsuchen…"
|
||||||
|
className="w-full bg-transparent text-[12.5px] text-zinc-700 outline-none placeholder:text-zinc-400 dark:text-zinc-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="Kontakte aktualisieren"
|
||||||
|
disabled={loading}
|
||||||
|
className="shrink-0 rounded-full border border-zinc-200 p-2 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={clsx(loading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
title="Als vCard exportieren"
|
||||||
|
className="shrink-0 rounded-full border border-zinc-200 p-2 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>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
title="vCard importieren"
|
||||||
|
className="shrink-0 rounded-full border border-zinc-200 p-2 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"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
</button>
|
||||||
|
<GoogleContactsMenu accounts={googleAccounts} onSync={onSyncGoogleContacts} onReconnect={onReconnectGoogleAccount} />
|
||||||
|
<button
|
||||||
|
onClick={onManageGroups}
|
||||||
|
title="Gruppen verwalten"
|
||||||
|
className="shrink-0 rounded-full border border-zinc-200 p-2 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"
|
||||||
|
>
|
||||||
|
<UsersRound size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-2 text-[12.5px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Kontakt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-200/80 px-7 py-2.5 dark:border-zinc-800/80">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedGroupId(null)}
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-[12px] font-medium transition',
|
||||||
|
!selectedGroup
|
||||||
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => setSelectedGroupId(g.id === selectedGroupId ? null : g.id)}
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-[12px] font-medium transition',
|
||||||
|
g.id === selectedGroupId
|
||||||
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{g.name} ({g.member_ids.length})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selectedGroup && selectedGroup.member_ids.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const emails = contacts
|
||||||
|
.filter((c) => selectedGroup.member_ids.includes(c.id))
|
||||||
|
.map((c) => c.email)
|
||||||
|
.filter((e): e is string => !!e)
|
||||||
|
onComposeToEmails(emails)
|
||||||
|
}}
|
||||||
|
className="ml-auto flex shrink-0 items-center gap-1.5 rounded-full border border-indigo-200 px-3 py-1 text-[12px] font-medium text-indigo-700 transition hover:bg-indigo-50 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
<Mail size={12} /> Gruppe anschreiben
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sourceOptions.length > 1 && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-200/80 px-7 py-2.5 dark:border-zinc-800/80">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSourceKey(null)}
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-[12px] font-medium transition',
|
||||||
|
!selectedSourceKey
|
||||||
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Alle Quellen
|
||||||
|
</button>
|
||||||
|
{sourceOptions.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => setSelectedSourceKey(o.key === selectedSourceKey ? null : o.key)}
|
||||||
|
className={clsx(
|
||||||
|
'flex shrink-0 items-center gap-1 rounded-full px-3 py-1 text-[12px] font-medium transition',
|
||||||
|
o.key === selectedSourceKey
|
||||||
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.key === 'local' ? <HardDrive size={11} /> : <Cloud size={11} />}
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-20 animate-pulse rounded-2xl bg-zinc-100 dark:bg-zinc-900" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||||
|
<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">
|
||||||
|
<UserRound size={26} strokeWidth={1.4} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
{query ? 'Keine Treffer' : 'Noch keine Kontakte – sie werden auch automatisch aus deinen E-Mails übernommen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{groupedBySource.map((group) => (
|
||||||
|
<div key={group.key}>
|
||||||
|
{!selectedSourceKey && (
|
||||||
|
<div className="mb-2.5 flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
{group.key === 'local' ? <HardDrive size={11} /> : <Cloud size={11} />}
|
||||||
|
{group.label}
|
||||||
|
<span className="font-normal normal-case text-zinc-300 dark:text-zinc-600">
|
||||||
|
({group.items.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
|
||||||
|
{group.items.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => onEdit(c)}
|
||||||
|
onContextMenu={(e) => onContextMenu?.(e, c)}
|
||||||
|
className="group flex cursor-default items-start gap-3 rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-indigo-800"
|
||||||
|
>
|
||||||
|
<Avatar name={c.name || c.email || c.phone || '?'} size={40} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[13.5px] font-medium text-zinc-800 dark:text-zinc-100">
|
||||||
|
{c.name || c.email || c.phone || c.address}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[12px] text-zinc-400">{c.email ?? c.phone ?? c.address}</p>
|
||||||
|
{c.company && <p className="truncate text-[11.5px] text-zinc-400">{c.company}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleFavorite(c)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-full p-1 transition',
|
||||||
|
c.favorite
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-zinc-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 dark:text-zinc-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star size={14} fill={c.favorite ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
{c.email && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCompose(c)
|
||||||
|
}}
|
||||||
|
title="Mail schreiben"
|
||||||
|
className="rounded-full p-1 text-zinc-300 opacity-0 transition group-hover:opacity-100 hover:text-indigo-500 dark:text-zinc-700"
|
||||||
|
>
|
||||||
|
<Mail size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
danger?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
submenu?: ContextMenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextMenuSection = ContextMenuItem[]
|
||||||
|
|
||||||
|
export interface ContextMenuState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
sections: ContextMenuSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook that owns {x,y,sections} state for a right-click menu. Call `openMenu`
|
||||||
|
/// from an `onContextMenu` handler with the sections to show; render the
|
||||||
|
/// returned state through <ContextMenu>.
|
||||||
|
export function useContextMenu() {
|
||||||
|
const [menuState, setMenuState] = useState<ContextMenuState | null>(null)
|
||||||
|
|
||||||
|
function openMenu(e: React.MouseEvent, sections: ContextMenuSection[]) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuState({ x: e.clientX, y: e.clientY, sections })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
setMenuState(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { menuState, openMenu, closeMenu }
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItemRow({ item, onDone }: { item: ContextMenuItem; onDone: () => void }) {
|
||||||
|
const itemClass = (danger?: boolean, disabled?: boolean) =>
|
||||||
|
clsx(
|
||||||
|
'flex w-full items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-[12.5px] transition',
|
||||||
|
disabled
|
||||||
|
? 'cursor-default text-zinc-300 dark:text-zinc-700'
|
||||||
|
: danger
|
||||||
|
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
|
||||||
|
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (item.submenu) {
|
||||||
|
return (
|
||||||
|
<div className="group/sub relative">
|
||||||
|
<button type="button" disabled={item.disabled} className={clsx(itemClass(item.danger, item.disabled), 'justify-between')}>
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={12} className="shrink-0 text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
{!item.disabled && item.submenu.length > 0 && (
|
||||||
|
<div className="invisible absolute left-full top-0 z-10 ml-1 w-56 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-1.5 opacity-0 shadow-xl backdrop-blur-xl transition-opacity group-hover/sub:visible group-hover/sub:opacity-100 dark:border-zinc-800 dark:bg-zinc-900/95">
|
||||||
|
{item.submenu.map((sub, i) => (
|
||||||
|
<MenuItemRow key={i} item={sub} onDone={onDone} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
onDone()
|
||||||
|
item.onClick?.()
|
||||||
|
}}
|
||||||
|
className={itemClass(item.danger, item.disabled)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({ state, onClose }: { state: ContextMenuState | null; onClose: () => void }) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state) return
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (panelRef.current?.contains(e.target as Node)) return
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClickOutside)
|
||||||
|
document.addEventListener('contextmenu', onClickOutside)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('scroll', onClose, true)
|
||||||
|
window.addEventListener('blur', onClose)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
document.removeEventListener('contextmenu', onClickOutside)
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('scroll', onClose, true)
|
||||||
|
window.removeEventListener('blur', onClose)
|
||||||
|
}
|
||||||
|
}, [state, onClose])
|
||||||
|
|
||||||
|
if (!state) return null
|
||||||
|
|
||||||
|
const menuWidth = 224
|
||||||
|
const left = Math.min(state.x, window.innerWidth - menuWidth - 8)
|
||||||
|
const top = Math.min(state.y, window.innerHeight - 40)
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={{ position: 'fixed', top, left, zIndex: 200, width: menuWidth }}
|
||||||
|
className="animate-panel-in overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
|
||||||
|
>
|
||||||
|
{state.sections.map((section, si) => (
|
||||||
|
<div key={si}>
|
||||||
|
{si > 0 && <div className="my-1 h-px bg-zinc-200/80 dark:bg-zinc-800" />}
|
||||||
|
{section.map((item, ii) => (
|
||||||
|
<MenuItemRow key={ii} item={item} onDone={onClose} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Trash2, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account, CaldavAccount, Event, NewEvent } from '../types'
|
||||||
|
import { CATEGORY_COLORS } from '../lib/categories'
|
||||||
|
|
||||||
|
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
|
||||||
|
const FREQ_MAP: Record<Exclude<RecurrenceOption, 'none'>, string> = {
|
||||||
|
daily: 'DAILY',
|
||||||
|
weekly: 'WEEKLY',
|
||||||
|
monthly: 'MONTHLY',
|
||||||
|
yearly: 'YEARLY',
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRrule(rrule: string | null): { recurrence: RecurrenceOption; until: string } {
|
||||||
|
if (!rrule) return { recurrence: 'none', until: '' }
|
||||||
|
const freqMatch = /FREQ=([A-Z]+)/.exec(rrule)
|
||||||
|
const untilMatch = /UNTIL=(\d{8})/.exec(rrule)
|
||||||
|
const freq = freqMatch?.[1]
|
||||||
|
const recurrence = (Object.entries(FREQ_MAP).find(([, v]) => v === freq)?.[0] as RecurrenceOption) ?? 'none'
|
||||||
|
const until = untilMatch ? `${untilMatch[1].slice(0, 4)}-${untilMatch[1].slice(4, 6)}-${untilMatch[1].slice(6, 8)}` : ''
|
||||||
|
return { recurrence, until }
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMINDER_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: 'Keine' },
|
||||||
|
{ value: '10', label: '10 Minuten vorher' },
|
||||||
|
{ value: '30', label: '30 Minuten vorher' },
|
||||||
|
{ value: '60', label: '1 Stunde vorher' },
|
||||||
|
{ value: '1440', label: '1 Tag vorher' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 toDateStr(d: Date) {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimeStr(d: Date) {
|
||||||
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
|
const min = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${h}:${min}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventModal({
|
||||||
|
open,
|
||||||
|
event,
|
||||||
|
defaultDate,
|
||||||
|
googleAccounts,
|
||||||
|
caldavAccounts,
|
||||||
|
isOccurrenceEdit,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
event: Event | null
|
||||||
|
defaultDate: Date | null
|
||||||
|
googleAccounts: Account[]
|
||||||
|
caldavAccounts?: CaldavAccount[]
|
||||||
|
isOccurrenceEdit?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (data: NewEvent, existingId: string | null) => Promise<void>
|
||||||
|
onDelete: (event: Event) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [date, setDate] = useState('')
|
||||||
|
const [startTime, setStartTime] = useState('09:00')
|
||||||
|
const [endTime, setEndTime] = useState('10:00')
|
||||||
|
const [allDay, setAllDay] = useState(false)
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [calendarTarget, setCalendarTarget] = useState('')
|
||||||
|
const [recurrence, setRecurrence] = useState<RecurrenceOption>('none')
|
||||||
|
const [recurrenceUntil, setRecurrenceUntil] = useState('')
|
||||||
|
const [reminder, setReminder] = useState('')
|
||||||
|
const [category, setCategory] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setConfirmDelete(false)
|
||||||
|
if (event) {
|
||||||
|
const start = new Date(event.start_ts * 1000)
|
||||||
|
const end = new Date(event.end_ts * 1000)
|
||||||
|
const { recurrence: rec, until } = parseRrule(event.rrule)
|
||||||
|
setTitle(event.title)
|
||||||
|
setDate(toDateStr(start))
|
||||||
|
setStartTime(toTimeStr(start))
|
||||||
|
setEndTime(toTimeStr(end))
|
||||||
|
setAllDay(event.all_day)
|
||||||
|
setLocation(event.location ?? '')
|
||||||
|
setDescription(event.description ?? '')
|
||||||
|
setCalendarTarget(event.account_id ?? '')
|
||||||
|
setRecurrence(rec)
|
||||||
|
setRecurrenceUntil(until)
|
||||||
|
setReminder(event.reminder_minutes != null ? String(event.reminder_minutes) : '')
|
||||||
|
setCategory(event.category)
|
||||||
|
} else {
|
||||||
|
const base = defaultDate ?? new Date()
|
||||||
|
setTitle('')
|
||||||
|
setDate(toDateStr(base))
|
||||||
|
setStartTime('09:00')
|
||||||
|
setEndTime('10:00')
|
||||||
|
setAllDay(false)
|
||||||
|
setLocation('')
|
||||||
|
setDescription('')
|
||||||
|
setCalendarTarget('')
|
||||||
|
setRecurrence('none')
|
||||||
|
setRecurrenceUntil('')
|
||||||
|
setReminder('')
|
||||||
|
setCategory(null)
|
||||||
|
}
|
||||||
|
}, [open, event, defaultDate])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim() || !date) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
let start_ts: number
|
||||||
|
let end_ts: number
|
||||||
|
if (allDay) {
|
||||||
|
start_ts = Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000)
|
||||||
|
end_ts = start_ts + 86400
|
||||||
|
} else {
|
||||||
|
start_ts = Math.floor(new Date(`${date}T${startTime}:00`).getTime() / 1000)
|
||||||
|
end_ts = Math.floor(new Date(`${date}T${endTime}:00`).getTime() / 1000)
|
||||||
|
if (end_ts <= start_ts) end_ts = start_ts + 3600
|
||||||
|
}
|
||||||
|
let rrule: string | undefined
|
||||||
|
if (recurrence !== 'none') {
|
||||||
|
rrule = `FREQ=${FREQ_MAP[recurrence]}`
|
||||||
|
if (recurrenceUntil) rrule += `;UNTIL=${recurrenceUntil.replace(/-/g, '')}`
|
||||||
|
}
|
||||||
|
await onSave(
|
||||||
|
{
|
||||||
|
title: title.trim(),
|
||||||
|
location: location.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
start_ts,
|
||||||
|
end_ts,
|
||||||
|
all_day: allDay,
|
||||||
|
account_id: event ? undefined : calendarTarget || null,
|
||||||
|
rrule,
|
||||||
|
reminder_minutes: reminder ? Number(reminder) : undefined,
|
||||||
|
category,
|
||||||
|
},
|
||||||
|
event?.base_id ?? null,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!event) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await onDelete(event)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="animate-panel-in flex max-h-[85vh] w-full max-w-md 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">
|
||||||
|
{isOccurrenceEdit ? 'Diesen Termin bearbeiten' : event ? 'Termin bearbeiten' : 'Neuer Termin'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="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-3.5 overflow-y-auto px-6 py-5">
|
||||||
|
{isOccurrenceEdit && (
|
||||||
|
<p className="rounded-lg bg-indigo-50 px-3 py-2 text-[11.5px] text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||||
|
Änderungen gelten nur für diesen einen Termin, nicht für die ganze Serie.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Field label="Titel">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="z. B. Teammeeting"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Datum">
|
||||||
|
<input className={inputClass} type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-[12px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allDay}
|
||||||
|
onChange={(e) => setAllDay(e.target.checked)}
|
||||||
|
className="rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
Ganztägig
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!allDay && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Beginn">
|
||||||
|
<input className={inputClass} type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Ende">
|
||||||
|
<input className={inputClass} type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Kalender">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={calendarTarget}
|
||||||
|
onChange={(e) => setCalendarTarget(e.target.value)}
|
||||||
|
disabled={!!event}
|
||||||
|
>
|
||||||
|
<option value="">Lokal (nur in dieser App)</option>
|
||||||
|
{googleAccounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label} (Google)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{(caldavAccounts ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label} (CalDAV)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{!isOccurrenceEdit && (
|
||||||
|
<Field label="Wiederholung">
|
||||||
|
<select className={inputClass} value={recurrence} onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}>
|
||||||
|
<option value="none">Keine</option>
|
||||||
|
<option value="daily">Täglich</option>
|
||||||
|
<option value="weekly">Wöchentlich</option>
|
||||||
|
<option value="monthly">Monatlich</option>
|
||||||
|
<option value="yearly">Jährlich</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Erinnerung">
|
||||||
|
<select className={inputClass} value={reminder} onChange={(e) => setReminder(e.target.value)}>
|
||||||
|
{REMINDER_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOccurrenceEdit && recurrence !== 'none' && (
|
||||||
|
<Field label="Endet am (optional)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="date"
|
||||||
|
value={recurrenceUntil}
|
||||||
|
onChange={(e) => setRecurrenceUntil(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Kategorie">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{CATEGORY_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
title={c.label}
|
||||||
|
onClick={() => setCategory(c.id === category ? null : c.id)}
|
||||||
|
className={clsx('h-5 w-5 rounded-full transition', c.id === category && 'ring-2 ring-offset-1 ring-zinc-400')}
|
||||||
|
style={{ backgroundColor: c.hex }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Keine"
|
||||||
|
onClick={() => setCategory(null)}
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full border border-zinc-300 text-zinc-400 dark:border-zinc-600"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{event?.rrule && (
|
||||||
|
<p className="rounded-lg bg-amber-50 px-3 py-2 text-[11.5px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
|
||||||
|
Teil einer Serie – Änderungen und Löschen gelten für die gesamte Serie.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Ort (optional)">
|
||||||
|
<input className={inputClass} value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Beschreibung (optional)">
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{event && (
|
||||||
|
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-3 dark:border-red-950/60 dark:bg-red-950/20">
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-red-700 dark:text-red-400">Termin wirklich löschen?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="ml-auto rounded-full bg-red-600 px-3 py-1 text-[11.5px] font-medium text-white transition hover:bg-red-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{deleting ? 'Lösche…' : 'Löschen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
className="rounded-full px-3 py-1 text-[11.5px] font-medium text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-600 transition hover:text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Termin löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { CalendarSync, Link2, RefreshCw } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account } from '../types'
|
||||||
|
import { RibbonButton } from './RibbonButton'
|
||||||
|
import { Popover } from './Popover'
|
||||||
|
|
||||||
|
export function GoogleCalendarMenu({
|
||||||
|
accounts,
|
||||||
|
onSync,
|
||||||
|
onReconnect,
|
||||||
|
}: {
|
||||||
|
accounts: Account[]
|
||||||
|
onSync: (accountId: string) => Promise<void>
|
||||||
|
onReconnect: (accountId: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (accounts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
trigger={<RibbonButton icon={CalendarSync} label="Google-Konten" onClick={() => {}} />}
|
||||||
|
panelClassName="animate-panel-in w-72 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-2 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
|
||||||
|
>
|
||||||
|
<p className="px-2 py-1 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Google Kalender</p>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-center gap-1.5 rounded-xl px-2 py-1.5">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-700 dark:text-zinc-300">{a.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Verbinden / Zugriff erneuern"
|
||||||
|
disabled={busy === a.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(a.id)
|
||||||
|
try {
|
||||||
|
await onReconnect(a.id)
|
||||||
|
await onSync(a.id)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<Link2 size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Jetzt synchronisieren"
|
||||||
|
disabled={busy === a.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(a.id)
|
||||||
|
try {
|
||||||
|
await onSync(a.id)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={clsx(busy === a.id && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="px-2 pt-1 text-[11px] text-zinc-400">
|
||||||
|
Neu hinzugefügte Google-Konten synchronisieren automatisch. Bereits vorhandene Konten brauchen einmalig „Verbinden“ (synchronisiert danach sofort mit), um Kalenderzugriff zu erlauben.
|
||||||
|
</p>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link2, RefreshCw, UserCog } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account } from '../types'
|
||||||
|
import { Popover } from './Popover'
|
||||||
|
|
||||||
|
export function GoogleContactsMenu({
|
||||||
|
accounts,
|
||||||
|
onSync,
|
||||||
|
onReconnect,
|
||||||
|
}: {
|
||||||
|
accounts: Account[]
|
||||||
|
onSync: (accountId: string) => Promise<void>
|
||||||
|
onReconnect: (accountId: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (accounts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
title="Google-Kontakte"
|
||||||
|
className="shrink-0 rounded-full border border-zinc-200 p-2 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"
|
||||||
|
>
|
||||||
|
<UserCog size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
align="end"
|
||||||
|
panelClassName="animate-panel-in w-72 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-2 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
|
||||||
|
>
|
||||||
|
<p className="px-2 py-1 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Google Kontakte</p>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-center gap-1.5 rounded-xl px-2 py-1.5">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-700 dark:text-zinc-300">{a.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Verbinden / Zugriff erneuern"
|
||||||
|
disabled={busy === a.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(a.id)
|
||||||
|
try {
|
||||||
|
await onReconnect(a.id)
|
||||||
|
await onSync(a.id)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<Link2 size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Jetzt synchronisieren"
|
||||||
|
disabled={busy === a.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(a.id)
|
||||||
|
try {
|
||||||
|
await onSync(a.id)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={clsx(busy === a.id && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="px-2 pt-1 text-[11px] text-zinc-400">
|
||||||
|
Holt Kontakte aus Google (nur Import, kein Zurückschreiben). Bereits vorhandene Google-Konten brauchen
|
||||||
|
einmalig „Verbinden“ (synchronisiert danach sofort mit), um Kontaktzugriff zu erlauben.
|
||||||
|
</p>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { HardDrive, Inbox, Upload, X } from 'lucide-react'
|
||||||
|
import type { Account, Folder } from '../types'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
export function ImportModal({
|
||||||
|
open: isOpen,
|
||||||
|
accounts,
|
||||||
|
onClose,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
accounts: Account[]
|
||||||
|
onClose: () => void
|
||||||
|
onImported: (accountId: string, folder: string) => void
|
||||||
|
}) {
|
||||||
|
const targets = accounts.filter((a) => a.protocol === 'local' || a.protocol === 'imap')
|
||||||
|
const [accountId, setAccountId] = useState('')
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([])
|
||||||
|
const [folder, setFolder] = useState('')
|
||||||
|
const [loadingFolders, setLoadingFolders] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const first = targets[0]?.id ?? ''
|
||||||
|
setAccountId(first)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accountId) return
|
||||||
|
const account = accounts.find((a) => a.id === accountId)
|
||||||
|
if (!account) return
|
||||||
|
if (account.protocol === 'local') {
|
||||||
|
setFolders([{ name: 'Importiert', display_name: 'Importiert', unread: 0 }])
|
||||||
|
setFolder('Importiert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoadingFolders(true)
|
||||||
|
api
|
||||||
|
.listFolders(accountId)
|
||||||
|
.then((f) => {
|
||||||
|
setFolders(f)
|
||||||
|
setFolder(f[0]?.name ?? '')
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoadingFolders(false))
|
||||||
|
}, [accountId, accounts])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
async function handlePickAndImport() {
|
||||||
|
if (!accountId || !folder) return
|
||||||
|
setError(null)
|
||||||
|
const path = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: 'E-Mail', extensions: ['eml', 'mbox', 'mbx'] }],
|
||||||
|
})
|
||||||
|
if (!path || Array.isArray(path)) return
|
||||||
|
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
if (path.toLowerCase().endsWith('.eml')) {
|
||||||
|
await api.importEmlFile(accountId, folder, path)
|
||||||
|
} else {
|
||||||
|
await api.importMboxFile(accountId, folder, path)
|
||||||
|
}
|
||||||
|
onImported(accountId, folder)
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e))
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 w-full max-w-sm 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">
|
||||||
|
Mail importieren
|
||||||
|
</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="space-y-3.5 px-6 py-5">
|
||||||
|
<p className="text-[12.5px] text-zinc-500 dark:text-zinc-400">
|
||||||
|
Unterstützt .eml (einzelne Nachricht) und .mbox (mehrere Nachrichten). Wähle zuerst, wohin importiert
|
||||||
|
werden soll.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Ziel</span>
|
||||||
|
<select className={inputClass} value={accountId} onChange={(e) => setAccountId(e.target.value)}>
|
||||||
|
{targets.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.protocol === 'local' ? 'Lokal (nur in dieser App)' : `${a.label} (${a.email})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{accounts.find((a) => a.id === accountId)?.protocol === 'imap' && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Zielordner</span>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={folder}
|
||||||
|
onChange={(e) => setFolder(e.target.value)}
|
||||||
|
disabled={loadingFolders}
|
||||||
|
>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<option key={f.name} value={f.name}>
|
||||||
|
{f.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accounts.find((a) => a.id === accountId)?.protocol === 'local' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-[12px] text-zinc-500 dark:bg-zinc-800/60 dark:text-zinc-400">
|
||||||
|
<HardDrive size={13} className="shrink-0" />
|
||||||
|
Nachrichten werden nur lokal gespeichert, nicht auf einen Server hochgeladen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accounts.find((a) => a.id === accountId)?.protocol === 'imap' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-[12px] text-zinc-500 dark:bg-zinc-800/60 dark:text-zinc-400">
|
||||||
|
<Inbox size={13} className="shrink-0" />
|
||||||
|
Nachrichten werden per IMAP wirklich in dieses Postfach hochgeladen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePickAndImport}
|
||||||
|
disabled={importing || !accountId || !folder}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{importing ? 'Importiere…' : 'Datei wählen…'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import { isToday, isYesterday } from 'date-fns'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Inbox, Paperclip, Search, SlidersHorizontal, Star, X } from 'lucide-react'
|
||||||
|
import type { Account, AdvancedSearchFilters, MessageSummary } from '../types'
|
||||||
|
import type { SortBy } from './Ribbon'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { Popover } from './Popover'
|
||||||
|
import { formatListDate } from '../lib/format'
|
||||||
|
import { CATEGORY_COLORS, categoryHex } from '../lib/categories'
|
||||||
|
|
||||||
|
type ThreadRow = MessageSummary & { threadCount: number }
|
||||||
|
|
||||||
|
function sortRows<T extends MessageSummary>(rows: T[], sortBy: SortBy, desc: boolean): T[] {
|
||||||
|
const sorted = [...rows].sort((a, b) => {
|
||||||
|
let cmp: number
|
||||||
|
if (sortBy === 'date') cmp = a.timestamp - b.timestamp
|
||||||
|
else if (sortBy === 'from') cmp = (a.from_name || a.from_addr).localeCompare(b.from_name || b.from_addr)
|
||||||
|
else cmp = a.subject.localeCompare(b.subject)
|
||||||
|
return desc ? -cmp : cmp
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(m: MessageSummary) {
|
||||||
|
return `${m.account_id}-${m.folder}-${m.uid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Groups by thread_id (root of the References chain, or the message's own id
|
||||||
|
/// when it has no parent) — real header-based threading, not a subject guess.
|
||||||
|
function groupConversationsFn(messages: MessageSummary[]): ThreadRow[] {
|
||||||
|
const map = new Map<string, MessageSummary[]>()
|
||||||
|
for (const m of messages) {
|
||||||
|
const key = m.thread_id || `__${m.folder}_${m.uid}`
|
||||||
|
const list = map.get(key)
|
||||||
|
if (list) list.push(m)
|
||||||
|
else map.set(key, [m])
|
||||||
|
}
|
||||||
|
const rows: ThreadRow[] = []
|
||||||
|
for (const list of map.values()) {
|
||||||
|
const latest = list.reduce((a, b) => (b.timestamp > a.timestamp ? b : a))
|
||||||
|
const anyUnread = list.some((x) => !x.is_read)
|
||||||
|
rows.push({ ...latest, is_read: !anyUnread, threadCount: list.length })
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryDot({
|
||||||
|
category,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
category: string | null
|
||||||
|
onChange: (category: string | null) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const hex = categoryHex(category)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
align="end"
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
title="Kategorie"
|
||||||
|
className={clsx(
|
||||||
|
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border transition',
|
||||||
|
hex ? 'border-transparent' : 'border-zinc-300 opacity-0 group-hover:opacity-100 dark:border-zinc-600',
|
||||||
|
)}
|
||||||
|
style={hex ? { backgroundColor: hex } : undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
panelClassName="animate-panel-in flex items-center gap-1 rounded-full border border-zinc-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
|
||||||
|
>
|
||||||
|
{CATEGORY_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
title={c.label}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(c.id === category ? null : c.id)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={clsx('h-4 w-4 rounded-full transition', c.id === category && 'ring-2 ring-offset-1 ring-zinc-400')}
|
||||||
|
style={{ backgroundColor: c.hex }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
title="Keine"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(null)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full border border-zinc-300 text-zinc-400 dark:border-zinc-600"
|
||||||
|
>
|
||||||
|
<X size={9} />
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(messages: ThreadRow[]) {
|
||||||
|
const groups: { label: string; items: ThreadRow[] }[] = []
|
||||||
|
for (const m of messages) {
|
||||||
|
const date = new Date(m.timestamp * 1000)
|
||||||
|
const label = !m.timestamp ? 'Unbekannt' : isToday(date) ? 'Heute' : isYesterday(date) ? 'Gestern' : 'Älter'
|
||||||
|
const last = groups[groups.length - 1]
|
||||||
|
if (last && last.label === label) {
|
||||||
|
last.items.push(m)
|
||||||
|
} else {
|
||||||
|
groups.push({ label, items: [m] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({
|
||||||
|
messages,
|
||||||
|
selectedKeys,
|
||||||
|
onRowClick,
|
||||||
|
onToggleFlag,
|
||||||
|
onSetCategory,
|
||||||
|
onContextMenu,
|
||||||
|
onDragStartMessage,
|
||||||
|
loading,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
advancedSearch,
|
||||||
|
onAdvancedSearchChange,
|
||||||
|
compact,
|
||||||
|
groupConversations,
|
||||||
|
accounts,
|
||||||
|
sortBy,
|
||||||
|
sortDesc,
|
||||||
|
forceAccountBadge,
|
||||||
|
}: {
|
||||||
|
messages: MessageSummary[]
|
||||||
|
selectedKeys: Set<string>
|
||||||
|
onRowClick: (m: MessageSummary, index: number, e: React.MouseEvent) => void
|
||||||
|
onToggleFlag: (m: MessageSummary) => void
|
||||||
|
onSetCategory: (m: MessageSummary, category: string | null) => void
|
||||||
|
onContextMenu?: (e: React.MouseEvent, m: MessageSummary, index: number) => void
|
||||||
|
onDragStartMessage?: (m: MessageSummary, index: number) => void
|
||||||
|
loading: boolean
|
||||||
|
searchQuery: string
|
||||||
|
onSearchChange: (q: string) => void
|
||||||
|
advancedSearch: AdvancedSearchFilters
|
||||||
|
onAdvancedSearchChange: (f: AdvancedSearchFilters) => void
|
||||||
|
compact: boolean
|
||||||
|
groupConversations: boolean
|
||||||
|
accounts: Account[]
|
||||||
|
sortBy: SortBy
|
||||||
|
sortDesc: boolean
|
||||||
|
forceAccountBadge?: boolean
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const accountLabels = useMemo(() => new Map(accounts.map((a) => [a.id, a.label])), [accounts])
|
||||||
|
const showAccountBadge = forceAccountBadge || searchQuery.trim().length > 0
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const base = groupConversations ? groupConversationsFn(messages) : messages.map((m) => ({ ...m, threadCount: 1 }))
|
||||||
|
return sortRows(base, sortBy, sortDesc)
|
||||||
|
}, [messages, groupConversations, sortBy, sortDesc])
|
||||||
|
const groups = useMemo(
|
||||||
|
() => (sortBy === 'date' ? groupByDate(rows) : [{ label: '', items: rows }]),
|
||||||
|
[rows, sortBy],
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (rows.length === 0) return
|
||||||
|
const currentIndex = selectedKeys.size === 1 ? rows.findIndex((m) => selectedKeys.has(keyOf(m))) : -1
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = Math.min(currentIndex + 1, rows.length - 1)
|
||||||
|
onRowClick(rows[idx] ?? rows[0], idx, e as unknown as React.MouseEvent)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = Math.max(currentIndex - 1, 0)
|
||||||
|
onRowClick(rows[idx] ?? rows[0], idx, e as unknown as React.MouseEvent)
|
||||||
|
} else if (e.key === 'Enter' && currentIndex === -1 && rows[0]) {
|
||||||
|
onRowClick(rows[0], 0, e as unknown as React.MouseEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let runningIndex = -1
|
||||||
|
const hasAdvancedFilters = !!(
|
||||||
|
advancedSearch.from.trim() ||
|
||||||
|
advancedSearch.dateFrom ||
|
||||||
|
advancedSearch.dateTo ||
|
||||||
|
advancedSearch.hasAttachment
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-96 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2 border-b border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800/80">
|
||||||
|
<Search size={14} className="shrink-0 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Suchen…"
|
||||||
|
className="flex-1 bg-transparent text-[12.5px] text-zinc-700 outline-none placeholder:text-zinc-400 dark:text-zinc-200"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSearchChange('')}
|
||||||
|
className="rounded-full p-0.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Popover
|
||||||
|
open={advancedOpen}
|
||||||
|
onOpenChange={setAdvancedOpen}
|
||||||
|
align="end"
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
title="Erweiterte Suche"
|
||||||
|
className={clsx(
|
||||||
|
'relative rounded-full p-1 transition',
|
||||||
|
hasAdvancedFilters
|
||||||
|
? 'text-indigo-600 dark:text-indigo-400'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} />
|
||||||
|
{hasAdvancedFilters && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-indigo-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
panelClassName="animate-panel-in w-64 space-y-2.5 rounded-2xl border border-zinc-200/80 bg-white/95 p-3.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Erweiterte Suche</p>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Absender</span>
|
||||||
|
<input
|
||||||
|
value={advancedSearch.from}
|
||||||
|
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, from: e.target.value })}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
className="rounded-lg border border-zinc-200 bg-white px-2.5 py-1 text-[12px] text-zinc-800 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex flex-1 flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Von</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={advancedSearch.dateFrom}
|
||||||
|
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, dateFrom: e.target.value })}
|
||||||
|
className="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-[12px] text-zinc-800 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-1 flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Bis</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={advancedSearch.dateTo}
|
||||||
|
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, dateTo: e.target.value })}
|
||||||
|
className="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-[12px] text-zinc-800 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-[12px] text-zinc-600 dark:text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={advancedSearch.hasAttachment}
|
||||||
|
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, hasAttachment: e.target.checked })}
|
||||||
|
className="rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
Nur mit Anhang
|
||||||
|
</label>
|
||||||
|
{hasAdvancedFilters && (
|
||||||
|
<button
|
||||||
|
onClick={() => onAdvancedSearchChange({ from: '', dateFrom: '', dateTo: '', hasAttachment: false })}
|
||||||
|
className="text-[11.5px] font-medium text-indigo-600 transition hover:text-indigo-700 dark:text-indigo-400"
|
||||||
|
>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && rows.length === 0 ? (
|
||||||
|
<div className="flex flex-col gap-2 p-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-xl bg-zinc-100 dark:bg-zinc-900" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
|
||||||
|
<Inbox size={28} className="text-zinc-300 dark:text-zinc-700" />
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
{searchQuery ? 'Keine Treffer' : 'Keine Nachrichten in diesem Ordner'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex-1 overflow-y-auto outline-none"
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label || 'flat'}>
|
||||||
|
{group.label && (
|
||||||
|
<p className="sticky top-0 z-10 bg-zinc-50/90 px-4 py-1.5 text-[10.5px] font-semibold tracking-wide text-zinc-400 uppercase backdrop-blur dark:bg-zinc-950/90">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{group.items.map((m) => {
|
||||||
|
runningIndex += 1
|
||||||
|
const index = runningIndex
|
||||||
|
const active = selectedKeys.has(keyOf(m))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyOf(m)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => onRowClick(m, index, e)}
|
||||||
|
onContextMenu={(e) => onContextMenu?.(e, m, index)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
onDragStartMessage?.(m, index)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'group relative flex w-full cursor-default items-start gap-3 border-b border-zinc-100 px-4 text-left transition dark:border-zinc-900',
|
||||||
|
compact ? 'py-1.5' : 'py-3',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50/80 dark:bg-indigo-500/10'
|
||||||
|
: 'hover:bg-zinc-50 dark:hover:bg-zinc-900/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span className="absolute inset-y-2 left-0 w-[3px] rounded-r-full bg-indigo-500" />
|
||||||
|
)}
|
||||||
|
<Avatar name={m.from_name || m.from_addr} size={compact ? 26 : 34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'truncate text-[13px]',
|
||||||
|
!m.is_read
|
||||||
|
? 'font-semibold text-zinc-900 dark:text-zinc-50'
|
||||||
|
: 'font-medium text-zinc-600 dark:text-zinc-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.from_name || m.from_addr}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-zinc-400">
|
||||||
|
{formatListDate(m.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5">
|
||||||
|
{!m.is_read && (
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'truncate text-[12.5px]',
|
||||||
|
!m.is_read
|
||||||
|
? 'font-medium text-zinc-800 dark:text-zinc-200'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.subject || '(kein Betreff)'}
|
||||||
|
</span>
|
||||||
|
{m.threadCount > 1 && (
|
||||||
|
<span className="shrink-0 rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold text-zinc-500 tabular-nums dark:bg-zinc-800 dark:text-zinc-400">
|
||||||
|
{m.threadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{m.has_attachments && (
|
||||||
|
<Paperclip size={11} className="shrink-0 text-zinc-400" />
|
||||||
|
)}
|
||||||
|
{showAccountBadge && (
|
||||||
|
<span className="shrink-0 truncate rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
|
||||||
|
{accountLabels.get(m.account_id) ?? m.account_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!compact && m.snippet && (
|
||||||
|
<p className="mt-0.5 truncate text-[12px] text-zinc-400">{m.snippet}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleFlag(m)
|
||||||
|
}}
|
||||||
|
title={m.flagged ? 'Markierung entfernen' : 'Markieren'}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-full p-1 transition',
|
||||||
|
m.flagged
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-zinc-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 dark:text-zinc-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star size={14} fill={m.flagged ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
<CategoryDot category={m.category} onChange={(c) => onSetCategory(m, c)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Mail, X } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface NewMailNotification {
|
||||||
|
accountId: string
|
||||||
|
accountLabel: string
|
||||||
|
folder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_DISMISS_MS = 6000
|
||||||
|
|
||||||
|
export function NewMailPopup({
|
||||||
|
notification,
|
||||||
|
onOpen,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
notification: NewMailNotification | null
|
||||||
|
onOpen: () => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notification) return
|
||||||
|
const t = setTimeout(onDismiss, AUTO_DISMISS_MS)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [notification])
|
||||||
|
|
||||||
|
if (!notification) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-toast-in fixed right-6 top-6 z-50 w-80 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 shadow-[0_24px_70px_-12px_rgba(15,15,25,0.35)] backdrop-blur-2xl dark:border-zinc-800 dark:bg-zinc-900/95 relative">
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
className="flex w-full items-start gap-3 px-4 py-3.5 text-left transition hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-50 text-indigo-500 dark:bg-indigo-500/15 dark:text-indigo-300">
|
||||||
|
<Mail size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-semibold text-zinc-800 dark:text-zinc-100">Neue E-Mail</p>
|
||||||
|
<p className="truncate text-[12px] text-zinc-500 dark:text-zinc-400">{notification.accountLabel}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
title="Schließen"
|
||||||
|
className="absolute right-2 top-2 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
interface Pos {
|
||||||
|
top?: number
|
||||||
|
bottom?: number
|
||||||
|
left?: number
|
||||||
|
right?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger + panel pair where the panel is portaled to document.body and
|
||||||
|
/// positioned via a fixed-position rect computed from the trigger element.
|
||||||
|
/// Needed because several triggers live inside scrollable/overflow-clipped
|
||||||
|
/// containers (ribbon body, message list, sidebar) — a plain `absolute`
|
||||||
|
/// child would get clipped by that ancestor instead of floating above it.
|
||||||
|
export function Popover({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
trigger,
|
||||||
|
children,
|
||||||
|
align = 'start',
|
||||||
|
side = 'bottom',
|
||||||
|
panelClassName,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
trigger: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
align?: 'start' | 'end'
|
||||||
|
side?: 'bottom' | 'top'
|
||||||
|
panelClassName?: string
|
||||||
|
}) {
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState<Pos | null>(null)
|
||||||
|
|
||||||
|
function computePos() {
|
||||||
|
if (!triggerRef.current) return
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
const next: Pos = {}
|
||||||
|
if (side === 'bottom') next.top = rect.bottom + 4
|
||||||
|
else next.bottom = window.innerHeight - rect.top + 4
|
||||||
|
if (align === 'start') next.left = rect.left
|
||||||
|
else next.right = window.innerWidth - rect.right
|
||||||
|
setPos(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open) computePos()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, align, side])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (triggerRef.current?.contains(target)) return
|
||||||
|
if (panelRef.current?.contains(target)) return
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
function onReposition() {
|
||||||
|
computePos()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClickOutside)
|
||||||
|
window.addEventListener('scroll', onReposition, true)
|
||||||
|
window.addEventListener('resize', onReposition)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
window.removeEventListener('scroll', onReposition, true)
|
||||||
|
window.removeEventListener('resize', onReposition)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpenChange(!open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</div>
|
||||||
|
{open &&
|
||||||
|
pos &&
|
||||||
|
createPortal(
|
||||||
|
<div ref={panelRef} style={{ position: 'fixed', zIndex: 100, ...pos }} className={panelClassName}>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Server } from 'lucide-react'
|
||||||
|
|
||||||
|
function IconShell({
|
||||||
|
children,
|
||||||
|
bg,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
bg: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full shadow-inner"
|
||||||
|
style={{ background: bg }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GmailIcon() {
|
||||||
|
return (
|
||||||
|
<IconShell bg="#ffffff">
|
||||||
|
<svg viewBox="0 0 24 24" width="21" height="21">
|
||||||
|
<path d="M2 6.5A2.5 2.5 0 0 1 4.5 4h15A2.5 2.5 0 0 1 22 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-1V9.1l-6.2 4.9a1.8 1.8 0 0 1-2.2 0L3.9 9.1V20h-1A2.5 2.5 0 0 1 0 17.5v-11z" fill="none" />
|
||||||
|
<path d="M4.5 4h1.9l5.6 4.5L17.6 4h1.9A2.5 2.5 0 0 1 22 6.5v.4l-9.1 7.2a1.5 1.5 0 0 1-1.8 0L2 6.9v-.4A2.5 2.5 0 0 1 4.5 4z" fill="#ea4335" />
|
||||||
|
<path d="M2 6.9l7.4 5.9v7.1H4.5A2.5 2.5 0 0 1 2 17.4V6.9z" fill="#4285f4" opacity="0.001" />
|
||||||
|
<path d="M2 7.3l7.7 6.1v6.5H4.5A2.5 2.5 0 0 1 2 17.4V7.3z" fill="#34a853" />
|
||||||
|
<path d="M22 7.3v10.1a2.5 2.5 0 0 1-2.5 2.5h-5.2v-6.5L22 7.3z" fill="#4285f4" />
|
||||||
|
<path d="M2 6.9v.4l7.7 6.1V7.9L6.4 4H4.5A2.5 2.5 0 0 0 2 6.5v.4z" fill="#fbbc04" />
|
||||||
|
<path d="M22 6.9v.4l-7.7 6.1V7.9L17.6 4h1.9A2.5 2.5 0 0 1 22 6.5v.4z" fill="#ea4335" />
|
||||||
|
</svg>
|
||||||
|
</IconShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutlookIcon() {
|
||||||
|
return (
|
||||||
|
<IconShell bg="linear-gradient(135deg,#0a2767,#1a5cd6)">
|
||||||
|
<svg viewBox="0 0 24 24" width="19" height="19">
|
||||||
|
<rect x="12.2" y="5" width="9.3" height="8.4" rx="1" fill="#28a8ea" />
|
||||||
|
<rect x="12.2" y="5" width="9.3" height="8.4" rx="1" fill="#0364b8" opacity="0.001" />
|
||||||
|
<path d="M12.2 5h9.3v8.4h-9.3z" fill="#28a8ea" />
|
||||||
|
<path d="M21.5 13.4V19a1 1 0 0 1-1 1h-8.3v-6.6z" fill="#0078d4" opacity=".001" />
|
||||||
|
<circle cx="6.3" cy="12" r="5.3" fill="#0364b8" />
|
||||||
|
<path
|
||||||
|
d="M6.3 8.6c-1.7 0-3 1.5-3 3.4s1.3 3.4 3 3.4 3-1.5 3-3.4-1.3-3.4-3-3.4zm0 5.3c-.9 0-1.6-.9-1.6-1.9s.7-1.9 1.6-1.9 1.6.9 1.6 1.9-.7 1.9-1.6 1.9z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</IconShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function YahooIcon() {
|
||||||
|
return (
|
||||||
|
<IconShell bg="#5f01d1">
|
||||||
|
<span className="text-[13px] font-black tracking-tighter text-white">y!</span>
|
||||||
|
</IconShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ICloudIcon() {
|
||||||
|
return (
|
||||||
|
<IconShell bg="linear-gradient(135deg,#e6f4ff,#cfeaff)">
|
||||||
|
<svg viewBox="0 0 24 24" width="21" height="21">
|
||||||
|
<path
|
||||||
|
d="M7.5 17.5a4 4 0 0 1-.4-7.98 5 5 0 0 1 9.62-1.9 3.8 3.8 0 0 1 3.78 3.8c0 2.1-1.7 3.8-3.8 3.8V17.5z"
|
||||||
|
fill="#3693f3"
|
||||||
|
/>
|
||||||
|
<rect x="3.1" y="14.8" width="14.8" height="2.7" rx="1.35" fill="#3693f3" />
|
||||||
|
</svg>
|
||||||
|
</IconShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomIcon() {
|
||||||
|
return (
|
||||||
|
<IconShell bg="linear-gradient(135deg,#71717a,#52525b)">
|
||||||
|
<Server size={16} className="text-white" />
|
||||||
|
</IconShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderIcon({ id }: { id: string }) {
|
||||||
|
switch (id) {
|
||||||
|
case 'gmail':
|
||||||
|
return <GmailIcon />
|
||||||
|
case 'outlook':
|
||||||
|
return <OutlookIcon />
|
||||||
|
case 'yahoo':
|
||||||
|
return <YahooIcon />
|
||||||
|
case 'icloud':
|
||||||
|
return <ICloudIcon />
|
||||||
|
default:
|
||||||
|
return <CustomIcon />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Contact } from '../types'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
export function RecipientInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
}) {
|
||||||
|
const [suggestions, setSuggestions] = useState<Contact[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = value.split(',').pop()?.trim() ?? ''
|
||||||
|
if (token.length < 2) {
|
||||||
|
setSuggestions([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.searchContacts(token)
|
||||||
|
.then((res) => {
|
||||||
|
const withEmail = res.filter((c) => !!c.email)
|
||||||
|
setSuggestions(withEmail)
|
||||||
|
setOpen(withEmail.length > 0)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, 150)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function pick(c: Contact) {
|
||||||
|
const parts = value.split(',')
|
||||||
|
parts[parts.length - 1] = ` ${c.name ? `${c.name} <${c.email}>` : c.email}`
|
||||||
|
onChange(
|
||||||
|
parts
|
||||||
|
.join(',')
|
||||||
|
.replace(/^,\s*/, '')
|
||||||
|
.trimStart() + ', ',
|
||||||
|
)
|
||||||
|
setOpen(false)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="relative flex-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={() => suggestions.length > 0 && setOpen(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-transparent text-[13px] text-zinc-800 outline-none placeholder:text-zinc-300 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<div className="animate-panel-in absolute top-full left-0 right-0 z-20 mt-1.5 max-h-52 overflow-y-auto rounded-2xl border border-zinc-200/80 bg-white/95 py-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95">
|
||||||
|
{suggestions.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(c)}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition hover:bg-indigo-50 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
<Avatar name={c.name || c.email || '?'} size={24} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[12.5px] text-zinc-700 dark:text-zinc-200">{c.name || c.email}</p>
|
||||||
|
<p className="truncate text-[11px] text-zinc-400">{c.email}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { CalendarDays, Repeat, X } from 'lucide-react'
|
||||||
|
|
||||||
|
export function RecurrenceScopeModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onChoose,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
onChoose: (scope: 'occurrence' | 'series') => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
if (!open) return null
|
||||||
|
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"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="animate-panel-in w-full max-w-sm 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"
|
||||||
|
>
|
||||||
|
<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">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
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="space-y-2 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onChoose('occurrence')}
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl border border-zinc-200/80 px-4 py-3 text-left transition hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-zinc-800 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
<CalendarDays size={17} className="shrink-0 text-indigo-500" />
|
||||||
|
<span className="text-[13px] font-medium text-zinc-700 dark:text-zinc-200">Nur diesen Termin</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChoose('series')}
|
||||||
|
className="flex w-full items-center gap-3 rounded-2xl border border-zinc-200/80 px-4 py-3 text-left transition hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-zinc-800 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
<Repeat size={17} className="shrink-0 text-indigo-500" />
|
||||||
|
<span className="text-[13px] font-medium text-zinc-700 dark:text-zinc-200">Die ganze Serie</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlignJustify,
|
||||||
|
ArrowDownAZ,
|
||||||
|
ArrowUpDown,
|
||||||
|
Archive,
|
||||||
|
Bell,
|
||||||
|
BellRing,
|
||||||
|
BookUser,
|
||||||
|
CalendarDays,
|
||||||
|
CalendarPlus,
|
||||||
|
Download,
|
||||||
|
FilePlus2,
|
||||||
|
Filter,
|
||||||
|
FolderCog,
|
||||||
|
FolderInput,
|
||||||
|
FolderPlus,
|
||||||
|
FolderX,
|
||||||
|
Forward,
|
||||||
|
Globe,
|
||||||
|
Keyboard,
|
||||||
|
Info,
|
||||||
|
ListFilter,
|
||||||
|
ListPlus,
|
||||||
|
MailCheck,
|
||||||
|
MailOpen,
|
||||||
|
MailPlus,
|
||||||
|
MailSearch,
|
||||||
|
MessagesSquare,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Pencil,
|
||||||
|
RefreshCcw,
|
||||||
|
Reply,
|
||||||
|
ReplyAll,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Rows3,
|
||||||
|
Rows4,
|
||||||
|
Settings,
|
||||||
|
ShieldOff,
|
||||||
|
Star,
|
||||||
|
Sun,
|
||||||
|
Tags,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
UserPlus,
|
||||||
|
Volume2,
|
||||||
|
WifiOff,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account, Event as CalendarEvent, Folder, MessageFull, MessageSummary } from '../types'
|
||||||
|
import type { SidebarView } from './Sidebar'
|
||||||
|
import type { Density, Theme } from '../lib/theme'
|
||||||
|
import { CATEGORY_COLORS } from '../lib/categories'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { ClassicButton, ClassicDropdown, ClassicMenuItem, ClassicRibbonBody, ClassicRibbonGroup, ClassicTabBar } from './ClassicRibbonUI'
|
||||||
|
import { GoogleCalendarMenu } from './GoogleCalendarMenu'
|
||||||
|
|
||||||
|
type RibbonTab = 'start' | 'sendrecv' | 'folder' | 'view' | 'help'
|
||||||
|
export type SortBy = 'date' | 'from' | 'subject'
|
||||||
|
export type EmailFilter = 'none' | 'unread' | 'flagged' | 'attachments'
|
||||||
|
|
||||||
|
function MoveMenu({
|
||||||
|
folders,
|
||||||
|
currentFolder,
|
||||||
|
onMove,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
folders: Folder[]
|
||||||
|
currentFolder: string | null
|
||||||
|
onMove: (target: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const targets = folders.filter((f) => f.name !== currentFolder)
|
||||||
|
return (
|
||||||
|
<ClassicDropdown
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
button={<ClassicButton icon={FolderInput} label="Verschieben" disabled={disabled || targets.length === 0} />}
|
||||||
|
>
|
||||||
|
{targets.map((f) => (
|
||||||
|
<ClassicMenuItem
|
||||||
|
key={f.name}
|
||||||
|
onClick={() => {
|
||||||
|
onMove(f.name)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.display_name}
|
||||||
|
</ClassicMenuItem>
|
||||||
|
))}
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryMenu({ onSet, disabled }: { onSet: (c: string | null) => void; disabled: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<ClassicDropdown
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
button={<ClassicButton icon={Tags} label="Kategorisieren" disabled={disabled} />}
|
||||||
|
>
|
||||||
|
{CATEGORY_COLORS.map((c) => (
|
||||||
|
<ClassicMenuItem
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSet(c.id)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: c.hex }} />
|
||||||
|
{c.label}
|
||||||
|
</ClassicMenuItem>
|
||||||
|
))}
|
||||||
|
<ClassicMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onSet(null)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine Kategorie
|
||||||
|
</ClassicMenuItem>
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewItemMenu({ onEmail, onEvent, onContact }: { onEmail: () => void; onEvent: () => void; onContact: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<ClassicDropdown
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
button={<ClassicButton icon={FilePlus2} label="Neue Elemente" />}
|
||||||
|
>
|
||||||
|
<ClassicMenuItem onClick={() => { onEmail(); setOpen(false) }}>
|
||||||
|
<MailPlus size={13} /> E-Mail-Nachricht
|
||||||
|
</ClassicMenuItem>
|
||||||
|
<ClassicMenuItem onClick={() => { onEvent(); setOpen(false) }}>
|
||||||
|
<CalendarPlus size={13} /> Termin
|
||||||
|
</ClassicMenuItem>
|
||||||
|
<ClassicMenuItem onClick={() => { onContact(); setOpen(false) }}>
|
||||||
|
<UserPlus size={13} /> Kontakt
|
||||||
|
</ClassicMenuItem>
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterMenu({ value, onChange }: { value: EmailFilter; onChange: (f: EmailFilter) => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const labels: Record<EmailFilter, string> = { none: 'Kein Filter', unread: 'Ungelesen', flagged: 'Markiert', attachments: 'Hat Anhänge' }
|
||||||
|
return (
|
||||||
|
<ClassicDropdown open={open} onOpenChange={setOpen} button={<ClassicButton icon={ListFilter} label="E-Mail filtern" active={value !== 'none'} />}>
|
||||||
|
{(Object.keys(labels) as EmailFilter[]).map((k) => (
|
||||||
|
<ClassicMenuItem key={k} onClick={() => { onChange(k); setOpen(false) }}>
|
||||||
|
{value === k ? '✓ ' : ''}{labels[k]}
|
||||||
|
</ClassicMenuItem>
|
||||||
|
))}
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpMenu({ onShowShortcuts }: { onShowShortcuts: () => void }) {
|
||||||
|
return <ClassicButton icon={Keyboard} label="Tastenkürzel" onClick={onShowShortcuts} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AboutMenu() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<ClassicDropdown open={open} onOpenChange={setOpen} widthClass="w-72" button={<ClassicButton icon={Info} label="Über" />}>
|
||||||
|
<div className="px-3 py-2 text-[12.5px] text-zinc-600 dark:text-zinc-300">
|
||||||
|
<p className="mb-1 font-semibold text-zinc-800 dark:text-zinc-100">Mail Client</p>
|
||||||
|
<p>Ein nativer Desktop-Mail-Client mit eigenem IMAP-, POP3- und SMTP-Backend.</p>
|
||||||
|
</div>
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemindersMenu() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [items, setItems] = useState<CalendarEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true)
|
||||||
|
api.listUpcomingReminders(24).then(setItems).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClassicDropdown
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => { setOpen(v); if (v) load() }}
|
||||||
|
widthClass="w-72"
|
||||||
|
button={<ClassicButton icon={Bell} label="Erinnerungsfenster" />}
|
||||||
|
>
|
||||||
|
<p className="px-3 pb-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Nächste 24 Stunden</p>
|
||||||
|
{loading ? (
|
||||||
|
<p className="px-3 py-2 text-[12px] text-zinc-400">Lade…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-[12px] text-zinc-400">Keine anstehenden Erinnerungen.</p>
|
||||||
|
) : (
|
||||||
|
items.map((e) => (
|
||||||
|
<div key={e.id} className="px-3 py-1.5 text-[12.5px] text-zinc-700 dark:text-zinc-200">
|
||||||
|
<p className="font-medium">{e.title}</p>
|
||||||
|
<p className="text-[11px] text-zinc-400">{new Date(e.start_ts * 1000).toLocaleString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ClassicDropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ribbon({
|
||||||
|
view,
|
||||||
|
onChangeView,
|
||||||
|
selectedMessage,
|
||||||
|
selectedSummary,
|
||||||
|
selectionCount = 0,
|
||||||
|
folders,
|
||||||
|
currentAccountId,
|
||||||
|
accounts,
|
||||||
|
onCompose,
|
||||||
|
composeDisabled,
|
||||||
|
onReply,
|
||||||
|
onReplyAll,
|
||||||
|
onForward,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
|
onMove,
|
||||||
|
onToggleRead,
|
||||||
|
onMarkDone,
|
||||||
|
onToggleFlag,
|
||||||
|
onSetCategory,
|
||||||
|
onRefresh,
|
||||||
|
onRefreshAll,
|
||||||
|
refreshing,
|
||||||
|
offlineMode,
|
||||||
|
onToggleOffline,
|
||||||
|
emailFilter,
|
||||||
|
onEmailFilterChange,
|
||||||
|
onAddContact,
|
||||||
|
onNewFolder,
|
||||||
|
onRenameFolder,
|
||||||
|
onDeleteFolder,
|
||||||
|
onMarkFolderRead,
|
||||||
|
onApplyRulesToFolder,
|
||||||
|
onManageRules,
|
||||||
|
onManageBlocked,
|
||||||
|
onShowSnoozed,
|
||||||
|
onShowShortcuts,
|
||||||
|
onSortFoldersAZ,
|
||||||
|
onAddFavorite,
|
||||||
|
onNewSearchFolder,
|
||||||
|
groupConversations,
|
||||||
|
onGroupConversationsChange,
|
||||||
|
density,
|
||||||
|
onDensityChange,
|
||||||
|
sidebarCollapsed,
|
||||||
|
onToggleSidebar,
|
||||||
|
sortBy,
|
||||||
|
sortDesc,
|
||||||
|
onSortByChange,
|
||||||
|
onToggleSortDesc,
|
||||||
|
onResetView,
|
||||||
|
onNewTask,
|
||||||
|
onNewEvent,
|
||||||
|
onGoToToday,
|
||||||
|
onImportIcs,
|
||||||
|
onExportIcs,
|
||||||
|
googleAccounts,
|
||||||
|
onSyncGoogleCalendar,
|
||||||
|
onReconnectGoogleAccount,
|
||||||
|
onOpenCaldav,
|
||||||
|
theme,
|
||||||
|
onThemeChange,
|
||||||
|
onOpenSettings,
|
||||||
|
}: {
|
||||||
|
view: SidebarView
|
||||||
|
onChangeView: (v: SidebarView) => void
|
||||||
|
selectedMessage: MessageFull | null
|
||||||
|
selectedSummary: MessageSummary | null
|
||||||
|
selectionCount?: number
|
||||||
|
folders: Folder[]
|
||||||
|
currentAccountId: string | null
|
||||||
|
accounts: Account[]
|
||||||
|
onCompose: () => void
|
||||||
|
composeDisabled: boolean
|
||||||
|
onReply: () => void
|
||||||
|
onReplyAll: () => void
|
||||||
|
onForward: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onArchive: () => void
|
||||||
|
onMove: (target: string) => void
|
||||||
|
onToggleRead: () => void
|
||||||
|
onMarkDone: () => void
|
||||||
|
onToggleFlag: () => void
|
||||||
|
onSetCategory: (c: string | null) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
onRefreshAll: () => void
|
||||||
|
refreshing: boolean
|
||||||
|
offlineMode: boolean
|
||||||
|
onToggleOffline: () => void
|
||||||
|
emailFilter: EmailFilter
|
||||||
|
onEmailFilterChange: (f: EmailFilter) => void
|
||||||
|
onAddContact: () => void
|
||||||
|
onNewFolder: () => void
|
||||||
|
onRenameFolder: () => void
|
||||||
|
onDeleteFolder: () => void
|
||||||
|
onMarkFolderRead: () => void
|
||||||
|
onApplyRulesToFolder: () => void
|
||||||
|
onManageRules: () => void
|
||||||
|
onManageBlocked: () => void
|
||||||
|
onShowSnoozed: () => void
|
||||||
|
onShowShortcuts: () => void
|
||||||
|
onSortFoldersAZ: () => void
|
||||||
|
onAddFavorite: () => void
|
||||||
|
onNewSearchFolder: () => void
|
||||||
|
groupConversations: boolean
|
||||||
|
onGroupConversationsChange: (v: boolean) => void
|
||||||
|
density: Density
|
||||||
|
onDensityChange: (d: Density) => void
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
onToggleSidebar: () => void
|
||||||
|
sortBy: SortBy
|
||||||
|
sortDesc: boolean
|
||||||
|
onSortByChange: (s: SortBy) => void
|
||||||
|
onToggleSortDesc: () => void
|
||||||
|
onResetView: () => void
|
||||||
|
onNewTask?: () => void
|
||||||
|
onNewEvent?: () => void
|
||||||
|
onGoToToday?: () => void
|
||||||
|
onImportIcs?: () => void
|
||||||
|
onExportIcs?: () => void
|
||||||
|
googleAccounts?: Account[]
|
||||||
|
onSyncGoogleCalendar?: (accountId: string) => Promise<void>
|
||||||
|
onReconnectGoogleAccount?: (accountId: string) => Promise<void>
|
||||||
|
onOpenCaldav?: () => void
|
||||||
|
theme: Theme
|
||||||
|
onThemeChange: (t: Theme) => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<RibbonTab>('start')
|
||||||
|
const hasSingle = !!selectedMessage
|
||||||
|
const hasSelection = hasSingle || selectionCount > 1
|
||||||
|
const account = accounts.find((a) => a.id === currentAccountId) ?? null
|
||||||
|
const isLocal = account?.protocol === 'local'
|
||||||
|
|
||||||
|
function readAloud() {
|
||||||
|
if (!selectedMessage) return
|
||||||
|
const text = selectedMessage.body_text || (selectedMessage.body_html ?? '').replace(/<[^>]+>/g, ' ')
|
||||||
|
if (!('speechSynthesis' in window) || !text.trim()) return
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
const utter = new SpeechSynthesisUtterance(`${selectedMessage.subject}. ${text}`)
|
||||||
|
utter.lang = 'de-DE'
|
||||||
|
window.speechSynthesis.speak(utter)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'start', label: 'Start' },
|
||||||
|
{ id: 'sendrecv', label: 'Senden/Empfangen' },
|
||||||
|
{ id: 'folder', label: 'Ordner' },
|
||||||
|
{ id: 'view', label: 'Ansicht' },
|
||||||
|
{ id: 'help', label: 'Hilfe' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full border-b border-[#0d3a6e] shadow-sm">
|
||||||
|
<ClassicTabBar
|
||||||
|
tabs={view === 'mail' ? tabs : tabs.filter((t) => t.id !== 'sendrecv' && t.id !== 'folder')}
|
||||||
|
active={tab}
|
||||||
|
onChange={(id) => setTab(id as RibbonTab)}
|
||||||
|
rightSlot={
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
title="Einstellungen"
|
||||||
|
className="rounded-full p-1.5 text-white/80 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<Settings size={15} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ClassicRibbonBody>
|
||||||
|
{tab === 'start' && view === 'mail' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Neu">
|
||||||
|
<ClassicButton icon={MailPlus} label="Neue E-Mail" onClick={onCompose} disabled={composeDisabled} />
|
||||||
|
<NewItemMenu onEmail={onCompose} onEvent={() => { onChangeView('calendar'); onNewEvent?.() }} onContact={onAddContact} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Löschen">
|
||||||
|
<ClassicButton icon={Trash2} label={selectionCount > 1 ? `Löschen (${selectionCount})` : 'Löschen'} onClick={onDelete} disabled={!hasSelection} />
|
||||||
|
<ClassicButton icon={Archive} label="Archivieren" onClick={onArchive} disabled={!hasSelection || isLocal} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Antworten">
|
||||||
|
<ClassicButton icon={Reply} label="Antworten" onClick={onReply} disabled={!hasSingle} />
|
||||||
|
<ClassicButton icon={ReplyAll} label="Allen antw." onClick={onReplyAll} disabled={!hasSingle} />
|
||||||
|
<ClassicButton icon={Forward} label="Weiterleiten" onClick={onForward} disabled={!hasSingle} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="QuickSteps">
|
||||||
|
<MoveMenu folders={folders} currentFolder={selectionCount > 1 ? null : selectedMessage?.folder ?? null} onMove={onMove} disabled={!hasSelection} />
|
||||||
|
<ClassicButton icon={MailCheck} label="Erledigt" onClick={onMarkDone} disabled={!hasSelection} title="Als gelesen markieren und Kennzeichnung entfernen" />
|
||||||
|
<ClassicButton icon={Pencil} label="Neu erstellen" disabled title="Individuelle QuickSteps: in dieser Version nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Verschieben">
|
||||||
|
<MoveMenu folders={folders} currentFolder={selectionCount > 1 ? null : selectedMessage?.folder ?? null} onMove={onMove} disabled={!hasSelection} />
|
||||||
|
<ClassicButton icon={Filter} label="Regeln" onClick={onManageRules} />
|
||||||
|
<ClassicButton icon={ShieldOff} label="Blockiert" onClick={onManageBlocked} />
|
||||||
|
<ClassicButton icon={BellRing} label="Wiedervorlage" onClick={onShowSnoozed} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Kategorien">
|
||||||
|
<CategoryMenu onSet={onSetCategory} disabled={!selectedSummary && selectionCount <= 1} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Suchen">
|
||||||
|
<ClassicButton icon={BookUser} label="Adressbuch" onClick={() => onChangeView('contacts')} />
|
||||||
|
<FilterMenu value={emailFilter} onChange={onEmailFilterChange} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Sprache">
|
||||||
|
<ClassicButton icon={Volume2} label="Laut vorlesen" onClick={readAloud} disabled={!hasSingle} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Kennzeichnen">
|
||||||
|
<ClassicButton icon={MailOpen} label="Ungelesen" onClick={onToggleRead} disabled={!hasSelection} />
|
||||||
|
<ClassicButton icon={Star} label="Markieren" onClick={onToggleFlag} disabled={!hasSelection} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Add-Ins">
|
||||||
|
<ClassicButton icon={FolderCog} label="Add-Ins abrufen" disabled title="Add-In-Marktplatz: in dieser Version nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'start' && view === 'contacts' && (
|
||||||
|
<ClassicRibbonGroup caption="Neu">
|
||||||
|
<ClassicButton icon={UserPlus} label="Neuer Kontakt" onClick={onAddContact} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'start' && view === 'calendar' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Neu">
|
||||||
|
<ClassicButton icon={CalendarPlus} label="Neuer Termin" onClick={() => onNewEvent?.()} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Ansicht">
|
||||||
|
<ClassicButton icon={CalendarDays} label="Heute" onClick={() => onGoToToday?.()} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Austausch">
|
||||||
|
<ClassicButton icon={Upload} label="Importieren (.ics)" onClick={() => onImportIcs?.()} />
|
||||||
|
<ClassicButton icon={Download} label="Exportieren (.ics)" onClick={() => onExportIcs?.()} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
{googleAccounts && googleAccounts.length > 0 && onSyncGoogleCalendar && onReconnectGoogleAccount && (
|
||||||
|
<ClassicRibbonGroup caption="Google">
|
||||||
|
<GoogleCalendarMenu accounts={googleAccounts} onSync={onSyncGoogleCalendar} onReconnect={onReconnectGoogleAccount} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
)}
|
||||||
|
{onOpenCaldav && (
|
||||||
|
<ClassicRibbonGroup caption="Andere Kalender">
|
||||||
|
<ClassicButton icon={Globe} label="CalDAV" onClick={onOpenCaldav} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'start' && view === 'tasks' && (
|
||||||
|
<ClassicRibbonGroup caption="Neu">
|
||||||
|
<ClassicButton icon={ListPlus} label="Neue Aufgabe" onClick={() => onNewTask?.()} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'sendrecv' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Senden und Empfangen">
|
||||||
|
<ClassicButton icon={RefreshCcw} label="Alle Ordner senden/empfangen" onClick={onRefreshAll} disabled={refreshing} />
|
||||||
|
<ClassicButton icon={RefreshCw} label="Ordner aktualisieren" onClick={onRefresh} disabled={refreshing || !currentAccountId} />
|
||||||
|
<ClassicButton icon={Upload} label="Alle senden" disabled title="Kein Offline-Postausgang in dieser Version" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Download">
|
||||||
|
<ClassicButton icon={Info} label="Status anzeigen" disabled title="Nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Server">
|
||||||
|
<ClassicButton icon={Download} label="Kopfzeilen herunterladen" disabled title="Nicht verfügbar für dieses Konto" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Einstellungen">
|
||||||
|
<ClassicButton icon={FolderCog} label="Downloadeinstellungen" disabled title="Nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Offline">
|
||||||
|
<ClassicButton icon={WifiOff} label="Offline arbeiten" onClick={onToggleOffline} active={offlineMode} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'folder' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Neu">
|
||||||
|
<ClassicButton icon={FolderPlus} label="Neuer Ordner" onClick={onNewFolder} disabled={!currentAccountId || isLocal} />
|
||||||
|
<ClassicButton icon={MailSearch} label="Neuer Suchordner" onClick={onNewSearchFolder} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Aktionen">
|
||||||
|
<ClassicButton icon={Pencil} label="Ordner umbenennen" onClick={onRenameFolder} disabled={!currentAccountId || isLocal} />
|
||||||
|
<ClassicButton icon={FolderX} label="Ordner löschen" onClick={onDeleteFolder} disabled={!currentAccountId || isLocal} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Aufräumen">
|
||||||
|
<ClassicButton icon={MailCheck} label="Alles als gelesen markieren" onClick={onMarkFolderRead} disabled={!currentAccountId} />
|
||||||
|
<ClassicButton icon={Filter} label="Regeln jetzt anwenden" onClick={onApplyRulesToFolder} disabled={!currentAccountId || isLocal} />
|
||||||
|
<ClassicButton icon={ArrowDownAZ} label="Alle Ordner von A bis Z" onClick={onSortFoldersAZ} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Favoriten">
|
||||||
|
<ClassicButton icon={Star} label="Zu Favoriten hinzufügen" onClick={onAddFavorite} disabled={!currentAccountId} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Eigenschaften">
|
||||||
|
<ClassicButton icon={FolderCog} label="Ordnereigenschaften" disabled title="Nicht verfügbar" />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'view' && (
|
||||||
|
<>
|
||||||
|
<ClassicRibbonGroup caption="Aktuelle Ansicht">
|
||||||
|
<ClassicButton icon={RotateCcw} label="Ansicht zurücksetzen" onClick={onResetView} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Nachrichten">
|
||||||
|
<ClassicButton icon={MessagesSquare} label="Als Unterhaltungen anzeigen" onClick={() => onGroupConversationsChange(!groupConversations)} active={groupConversations} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Anordnung">
|
||||||
|
<div className="flex flex-col gap-0.5 pt-1">
|
||||||
|
{(['date', 'from', 'subject'] as SortBy[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onSortByChange(s)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-sm px-1.5 py-0.5 text-left text-[11.5px]',
|
||||||
|
sortBy === s ? 'bg-[#cce4f7] dark:bg-blue-500/20' : 'hover:bg-[#e6f2fb] dark:hover:bg-zinc-700/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s === 'date' ? 'Datum' : s === 'from' ? 'Von' : 'Betreff'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ClassicButton icon={ArrowUpDown} label="Sortierreihenfolge umkehren" onClick={onToggleSortDesc} active={!sortDesc} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Layout">
|
||||||
|
<ClassicButton icon={Rows4} label="Normal" onClick={() => onDensityChange('comfortable')} active={density === 'comfortable'} />
|
||||||
|
<ClassicButton icon={Rows3} label="Kompakt" onClick={() => onDensityChange('compact')} active={density === 'compact'} />
|
||||||
|
<ClassicButton icon={AlignJustify} label="Ordnerbereich" onClick={onToggleSidebar} active={sidebarCollapsed} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Design">
|
||||||
|
<ClassicButton icon={Sun} label="Hell" onClick={() => onThemeChange('light')} active={theme === 'light'} />
|
||||||
|
<ClassicButton icon={Moon} label="Dunkel" onClick={() => onThemeChange('dark')} active={theme === 'dark'} />
|
||||||
|
<ClassicButton icon={Monitor} label="System" onClick={() => onThemeChange('system')} active={theme === 'system'} />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
<ClassicRibbonGroup caption="Fenster">
|
||||||
|
<RemindersMenu />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'help' && (
|
||||||
|
<ClassicRibbonGroup caption="Support">
|
||||||
|
<HelpMenu onShowShortcuts={onShowShortcuts} />
|
||||||
|
<AboutMenu />
|
||||||
|
</ClassicRibbonGroup>
|
||||||
|
)}
|
||||||
|
</ClassicRibbonBody>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function RibbonButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
active,
|
||||||
|
primary,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ size?: number }>
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
active?: boolean
|
||||||
|
primary?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={clsx(
|
||||||
|
'flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg text-[11px] font-medium transition disabled:cursor-not-allowed disabled:opacity-35',
|
||||||
|
primary
|
||||||
|
? 'text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10'
|
||||||
|
: active
|
||||||
|
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
|
||||||
|
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={19} />
|
||||||
|
<span className="leading-none">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||||
|
import { htmlIsEmpty } from '../lib/richtext'
|
||||||
|
|
||||||
|
export interface RichTextEditorHandle {
|
||||||
|
exec: (command: string, value?: string) => void
|
||||||
|
focus: () => void
|
||||||
|
wordCount: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RichTextEditor = forwardRef<
|
||||||
|
RichTextEditorHandle,
|
||||||
|
{
|
||||||
|
html: string
|
||||||
|
resetKey: unknown
|
||||||
|
onChange: (html: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
spellCheck?: boolean
|
||||||
|
}
|
||||||
|
>(function RichTextEditor({ html, resetKey, onChange, placeholder, spellCheck = true }, ref) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.innerHTML = html
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [resetKey])
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
onChange(editorRef.current?.innerHTML ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
exec(command: string, value?: string) {
|
||||||
|
editorRef.current?.focus()
|
||||||
|
document.execCommand(command, false, value)
|
||||||
|
handleInput()
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
editorRef.current?.focus()
|
||||||
|
},
|
||||||
|
wordCount() {
|
||||||
|
const text = editorRef.current?.textContent ?? ''
|
||||||
|
return text.trim() ? text.trim().split(/\s+/).length : 0
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}))
|
||||||
|
|
||||||
|
const empty = htmlIsEmpty(html)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{empty && (
|
||||||
|
<span className="pointer-events-none absolute top-5 left-7 text-[13.5px] text-zinc-300 select-none dark:text-zinc-600">
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
spellCheck={spellCheck}
|
||||||
|
onInput={handleInput}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
className="compose-body min-h-full px-7 py-5 text-[13.5px] leading-relaxed text-zinc-800 outline-none dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Bell, MonitorSmartphone, PictureInPicture2, X } from 'lucide-react'
|
||||||
|
import type { AppSettings } from '../lib/settings'
|
||||||
|
|
||||||
|
export function SettingsModal({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
settings: AppSettings
|
||||||
|
onChange: (v: AppSettings) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
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 w-full max-w-md 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">
|
||||||
|
Einstellungen
|
||||||
|
</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="space-y-4 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
Benachrichtigungen bei neuer E-Mail
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<label className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<span className="flex items-center gap-2.5 text-[13px] text-zinc-700 dark:text-zinc-200">
|
||||||
|
<Bell size={15} className="text-zinc-400" />
|
||||||
|
Popup in der App anzeigen
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.popup}
|
||||||
|
onChange={(e) => onChange({ ...settings, popup: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<span className="flex items-center gap-2.5 text-[13px] text-zinc-700 dark:text-zinc-200">
|
||||||
|
<MonitorSmartphone size={15} className="text-zinc-400" />
|
||||||
|
Desktop-Benachrichtigung anzeigen
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.desktop}
|
||||||
|
onChange={(e) => onChange({ ...settings, desktop: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-400">
|
||||||
|
Beide lassen sich unabhängig voneinander abschalten, um keine Benachrichtigungen mehr zu erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Verfassen</p>
|
||||||
|
<label className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800">
|
||||||
|
<span className="flex items-center gap-2.5 text-[13px] text-zinc-700 dark:text-zinc-200">
|
||||||
|
<PictureInPicture2 size={15} className="text-zinc-400" />
|
||||||
|
Neu/Antworten als Popup-Fenster öffnen
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.composePopup}
|
||||||
|
onChange={(e) => onChange({ ...settings, composePopup: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-400">
|
||||||
|
Aus: Verfassen ersetzt die Lesefläche (Standard). An: Verfassen öffnet als schwebendes Fenster, die Nachrichtenliste bleibt sichtbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
const SHORTCUTS: { keys: string; label: string }[] = [
|
||||||
|
{ keys: 'N', label: 'Neue Mail verfassen' },
|
||||||
|
{ keys: 'R', label: 'Antworten' },
|
||||||
|
{ keys: 'F', label: 'Weiterleiten' },
|
||||||
|
{ keys: 'Entf / Rücktaste', label: 'Ausgewählte Nachricht löschen' },
|
||||||
|
{ keys: '↑ / ↓', label: 'In der Mailliste navigieren' },
|
||||||
|
{ keys: 'Strg/Cmd + Klick', label: 'Mehrfachauswahl (einzelne Nachrichten)' },
|
||||||
|
{ keys: 'Umschalt + Klick', label: 'Bereich auswählen' },
|
||||||
|
{ keys: 'Rechtsklick', label: 'Kontextmenü öffnen (Mails, Kontakte, Ordner, Termine)' },
|
||||||
|
{ keys: 'Esc', label: 'Menü/Popup schließen' },
|
||||||
|
{ keys: '?', label: 'Diese Übersicht anzeigen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ShortcutsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
if (!open) return null
|
||||||
|
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"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="animate-panel-in flex w-full max-w-sm 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">
|
||||||
|
Tastenkürzel
|
||||||
|
</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-1.5 px-6 py-5">
|
||||||
|
{SHORTCUTS.map((s) => (
|
||||||
|
<div key={s.keys} className="flex items-center justify-between gap-3 py-0.5">
|
||||||
|
<span className="text-[12.5px] text-zinc-600 dark:text-zinc-300">{s.label}</span>
|
||||||
|
<kbd className="shrink-0 rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] font-medium text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
|
||||||
|
{s.keys}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
HardDrive,
|
||||||
|
Inbox,
|
||||||
|
Layers,
|
||||||
|
ListTodo,
|
||||||
|
Mail,
|
||||||
|
MailSearch,
|
||||||
|
MoreHorizontal,
|
||||||
|
PenLine,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
Star,
|
||||||
|
Upload,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Account, Folder } from '../types'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import { Popover } from './Popover'
|
||||||
|
|
||||||
|
export type SidebarView = 'mail' | 'contacts' | 'calendar' | 'tasks'
|
||||||
|
|
||||||
|
export interface FavoriteFolder {
|
||||||
|
accountId: string
|
||||||
|
folder: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedSearch {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderIcon(name: string) {
|
||||||
|
const n = name.toLowerCase()
|
||||||
|
if (n.includes('inbox') || n.includes('posteingang')) return Inbox
|
||||||
|
if (n.includes('entwürfe') || n.includes('entwuerfe') || n.includes('draft')) return PenLine
|
||||||
|
return FolderIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderMenu({
|
||||||
|
onExport,
|
||||||
|
onImport,
|
||||||
|
onManageRules,
|
||||||
|
onFullBackup,
|
||||||
|
}: {
|
||||||
|
onExport: () => void
|
||||||
|
onImport: () => void
|
||||||
|
onManageRules: () => void
|
||||||
|
onFullBackup: () => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
title="Import / Export"
|
||||||
|
className="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
panelClassName="animate-panel-in w-56 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 py-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onExport()
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
<Download size={13} /> Ordner exportieren (.mbox)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onImport()
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
<Upload size={13} /> Mail importieren…
|
||||||
|
</button>
|
||||||
|
<div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onFullBackup()
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
<HardDrive size={13} /> Komplettes Postfach sichern…
|
||||||
|
</button>
|
||||||
|
<div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onManageRules()
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
<Filter size={13} /> Regeln verwalten…
|
||||||
|
</button>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderRow({
|
||||||
|
folder,
|
||||||
|
active,
|
||||||
|
isFavorite,
|
||||||
|
onSelect,
|
||||||
|
onToggleFavorite,
|
||||||
|
onContextMenu,
|
||||||
|
onDropMessages,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
folder: Folder
|
||||||
|
active: boolean
|
||||||
|
isFavorite: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onToggleFavorite: () => void
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void
|
||||||
|
onDropMessages?: () => void
|
||||||
|
compact: boolean
|
||||||
|
}) {
|
||||||
|
const Icon = folderIcon(folder.name)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!onDropMessages) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}}
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
if (!onDropMessages) return
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!onDropMessages) return
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(false)
|
||||||
|
onDropMessages()
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'group flex w-full items-center gap-2.5 rounded-xl px-2.5 text-left text-[13px] transition',
|
||||||
|
compact ? 'py-1' : 'py-1.5',
|
||||||
|
dragOver
|
||||||
|
? 'bg-indigo-100 ring-2 ring-indigo-400 dark:bg-indigo-500/25'
|
||||||
|
: active
|
||||||
|
? 'bg-white font-medium text-indigo-700 shadow-sm ring-1 ring-black/[0.04] dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-white/5'
|
||||||
|
: 'text-zinc-600 hover:bg-white/60 dark:text-zinc-400 dark:hover:bg-zinc-800/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={15} className="shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{folder.display_name}</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleFavorite()
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 rounded-full p-0.5 transition',
|
||||||
|
isFavorite ? 'text-amber-400' : 'text-zinc-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 dark:text-zinc-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star size={12} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||||
|
</span>
|
||||||
|
{folder.unread > 0 && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold tabular-nums',
|
||||||
|
active ? 'bg-indigo-600 text-white' : 'bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{folder.unread > 99 ? '99+' : folder.unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
accounts,
|
||||||
|
accountFolders,
|
||||||
|
expandedAccounts,
|
||||||
|
onToggleExpand,
|
||||||
|
selectedAccountId,
|
||||||
|
selectedFolder,
|
||||||
|
onSelectFolder,
|
||||||
|
favorites,
|
||||||
|
onToggleFavorite,
|
||||||
|
onFolderContextMenu,
|
||||||
|
onDropMessagesOnFolder,
|
||||||
|
onAddAccount,
|
||||||
|
onRefresh,
|
||||||
|
refreshing,
|
||||||
|
view,
|
||||||
|
onChangeView,
|
||||||
|
onExportFolder,
|
||||||
|
onFullBackup,
|
||||||
|
onOpenImport,
|
||||||
|
onManageRules,
|
||||||
|
onOpenAccountSettings,
|
||||||
|
compact,
|
||||||
|
savedSearches,
|
||||||
|
onSelectSavedSearch,
|
||||||
|
onDeleteSavedSearch,
|
||||||
|
unifiedInboxActive,
|
||||||
|
onSelectUnifiedInbox,
|
||||||
|
}: {
|
||||||
|
accounts: Account[]
|
||||||
|
accountFolders: Record<string, Folder[]>
|
||||||
|
expandedAccounts: Set<string>
|
||||||
|
onToggleExpand: (accountId: string) => void
|
||||||
|
selectedAccountId: string | null
|
||||||
|
selectedFolder: string | null
|
||||||
|
onSelectFolder: (accountId: string, folderName: string) => void
|
||||||
|
favorites: FavoriteFolder[]
|
||||||
|
onToggleFavorite: (accountId: string, folder: Folder) => void
|
||||||
|
onFolderContextMenu?: (e: React.MouseEvent, accountId: string, folder: Folder) => void
|
||||||
|
onDropMessagesOnFolder?: (accountId: string, folder: Folder) => void
|
||||||
|
onAddAccount: () => void
|
||||||
|
onRefresh: () => void
|
||||||
|
refreshing: boolean
|
||||||
|
view: SidebarView
|
||||||
|
onChangeView: (v: SidebarView) => void
|
||||||
|
onExportFolder: () => void
|
||||||
|
onFullBackup: () => void
|
||||||
|
onOpenImport: () => void
|
||||||
|
onManageRules: () => void
|
||||||
|
onOpenAccountSettings: (account: Account) => void
|
||||||
|
compact: boolean
|
||||||
|
savedSearches: SavedSearch[]
|
||||||
|
onSelectSavedSearch: (query: string) => void
|
||||||
|
onDeleteSavedSearch: (id: string) => void
|
||||||
|
unifiedInboxActive: boolean
|
||||||
|
onSelectUnifiedInbox: () => void
|
||||||
|
}) {
|
||||||
|
const isFavorite = (accountId: string, folder: string) =>
|
||||||
|
favorites.some((f) => f.accountId === accountId && f.folder === folder)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-zinc-200/80 bg-gradient-to-b from-zinc-50 to-zinc-100/60 dark:border-zinc-800/80 dark:from-zinc-950 dark:to-zinc-900/40">
|
||||||
|
<div className="px-3 pt-4">
|
||||||
|
<div className="flex rounded-xl bg-zinc-200/60 p-1 dark:bg-zinc-900/60">
|
||||||
|
<button
|
||||||
|
onClick={() => onChangeView('mail')}
|
||||||
|
title="Mail"
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
|
||||||
|
view === 'mail'
|
||||||
|
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Mail size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChangeView('contacts')}
|
||||||
|
title="Kontakte"
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
|
||||||
|
view === 'contacts'
|
||||||
|
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChangeView('calendar')}
|
||||||
|
title="Kalender"
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
|
||||||
|
view === 'calendar'
|
||||||
|
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calendar size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChangeView('tasks')}
|
||||||
|
title="Aufgaben"
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
|
||||||
|
view === 'tasks'
|
||||||
|
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ListTodo size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'mail' && (
|
||||||
|
<div className="mt-4 flex-1 overflow-y-auto px-3 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={onSelectUnifiedInbox}
|
||||||
|
className={clsx(
|
||||||
|
'mb-3 flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-[13px] font-medium transition',
|
||||||
|
unifiedInboxActive
|
||||||
|
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/[0.04] dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-white/5'
|
||||||
|
: 'text-zinc-600 hover:bg-white/60 dark:text-zinc-400 dark:hover:bg-zinc-800/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Layers size={15} className="shrink-0" />
|
||||||
|
Alle Posteingänge
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{favorites.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||||
|
<span className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase dark:text-zinc-500">
|
||||||
|
Favoriten
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{favorites.map((f) => {
|
||||||
|
const folders = accountFolders[f.accountId] ?? []
|
||||||
|
const folder = folders.find((x) => x.name === f.folder)
|
||||||
|
if (!folder) return null
|
||||||
|
return (
|
||||||
|
<FolderRow
|
||||||
|
key={`${f.accountId}-${f.folder}`}
|
||||||
|
folder={folder}
|
||||||
|
active={selectedAccountId === f.accountId && selectedFolder === f.folder}
|
||||||
|
isFavorite
|
||||||
|
onSelect={() => onSelectFolder(f.accountId, f.folder)}
|
||||||
|
onToggleFavorite={() => onToggleFavorite(f.accountId, folder)}
|
||||||
|
onContextMenu={(e) => onFolderContextMenu?.(e, f.accountId, folder)}
|
||||||
|
onDropMessages={() => onDropMessagesOnFolder?.(f.accountId, folder)}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{savedSearches.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||||
|
<span className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase dark:text-zinc-500">
|
||||||
|
Suchordner
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
{savedSearches.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onSelectSavedSearch(s.query)}
|
||||||
|
className="group flex w-full items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-[13px] text-zinc-600 transition hover:bg-white/60 dark:text-zinc-400 dark:hover:bg-zinc-800/60"
|
||||||
|
>
|
||||||
|
<MailSearch size={15} className="shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{s.label}</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDeleteSavedSearch(s.id)
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-full p-0.5 text-zinc-300 opacity-0 transition group-hover:opacity-100 hover:text-red-500 dark:text-zinc-700"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-1 pb-1.5">
|
||||||
|
<span className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase dark:text-zinc-500">
|
||||||
|
Konten
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={!selectedAccountId || refreshing}
|
||||||
|
title="Aktualisieren"
|
||||||
|
className="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-200 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={clsx(refreshing && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<FolderMenu onExport={onExportFolder} onImport={onOpenImport} onManageRules={onManageRules} onFullBackup={onFullBackup} />
|
||||||
|
<button
|
||||||
|
onClick={onAddAccount}
|
||||||
|
title="Konto hinzufügen"
|
||||||
|
className="rounded-full p-1 text-zinc-400 transition hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{[...accounts]
|
||||||
|
.sort((a, b) => (a.protocol === 'local' ? 1 : 0) - (b.protocol === 'local' ? 1 : 0))
|
||||||
|
.map((a) => {
|
||||||
|
const expanded = expandedAccounts.has(a.id)
|
||||||
|
const folders = accountFolders[a.id] ?? []
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="group/account">
|
||||||
|
<div className="flex w-full items-center gap-1.5 rounded-lg px-1 py-1 transition hover:bg-white/50 dark:hover:bg-zinc-800/50">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleExpand(a.id)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={13}
|
||||||
|
className={clsx('shrink-0 text-zinc-400 transition-transform', expanded && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
{a.protocol === 'local' ? (
|
||||||
|
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-zinc-300 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
<HardDrive size={11} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Avatar name={a.label || a.email} size={20} />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
{a.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{a.protocol !== 'local' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpenAccountSettings(a)
|
||||||
|
}}
|
||||||
|
title="Kontoeinstellungen"
|
||||||
|
className="shrink-0 rounded-md p-1 text-zinc-300 opacity-0 transition group-hover/account:opacity-100 hover:bg-zinc-200 hover:text-zinc-600 dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<nav className="mt-0.5 flex flex-col gap-0.5 pl-3">
|
||||||
|
{folders.map((f) => (
|
||||||
|
<FolderRow
|
||||||
|
key={f.name}
|
||||||
|
folder={f}
|
||||||
|
active={selectedAccountId === a.id && selectedFolder === f.name}
|
||||||
|
isFavorite={isFavorite(a.id, f.name)}
|
||||||
|
onSelect={() => onSelectFolder(a.id, f.name)}
|
||||||
|
onToggleFavorite={() => onToggleFavorite(a.id, f)}
|
||||||
|
onContextMenu={(e) => onFolderContextMenu?.(e, a.id, f)}
|
||||||
|
onDropMessages={() => onDropMessagesOnFolder?.(a.id, f)}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{folders.length === 0 && (
|
||||||
|
<p className="px-2.5 py-1.5 text-xs text-zinc-400">Lade Ordner…</p>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onAddAccount}
|
||||||
|
className="rounded-xl border border-dashed border-zinc-300 px-2.5 py-2.5 text-left text-xs text-zinc-500 transition hover:border-indigo-300 hover:bg-white/50 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-500 dark:hover:border-indigo-800"
|
||||||
|
>
|
||||||
|
Erstes Konto hinzufügen…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view === 'contacts' && <div className="flex-1" />}
|
||||||
|
{view === 'calendar' && <div className="flex-1" />}
|
||||||
|
{view === 'tasks' && <div className="flex-1" />}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { BellRing, X } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { MessageSummary, SnoozedMessage } from '../types'
|
||||||
|
|
||||||
|
export function SnoozedModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onToast,
|
||||||
|
onOpenMessage,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
onOpenMessage: (m: MessageSummary) => void
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<SnoozedMessage[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setLoading(true)
|
||||||
|
api
|
||||||
|
.listSnoozedMessages()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e) => onToast(`Liste konnte nicht geladen werden: ${e}`))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) refresh()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleUnsnooze(m: SnoozedMessage) {
|
||||||
|
setItems((prev) => prev.filter((x) => x !== m))
|
||||||
|
try {
|
||||||
|
await api.unsnoozeMessage(m.account_id, m.folder, m.uid)
|
||||||
|
} catch (e) {
|
||||||
|
onToast(String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-[80vh] w-full max-w-md 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="flex items-center gap-2 text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
<BellRing size={16} className="text-zinc-400" /> Wiedervorlage
|
||||||
|
</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-1.5 overflow-y-auto px-6 py-5">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-zinc-400">Lade…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-400">Nichts auf Wiedervorlage.</p>
|
||||||
|
) : (
|
||||||
|
items.map((m) => (
|
||||||
|
<div
|
||||||
|
key={`${m.account_id}-${m.folder}-${m.uid}`}
|
||||||
|
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onOpenMessage(m)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
>
|
||||||
|
<p className="truncate text-[12.5px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
{m.subject || '(kein Betreff)'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[11px] text-zinc-400">
|
||||||
|
{m.from_name || m.from_addr} · wieder ab {new Date(m.until_ts * 1000).toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnsnooze(m)}
|
||||||
|
title="Jetzt wiedervorlegen"
|
||||||
|
className="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium text-indigo-600 transition hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
|
||||||
|
>
|
||||||
|
Jetzt zeigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Trash2, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { NewTask, Task, TaskPriority } from '../types'
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
const PRIORITIES: { value: TaskPriority; label: string; color: string }[] = [
|
||||||
|
{ value: 'low', label: 'Niedrig', color: '#94a3b8' },
|
||||||
|
{ value: 'normal', label: 'Normal', color: '#3b82f6' },
|
||||||
|
{ value: 'high', label: 'Hoch', color: '#ef4444' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function toDateStr(ts: number) {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskModal({
|
||||||
|
open,
|
||||||
|
task,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
task: Task | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (data: NewTask, existingId: string | null) => Promise<void>
|
||||||
|
onDelete: (task: Task) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [dueDate, setDueDate] = useState('')
|
||||||
|
const [priority, setPriority] = useState<TaskPriority>('normal')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setConfirmDelete(false)
|
||||||
|
if (task) {
|
||||||
|
setTitle(task.title)
|
||||||
|
setDescription(task.description ?? '')
|
||||||
|
setDueDate(task.due_ts ? toDateStr(task.due_ts) : '')
|
||||||
|
setPriority(task.priority)
|
||||||
|
} else {
|
||||||
|
setTitle('')
|
||||||
|
setDescription('')
|
||||||
|
setDueDate('')
|
||||||
|
setPriority('normal')
|
||||||
|
}
|
||||||
|
}, [open, task])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!title.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const due_ts = dueDate ? Math.floor(new Date(`${dueDate}T09:00:00`).getTime() / 1000) : null
|
||||||
|
await onSave(
|
||||||
|
{
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
due_ts,
|
||||||
|
priority,
|
||||||
|
},
|
||||||
|
task?.id ?? null,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!task) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await onDelete(task)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="animate-panel-in flex max-h-[85vh] w-full max-w-md 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">
|
||||||
|
{task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="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-3.5 overflow-y-auto px-6 py-5">
|
||||||
|
<Field label="Titel">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="z. B. Angebot nachfassen"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Fällig am (optional)">
|
||||||
|
<input className={inputClass} type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Priorität">
|
||||||
|
<div className="flex items-center gap-1.5 pt-1">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriority(p.value)}
|
||||||
|
title={p.label}
|
||||||
|
className={clsx(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded-full border-2 transition',
|
||||||
|
priority === p.value ? 'border-zinc-400 dark:border-zinc-300' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="h-3.5 w-3.5 rounded-full" style={{ backgroundColor: p.color }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Beschreibung (optional)">
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{task && (
|
||||||
|
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-3 dark:border-red-950/60 dark:bg-red-950/20">
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-red-700 dark:text-red-400">Aufgabe wirklich löschen?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="ml-auto rounded-full bg-red-600 px-3 py-1 text-[11.5px] font-medium text-white transition hover:bg-red-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{deleting ? 'Lösche…' : 'Löschen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
className="rounded-full px-3 py-1 text-[11.5px] font-medium text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-600 transition hover:text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Aufgabe löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { CheckSquare, ListTodo, Plus } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Task } from '../types'
|
||||||
|
|
||||||
|
const PRIORITY_COLOR: Record<Task['priority'], string> = {
|
||||||
|
low: '#94a3b8',
|
||||||
|
normal: '#3b82f6',
|
||||||
|
high: '#ef4444',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(task: Task) {
|
||||||
|
if (!task.due_ts || task.completed) return false
|
||||||
|
return task.due_ts * 1000 < Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDue(ts: number) {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TasksView({
|
||||||
|
tasks,
|
||||||
|
loading,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onToggleCompleted,
|
||||||
|
}: {
|
||||||
|
tasks: Task[]
|
||||||
|
loading: boolean
|
||||||
|
onAdd: () => void
|
||||||
|
onEdit: (task: Task) => void
|
||||||
|
onToggleCompleted: (task: Task) => void
|
||||||
|
}) {
|
||||||
|
const open = useMemo(() => tasks.filter((t) => !t.completed), [tasks])
|
||||||
|
const completed = useMemo(() => tasks.filter((t) => t.completed), [tasks])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 border-b border-zinc-200/80 px-6 py-3.5 dark:border-zinc-800/80">
|
||||||
|
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">Aufgaben</h2>
|
||||||
|
<span className="text-[12px] text-zinc-400">{open.length} offen</span>
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="ml-auto flex items-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-3.5 py-1.5 text-[12.5px] font-medium text-white shadow-sm transition hover:from-indigo-400 hover:to-indigo-500"
|
||||||
|
>
|
||||||
|
<Plus size={13} /> Neue Aufgabe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-zinc-400">Lade…</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20 text-center">
|
||||||
|
<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">
|
||||||
|
<ListTodo size={26} strokeWidth={1.4} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-400">Noch keine Aufgaben angelegt.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{open.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => onEdit(task)}
|
||||||
|
role="button"
|
||||||
|
className="group flex cursor-default items-start gap-3 rounded-xl px-3 py-2.5 transition hover:bg-zinc-50 dark:hover:bg-zinc-900/60"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleCompleted(task)
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 border-zinc-300 transition hover:border-indigo-400 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: PRIORITY_COLOR[task.priority] }}
|
||||||
|
title={task.priority}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-100">{task.title}</p>
|
||||||
|
{task.description && (
|
||||||
|
<p className="truncate text-[12px] text-zinc-400">{task.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.due_ts && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[10.5px] font-medium',
|
||||||
|
isOverdue(task)
|
||||||
|
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatDue(task.due_ts)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{open.length === 0 && <p className="px-3 text-[12.5px] text-zinc-400">Alles erledigt 🎉</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 px-3 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
|
||||||
|
Erledigt ({completed.length})
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{completed.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => onEdit(task)}
|
||||||
|
role="button"
|
||||||
|
className="group flex cursor-default items-center gap-3 rounded-xl px-3 py-2 opacity-60 transition hover:bg-zinc-50 hover:opacity-100 dark:hover:bg-zinc-900/60"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleCompleted(task)
|
||||||
|
}}
|
||||||
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-indigo-500 bg-indigo-500 text-white"
|
||||||
|
>
|
||||||
|
<CheckSquare size={10} />
|
||||||
|
</button>
|
||||||
|
<p className="min-w-0 flex-1 truncate text-[13px] text-zinc-500 line-through dark:text-zinc-500">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FileText, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { Template } from '../types'
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onToast,
|
||||||
|
onInsert,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onToast: (msg: string) => void
|
||||||
|
onInsert?: (t: Template) => void
|
||||||
|
}) {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Template | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
setLoading(true)
|
||||||
|
api
|
||||||
|
.listTemplates()
|
||||||
|
.then(setTemplates)
|
||||||
|
.catch((e) => onToast(`Vorlagen konnten nicht geladen werden: ${e}`))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
refresh()
|
||||||
|
setEditing(null)
|
||||||
|
setName('')
|
||||||
|
setSubject('')
|
||||||
|
setBody('')
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function startEdit(t: Template) {
|
||||||
|
setEditing(t)
|
||||||
|
setName(t.name)
|
||||||
|
setSubject(t.subject)
|
||||||
|
setBody(t.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
setEditing({ id: '', name: '', subject: '', body: '' })
|
||||||
|
setName('')
|
||||||
|
setSubject('')
|
||||||
|
setBody('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editing?.id) {
|
||||||
|
await api.updateTemplate({ id: editing.id, name: name.trim(), subject, body })
|
||||||
|
} else {
|
||||||
|
await api.createTemplate({ name: name.trim(), subject, body })
|
||||||
|
}
|
||||||
|
setEditing(null)
|
||||||
|
refresh()
|
||||||
|
onToast('Vorlage gespeichert')
|
||||||
|
} catch (err) {
|
||||||
|
onToast(`Fehlgeschlagen: ${err}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(t: Template) {
|
||||||
|
setTemplates((prev) => prev.filter((x) => x.id !== t.id))
|
||||||
|
if (editing?.id === t.id) setEditing(null)
|
||||||
|
try {
|
||||||
|
await api.deleteTemplate(t.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="flex items-center gap-2 text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
|
<FileText size={16} className="text-zinc-400" /> E-Mail-Vorlagen
|
||||||
|
</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">
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={handleSave} 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">
|
||||||
|
{editing.id ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
|
||||||
|
</p>
|
||||||
|
<Field label="Name">
|
||||||
|
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="z. B. Angebot" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Betreff (optional)">
|
||||||
|
<input className={inputClass} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Text">
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !name.trim()}
|
||||||
|
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-1.5 text-[12.5px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="rounded-full px-4 py-1.5 text-[12.5px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startNew}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 rounded-2xl border border-dashed border-zinc-300 py-2.5 text-[12.5px] font-medium text-zinc-500 transition hover:border-indigo-300 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Neue Vorlage
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-zinc-400">Lade…</p>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-400">Noch keine Vorlagen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[12.5px] font-medium text-zinc-700 dark:text-zinc-300">{t.name}</p>
|
||||||
|
{t.subject && <p className="truncate text-[11px] text-zinc-400">{t.subject}</p>}
|
||||||
|
</div>
|
||||||
|
{onInsert && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onInsert(t)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700 transition hover:bg-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
Einfügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(t)}
|
||||||
|
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(t)}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Send, Undo2 } from 'lucide-react'
|
||||||
|
|
||||||
|
function nowSec() {
|
||||||
|
return Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UndoSendBanner({
|
||||||
|
deadline,
|
||||||
|
onUndo,
|
||||||
|
}: {
|
||||||
|
deadline: number
|
||||||
|
onUndo: () => void
|
||||||
|
}) {
|
||||||
|
const [remaining, setRemaining] = useState(() => Math.max(0, deadline - nowSec()))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(Math.max(0, deadline - nowSec()))
|
||||||
|
const interval = setInterval(() => setRemaining(Math.max(0, deadline - nowSec())), 500)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [deadline])
|
||||||
|
|
||||||
|
const label =
|
||||||
|
remaining > 90
|
||||||
|
? `Wird gesendet um ${new Date(deadline * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
: `Wird in ${remaining}s gesendet`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-toast-in fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-zinc-900/95 px-5 py-2.5 text-[13px] font-medium text-white shadow-[0_12px_35px_-8px_rgba(0,0,0,0.5)] backdrop-blur-xl dark:border-black/10 dark:bg-zinc-100/95 dark:text-zinc-900">
|
||||||
|
<Send size={14} className="shrink-0 text-zinc-400 dark:text-zinc-500" />
|
||||||
|
<span>{label}</span>
|
||||||
|
<button
|
||||||
|
onClick={onUndo}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-full bg-white/10 px-3 py-1 text-[12.5px] font-semibold transition hover:bg-white/20 dark:bg-black/10 dark:hover:bg-black/20"
|
||||||
|
>
|
||||||
|
<Undo2 size={13} /> Rückgängig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--color-indigo-500) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* thin, unobtrusive scrollbars that match the theme */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, currentColor 18%, transparent) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 28%, transparent);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overlay-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 10px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes row-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-overlay-in {
|
||||||
|
animation: overlay-in 180ms ease-out both;
|
||||||
|
}
|
||||||
|
.animate-panel-in {
|
||||||
|
animation: panel-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
.animate-toast-in {
|
||||||
|
animation: toast-in 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
.animate-row-in {
|
||||||
|
animation: row-in 240ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-body ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
.compose-body ol {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
.compose-body a {
|
||||||
|
color: var(--color-indigo-600);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.compose-body blockquote {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid color-mix(in srgb, currentColor 25%, transparent);
|
||||||
|
color: color-mix(in srgb, currentColor 65%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function parseAddressList(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => {
|
||||||
|
const match = s.match(/<([^>]+)>/)
|
||||||
|
return (match ? match[1] : s).trim().toLowerCase()
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
CaldavAccount,
|
||||||
|
ComposeMessage,
|
||||||
|
Contact,
|
||||||
|
ContactGroup,
|
||||||
|
DraftPayload,
|
||||||
|
Event,
|
||||||
|
Folder,
|
||||||
|
InviteInfo,
|
||||||
|
InviteResponse,
|
||||||
|
MessageFull,
|
||||||
|
MessageSummary,
|
||||||
|
NewAccount,
|
||||||
|
NewContact,
|
||||||
|
NewContactGroup,
|
||||||
|
NewEvent,
|
||||||
|
NewCaldavAccount,
|
||||||
|
NewRule,
|
||||||
|
NewTask,
|
||||||
|
NewTemplate,
|
||||||
|
NewVacationSettings,
|
||||||
|
Rule,
|
||||||
|
SnoozedMessage,
|
||||||
|
Task,
|
||||||
|
Template,
|
||||||
|
VacationSettings,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
addAccount: (newAccount: NewAccount) => invoke<Account>('add_account', { newAccount }),
|
||||||
|
listAccounts: () => invoke<Account[]>('list_accounts'),
|
||||||
|
deleteAccount: (accountId: string) => invoke<void>('delete_account', { accountId }),
|
||||||
|
updateAccount: (account: Account, newPassword?: string) =>
|
||||||
|
invoke<void>('update_account', { account, newPassword: newPassword || undefined }),
|
||||||
|
listFolders: (accountId: string) => invoke<Folder[]>('list_folders', { accountId }),
|
||||||
|
createFolder: (accountId: string, name: string) => invoke<void>('create_folder', { accountId, name }),
|
||||||
|
renameFolder: (accountId: string, from: string, to: string) => invoke<void>('rename_folder', { accountId, from, to }),
|
||||||
|
deleteFolder: (accountId: string, name: string) => invoke<void>('delete_folder', { accountId, name }),
|
||||||
|
markFolderRead: (accountId: string, folder: string) => invoke<number>('mark_folder_read', { accountId, folder }),
|
||||||
|
applyRulesToFolder: (accountId: string, folder: string) => invoke<number>('apply_rules_to_folder', { accountId, folder }),
|
||||||
|
syncMessages: (accountId: string, folder: string) =>
|
||||||
|
invoke<MessageSummary[]>('sync_messages', { accountId, folder }),
|
||||||
|
listCachedMessages: (accountId: string, folder: string) =>
|
||||||
|
invoke<MessageSummary[]>('list_cached_messages', { accountId, folder }),
|
||||||
|
getMessage: (accountId: string, folder: string, uid: number) =>
|
||||||
|
invoke<MessageFull>('get_message', { accountId, folder, uid }),
|
||||||
|
sendMessage: (message: ComposeMessage) => invoke<void>('send_message', { message }),
|
||||||
|
sendMessageAt: (message: ComposeMessage, sendAtTs: number) => invoke<string>('send_message_at', { message, sendAtTs }),
|
||||||
|
cancelScheduledSend: (id: string) => invoke<boolean>('cancel_scheduled_send', { id }),
|
||||||
|
markRead: (accountId: string, folder: string, uid: number, isRead: boolean) =>
|
||||||
|
invoke<void>('mark_read', { accountId, folder, uid, isRead }),
|
||||||
|
deleteMessage: (accountId: string, folder: string, uid: number) =>
|
||||||
|
invoke<void>('delete_message', { accountId, folder, uid }),
|
||||||
|
searchMessages: (accountId: string, query: string) =>
|
||||||
|
invoke<MessageSummary[]>('search_messages', { accountId, query }),
|
||||||
|
searchAllMessages: (query: string) => invoke<MessageSummary[]>('search_all_messages', { query }),
|
||||||
|
setMessageCategory: (accountId: string, folder: string, uid: number, category: string | null) =>
|
||||||
|
invoke<void>('set_message_category', { accountId, folder, uid, category }),
|
||||||
|
toggleFlag: (accountId: string, folder: string, uid: number, flagged: boolean) =>
|
||||||
|
invoke<void>('toggle_flag', { accountId, folder, uid, flagged }),
|
||||||
|
moveMessage: (accountId: string, folder: string, uid: number, targetFolder: string) =>
|
||||||
|
invoke<void>('move_message', { accountId, folder, uid, targetFolder }),
|
||||||
|
listContacts: () => invoke<Contact[]>('list_contacts'),
|
||||||
|
searchContacts: (query: string) => invoke<Contact[]>('search_contacts', { query }),
|
||||||
|
addContact: (newContact: NewContact) => invoke<Contact>('add_contact', { newContact }),
|
||||||
|
updateContact: (contact: Contact) => invoke<void>('update_contact', { contact }),
|
||||||
|
deleteContact: (contactId: string) => invoke<void>('delete_contact', { contactId }),
|
||||||
|
toggleContactFavorite: (contactId: string, favorite: boolean) =>
|
||||||
|
invoke<void>('toggle_contact_favorite', { contactId, favorite }),
|
||||||
|
exportContactsVcard: (path: string) => invoke<number>('export_contacts_vcard', { path }),
|
||||||
|
importContactsVcard: (path: string) => invoke<number>('import_contacts_vcard', { path }),
|
||||||
|
exportMessageEml: (accountId: string, folder: string, uid: number, path: string) =>
|
||||||
|
invoke<void>('export_message_eml', { accountId, folder, uid, path }),
|
||||||
|
exportFolderMbox: (accountId: string, folder: string, path: string) =>
|
||||||
|
invoke<number>('export_folder_mbox', { accountId, folder, path }),
|
||||||
|
exportFullBackup: (dir: string) => invoke<number>('export_full_backup', { dir }),
|
||||||
|
importEmlFile: (accountId: string, folder: string, path: string) =>
|
||||||
|
invoke<void>('import_eml_file', { accountId, folder, path }),
|
||||||
|
importMboxFile: (accountId: string, folder: string, path: string) =>
|
||||||
|
invoke<number>('import_mbox_file', { accountId, folder, path }),
|
||||||
|
saveDraft: (uid: number | null, message: ComposeMessage) =>
|
||||||
|
invoke<number>('save_draft', { uid, message }),
|
||||||
|
loadDraft: (uid: number) => invoke<DraftPayload>('load_draft', { uid }),
|
||||||
|
googleOAuthAddAccount: () => invoke<Account>('google_oauth_add_account'),
|
||||||
|
listRules: () => invoke<Rule[]>('list_rules'),
|
||||||
|
createRule: (newRule: NewRule) => invoke<Rule>('create_rule', { newRule }),
|
||||||
|
setRuleEnabled: (ruleId: string, enabled: boolean) => invoke<void>('set_rule_enabled', { ruleId, enabled }),
|
||||||
|
deleteRule: (ruleId: string) => invoke<void>('delete_rule', { ruleId }),
|
||||||
|
listEvents: (startTs: number, endTs: number) => invoke<Event[]>('list_events', { startTs, endTs }),
|
||||||
|
createEvent: (newEvent: NewEvent) => invoke<Event>('create_event', { newEvent }),
|
||||||
|
updateEvent: (event: Event) => invoke<void>('update_event', { event }),
|
||||||
|
deleteEvent: (eventId: string) => invoke<void>('delete_event', { eventId }),
|
||||||
|
updateEventOccurrence: (baseId: string, occurrenceStart: number, data: NewEvent) =>
|
||||||
|
invoke<Event>('update_event_occurrence', { baseId, occurrenceStart, data }),
|
||||||
|
deleteEventOccurrence: (baseId: string, occurrenceStart: number) =>
|
||||||
|
invoke<void>('delete_event_occurrence', { baseId, occurrenceStart }),
|
||||||
|
listCaldavAccounts: () => invoke<CaldavAccount[]>('list_caldav_accounts'),
|
||||||
|
addCaldavAccount: (newAccount: NewCaldavAccount) => invoke<CaldavAccount>('add_caldav_account', { newAccount }),
|
||||||
|
deleteCaldavAccount: (accountId: string) => invoke<void>('delete_caldav_account', { accountId }),
|
||||||
|
syncCaldavCalendar: (accountId: string) => invoke<number>('sync_caldav_calendar', { accountId }),
|
||||||
|
importIcsFile: (path: string) => invoke<number>('import_ics_file', { path }),
|
||||||
|
importIcsText: (text: string) => invoke<number>('import_ics_text', { text }),
|
||||||
|
previewInvite: (icsBase64: string) => invoke<InviteInfo | null>('preview_invite', { icsBase64 }),
|
||||||
|
replyToInvite: (accountId: string, icsBase64: string, response: InviteResponse) =>
|
||||||
|
invoke<void>('reply_to_invite', { accountId, icsBase64, response }),
|
||||||
|
getVacationSettings: (accountId: string) => invoke<VacationSettings>('get_vacation_settings', { accountId }),
|
||||||
|
saveVacationSettings: (accountId: string, settings: NewVacationSettings) =>
|
||||||
|
invoke<void>('save_vacation_settings', { accountId, settings }),
|
||||||
|
snoozeMessage: (accountId: string, folder: string, uid: number, untilTs: number) =>
|
||||||
|
invoke<void>('snooze_message', { accountId, folder, uid, untilTs }),
|
||||||
|
unsnoozeMessage: (accountId: string, folder: string, uid: number) =>
|
||||||
|
invoke<void>('unsnooze_message', { accountId, folder, uid }),
|
||||||
|
listSnoozedMessages: () => invoke<SnoozedMessage[]>('list_snoozed_messages'),
|
||||||
|
listTemplates: () => invoke<Template[]>('list_templates'),
|
||||||
|
createTemplate: (newTemplate: NewTemplate) => invoke<Template>('create_template', { newTemplate }),
|
||||||
|
updateTemplate: (template: Template) => invoke<void>('update_template', { template }),
|
||||||
|
deleteTemplate: (templateId: string) => invoke<void>('delete_template', { templateId }),
|
||||||
|
searchMessagesAdvanced: (
|
||||||
|
query: string,
|
||||||
|
from: string | undefined,
|
||||||
|
dateFrom: number | undefined,
|
||||||
|
dateTo: number | undefined,
|
||||||
|
hasAttachment: boolean,
|
||||||
|
) => invoke<MessageSummary[]>('search_messages_advanced', { query, from, dateFrom, dateTo, hasAttachment }),
|
||||||
|
exportEventsIcs: (path: string) => invoke<number>('export_events_ics', { path }),
|
||||||
|
getEvent: (eventId: string) => invoke<Event>('get_event', { eventId }),
|
||||||
|
listUpcomingReminders: (withinHours: number) => invoke<Event[]>('list_upcoming_reminders', { withinHours }),
|
||||||
|
syncGoogleCalendar: (accountId: string) => invoke<number>('sync_google_calendar', { accountId }),
|
||||||
|
syncGoogleContacts: (accountId: string) => invoke<number>('sync_google_contacts', { accountId }),
|
||||||
|
reconnectGoogleAccount: (accountId: string) => invoke<void>('reconnect_google_account', { accountId }),
|
||||||
|
setDesktopNotificationsEnabled: (enabled: boolean) =>
|
||||||
|
invoke<void>('set_desktop_notifications_enabled', { enabled }),
|
||||||
|
listTasks: () => invoke<Task[]>('list_tasks'),
|
||||||
|
createTask: (newTask: NewTask) => invoke<Task>('create_task', { newTask }),
|
||||||
|
updateTask: (task: Task) => invoke<void>('update_task', { task }),
|
||||||
|
deleteTask: (taskId: string) => invoke<void>('delete_task', { taskId }),
|
||||||
|
setTaskCompleted: (taskId: string, completed: boolean) =>
|
||||||
|
invoke<void>('set_task_completed', { taskId, completed }),
|
||||||
|
createTaskFromMessage: (accountId: string, folder: string, uid: number, title: string) =>
|
||||||
|
invoke<Task>('create_task_from_message', { accountId, folder, uid, title }),
|
||||||
|
listContactGroups: () => invoke<ContactGroup[]>('list_contact_groups'),
|
||||||
|
createContactGroup: (newGroup: NewContactGroup) => invoke<ContactGroup>('create_contact_group', { newGroup }),
|
||||||
|
updateContactGroup: (group: ContactGroup) => invoke<void>('update_contact_group', { group }),
|
||||||
|
deleteContactGroup: (groupId: string) => invoke<void>('delete_contact_group', { groupId }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface CategoryColor {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
hex: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_COLORS: CategoryColor[] = [
|
||||||
|
{ id: 'red', label: 'Rot', hex: '#ef4444' },
|
||||||
|
{ id: 'orange', label: 'Orange', hex: '#f97316' },
|
||||||
|
{ id: 'yellow', label: 'Gelb', hex: '#eab308' },
|
||||||
|
{ id: 'green', label: 'Grün', hex: '#22c55e' },
|
||||||
|
{ id: 'blue', label: 'Blau', hex: '#3b82f6' },
|
||||||
|
{ id: 'purple', label: 'Lila', hex: '#a855f7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function categoryHex(id: string | null | undefined): string | null {
|
||||||
|
if (!id) return null
|
||||||
|
return CATEGORY_COLORS.find((c) => c.id === id)?.hex ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { format, isThisYear, isToday } from 'date-fns'
|
||||||
|
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#6366f1', '#8b5cf6', '#ec4899', '#f59e0b',
|
||||||
|
'#10b981', '#06b6d4', '#3b82f6', '#ef4444',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function initials(name: string): string {
|
||||||
|
const clean = name.trim()
|
||||||
|
if (!clean) return '?'
|
||||||
|
const parts = clean.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarColor(seed: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatListDate(timestamp: number): string {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
if (isToday(date)) return format(date, 'HH:mm')
|
||||||
|
if (isThisYear(date)) return format(date, 'd. MMM')
|
||||||
|
return format(date, 'd. MMM yyyy')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullDate(rawDate: string): string {
|
||||||
|
const date = new Date(rawDate)
|
||||||
|
if (Number.isNaN(date.getTime())) return rawDate
|
||||||
|
return format(date, 'EEEE, d. MMMM yyyy, HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Protocol } from '../types'
|
||||||
|
|
||||||
|
export interface ProviderPreset {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
protocol: Protocol
|
||||||
|
incoming_host: string
|
||||||
|
incoming_port: number
|
||||||
|
smtp_host: string
|
||||||
|
smtp_port: number
|
||||||
|
smtp_starttls: boolean
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const providerPresets: ProviderPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'gmail',
|
||||||
|
label: 'Google Mail',
|
||||||
|
color: '#ea4335',
|
||||||
|
protocol: 'imap',
|
||||||
|
incoming_host: 'imap.gmail.com',
|
||||||
|
incoming_port: 993,
|
||||||
|
smtp_host: 'smtp.gmail.com',
|
||||||
|
smtp_port: 465,
|
||||||
|
smtp_starttls: false,
|
||||||
|
hint: 'Nutze ein App-Passwort (Google-Konto -> Sicherheit -> App-Passwörter), nicht dein normales Passwort.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlook',
|
||||||
|
label: 'Outlook / Microsoft 365',
|
||||||
|
color: '#0078d4',
|
||||||
|
protocol: 'imap',
|
||||||
|
incoming_host: 'outlook.office365.com',
|
||||||
|
incoming_port: 993,
|
||||||
|
smtp_host: 'smtp.office365.com',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_starttls: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yahoo',
|
||||||
|
label: 'Yahoo Mail',
|
||||||
|
color: '#6001d2',
|
||||||
|
protocol: 'imap',
|
||||||
|
incoming_host: 'imap.mail.yahoo.com',
|
||||||
|
incoming_port: 993,
|
||||||
|
smtp_host: 'smtp.mail.yahoo.com',
|
||||||
|
smtp_port: 465,
|
||||||
|
smtp_starttls: false,
|
||||||
|
hint: 'Erfordert ein App-Passwort aus den Yahoo-Kontoeinstellungen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'icloud',
|
||||||
|
label: 'iCloud Mail',
|
||||||
|
color: '#3693f3',
|
||||||
|
protocol: 'imap',
|
||||||
|
incoming_host: 'imap.mail.me.com',
|
||||||
|
incoming_port: 993,
|
||||||
|
smtp_host: 'smtp.mail.me.com',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_starttls: true,
|
||||||
|
hint: 'Erfordert ein app-spezifisches Passwort von appleid.apple.com.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-imap',
|
||||||
|
label: 'Custom IMAP',
|
||||||
|
color: '#64748b',
|
||||||
|
protocol: 'imap',
|
||||||
|
incoming_host: '',
|
||||||
|
incoming_port: 993,
|
||||||
|
smtp_host: '',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_starttls: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-pop3',
|
||||||
|
label: 'Custom POP3',
|
||||||
|
color: '#64748b',
|
||||||
|
protocol: 'pop3',
|
||||||
|
incoming_host: '',
|
||||||
|
incoming_port: 995,
|
||||||
|
smtp_host: '',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_starttls: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function htmlIsEmpty(html: string): boolean {
|
||||||
|
return !html || html.replace(/<[^>]*>/g, '').trim().length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]!)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface AppSettings {
|
||||||
|
desktop: boolean
|
||||||
|
popup: boolean
|
||||||
|
composePopup: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'mailclient.notifySettings'
|
||||||
|
|
||||||
|
const DEFAULTS: AppSettings = { desktop: true, popup: true, composePopup: false }
|
||||||
|
|
||||||
|
export function loadAppSettings(): AppSettings {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return DEFAULTS
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AppSettings>
|
||||||
|
return {
|
||||||
|
desktop: parsed.desktop ?? DEFAULTS.desktop,
|
||||||
|
popup: parsed.popup ?? DEFAULTS.popup,
|
||||||
|
composePopup: parsed.composePopup ?? DEFAULTS.composePopup,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return DEFAULTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAppSettings(v: AppSettings) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(v))
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export interface Signature {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountSignatures {
|
||||||
|
signatures: Signature[]
|
||||||
|
defaultId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'mailclient.signatures.v2'
|
||||||
|
const LEGACY_KEY = 'mailclient.signatures'
|
||||||
|
|
||||||
|
function migrateLegacy(): Record<string, AccountSignatures> {
|
||||||
|
const out: Record<string, AccountSignatures> = {}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LEGACY_KEY)
|
||||||
|
if (!raw) return out
|
||||||
|
const legacy = JSON.parse(raw) as Record<string, string>
|
||||||
|
for (const [accountId, body] of Object.entries(legacy)) {
|
||||||
|
if (!body.trim()) continue
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
out[accountId] = { signatures: [{ id, name: 'Standard', body }], defaultId: id }
|
||||||
|
}
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(out))
|
||||||
|
} catch {
|
||||||
|
// ignore malformed legacy data
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAll(): Record<string, AccountSignatures> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return migrateLegacy()
|
||||||
|
return JSON.parse(raw) as Record<string, AccountSignatures>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAll(all: Record<string, AccountSignatures>) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSignatures(accountId: string): Signature[] {
|
||||||
|
return loadAll()[accountId]?.signatures ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSignature(accountId: string): Signature | null {
|
||||||
|
const acc = loadAll()[accountId]
|
||||||
|
if (!acc || acc.signatures.length === 0) return null
|
||||||
|
return acc.signatures.find((s) => s.id === acc.defaultId) ?? acc.signatures[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSignatureId(accountId: string): string | null {
|
||||||
|
return loadAll()[accountId]?.defaultId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSignatureList(accountId: string, signatures: Signature[], defaultId: string | null) {
|
||||||
|
const all = loadAll()
|
||||||
|
if (signatures.length === 0) {
|
||||||
|
delete all[accountId]
|
||||||
|
} else {
|
||||||
|
const resolvedDefault = signatures.some((s) => s.id === defaultId) ? defaultId : signatures[0].id
|
||||||
|
all[accountId] = { signatures, defaultId: resolvedDefault }
|
||||||
|
}
|
||||||
|
saveAll(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-compat helper: the single "signature text" a fresh compose should
|
||||||
|
/// start with — the account's default signature body, or empty.
|
||||||
|
export function loadSignature(accountId: string): string {
|
||||||
|
return getDefaultSignature(accountId)?.body ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the body of the account's default signature (creating a "Standard"
|
||||||
|
/// signature if none exists yet), leaving any other named signatures intact.
|
||||||
|
/// Used by the simple single-field signature editor in account settings.
|
||||||
|
export function saveSignature(accountId: string, body: string) {
|
||||||
|
const existing = listSignatures(accountId)
|
||||||
|
const defaultId = getDefaultSignatureId(accountId)
|
||||||
|
const current = existing.find((s) => s.id === defaultId) ?? existing[0]
|
||||||
|
if (current) {
|
||||||
|
const next = existing.map((s) => (s.id === current.id ? { ...s, body } : s))
|
||||||
|
saveSignatureList(accountId, next, current.id)
|
||||||
|
} else if (body.trim()) {
|
||||||
|
const sig: Signature = { id: crypto.randomUUID(), name: 'Standard', body }
|
||||||
|
saveSignatureList(accountId, [sig], sig.id)
|
||||||
|
}
|
||||||
|
}
|
||||||