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