commit 1d3818e725d04ad4d3048d63372358bf1709ec27 Author: Karim Gabriele Varano Date: Tue Jun 2 02:26:28 2026 +0200 docker-mailserver LXC für Proxmox: Stack + Admin-UI + Webmail + Hardening - dms-lxc.sh: Proxmox-Host-Installer (unprivilegierter LXC, Debian 13, Docker), curl-Self-Download, Multi-Domain-DKIM, SnappyMail-Provisionierung, PVE-Firewall - Stack: docker-mailserver, Node-Admin-API (Supabase-Auth), React-Admin-UI (OPENBUREAU-Look), SnappyMail (Shibui-Theme), Rspamd-Web-UI, docker-socket-proxy - Admin: Postfächer/Aliase/Catch-all/Quota, editierbare Domains+Settings, Server (Quota/Queue über abgesicherte Bridge), Status & DNS - Hardening: no-new-privileges, Whitelisted exec-Bridge, Rspamd-Passwort, .env chmod 600, PVE-CT-Firewall, generisch/teilbar (keine festen Domains) Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0422973 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Secrets & Laufzeitdaten +stack/.env +stack/docker-data/ +**/node_modules/ +**/dist/ + +# OS / Editor +.DS_Store +*.log + +# lokale Test-Compose (privat) – Beispiel: docker-compose.local.example.yml +docker-compose.local.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..b763592 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# docker-mailserver auf Proxmox (LXC) — mit React-Admin UI & SnappyMail + +Ein vollständiges, selbst gehostetes Mail-Setup für ein kleines Büro (4–5 Personen): + +| Komponente | Aufgabe | +|---|---| +| **docker-mailserver (DMS)** | Postfix + Dovecot + Rspamd (SMTP/IMAP, Spam, DKIM) | +| **Admin-API** (Node.js) | verwaltet Postfächer/Aliase/Quotas über die DMS-Config-Dateien — Auth via Supabase | +| **Admin-UI** (React-Admin) | Weboberfläche im Stil von OPENBUREAU | +| **SnappyMail** | schlankes Webmail für die Mitarbeiter | +| **Nginx Proxy Manager** *(bereits vorhanden)* | HTTPS/Let's-Encrypt für Admin-UI & Webmail | + +Das Ganze läuft in **einem unprivilegierten LXC-Container** (Debian 13) auf Proxmox, in dem Docker mit `nesting=1` betrieben wird. + +``` + Internet + │ + Port 25/465/587/ │ Port 80/443 + 143/993 ──────► ▼ ──────► Nginx Proxy Manager (HTTPS) + direkt zum DMS │ + (Mail-TLS im DMS) ├─► admin.example.com → Admin-UI (:8080) + └─► mail.example.com → SnappyMail (:8888) +``` + +--- + +## 1. Voraussetzungen + +- **Proxmox VE** (8.x), du arbeitest als `root` auf dem Host. +- Eine **Domain** (`example.com`) mit Zugriff auf die DNS-Einstellungen. +- **Port 25 ausgehend/eingehend** beim Hoster/ISP freigegeben (viele Privatanschlüsse blockieren 25!) und eine **statische öffentliche IP** mit der Möglichkeit, einen **PTR/rDNS** zu setzen. +- Eine **Supabase-Instanz** (dieselbe wie bei OPENBUREAU) für das Admin-Login. +- Der bereits vorhandene **Nginx Proxy Manager**. + +--- + +## 2. Installation + +**Variante A — Einzeiler** (auf dem Proxmox-Host als root). Das Skript lädt den +`stack/` bei Bedarf selbst herunter: + +```bash +bash <(curl -fsSL https://git.kgva.ch/karim/DOCKERMAILSERVER-LXC/raw/branch/main/dms-lxc.sh) +# privates Repo: GIT_TOKEN= bash <(curl -fsSL .../dms-lxc.sh) +``` + +**Variante B — Ordner kopieren** (inkl. `stack/`) und ausführen: + +```bash +cd /root/dms && bash dms-lxc.sh +``` + +Das Skript fragt **alles interaktiv ab** (Domains, Webmail-/Admin-Domain, Brand, +Supabase, NPM-IP …) — oder per ENV vorbelegen: + +```bash +CTID=110 \ +MAIL_FQDN=mail.example.com MAIL_DOMAIN=example.com MAIL_DOMAINS="example.com weitere.tld" \ +BRAND="Mein Büro" WEBMAIL_FQDN=mail.example.com ADMIN_FQDN=admin.example.com \ +NET_IP=192.168.1.50/24 NET_GW=192.168.1.1 \ +NPM_IP=192.168.1.10 \ +ADMIN_ALLOWED_EMAILS="admin@example.com" \ +SUPABASE_URL=https://xxxx.supabase.co SUPABASE_ANON_KEY=eyJ... \ +bash dms-lxc.sh +``` + +> **Generisch & teilbar:** Es sind **keine festen Domains** im Stack — alles kommt aus dem +> Dialog und ist später in der Admin-UI editierbar. Du kannst das Skript also weitergeben. + +Es macht automatisch: +1. unprivilegierten LXC (Debian 13, `nesting=1,keyctl=1`) anlegen + **PVE-CT-Firewall**, +2. Docker installieren, +3. **Self-signed-Cert + erstes Postfach vor-seeden**, dann den Stack bauen & starten, +4. **DKIM je Domain** erzeugen, SnappyMail provisionieren (Domain→mailserver, Shibui-Theme), +5. am Ende **DNS-Records (alle Domains, inkl. DKIM)** + **Rspamd-Passwort** + Hardening-Checkliste ausgeben. + +> **Wichtige Variablen** (oben im Skript / per ENV): `CORES`, `RAM_MB` (4096), `DISK_GB` (20), +> `STORAGE`, `BRIDGE`, `ADMIN_PORT`/`WEBMAIL_PORT`/`RSPAMD_PORT`, `NPM_IP`, `HARDEN_FIREWALL`, `ENABLE_CLAMAV`. + +Verwaltung später: + +```bash +pct enter 110 +cd /opt/dms-stack +docker compose ps +docker compose logs -f mailserver +``` + +--- + +## 3. DNS einrichten + +**Ein Mailserver, mehrere Domains:** Es gibt **einen** Mailhost (`mail.kgva.ch`), der die Postfächer +**aller** Domains bedient (kgva.ch, gabrielevarano.ch, karimgabrielevarano.xyz, openbureau.ch …). +Alle Clients/Webmail verbinden sich immer zu `mail.kgva.ch` — egal welche Adress-Domain. + +**Einmalig für den Mailhost** (`` = öffentliche IP): + +| Typ | Name | Wert | +|---|---|---| +| A | `mail.kgva.ch.` | `` | +| **PTR/rDNS** | `` | `mail.kgva.ch` — **beim Hoster/ISP setzen!** | + +**Pro Mail-Domain** (Beispiel `kgva.ch` — analog für jede weitere Domain): + +| Typ | Name | Wert | +|---|---|---| +| MX | `kgva.ch.` | `10 mail.kgva.ch.` | +| TXT (SPF) | `kgva.ch.` | `v=spf1 mx ~all` | +| TXT (DMARC) | `_dmarc.kgva.ch.` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@kgva.ch` | +| TXT (DKIM) | je Domain eigener Key — siehe Skript-Ausgabe / Admin-UI → **Status & DNS** | (langer Public-Key) | + +> Jede Domain hat einen **eigenen DKIM-Schlüssel**. Die fertigen Records (alle Domains) findest du +> nach dem Setup in der Admin-UI unter **Status & DNS** zum Kopieren — oder per Skript-Ausgabe. +> Weitere Domain später hinzufügen: `MAIL_DOMAINS` in `.env` ergänzen, Konto anlegen, +> `docker exec mailserver setup config dkim domain neue-domain.tld`, DNS setzen. + +Der **PTR-Record** ist entscheidend dafür, dass deine Mails nicht als Spam landen. + +DKIM später erneut anzeigen: +```bash +pct enter 110 +cat /opt/dms-stack/docker-data/dms/config/rspamd/dkim/*.dns.txt +``` + +Prüfen lassen kannst du alles bequem über **[mail-tester.com](https://www.mail-tester.com)** und **[dkimvalidator.com](https://dkimvalidator.com)**. + +--- + +## 4. Port-Weiterleitung / Firewall + +Diese Ports müssen von außen zum Container (``) gelangen: + +| Port | Zweck | +|---|---| +| 25 | SMTP (eingehende Mails von anderen Servern) | +| 587 / 465 | Mail-Versand der Mitarbeiter (Submission) | +| 143 / 993 | IMAP (Mailabruf) | + +Falls der Container hinter NAT liegt, am Router entsprechend weiterleiten. +Die Web-Ports **80/443** gehen an den **Nginx Proxy Manager**, nicht direkt an den Container. + +--- + +## 5. Supabase-Login einrichten (Admin-UI) + +Die Admin-UI nutzt Supabase-Auth (wie OPENBUREAU): + +1. In deinem Supabase-Projekt unter **Authentication → Users** einen Benutzer mit deiner E-Mail anlegen (oder Einladung). +2. Diese E-Mail muss in `ADMIN_ALLOWED_EMAILS` stehen (in `/opt/dms-stack/.env`). +3. `SUPABASE_URL` und `SUPABASE_ANON_KEY` in `.env` müssen gesetzt sein. + +Änderungen an der `.env` aktiv machen: +```bash +cd /opt/dms-stack +docker compose up -d # liest .env neu (UI/-API neu starten) +docker compose restart admin-ui admin-api +``` + +> Nur E-Mails aus `ADMIN_ALLOWED_EMAILS` erhalten Zugriff — alle anderen Supabase-User werden von der API mit 403 abgewiesen. + +--- + +## 6. Nginx Proxy Manager (HTTPS für Web) + +Im NPM zwei **Proxy Hosts** anlegen: + +| Domain | Forward Hostname/IP | Port | SSL | +|---|---|---|---| +| `admin.example.com` | `` | `8080` | Let's Encrypt anfordern, „Force SSL" | +| `mail.example.com` | `` | `8888` | Let's Encrypt anfordern, „Force SSL" | + +Für SnappyMail unter **Advanced** ggf. „Websockets Support" aktivieren. + +--- + +## 7. TLS für den Mailserver (Port 465/587/993) + +Mail-Protokolle laufen **nicht** über NPM, der Mailserver braucht also ein eigenes Zertifikat. +DMS ist auf **`SSL_TYPE=manual`** mit festem Pfad konfiguriert: + +``` +/opt/dms-stack/docker-data/certs/cert.pem (Zertifikat / fullchain) +/opt/dms-stack/docker-data/certs/key.pem (privater Schlüssel) +``` + +Das Setup-Skript legt dort beim ersten Start automatisch ein **self-signed** Zertifikat ab, +damit STARTTLS sofort funktioniert (Clients warnen wegen Selbstsignierung — für interne +Tests okay). **Für ein echtes Let's-Encrypt-Zertifikat einfach diese zwei Dateien ersetzen:** + +1. In NPM unter **SSL Certificates** ein Zertifikat für `mail.kgva.ch` via **DNS-Challenge** + anfordern (funktioniert auch ohne offenen Port 80). +2. Dessen `fullchain.pem` → `docker-data/certs/cert.pem` und `privkey.pem` → `docker-data/certs/key.pem` kopieren. +3. `cd /opt/dms-stack && docker compose restart mailserver` — **keine Config-Änderung nötig**, der Pfad bleibt gleich. + +> Bei Erneuerung (alle ~90 Tage) die beiden Dateien neu kopieren und `docker compose restart mailserver`. +> Das lässt sich per Cron/Hook automatisieren. Alternativ `acme.sh`/certbot direkt im LXC (DNS-Plugin) +> und die Cert-Dateien an dieselben Pfade schreiben. + +--- + +## 8. Admin-UI benutzen + +`https://admin.example.com` öffnen → mit Supabase-E-Mail/Passwort einloggen. + +- **Übersicht (Dashboard)**: Zähler (Postfächer/Aliase/Domains), Domains, Schnell-Links. +- **Postfächer**: Konten anlegen/löschen, Passwort ändern, **Quota** setzen (z.B. `5G`, leer = unbegrenzt). Validierung von E-Mail/Quota/Passwortlänge. +- **Aliase**: Weiterleitungen, z.B. `info@example.com` → `chef@example.com` (mehrere Ziele mit Komma). + **Catch-all**: als Quelle `@example.com` eintragen → fängt alle unbekannten Adressen der Domain. +- **Einstellungen**: **Domains hinzufügen/entfernen** (Hinzufügen erzeugt automatisch den DKIM-Key), + Brand, Webmail-/Admin-Domain und Mailserver-FQDN bearbeiten. (Erst-Befüllung aus dem Deploy-Dialog.) +- **Server**: **Quota-Auslastung** (belegt/frei je Postfach), **Mail-Queue**, **aktive Sessions**. + Läuft über eine abgesicherte Bridge (docker-socket-proxy, nur `exec`, Whitelist). +- **Status & DNS**: pro Domain MX/SPF/DMARC + DKIM zum Kopieren, „DKIM erzeugen/erneuern"-Button. + +Konten/Aliase werden direkt in die DMS-Config-Dateien geschrieben; DMS übernimmt Änderungen automatisch. + +--- + +## 9. SnappyMail (Webmail) einrichten + +1. `https://mail.example.com` öffnen. +2. Admin-Panel beim ersten Start: `https://mail.example.com/?admin` — das Admin-Passwort steht in + `/opt/dms-stack/docker-data/snappymail/_data_/_default_/admin_password.txt`. +3. Unter **Domains** die Domain `example.com` hinzufügen: + - IMAP: `mailserver` Port `993` (SSL/TLS) — innerhalb des Docker-Netzes per Containername erreichbar, + extern `mail.example.com` Port `993`. + - SMTP: Port `587` (STARTTLS). +4. Mitarbeiter loggen sich dann mit ihrer vollen E-Mail-Adresse + Passwort ein. + +### KGVA-Theme („Shibui") + +SnappyMail wird im selben Look wie das KGVA-Nextcloud-Theme ausgeliefert. Das Theme liegt unter +`stack/snappymail-theme/Shibui/styles.css` und wird per Volume nach `/snappymail/themes/` gemountet — +es erscheint in SnappyMail als **`Shibui@custom`**. + +Aktivieren (eine der beiden Varianten): +- **Für alle (empfohlen):** Admin-Panel (`https://mail.example.com/?admin`) → **Themes** → Standard-Theme + auf `Shibui` setzen. Alternativ in `data/_data_/_default_/configs/application.ini` unter `[webmail]` + `theme = "Shibui@custom"` eintragen und `docker compose restart snappymail`. +- **Pro Nutzer:** Webmail → **Einstellungen → Allgemein → Theme → Shibui**. + +> Anpassen: einfach `styles.css` editieren und `docker compose restart snappymail`. Die Farb-/Font-Variablen +> stehen gesammelt im `:root`-Block oben (Petrol-Akzent `#7BA89B`, washi-Neutraltöne, Inter/Instrument Serif/JetBrains Mono). +> Hinweis: Die Fonts werden aktuell von Google Fonts geladen; auf Wunsch hoste ich sie lokal (DSGVO). + +--- + +## 10. Mail-Client-Einstellungen (für Outlook/Thunderbird/Apple Mail) + +| Einstellung | Wert | +|---|---| +| Posteingang (IMAP) | `mail.example.com`, Port **993**, SSL/TLS | +| Postausgang (SMTP) | `mail.example.com`, Port **587** (STARTTLS) oder **465** (SSL) | +| Benutzername | volle E-Mail-Adresse | +| Authentifizierung | normales Passwort | + +--- + +## 11. Backup + +Alle Daten liegen unter `/opt/dms-stack/docker-data/`: + +```bash +pct enter 110 +cd /opt/dms-stack +docker compose down +tar czf /root/dms-backup-$(date +%F).tar.gz docker-data .env mailserver.env +docker compose up -d +``` + +Zusätzlich empfiehlt sich ein **Proxmox-Backup (vzdump)** des ganzen LXC sowie für Postfächer ggf. eine Offsite-Kopie von `docker-data/dms/mail-data/`. + +--- + +## 12. Troubleshooting + +```bash +pct enter 110 && cd /opt/dms-stack + +docker compose ps # Status aller Container +docker compose logs -f mailserver # Mail-Logs +docker exec mailserver setup email list # Postfächer auflisten (CLI) +docker exec mailserver postqueue -p # Mail-Queue ansehen +docker exec mailserver setup debug fetchmail # Debug +``` + +**Häufige Stolperfallen:** +- **Mails kommen nicht raus / landen im Spam:** Port 25 vom ISP geblockt, fehlender PTR, oder DKIM/SPF/DMARC nicht gesetzt → `mail-tester.com` nutzen. +- **Docker startet im LXC nicht:** `nesting=1,keyctl=1` prüfen (`pct config 110`); bei Bedarf `pct set 110 --features nesting=1,keyctl=1` und Container neu starten. +- **Admin-UI „Token ungültig":** E-Mail steht nicht in `ADMIN_ALLOWED_EMAILS`, oder `SUPABASE_URL/ANON_KEY` falsch. +- **Admin-UI lädt, aber Login schlägt fehl:** `config.js` prüfen → `curl http://:8080/config.js` sollte deine Supabase-URL zeigen; sonst `docker compose up -d admin-ui`. + +--- + +## 13. Spam-Filter (Rspamd) + +docker-mailserver bringt **Rspamd** mit (aktiviert: Bayes, SPF/DKIM/DMARC-Checks, Greylisting, RBLs) — +ein sehr guter Spam-Filter ohne Extra-Infrastruktur. Die **Web-UI** (Statistik/Training) läuft auf Port +`11334` und ist **passwortgeschützt** (Passwort wird beim Deploy generiert und ausgegeben; steht in +`docker-data/dms/config/rspamd/override.d/worker-controller.inc`). + +- Im NPM als Proxy-Host `rspamd.` → `:11334` (HTTPS). +- Passwort ändern: Datei editieren (`password` / `enable_password`) + `docker compose restart mailserver`. +- Optional härter hashen: `docker exec mailserver rspamadm pw` → Hash statt Klartext eintragen. + +> Für größere Setups/dedizierte Quarantäne kann man optional ein **Proxmox Mail Gateway** davorschalten — für ein kleines Büro ist Rspamd i.d.R. ausreichend. + +--- + +## 14. Hardening + +**Schon eingebaut:** +- **Unprivilegierter LXC** + Nesting; Container mit `no-new-privileges` (admin-api, admin-ui, snappymail, socket-proxy). +- **Mailserver-Bridge** nur über **docker-socket-proxy** (ausschließlich `exec`) + Kommando-Whitelist in der API — **kein** roher Docker-Socket an die App. +- **Rspamd-UI** passwortgeschützt; **Fail2ban** aktiv (`cap_add: NET_ADMIN`); DMS kein Open-Relay (`PERMIT_DOCKER=none`, `SPOOF_PROTECTION=1`). +- Admin nur für E-Mails aus `ADMIN_ALLOWED_EMAILS` (Supabase). **`.env` ist `chmod 600`**. +- **Proxmox-CT-Firewall**: Mail-Ports offen, Web/Admin/Webmail/Rspamd nur von `NPM_IP`. Wirkt, sobald die **Datacenter/Node-Firewall aktiv** ist (`Datacenter → Firewall → Options → Enable: Yes`). + +**Vor dem Echtbetrieb noch erledigen:** +- [ ] **Echtes TLS** für Mail: NPM-Zertifikat (DNS-Challenge) → `docker-data/certs/cert.pem|key.pem`, `restart mailserver` (ersetzt self-signed). +- [ ] Admin-UI/Webmail/Rspamd **nur über NPM (HTTPS)** erreichbar machen — rohe Ports nicht ins Internet; `NPM_IP` setzen. +- [ ] **Supabase-Login** mit 2FA absichern; starke Postfach-Passwörter. +- [ ] **Backups** (vzdump + `docker-data/`), siehe Abschnitt 11. +- [ ] **PTR/Reverse-DNS** auf `mail.` setzen; **Port 25** beim ISP prüfen (sonst Smarthost/`RELAY_HOST`). +- [ ] **DMARC** nach der Testphase auf `p=reject` verschärfen. + +--- + +## Projektstruktur + +``` +. +├── dms-lxc.sh # Proxmox-Host-Skript (LXC anlegen + Stack deployen) +├── README.md +└── stack/ # wird in den Container nach /opt/dms-stack kopiert + ├── docker-compose.yml + ├── .env.example + ├── mailserver.env + ├── api/ # Node.js Admin-API (Supabase-Auth) + │ ├── server.js + │ └── lib/{store,auth}.js + └── admin/ # React-Admin UI (Vite → nginx) + └── src/{App,Layout,Status,authProvider,dataProvider}.jsx ... +``` diff --git a/dms-lxc.sh b/dms-lxc.sh new file mode 100644 index 0000000..c91741b --- /dev/null +++ b/dms-lxc.sh @@ -0,0 +1,463 @@ +#!/usr/bin/env bash +# ============================================================================= +# docker-mailserver (DMS) als LXC auf Proxmox VE +# ----------------------------------------------------------------------------- +# Dieses Skript wird AUF DEM PROXMOX-HOST als root ausgeführt. +# Es legt einen UNPRIVILEGIERTEN LXC-Container (Debian 13) mit Nesting an, +# installiert Docker und deployt den kompletten Stack via docker compose: +# docker-mailserver + Admin-API + React-Admin-UI + SnappyMail. +# +# Der Ordner stack/ muss neben diesem Skript liegen. +# +# Aufruf: bash dms-lxc.sh +# oder: CTID=110 MAIL_FQDN=mail.example.com bash dms-lxc.sh +# ============================================================================= +set -Eeuo pipefail + +# --------------------------------------------------------------------------- +# Standard-Einstellungen (per ENV oder interaktiv überschreibbar) +# --------------------------------------------------------------------------- +CTID="${CTID:-}" # leer => nächste freie ID +HOSTNAME="${HOSTNAME:-mailserver}" # LXC-Hostname (Anzeigename) +MAIL_FQDN="${MAIL_FQDN:-}" # z.B. mail.example.com (PFLICHT) +MAIL_DOMAIN="${MAIL_DOMAIN:-}" # primäre Domain, z.B. kgva.ch (leer => aus FQDN abgeleitet) +MAIL_DOMAINS="${MAIL_DOMAINS:-}" # ALLE Mail-Domains (Leerzeichen-getrennt); leer => nur MAIL_DOMAIN +FIRST_EMAIL="${FIRST_EMAIL:-}" # erstes Postfach, z.B. admin@example.com +FIRST_PASSWORD="${FIRST_PASSWORD:-}" # Passwort für das erste Postfach + +# Admin-UI / Webmail / Supabase +ADMIN_PORT="${ADMIN_PORT:-8080}" # Port der Admin-UI (NPM zeigt hierauf) +WEBMAIL_PORT="${WEBMAIL_PORT:-8888}" # Port von SnappyMail (NPM zeigt hierauf) +RSPAMD_PORT="${RSPAMD_PORT:-11334}" # Rspamd Web-UI (NPM zeigt hierauf) +ADMIN_ALLOWED_EMAILS="${ADMIN_ALLOWED_EMAILS:-}" # Komma-getrennte Admin-E-Mails (Supabase-Login) +SUPABASE_URL="${SUPABASE_URL:-}" # https://xxxx.supabase.co +SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY:-}" # Supabase anon/public key + +# Branding / Web-Domains (Erst-Befüllung, später in der Admin-UI editierbar) +BRAND="${BRAND:-}" # Anzeigename im Dashboard +WEBMAIL_FQDN="${WEBMAIL_FQDN:-}" # Webmail-Domain (NPM) +ADMIN_FQDN="${ADMIN_FQDN:-}" # Admin-UI-Domain (NPM) + +# Hardening +NPM_IP="${NPM_IP:-}" # IP des Nginx Proxy Manager; schränkt Web-Ports per PVE-Firewall darauf ein (leer = offen, mit Warnung) +HARDEN_FIREWALL="${HARDEN_FIREWALL:-1}" # 1 = Proxmox-CT-Firewall-Regeln schreiben +RSPAMD_PASSWORD="${RSPAMD_PASSWORD:-}" # leer => wird generiert + +# Container-Ressourcen (5 Container + Rspamd -> 4 GB empfohlen) +CORES="${CORES:-2}" +RAM_MB="${RAM_MB:-4096}" # 4 GB; mit ClamAV besser 6144+ +DISK_GB="${DISK_GB:-20}" # nach erwartetem Mailvolumen wählen +SWAP_MB="${SWAP_MB:-512}" + +# Storage / Template / Netz +OS_TEMPLATE="${OS_TEMPLATE:-debian-13-standard}" # Debian 13 (Trixie); Fallback auf 12 automatisch +STORAGE="${STORAGE:-local-lvm}" # Storage für rootfs +TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}" # Storage für CT-Template +BRIDGE="${BRIDGE:-vmbr0}" +NET_IP="${NET_IP:-dhcp}" # "dhcp" oder z.B. "192.168.1.50/24" +NET_GW="${NET_GW:-}" # Gateway, nur bei statischer IP +TIMEZONE="${TIMEZONE:-Europe/Zurich}" + +# Funktionen / Optionen +ENABLE_CLAMAV="${ENABLE_CLAMAV:-0}" # 1 = Virenscan (braucht ~1GB RAM extra) +ENABLE_FAIL2BAN="${ENABLE_FAIL2BAN:-1}" +DMS_IMAGE="${DMS_IMAGE:-ghcr.io/docker-mailserver/docker-mailserver:latest}" + +# --------------------------------------------------------------------------- +# Hübsche Ausgabe +# --------------------------------------------------------------------------- +RD=$'\033[01;31m'; GN=$'\033[1;92m'; YW=$'\033[33m'; BL=$'\033[1;34m'; CL=$'\033[m' +msg_info() { echo -e " ${YW}•${CL} $1"; } +msg_ok() { echo -e " ${GN}✔${CL} $1"; } +msg_err() { echo -e " ${RD}✖${CL} $1" >&2; } +die() { msg_err "$1"; exit 1; } +trap 'msg_err "Fehler in Zeile $LINENO. Abbruch."' ERR + +# --------------------------------------------------------------------------- +# Pre-Flight Checks +# --------------------------------------------------------------------------- +[[ $EUID -eq 0 ]] || die "Bitte als root auf dem Proxmox-Host ausführen." +command -v pct >/dev/null 2>&1 || die "'pct' nicht gefunden – läuft das Skript wirklich auf einem Proxmox-Host?" +command -v pveam >/dev/null 2>&1 || die "'pveam' nicht gefunden – kein Proxmox VE?" +command -v openssl >/dev/null 2>&1 || die "'openssl' nicht gefunden (für Passwort-Hash) – 'apt install openssl'." +command -v curl >/dev/null 2>&1 || die "'curl' nicht gefunden – 'apt install curl'." + +# --------------------------------------------------------------------------- +# Stack-Ordner: neben dem Skript ODER per curl-Einzeiler selbst herunterladen +# curl-Einzeiler: bash <(curl -fsSL /dms-lxc.sh) +# Privates Repo: GIT_TOKEN= bash <(curl ... ) +# --------------------------------------------------------------------------- +REPO_ARCHIVE="${REPO_ARCHIVE:-https://git.kgva.ch/karim/DOCKERMAILSERVER-LXC/archive/main.tar.gz}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo /tmp)" +STACK_DIR="${STACK_DIR:-$SCRIPT_DIR/stack}" +if [[ ! -f "$STACK_DIR/docker-compose.yml" ]]; then + msg_info "stack/ nicht gefunden – lade Repo von $REPO_ARCHIVE ..." + DL="$(mktemp -d)" + AUTH=(); [[ -n "${GIT_TOKEN:-}" ]] && AUTH=(-H "Authorization: token ${GIT_TOKEN}") + curl -fsSL "${AUTH[@]}" "$REPO_ARCHIVE" -o "$DL/repo.tar.gz" || die "Download fehlgeschlagen (privates Repo? GIT_TOKEN setzen)." + tar -xzf "$DL/repo.tar.gz" -C "$DL" || die "Entpacken fehlgeschlagen." + FOUND="$(find "$DL" -maxdepth 4 -type f -name docker-compose.yml -path '*/stack/*' 2>/dev/null | head -1)" + [[ -n "$FOUND" ]] || die "stack/ im heruntergeladenen Archiv nicht gefunden." + STACK_DIR="$(dirname "$FOUND")" + msg_ok "Repo geladen, stack/ unter $STACK_DIR" +fi +[[ -f "$STACK_DIR/docker-compose.yml" ]] || die "stack/docker-compose.yml nicht gefunden unter $STACK_DIR" + +# --------------------------------------------------------------------------- +# Interaktive Abfragen (nur wenn nicht per ENV gesetzt) +# --------------------------------------------------------------------------- +ask() { local p="$1" d="${2:-}" v; read -rp "$(echo -e "${BL}?${CL} $p ${d:+[$d] }")" v; echo "${v:-$d}"; } + +if [[ -z "$CTID" ]]; then CTID="$(pvesh get /cluster/nextid)"; fi +CTID="$(ask "Container-ID (CTID)" "$CTID")" + +[[ -z "$MAIL_FQDN" ]] && MAIL_FQDN="$(ask "Mailserver-FQDN (z.B. mail.example.com)")" +[[ -n "$MAIL_FQDN" ]] || die "MAIL_FQDN ist Pflicht." +# Domain aus FQDN ableiten (alles nach dem ersten Punkt) falls nicht gesetzt +[[ -z "$MAIL_DOMAIN" ]] && MAIL_DOMAIN="${MAIL_FQDN#*.}" +MAIL_DOMAIN="$(ask "Primäre Mail-Domain" "$MAIL_DOMAIN")" +[[ -z "$MAIL_DOMAINS" ]] && MAIL_DOMAINS="$(ask "Alle Mail-Domains (Leerzeichen-getrennt)" "$MAIL_DOMAIN")" +# Primäre Domain sicher enthalten, Duplikate raus +MAIL_DOMAINS="$(echo "$MAIL_DOMAIN $MAIL_DOMAINS" | tr ' ' '\n' | awk 'NF && !seen[$0]++' | tr '\n' ' ' | sed 's/ *$//')" + +# Branding + Web-Domains (Defaults aus der primären Domain abgeleitet) +[[ -z "$BRAND" ]] && BRAND="$(ask "Anzeigename / Brand (Dashboard)" "$MAIL_DOMAIN")" +[[ -z "$WEBMAIL_FQDN" ]] && WEBMAIL_FQDN="$(ask "Webmail-Domain (NPM-Proxy-Host)" "mail.${MAIL_DOMAIN}")" +[[ -z "$ADMIN_FQDN" ]] && ADMIN_FQDN="$(ask "Admin-UI-Domain (NPM-Proxy-Host)" "admin.${MAIL_DOMAIN}")" + +[[ -z "$FIRST_EMAIL" ]] && FIRST_EMAIL="$(ask "Erstes Postfach (E-Mail)" "admin@${MAIL_DOMAIN}")" +if [[ -z "$FIRST_PASSWORD" ]]; then + read -rsp "$(echo -e "${BL}?${CL} Passwort für ${FIRST_EMAIL}: ")" FIRST_PASSWORD; echo + [[ -n "$FIRST_PASSWORD" ]] || die "Passwort darf nicht leer sein." +fi + +NET_IP="$(ask "IP (dhcp oder CIDR z.B. 192.168.1.50/24)" "$NET_IP")" +if [[ "$NET_IP" != "dhcp" && -z "$NET_GW" ]]; then + NET_GW="$(ask "Gateway (für statische IP)")" +fi + +# --- Admin-UI / Supabase --- +[[ -z "$ADMIN_ALLOWED_EMAILS" ]] && ADMIN_ALLOWED_EMAILS="$(ask "Admin-E-Mail(s) für UI-Login (Supabase, Komma-getrennt)" "$FIRST_EMAIL")" +[[ -z "$SUPABASE_URL" ]] && SUPABASE_URL="$(ask "Supabase URL (leer = später in .env eintragen)")" +[[ -z "$SUPABASE_ANON_KEY" ]] && SUPABASE_ANON_KEY="$(ask "Supabase anon key (leer = später in .env eintragen)")" + +# --- Hardening --- +[[ -z "$NPM_IP" ]] && NPM_IP="$(ask "IP des Nginx Proxy Manager (Web-Ports nur von dort; leer = offen)")" +# Rspamd-Controller-Passwort generieren, falls nicht gesetzt +[[ -z "$RSPAMD_PASSWORD" ]] && RSPAMD_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)" + +cat <} + Firewall .......... $([[ "$HARDEN_FIREWALL" == 1 ]] && echo "PVE-CT-Firewall an${NPM_IP:+, Web nur von $NPM_IP}" || echo aus) + ClamAV ............ $([[ "$ENABLE_CLAMAV" == 1 ]] && echo an || echo aus) + Fail2ban .......... $([[ "$ENABLE_FAIL2BAN" == 1 ]] && echo an || echo aus) +${GN}────────────────────────────────────────────────${CL} +EOF +read -rp "$(echo -e "${BL}?${CL} Fortfahren? [J/n] ")" go; [[ "${go:-J}" =~ ^[JjYy]?$ ]] || die "Abgebrochen." + +# --------------------------------------------------------------------------- +# CT-Template sicherstellen +# --------------------------------------------------------------------------- +msg_info "Suche $OS_TEMPLATE Template ..." +pveam update >/dev/null 2>&1 || true +TEMPLATE="$(pveam available --section system | awk -v t="$OS_TEMPLATE" '$2 ~ t {print $2}' | sort -V | tail -1)" +if [[ -z "$TEMPLATE" ]]; then + msg_err "$OS_TEMPLATE nicht verfügbar – fällt auf debian-12-standard zurück." + TEMPLATE="$(pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)" +fi +[[ -n "$TEMPLATE" ]] || die "Kein passendes Debian-Template in 'pveam available' gefunden." +if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then + msg_info "Lade Template $TEMPLATE herunter ..." + pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" +fi +TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" +msg_ok "Template bereit: $TEMPLATE" + +# --------------------------------------------------------------------------- +# Netzwerk-String bauen +# --------------------------------------------------------------------------- +FW_FLAG="" +[[ "$HARDEN_FIREWALL" == 1 ]] && FW_FLAG=",firewall=1" +if [[ "$NET_IP" == "dhcp" ]]; then + NET="name=eth0,bridge=${BRIDGE},ip=dhcp${FW_FLAG}" +else + NET="name=eth0,bridge=${BRIDGE},ip=${NET_IP}${NET_GW:+,gw=${NET_GW}}${FW_FLAG}" +fi + +# --------------------------------------------------------------------------- +# LXC erstellen (unprivilegiert) + Nesting/keyctl für Docker +# --------------------------------------------------------------------------- +msg_info "Erstelle LXC $CTID ..." +pct create "$CTID" "$TEMPLATE_REF" \ + --hostname "$HOSTNAME" \ + --cores "$CORES" \ + --memory "$RAM_MB" \ + --swap "$SWAP_MB" \ + --rootfs "${STORAGE}:${DISK_GB}" \ + --net0 "$NET" \ + --features nesting=1,keyctl=1 \ + --unprivileged 1 \ + --onboot 1 \ + --ostype debian \ + --timezone "$TIMEZONE" \ + --description "docker-mailserver (DMS) — ${MAIL_FQDN}" +msg_ok "LXC $CTID erstellt." + +# --------------------------------------------------------------------------- +# Proxmox-CT-Firewall: Mail-Ports offen, Web/Rspamd nur von NPM (falls gesetzt) +# (greift nur, wenn die Datacenter/Node-Firewall aktiviert ist — sonst inert) +# --------------------------------------------------------------------------- +if [[ "$HARDEN_FIREWALL" == 1 ]]; then + msg_info "Schreibe Proxmox-CT-Firewall-Regeln ..." + WEBSRC=""; [[ -n "$NPM_IP" ]] && WEBSRC=" -source ${NPM_IP}" + mkdir -p /etc/pve/firewall + cat > "/etc/pve/firewall/${CTID}.fw" </dev/null 2>&1 && break + sleep 2 +done +msg_ok "Container läuft." + +# --------------------------------------------------------------------------- +# Basis-Setup + Docker im Container installieren +# --------------------------------------------------------------------------- +msg_info "Installiere Updates & Docker im Container ..." +pct exec "$CTID" -- bash -c ' + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl gnupg openssl jq >/dev/null + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + . /etc/os-release + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null + systemctl enable --now docker >/dev/null 2>&1 +' +msg_ok "Docker installiert." + +# --------------------------------------------------------------------------- +# Stack-Ordner vorbereiten (.env + mailserver.env patchen) und übertragen +# --------------------------------------------------------------------------- +DEPLOY_DIR="/opt/dms-stack" +DMS_TAG="${DMS_IMAGE##*:}" +msg_info "Bereite Stack vor ..." +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +cp -r "$STACK_DIR" "$TMP/stack" + +# mailserver.env an Optionen anpassen +sed -i "s|^TZ=.*|TZ=${TIMEZONE}|" "$TMP/stack/mailserver.env" +sed -i "s|^ENABLE_CLAMAV=.*|ENABLE_CLAMAV=${ENABLE_CLAMAV}|" "$TMP/stack/mailserver.env" +sed -i "s|^ENABLE_FAIL2BAN=.*|ENABLE_FAIL2BAN=${ENABLE_FAIL2BAN}|" "$TMP/stack/mailserver.env" + +# .env für docker compose schreiben +cat > "$TMP/stack/.env" < "$TMP/stack/docker-data/dms/config/rspamd/override.d/worker-controller.inc" < TLS-Fehler, ohne Konto -> Shutdown nach 120s, +# und Port 25 öffnet erst NACH dem ersten Konto -> sonst Deadlock beim Warten.) +# --------------------------------------------------------------------------- +msg_info "Erzeuge Self-signed-Zertifikat (Fallback bis NPM/Let's Encrypt) ..." +pct exec "$CTID" -- bash -c " + set -e + mkdir -p $DEPLOY_DIR/docker-data/certs $DEPLOY_DIR/docker-data/dms/config + if [ ! -f $DEPLOY_DIR/docker-data/certs/cert.pem ]; then + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout $DEPLOY_DIR/docker-data/certs/key.pem \ + -out $DEPLOY_DIR/docker-data/certs/cert.pem \ + -subj '/CN=$MAIL_FQDN' -addext 'subjectAltName=DNS:$MAIL_FQDN' >/dev/null 2>&1 + fi +" + +msg_info "Lege erstes Postfach an: $FIRST_EMAIL" +# Passwort-Hash auf dem Host erzeugen (Passwort geht nie in den Container). +PW_HASH="$(printf '%s' "$FIRST_PASSWORD" | openssl passwd -6 -stdin)" +pct exec "$CTID" -- bash -c "grep -q '^${FIRST_EMAIL}|' $DEPLOY_DIR/docker-data/dms/config/postfix-accounts.cf 2>/dev/null || \ + printf '%s\n' '${FIRST_EMAIL}|{SHA512-CRYPT}${PW_HASH}' >> $DEPLOY_DIR/docker-data/dms/config/postfix-accounts.cf" + +# --------------------------------------------------------------------------- +# Stack bauen & starten + DKIM +# --------------------------------------------------------------------------- +msg_info "Baue & starte Stack (Image-Pull/Build kann einige Minuten dauern) ..." +pct exec "$CTID" -- bash -c "cd $DEPLOY_DIR && docker compose up -d --build" + +# Warten bis Mailserver auf Port 25 lauscht (kommt jetzt, da Konto+Cert vorhanden) +msg_info "Warte auf Mailserver-Start ..." +for i in {1..60}; do + pct exec "$CTID" -- bash -c 'docker exec mailserver ss -lnt 2>/dev/null | grep -q ":25 "' && break + sleep 3 +done + +msg_info "Erzeuge DKIM-Schlüssel (RSA 2048) pro Domain ..." +for d in $MAIL_DOMAINS; do + pct exec "$CTID" -- bash -c "docker exec mailserver setup config dkim keysize 2048 domain '$d'" \ + || msg_err "DKIM für $d fehlgeschlagen – später manuell nachholen." +done + +# alle DKIM-DNS-Records einsammeln (rspamd legt je Domain eine .dns.txt an) +DKIM_TXT="$(pct exec "$CTID" -- bash -c "for f in $DEPLOY_DIR/docker-data/dms/config/rspamd/dkim/*.dns.txt; do [ -f \"\$f\" ] && echo \"; --- \$(basename \"\$f\" .dns.txt) ---\" && cat \"\$f\" && echo; done 2>/dev/null" || true)" + +# --- SnappyMail provisionieren: Wildcard-Domain -> mailserver-Container, Shibui als Default --- +msg_info "Provisioniere SnappyMail (alle Domains -> mailserver, Shibui-Theme) ..." +SM_DEF="$DEPLOY_DIR/docker-data/snappymail/_data_/_default_" +for i in {1..30}; do + pct exec "$CTID" -- bash -c "[ -f '$SM_DEF/domains/default.json' ]" && break + sleep 2 +done +pct exec "$CTID" -- bash -c ' + set -e + D="$1"; STACK="$2" + [ -f "$D/domains/default.json" ] || exit 0 + jq ".IMAP.host=\"mailserver\" | .IMAP.port=143 | .IMAP.type=2 | .IMAP.ssl.allow_self_signed=true + | .SMTP.host=\"mailserver\" | .SMTP.port=587 | .SMTP.type=2 | .SMTP.useAuth=true | .SMTP.ssl.allow_self_signed=true + | .Sieve.host=\"mailserver\"" "$D/domains/default.json" > "$D/domains/default.json.tmp" + mv "$D/domains/default.json.tmp" "$D/domains/default.json" + sed -i "s/^theme = .*/theme = \"Shibui@custom\"/" "$D/configs/application.ini" 2>/dev/null || true + cd "$STACK" && docker compose restart snappymail >/dev/null 2>&1 +' _ "$SM_DEF" "$DEPLOY_DIR" || msg_err "SnappyMail-Provisionierung übersprungen (kann manuell erfolgen)." + +# Container-IP ermitteln +CT_IP="$(pct exec "$CTID" -- bash -c "hostname -I | awk '{print \$1}'" 2>/dev/null || true)" + +# --------------------------------------------------------------------------- +# Abschluss / DNS-Hinweise +# --------------------------------------------------------------------------- +cat <}:${ADMIN_PORT} (Supabase-Login) + Webmail ............ http://${CT_IP:-}:${WEBMAIL_PORT} (SnappyMail) + Rspamd-UI .......... http://${CT_IP:-}:${RSPAMD_PORT} (Passwort unten) + Verwaltung ......... pct enter $CTID → cd ${DEPLOY_DIR} + + ${BL}Tipp:${CL} Im Nginx Proxy Manager als Proxy-Hosts anlegen (HTTPS): + admin.${MAIL_DOMAIN} -> ${CT_IP:-}:${ADMIN_PORT} + mail.${MAIL_DOMAIN} -> ${CT_IP:-}:${WEBMAIL_PORT} + rspamd.${MAIL_DOMAIN} -> ${CT_IP:-}:${RSPAMD_PORT} + + ${RD}Rspamd-Web-UI-Passwort:${CL} ${RSPAMD_PASSWORD} + (steht auch in ${DEPLOY_DIR}/docker-data/dms/config/rspamd/override.d/worker-controller.inc) + +${YW}── DNS: einmal für den Mailhost ──${CL} + + A-Record: ${MAIL_FQDN}. IN A <öffentliche IP> + PTR/rDNS: <öffentliche IP> -> ${MAIL_FQDN} (beim Hoster/ISP setzen!) + +${YW}── DNS: pro Mail-Domain (MX/SPF/DMARC) ──${CL} +EOF +for d in $MAIL_DOMAINS; do +cat < cp .env.example .env +# (Beim LXC-Deploy werden diese Werte über den Dialog gesetzt.) +# ============================================================================ + +# --- Mail --- +MAIL_FQDN=mail.example.com # FQDN des Mailservers (= Container-hostname, ein Host für ALLE Domains) +MAIL_DOMAIN=example.com # primäre Domain (postmaster@, Defaults) +# ALLE Mail-Domains (Leerzeichen-getrennt). Ein Mailserver bedient mehrere Domains. +# Nur die Erst-Befüllung — danach in der Admin-UI unter „Einstellungen" editierbar: +MAIL_DOMAINS=example.com +DMS_TAG=latest # Image-Tag von docker-mailserver + +# --- Branding / Web-Domains (Erst-Befüllung, in der Admin-UI editierbar) --- +BRAND=example.com # Anzeigename im Admin-Dashboard +WEBMAIL_FQDN=mail.example.com # Webmail-Domain (NPM-Proxy-Host) +ADMIN_FQDN=admin.example.com # Admin-UI-Domain (NPM-Proxy-Host) + +# --- Veröffentlichte Web-Ports (Nginx Proxy Manager zeigt hierauf) --- +ADMIN_PORT=8080 # Admin-UI (React-Admin) +WEBMAIL_PORT=8888 # SnappyMail Webmail +RSPAMD_PORT=11334 # Rspamd Web-UI + +# --- Admin-API --- +# Liste der E-Mails, die sich im Admin anmelden dürfen (Komma-getrennt) +ADMIN_ALLOWED_EMAILS=admin@example.com + +# --- Supabase (Auth für die Admin-UI) --- +SUPABASE_URL=https://YOUR-PROJECT.supabase.co +SUPABASE_ANON_KEY=YOUR-ANON-KEY diff --git a/stack/admin/.dockerignore b/stack/admin/.dockerignore new file mode 100644 index 0000000..72f583e --- /dev/null +++ b/stack/admin/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +npm-debug.log diff --git a/stack/admin/Dockerfile b/stack/admin/Dockerfile new file mode 100644 index 0000000..9ce6f44 --- /dev/null +++ b/stack/admin/Dockerfile @@ -0,0 +1,16 @@ +# ---- Build-Stage: React-Admin mit Vite bauen ---- +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +# ---- Serve-Stage: nginx ---- +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +# Laufzeit-Config-Generator (nginx-Image führt /docker-entrypoint.d/*.sh aus) +COPY docker-entrypoint.d/40-config.sh /docker-entrypoint.d/40-config.sh +RUN chmod +x /docker-entrypoint.d/40-config.sh +EXPOSE 80 diff --git a/stack/admin/docker-entrypoint.d/40-config.sh b/stack/admin/docker-entrypoint.d/40-config.sh new file mode 100644 index 0000000..8b62feb --- /dev/null +++ b/stack/admin/docker-entrypoint.d/40-config.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Erzeugt zur Laufzeit /config.js aus den Umgebungsvariablen, damit die +# React-App Supabase-URL/Key ohne Neu-Build erhält (window.__CONFIG__). +# Das nginx-Image führt Skripte in /docker-entrypoint.d/ vor dem Start aus. +set -e +cat > /usr/share/nginx/html/config.js < + + + + + Mailserver Admin + + + + + + + +
+ + + diff --git a/stack/admin/nginx.conf b/stack/admin/nginx.conf new file mode 100644 index 0000000..d9fb396 --- /dev/null +++ b/stack/admin/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # API an den admin-api Container weiterreichen ( /api/accounts -> /accounts ) + location /api/ { + proxy_pass http://admin-api:3000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Authorization $http_authorization; + } + + # config.js nicht cachen (enthält Laufzeit-Konfiguration) + location = /config.js { + add_header Cache-Control "no-store"; + } + + # SPA-Fallback für react-admin Routing + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/stack/admin/package.json b/stack/admin/package.json new file mode 100644 index 0000000..3af8f33 --- /dev/null +++ b/stack/admin/package.json @@ -0,0 +1,23 @@ +{ + "name": "dms-admin-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "React-Admin Oberfläche für docker-mailserver (Stil: OPENBUREAU).", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "ra-data-simple-rest": "^5.4.0", + "react": "^18.3.1", + "react-admin": "^5.4.0", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/stack/admin/src/App.jsx b/stack/admin/src/App.jsx new file mode 100644 index 0000000..f64f690 --- /dev/null +++ b/stack/admin/src/App.jsx @@ -0,0 +1,51 @@ +import { Admin, Resource, CustomRoutes } from 'react-admin'; +import { Route } from 'react-router-dom'; +import EmailIcon from '@mui/icons-material/Email'; +import AltRouteIcon from '@mui/icons-material/AltRoute'; + +import { dataProvider } from './dataProvider'; +import { authProvider } from './authProvider'; +import { AccountList, AccountCreate, AccountEdit } from './resources/accounts'; +import { AliasList, AliasCreate, AliasEdit } from './resources/aliases'; +import { StatusPage } from './Status'; +import { ServerPage } from './Server'; +import { SettingsPage } from './Settings'; +import { Layout } from './Layout'; +import { Dashboard } from './Dashboard'; +import { openbureauTheme } from './theme'; + +const App = () => ( + + + + + } /> + } /> + } /> + + +); + +export default App; diff --git a/stack/admin/src/Dashboard.jsx b/stack/admin/src/Dashboard.jsx new file mode 100644 index 0000000..23f6502 --- /dev/null +++ b/stack/admin/src/Dashboard.jsx @@ -0,0 +1,65 @@ +import { useGetOne, useGetList, Link } from 'react-admin'; +import { Card, CardContent, Typography, Box, Stack, Chip, Button } from '@mui/material'; +import EmailIcon from '@mui/icons-material/Email'; +import AltRouteIcon from '@mui/icons-material/AltRoute'; +import DnsIcon from '@mui/icons-material/Dns'; +import LanguageIcon from '@mui/icons-material/Language'; + +const Stat = ({ icon, value, label, color }) => ( + + + + {icon} + + {value ?? '–'} + {label} + + + + +); + +export const Dashboard = () => { + const { data: status } = useGetOne('status', { id: 'status' }); + const { total: accountsTotal } = useGetList('accounts', { pagination: { page: 1, perPage: 1 } }); + const { total: aliasesTotal } = useGetList('aliases', { pagination: { page: 1, perPage: 1 } }); + const domains = status?.domains || []; + + return ( + + {status?.brand || 'Mailserver'} + + {status?.fqdn ? `Host ${status.fqdn}` : 'Übersicht'} + + + + } value={accountsTotal ?? status?.accounts} label="Postfächer" /> + } value={aliasesTotal ?? status?.aliases} label="Aliase" color="secondary.main" /> + } value={domains.length} label="Domains" color="success.main" /> + + + + + Domains + + {domains.length + ? domains.map((d) => ) + : Keine Domains konfiguriert.} + + + + + + + + + + + ); +}; diff --git a/stack/admin/src/Layout.jsx b/stack/admin/src/Layout.jsx new file mode 100644 index 0000000..84f5df2 --- /dev/null +++ b/stack/admin/src/Layout.jsx @@ -0,0 +1,16 @@ +import { Layout as RaLayout, Menu } from 'react-admin'; +import DnsIcon from '@mui/icons-material/Dns'; +import StorageIcon from '@mui/icons-material/Storage'; +import SettingsIcon from '@mui/icons-material/Settings'; + +// Eigenes Menü: Standard-Ressourcen + Status-, Server- und Einstellungen-Seite. +const AppMenu = () => ( + + + } /> + } /> + } /> + +); + +export const Layout = (props) => ; diff --git a/stack/admin/src/Server.jsx b/stack/admin/src/Server.jsx new file mode 100644 index 0000000..3b1b054 --- /dev/null +++ b/stack/admin/src/Server.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Title } from 'react-admin'; +import { + Card, CardContent, Typography, Box, Stack, Chip, LinearProgress, + Table, TableHead, TableRow, TableCell, TableBody, Button, Alert, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { apiFetch } from './dataProvider'; + +const fmtBytes = (b) => { + if (b == null) return '–'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; let n = b; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; +}; + +export const ServerPage = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(() => { + setLoading(true); + apiFetch('/mailserver/overview') + .then((r) => { setData(r.json); setError(null); }) + .catch((e) => setError(e?.body?.error || e?.message || 'Bridge nicht erreichbar')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + return ( + + + <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> + <Typography variant="h3">Server</Typography> + <Button onClick={load} startIcon={<RefreshIcon />} variant="outlined">Aktualisieren</Button> + </Stack> + + {error && ( + <Alert severity="warning" sx={{ mb: 2 }}> + Mailserver-Bridge nicht erreichbar: {error} + </Alert> + )} + {loading && <LinearProgress sx={{ mb: 2 }} />} + + {data && ( + <> + <Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}> + <Card sx={{ flex: 1, minWidth: 200 }}> + <CardContent> + <Typography variant="h6" gutterBottom>Mail-Queue</Typography> + {data.queue?.empty + ? <Chip color="success" label="Leer" /> + : <Chip color="warning" label={`${data.queue?.count ?? '?'} in Warteschlange`} />} + </CardContent> + </Card> + <Card sx={{ flex: 1, minWidth: 200 }}> + <CardContent> + <Typography variant="h6" gutterBottom>Aktive Sessions</Typography> + <Typography variant="h4">{data.who?.sessions?.length ?? 0}</Typography> + <Typography variant="body2" color="text.secondary">IMAP/POP-Verbindungen gerade</Typography> + </CardContent> + </Card> + </Stack> + + <Card> + <CardContent> + <Typography variant="h6" gutterBottom>Quota-Auslastung</Typography> + <Table size="small"> + <TableHead> + <TableRow> + <TableCell>Postfach</TableCell> + <TableCell align="right">Belegt</TableCell> + <TableCell align="right">Limit</TableCell> + <TableCell sx={{ width: 180 }}>Auslastung</TableCell> + </TableRow> + </TableHead> + <TableBody> + {(data.quota || []).map((q) => ( + <TableRow key={q.user}> + <TableCell>{q.user}</TableCell> + <TableCell align="right">{fmtBytes(q.usedBytes)}</TableCell> + <TableCell align="right">{q.limitBytes == null ? '∞' : fmtBytes(q.limitBytes)}</TableCell> + <TableCell> + {q.limitBytes == null + ? <Typography variant="body2" color="text.secondary">unbegrenzt</Typography> + : <Stack direction="row" spacing={1} alignItems="center"> + <LinearProgress variant="determinate" value={Math.min(q.percent || 0, 100)} + sx={{ flex: 1, height: 8, borderRadius: 4 }} /> + <Typography variant="caption">{q.percent ?? 0}%</Typography> + </Stack>} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + </> + )} + </Box> + ); +}; diff --git a/stack/admin/src/Settings.jsx b/stack/admin/src/Settings.jsx new file mode 100644 index 0000000..b794d32 --- /dev/null +++ b/stack/admin/src/Settings.jsx @@ -0,0 +1,92 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Title, useNotify } from 'react-admin'; +import { + Card, CardContent, Typography, Box, Stack, TextField, Button, Chip, + Divider, InputAdornment, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import SaveIcon from '@mui/icons-material/Save'; +import { apiFetch } from './dataProvider'; + +export const SettingsPage = () => { + const notify = useNotify(); + const [s, setS] = useState(null); + const [newDomain, setNewDomain] = useState(''); + + const load = useCallback(() => { + apiFetch('/settings').then((r) => setS(r.json)).catch(() => notify('Einstellungen nicht ladbar', { type: 'warning' })); + }, [notify]); + useEffect(() => { load(); }, [load]); + + const set = (k) => (e) => setS((v) => ({ ...v, [k]: e.target.value })); + + const save = () => { + apiFetch('/settings/settings', { method: 'PUT', body: JSON.stringify(s) }) + .then((r) => { setS(r.json); notify('Gespeichert', { type: 'success' }); }) + .catch((e) => notify(e?.body?.error || 'Speichern fehlgeschlagen', { type: 'warning' })); + }; + + const addDomain = () => { + const d = newDomain.trim().toLowerCase(); + if (!d) return; + apiFetch('/settings/domains', { method: 'POST', body: JSON.stringify({ domain: d }) }) + .then((r) => { setS(r.json); setNewDomain(''); notify(`${d} hinzugefügt – DKIM wird erzeugt`, { type: 'success' }); }) + .catch((e) => notify(e?.body?.error || 'Domain konnte nicht hinzugefügt werden', { type: 'warning' })); + }; + + const removeDomain = (d) => { + apiFetch(`/settings/domains/${encodeURIComponent(d)}`, { method: 'DELETE' }) + .then((r) => { setS(r.json); notify(`${d} entfernt`, { type: 'info' }); }) + .catch(() => notify('Entfernen fehlgeschlagen', { type: 'warning' })); + }; + + if (!s) return <Card><CardContent>Lädt …</CardContent></Card>; + + return ( + <Box sx={{ p: { xs: 1, sm: 2 }, maxWidth: 760 }}> + <Title title="Einstellungen" /> + <Typography variant="h3" sx={{ mb: 2 }}>Einstellungen</Typography> + + <Card sx={{ mb: 3 }}> + <CardContent> + <Typography variant="h6" gutterBottom>Mail-Domains</Typography> + <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> + Domains, für die Postfächer/Adressen angelegt werden können. Hinzufügen erzeugt direkt den DKIM-Schlüssel. + </Typography> + <Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 2 }}> + {(s.domains || []).map((d) => ( + <Chip key={d} label={d} onDelete={() => removeDomain(d)} + color={d === s.primaryDomain ? 'primary' : 'default'} + variant={d === s.primaryDomain ? 'filled' : 'outlined'} /> + ))} + </Stack> + <Stack direction="row" spacing={1}> + <TextField size="small" placeholder="neue-domain.tld" value={newDomain} + onChange={(e) => setNewDomain(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addDomain()} + InputProps={{ startAdornment: <InputAdornment position="start">@</InputAdornment> }} /> + <Button onClick={addDomain} startIcon={<AddIcon />} variant="contained">Hinzufügen</Button> + </Stack> + </CardContent> + </Card> + + <Card> + <CardContent> + <Typography variant="h6" gutterBottom>Allgemein</Typography> + <Stack spacing={2} sx={{ maxWidth: 480 }}> + <TextField label="Anzeigename / Brand" value={s.brand || ''} onChange={set('brand')} fullWidth /> + <TextField label="Mailserver-FQDN" value={s.fqdn || ''} onChange={set('fqdn')} fullWidth + helperText="Host, zu dem sich Clients/Webmail verbinden (= MX-Ziel)" /> + <TextField label="Primäre Domain" value={s.primaryDomain || ''} onChange={set('primaryDomain')} fullWidth /> + <Divider /> + <TextField label="Webmail-Domain (NPM)" value={s.webmailFqdn || ''} onChange={set('webmailFqdn')} fullWidth /> + <TextField label="Admin-UI-Domain (NPM)" value={s.adminFqdn || ''} onChange={set('adminFqdn')} fullWidth /> + </Stack> + <Box sx={{ mt: 2 }}> + <Button onClick={save} startIcon={<SaveIcon />} variant="contained">Speichern</Button> + </Box> + </CardContent> + </Card> + </Box> + ); +}; diff --git a/stack/admin/src/Status.jsx b/stack/admin/src/Status.jsx new file mode 100644 index 0000000..70e0c36 --- /dev/null +++ b/stack/admin/src/Status.jsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { useGetOne, Title, useNotify } from 'react-admin'; +import { Card, CardContent, Typography, Box, Chip, Stack, Divider, Button } from '@mui/material'; +import VpnKeyIcon from '@mui/icons-material/VpnKey'; +import { apiFetch } from './dataProvider'; + +const Mono = ({ children }) => ( + <Box + component="pre" + sx={{ + background: '#1e1e1e', color: '#e0e0e0', p: 1.5, borderRadius: 1, m: 0, + overflowX: 'auto', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all', + }} + > + {children} + </Box> +); + +const DkimButton = ({ domain, onDone }) => { + const [busy, setBusy] = useState(false); + const notify = useNotify(); + const gen = () => { + setBusy(true); + apiFetch('/mailserver/dkim', { method: 'POST', body: JSON.stringify({ domain }) }) + .then(() => { notify(`DKIM für ${domain} erzeugt`, { type: 'success' }); onDone?.(); }) + .catch((e) => notify(e?.body?.error || 'DKIM-Erzeugung fehlgeschlagen', { type: 'warning' })) + .finally(() => setBusy(false)); + }; + return ( + <Button size="small" startIcon={<VpnKeyIcon />} onClick={gen} disabled={busy} sx={{ mt: 1 }}> + {busy ? 'Erzeuge …' : 'DKIM erzeugen / erneuern'} + </Button> + ); +}; + +export const StatusPage = () => { + const { data, isLoading, error, refetch } = useGetOne('status', { id: 'status' }); + + if (isLoading) return <Card><CardContent>Lädt …</CardContent></Card>; + if (error) return <Card><CardContent>Fehler beim Laden des Status.</CardContent></Card>; + + return ( + <Card> + <Title title="Status & DNS" /> + <CardContent> + <Typography variant="h6" gutterBottom>Mailserver</Typography> + <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}> + <Chip label={`Host: ${data.fqdn}`} /> + <Chip color="primary" label={`${data.accounts} Postfächer`} /> + <Chip color="secondary" label={`${data.aliases} Aliase`} /> + {(data.domains || []).map((d) => <Chip key={d} variant="outlined" label={d} />)} + </Stack> + + <Typography variant="h6" gutterBottom>DNS — Mailhost (einmalig)</Typography> + <Mono>{[data.host?.a, data.host?.ptr].filter(Boolean).join('\n')}</Mono> + + {(data.records || []).map((r) => ( + <Box key={r.domain} sx={{ mt: 3 }}> + <Divider sx={{ mb: 1 }} /> + <Typography variant="h6" gutterBottom>{r.domain}</Typography> + <Mono>{[r.mx, r.spf, r.dmarc].join('\n')}</Mono> + <Typography variant="subtitle2" sx={{ mt: 1 }}>DKIM (TXT)</Typography> + {r.dkim + ? <Mono>{r.dkim}</Mono> + : <Typography color="text.secondary" variant="body2">Noch kein DKIM-Schlüssel.</Typography>} + <DkimButton domain={r.domain} onDone={refetch} /> + </Box> + ))} + </CardContent> + </Card> + ); +}; diff --git a/stack/admin/src/authProvider.js b/stack/admin/src/authProvider.js new file mode 100644 index 0000000..bbfff15 --- /dev/null +++ b/stack/admin/src/authProvider.js @@ -0,0 +1,47 @@ +import { supabase } from './supabaseClient'; + +// Dev-Bypass: nur lokal (config.js / window.__CONFIG__.AUTH_DISABLED), nie im Deploy. +const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {}; +const AUTH_DISABLED = String(cfg.AUTH_DISABLED) === 'true'; + +// AuthProvider auf Basis von Supabase (wie OPENBUREAU). +export const authProvider = { + async login({ username, password }) { + if (AUTH_DISABLED) return; + const { error } = await supabase.auth.signInWithPassword({ + email: username, + password, + }); + if (error) throw new Error(error.message); + }, + + async logout() { + if (!AUTH_DISABLED && supabase) await supabase.auth.signOut(); + }, + + async checkAuth() { + if (AUTH_DISABLED) return; + const { data } = await supabase.auth.getSession(); + if (!data?.session) throw new Error('Nicht angemeldet'); + }, + + async checkError(error) { + if (AUTH_DISABLED) return; + const status = error?.status; + if (status === 401 || status === 403) { + await supabase.auth.signOut(); + throw new Error('Sitzung abgelaufen'); + } + }, + + async getIdentity() { + if (AUTH_DISABLED) return { id: 'dev', fullName: 'Dev (Auth aus)' }; + const { data } = await supabase.auth.getUser(); + const user = data?.user; + return { id: user?.id || 'me', fullName: user?.email || 'Admin' }; + }, + + async getPermissions() { + return 'admin'; + }, +}; diff --git a/stack/admin/src/dataProvider.js b/stack/admin/src/dataProvider.js new file mode 100644 index 0000000..9c1c0a4 --- /dev/null +++ b/stack/admin/src/dataProvider.js @@ -0,0 +1,20 @@ +import simpleRestProvider from 'ra-data-simple-rest'; +import { fetchUtils } from 'react-admin'; +import { supabase } from './supabaseClient'; + +// HTTP-Client, der das Supabase-Access-Token als Bearer mitschickt. +const httpClient = async (url, options = {}) => { + const headers = new Headers(options.headers || { Accept: 'application/json' }); + if (supabase) { + const { data } = await supabase.auth.getSession(); + const token = data?.session?.access_token; + if (token) headers.set('Authorization', `Bearer ${token}`); + } + return fetchUtils.fetchJson(url, { ...options, headers }); +}; + +// nginx proxyt /api -> admin-api (Prod); Vite proxyt /api -> :3000 (Dev). +export const dataProvider = simpleRestProvider('/api', httpClient); + +// Helfer für eigene Endpunkte (Mailserver-Bridge): apiFetch('/mailserver/overview') +export const apiFetch = (path, options) => httpClient('/api' + path, options); diff --git a/stack/admin/src/main.jsx b/stack/admin/src/main.jsx new file mode 100644 index 0000000..8e50ea1 --- /dev/null +++ b/stack/admin/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')).render( + <React.StrictMode> + <App /> + </React.StrictMode> +); diff --git a/stack/admin/src/resources/accounts.jsx b/stack/admin/src/resources/accounts.jsx new file mode 100644 index 0000000..4681811 --- /dev/null +++ b/stack/admin/src/resources/accounts.jsx @@ -0,0 +1,48 @@ +import { + List, Datagrid, TextField, EmailField, + Create, Edit, SimpleForm, TextInput, PasswordInput, + required, email, EditButton, DeleteButton, SearchInput, +} from 'react-admin'; + +const accountFilters = [<SearchInput key="q" source="q" alwaysOn />]; + +// Validatoren +const quotaValidate = (v) => + !v || /^\d+\s*[KMGT]?$/i.test(v) ? undefined : 'Format z.B. 5G, 500M (leer = unbegrenzt)'; +const min8 = (v) => (v && v.length >= 8 ? undefined : 'Mindestens 8 Zeichen'); +const min8Optional = (v) => (!v || v.length >= 8 ? undefined : 'Mindestens 8 Zeichen'); + +export const AccountList = () => ( + <List filters={accountFilters} sort={{ field: 'email', order: 'ASC' }} perPage={25}> + <Datagrid rowClick="edit" bulkActionButtons={false}> + <EmailField source="email" label="Postfach" /> + <TextField source="quota" label="Quota" emptyText="∞" /> + <EditButton /> + <DeleteButton /> + </Datagrid> + </List> +); + +export const AccountCreate = () => ( + <Create redirect="list"> + <SimpleForm> + <TextInput source="email" label="E-Mail-Adresse" validate={[required(), email()]} fullWidth + helperText="z.B. max@example.com" /> + <PasswordInput source="password" label="Passwort" validate={[required(), min8]} + helperText="Mindestens 8 Zeichen" /> + <TextInput source="quota" label="Quota" validate={quotaValidate} + helperText="z.B. 5G, 500M – leer = unbegrenzt" /> + </SimpleForm> + </Create> +); + +export const AccountEdit = () => ( + <Edit redirect="list"> + <SimpleForm> + <TextInput source="email" label="E-Mail-Adresse" disabled fullWidth /> + <PasswordInput source="password" label="Neues Passwort (leer = unverändert)" validate={min8Optional} /> + <TextInput source="quota" label="Quota" validate={quotaValidate} + helperText="z.B. 5G, 500M – leer = unbegrenzt" /> + </SimpleForm> + </Edit> +); diff --git a/stack/admin/src/resources/aliases.jsx b/stack/admin/src/resources/aliases.jsx new file mode 100644 index 0000000..35923b5 --- /dev/null +++ b/stack/admin/src/resources/aliases.jsx @@ -0,0 +1,44 @@ +import { + List, Datagrid, TextField, + Create, Edit, SimpleForm, TextInput, + required, EditButton, DeleteButton, SearchInput, +} from 'react-admin'; + +const aliasFilters = [<SearchInput key="q" source="q" alwaysOn />]; + +// Quelle: vollständige E-Mail ODER @domain.tld (Catch-all) +const aliasSource = (v) => + /^([^@\s]+)?@[^@\s]+\.[^@\s]+$/.test(v || '') ? undefined + : 'Vollständige E-Mail oder @domain.tld (Catch-all)'; + +export const AliasList = () => ( + <List filters={aliasFilters} sort={{ field: 'source', order: 'ASC' }} perPage={25}> + <Datagrid rowClick="edit" bulkActionButtons={false}> + <TextField source="source" label="Alias" /> + <TextField source="destination" label="Ziel(e)" /> + <EditButton /> + <DeleteButton /> + </Datagrid> + </List> +); + +export const AliasCreate = () => ( + <Create redirect="list"> + <SimpleForm> + <TextInput source="source" label="Alias-Adresse" validate={[required(), aliasSource]} fullWidth + helperText="z.B. info@example.com — oder @example.com als Catch-all (fängt alle unbekannten Adressen der Domain)" /> + <TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth + helperText="Eine oder mehrere Zieladressen, mit Komma getrennt" /> + </SimpleForm> + </Create> +); + +export const AliasEdit = () => ( + <Edit redirect="list"> + <SimpleForm> + <TextInput source="source" label="Alias-Adresse" disabled fullWidth /> + <TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth + helperText="Mehrere mit Komma getrennt" /> + </SimpleForm> + </Edit> +); diff --git a/stack/admin/src/supabaseClient.js b/stack/admin/src/supabaseClient.js new file mode 100644 index 0000000..14c169b --- /dev/null +++ b/stack/admin/src/supabaseClient.js @@ -0,0 +1,17 @@ +import { createClient } from '@supabase/supabase-js'; + +// Konfiguration kommt zur Laufzeit aus /config.js (window.__CONFIG__), +// im Dev-Modus aus den VITE_* Umgebungsvariablen. +const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {}; +const url = cfg.SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL || ''; +const key = cfg.SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_ANON_KEY || ''; + +if (!url || !key) { + // eslint-disable-next-line no-console + console.warn('[admin] Supabase URL/Key fehlen — Login wird nicht funktionieren.'); +} + +// Kein Client, wenn keine Konfiguration vorhanden ist (z.B. lokaler Dev-Bypass). +export const supabase = (url && key) + ? createClient(url, key, { auth: { persistSession: true, autoRefreshToken: true } }) + : null; diff --git a/stack/admin/src/theme.js b/stack/admin/src/theme.js new file mode 100644 index 0000000..ef4bb20 --- /dev/null +++ b/stack/admin/src/theme.js @@ -0,0 +1,138 @@ +// ============================================================ +// Admin-Theme im OPENBUREAU-Look (warmes Cream-Design-System) +// Akzent: petrol #7BA89B (auf Wunsch, statt OPENBUREAU-Terrakotta) +// ============================================================ +const accent = '#7BA89B'; // petrol +const accentDeep = '#557A6D'; +const accentRing = 'rgba(123,168,155,0.20)'; + +const fonts = { + sans: '"Inter", system-ui, -apple-system, sans-serif', + serif: '"Newsreader", Georgia, serif', + display: '"Space Grotesk", "Inter", sans-serif', + mono: '"IBM Plex Mono", ui-monospace, monospace', +}; + +const BG = 'hsl(35, 14%, 96%)'; // warmes Off-White +const PANEL = '#fffdf9'; +const PANEL2 = 'hsl(35, 14%, 93%)'; +const LINE = 'hsl(35, 14%, 86%)'; +const TEXT = 'hsl(25, 18%, 12%)'; +const MUTED = 'hsl(25, 8%, 42%)'; +const DARK = '#191919'; +const SHADOW = '0 10px 34px -22px rgba(40,20,10,.5)'; + +export const openbureauTheme = { + palette: { + mode: 'light', + primary: { main: accent, dark: accentDeep, contrastText: '#ffffff' }, + secondary: { main: accentDeep, contrastText: '#ffffff' }, + background: { default: BG, paper: PANEL }, + text: { primary: TEXT, secondary: MUTED }, + divider: LINE, + success: { main: '#5d7d4b' }, + warning: { main: '#b8902f' }, + error: { main: '#b54a2c' }, + }, + shape: { borderRadius: 11 }, + typography: { + fontFamily: fonts.sans, + fontWeightMedium: 500, + h1: { fontFamily: fonts.serif, fontWeight: 600 }, + h2: { fontFamily: fonts.serif, fontWeight: 600 }, + h3: { fontFamily: fonts.serif, fontWeight: 600 }, + h4: { fontFamily: fonts.serif, fontWeight: 600 }, + h5: { fontFamily: fonts.serif, fontWeight: 600 }, + h6: { fontFamily: fonts.serif, fontWeight: 600 }, + }, + components: { + // dunkle Topbar wie OPENBUREAU + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: DARK, + color: '#f0f0f0', + boxShadow: 'none', + backgroundImage: 'none', + borderBottom: 'none', + }, + }, + }, + RaAppBar: { + styleOverrides: { + root: { + '& .RaAppBar-title': { + fontFamily: fonts.display, + fontWeight: 700, + letterSpacing: '0.12em', + }, + }, + }, + }, + // Pill-Buttons (22px), keine Versalien + MuiButton: { + styleOverrides: { + root: { borderRadius: 22, textTransform: 'none', fontWeight: 500, paddingLeft: 16, paddingRight: 16 }, + containedPrimary: { color: '#fff', '&:hover': { backgroundColor: accentDeep } }, + outlined: { borderColor: LINE, backgroundColor: PANEL }, + }, + }, + MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } }, + MuiCard: { + styleOverrides: { + root: { border: `1px solid ${LINE}`, boxShadow: SHADOW, borderRadius: 11, backgroundColor: PANEL }, + }, + }, + // Inputs: Radius 9, Petrol-Fokusring + MuiOutlinedInput: { + styleOverrides: { + root: { + borderRadius: 9, + backgroundColor: PANEL, + '& .MuiOutlinedInput-notchedOutline': { borderColor: LINE }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: accent, borderWidth: 1 }, + '&.Mui-focused': { boxShadow: `0 0 0 3px ${accentRing}` }, + }, + }, + }, + // Sidebar warm, aktives Item mit Petrol-Kante + RaSidebar: { + styleOverrides: { + root: { backgroundColor: PANEL2, borderRight: `1px solid ${LINE}` }, + }, + }, + RaMenuItemLink: { + styleOverrides: { + root: { + borderRadius: 11, + margin: '3px 6px', + paddingLeft: 10, + paddingRight: 10, + color: MUTED, + '&:hover': { backgroundColor: 'rgba(0,0,0,0.05)' }, + // klarer Petrol-Akzent im aktiven Zustand (auch eingeklappt sichtbar) + '&.RaMenuItemLink-active': { + backgroundColor: accentRing, + color: accentDeep, + fontWeight: 600, + }, + // Icon in der eingeklappten Sidebar mittig + '& .RaMenuItemLink-icon': { + color: 'inherit', + minWidth: 32, + justifyContent: 'center', + }, + }, + }, + }, + RaLayout: { styleOverrides: { root: { backgroundColor: BG } } }, + RaDatagrid: { + styleOverrides: { + root: { + '& .RaDatagrid-headerCell': { fontWeight: 600, color: MUTED, backgroundColor: 'transparent' }, + '& .RaDatagrid-rowCell': { borderBottomColor: LINE }, + }, + }, + }, + }, +}; diff --git a/stack/admin/vite.config.js b/stack/admin/vite.config.js new file mode 100644 index 0000000..fceafc1 --- /dev/null +++ b/stack/admin/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// In der Produktion serviert nginx die statischen Dateien und proxyt /api +// an den admin-api Container. Im Dev-Modus proxyen wir /api lokal. +export default defineConfig({ + plugins: [react()], + base: '/', + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ''), + }, + }, + }, +}); diff --git a/stack/api/.dockerignore b/stack/api/.dockerignore new file mode 100644 index 0000000..c81b8d3 --- /dev/null +++ b/stack/api/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log +.env diff --git a/stack/api/Dockerfile b/stack/api/Dockerfile new file mode 100644 index 0000000..cefaa2d --- /dev/null +++ b/stack/api/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine +WORKDIR /app + +# Abhängigkeiten zuerst (besseres Layer-Caching) +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 + +# als non-root laufen +USER node + +CMD ["node", "server.js"] diff --git a/stack/api/lib/auth.js b/stack/api/lib/auth.js new file mode 100644 index 0000000..0b5b8ee --- /dev/null +++ b/stack/api/lib/auth.js @@ -0,0 +1,60 @@ +// --------------------------------------------------------------------------- +// auth.js — Supabase-Token-Prüfung (wie in OPENBUREAU) +// +// Die React-Admin-UI loggt sich per Supabase ein und schickt das Access-Token +// als Authorization: Bearer <token>. Hier validieren wir es gegen Supabase +// und prüfen, ob die E-Mail in ADMIN_ALLOWED_EMAILS steht. +// --------------------------------------------------------------------------- +import { createClient } from '@supabase/supabase-js'; + +const SUPABASE_URL = process.env.SUPABASE_URL; +const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY; +const ALLOWED = (process.env.ADMIN_ALLOWED_EMAILS || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + +// Nur für lokale Tests: hebt die Auth komplett auf. Standardmäßig AUS. +// Wird ausschließlich im docker-compose.local.yml gesetzt, niemals im Deploy. +const AUTH_DISABLED = process.env.AUTH_DISABLED === 'true'; +if (AUTH_DISABLED) { + console.warn('[auth] ⚠ AUTH_DISABLED=true — KEINE Authentifizierung! Nur für lokale Tests verwenden.'); +} + +// Supabase-Client nur erstellen, wenn Auth aktiv und konfiguriert ist +// (createClient wirft bei leerer URL — würde sonst den Start verhindern). +let supabase = null; +if (!AUTH_DISABLED) { + if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { + console.warn('[auth] SUPABASE_URL / SUPABASE_ANON_KEY nicht gesetzt — Auth wird fehlschlagen.'); + } else { + supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + } +} + +export async function requireAdmin(req, res, next) { + try { + if (AUTH_DISABLED) { + req.user = { email: 'dev@local.test' }; + return next(); + } + if (!supabase) return res.status(500).json({ error: 'Auth nicht konfiguriert (SUPABASE_URL/ANON_KEY fehlen).' }); + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Kein Token.' }); + + const { data, error } = await supabase.auth.getUser(token); + if (error || !data?.user) return res.status(401).json({ error: 'Token ungültig.' }); + + const email = (data.user.email || '').toLowerCase(); + if (ALLOWED.length && !ALLOWED.includes(email)) { + return res.status(403).json({ error: 'Kein Admin-Zugriff für diese E-Mail.' }); + } + req.user = { email }; + next(); + } catch (e) { + res.status(500).json({ error: 'Auth-Fehler: ' + e.message }); + } +} diff --git a/stack/api/lib/mailserver.js b/stack/api/lib/mailserver.js new file mode 100644 index 0000000..1d2f5b4 --- /dev/null +++ b/stack/api/lib/mailserver.js @@ -0,0 +1,106 @@ +// --------------------------------------------------------------------------- +// mailserver.js — abgesicherte Bridge zum docker-mailserver Container +// +// Läuft NUR über einen docker-socket-proxy (nur exec freigegeben) und führt +// ausschließlich WHITELISTED Kommandos im Mailserver-Container aus. Argumente +// werden als argv-Array übergeben (keine Shell -> keine Injection). +// --------------------------------------------------------------------------- +import Docker from 'dockerode'; +import { httpErr } from './store.js'; + +const MAILSERVER = process.env.MAILSERVER_CONTAINER || 'mailserver'; +// DOCKER_PROXY = "host:port" (docker-socket-proxy), z.B. socket-proxy:2375 +const [proxyHost, proxyPort] = (process.env.DOCKER_PROXY || 'socket-proxy:2375').split(':'); +const docker = new Docker({ host: proxyHost, port: Number(proxyPort) || 2375 }); + +const isEmail = (s) => /^@?[^@\s]*@?[^@\s]+\.[^@\s]+$/.test(s); // erlaubt auch @domain +const isDomain = (s) => /^[^@\s]+\.[^@\s]+$/.test(s); + +// --- whitelisted exec: nimmt ein argv-Array, gibt stdout zurück ------------ +async function exec(cmd) { + const container = docker.getContainer(MAILSERVER); + const ex = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }); + const stream = await ex.start({ hijack: true, stdin: false }); + const out = []; + const err = []; + await new Promise((resolve, reject) => { + container.modem.demuxStream( + stream, + { write: (d) => out.push(d) }, + { write: (d) => err.push(d) }, + ); + stream.on('end', resolve); + stream.on('error', reject); + }); + const info = await ex.inspect(); + if (info.ExitCode && info.ExitCode !== 0) { + throw httpErr(502, `Mailserver-Kommando fehlgeschlagen: ${Buffer.concat(err).toString() || 'Exit ' + info.ExitCode}`); + } + return Buffer.concat(out).toString('utf8'); +} + +// =========================================================================== +// QUOTA-Auslastung (doveadm quota get -A) +// =========================================================================== +export async function quotaUsage() { + const raw = await exec(['doveadm', 'quota', 'get', '-A']); + const rows = {}; + for (const line of raw.split('\n')) { + // Username ... STORAGE <value KB> <limit KB|-> <%> + const m = line.match(/^(\S+)\s+User quota\s+STORAGE\s+(\d+)\s+(\d+|-)\s+(\d+|-)/); + if (m) { + const [, user, valueKB, limitKB, pct] = m; + rows[user] = { + id: user, + user, + usedBytes: Number(valueKB) * 1024, + limitBytes: limitKB === '-' ? null : Number(limitKB) * 1024, + percent: pct === '-' ? null : Number(pct), + }; + } + } + return Object.values(rows); +} + +// =========================================================================== +// Mail-Queue (postqueue -p) +// =========================================================================== +export async function queue() { + const raw = await exec(['postqueue', '-p']); + const empty = /Mail queue is empty/.test(raw); + // letzte Zeile: "-- N Kbytes in M Requests." + const m = raw.match(/in (\d+) Request/i); + return { empty, count: empty ? 0 : (m ? Number(m[1]) : null), raw: raw.trim() }; +} + +// =========================================================================== +// Aktive Logins (doveadm who) +// =========================================================================== +export async function who() { + const raw = await exec(['doveadm', 'who']); + const lines = raw.trim().split('\n').slice(1).filter(Boolean); // ohne Header + return { + sessions: lines.map((l) => { + const [username, count, proto] = l.split(/\s+/); + return { username, count: Number(count) || count, proto }; + }), + raw: raw.trim(), + }; +} + +// =========================================================================== +// DKIM-Schlüssel pro Domain erzeugen (setup config dkim ...) +// =========================================================================== +export async function generateDkim(domain) { + if (!isDomain(domain)) throw httpErr(400, 'Ungültige Domain.'); + await exec(['setup', 'config', 'dkim', 'keysize', '2048', 'domain', domain]); + return { ok: true, domain }; +} + +// =========================================================================== +// Übersicht +// =========================================================================== +export async function overview() { + const [q, qu, w] = await Promise.all([queue(), quotaUsage(), who()]); + return { queue: q, quota: qu, who: w }; +} diff --git a/stack/api/lib/settings.js b/stack/api/lib/settings.js new file mode 100644 index 0000000..469498a --- /dev/null +++ b/stack/api/lib/settings.js @@ -0,0 +1,77 @@ +// --------------------------------------------------------------------------- +// settings.js — editierbare Admin-Einstellungen (Domains, Webmail-Domain, Brand) +// +// Liegt als JSON in der DMS-Config (persistent). Beim ersten Start aus den +// ENV-Variablen (Deploy-Dialog) geseedet, danach in der Admin-UI editierbar. +// --------------------------------------------------------------------------- +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const CONFIG_DIR = process.env.CONFIG_DIR || '/config'; +const FILE = path.join(CONFIG_DIR, 'admin-settings.json'); + +const isDomain = (s) => /^[^@\s]+\.[^@\s]+$/.test(s); +const envDomains = () => + (process.env.MAIL_DOMAINS || process.env.MAIL_DOMAIN || '').split(/[\s,]+/).filter(Boolean); + +function seed() { + const domains = envDomains(); + const primary = process.env.MAIL_DOMAIN || domains[0] || 'example.com'; + return { + brand: process.env.BRAND || primary, + fqdn: process.env.MAIL_FQDN || `mail.${primary}`, + primaryDomain: primary, + domains: domains.length ? domains : [primary], + webmailFqdn: process.env.WEBMAIL_FQDN || `mail.${primary}`, + adminFqdn: process.env.ADMIN_FQDN || `admin.${primary}`, + }; +} + +let chain = Promise.resolve(); +const withLock = (fn) => { const r = chain.then(fn, fn); chain = r.catch(() => {}); return r; }; +const save = (s) => fs.writeFile(FILE, JSON.stringify(s, null, 2) + '\n', 'utf8'); + +export async function readSettings() { + try { + return { id: 'settings', ...JSON.parse(await fs.readFile(FILE, 'utf8')) }; + } catch (e) { + if (e.code === 'ENOENT') { const s = seed(); await save(s); return { id: 'settings', ...s }; } + throw e; + } +} + +export function writeSettings(patch) { + return withLock(async () => { + const { id, ...cur } = await readSettings(); + const next = { ...cur }; + // nur erlaubte Felder + for (const k of ['brand', 'fqdn', 'primaryDomain', 'webmailFqdn', 'adminFqdn']) { + if (typeof patch[k] === 'string' && patch[k].trim()) next[k] = patch[k].trim(); + } + if (Array.isArray(patch.domains)) { + next.domains = [...new Set(patch.domains.map((d) => String(d).trim()).filter(isDomain))]; + } + await save(next); + return { id: 'settings', ...next }; + }); +} + +export function addDomain(domain) { + return withLock(async () => { + domain = String(domain || '').trim().toLowerCase(); + if (!isDomain(domain)) { const e = new Error('Ungültige Domain.'); e.status = 400; throw e; } + const { id, ...cur } = await readSettings(); + if (!cur.domains.includes(domain)) cur.domains.push(domain); + await save(cur); + return { id: 'settings', ...cur }; + }); +} + +export function removeDomain(domain) { + return withLock(async () => { + const { id, ...cur } = await readSettings(); + cur.domains = cur.domains.filter((d) => d !== domain); + await save(cur); + return { id: 'settings', ...cur }; + }); +} diff --git a/stack/api/lib/store.js b/stack/api/lib/store.js new file mode 100644 index 0000000..71bbf60 --- /dev/null +++ b/stack/api/lib/store.js @@ -0,0 +1,229 @@ +// --------------------------------------------------------------------------- +// store.js — liest/schreibt die docker-mailserver Config-Dateien +// +// postfix-accounts.cf email|{SHA512-CRYPT}$6$... (ein Konto pro Zeile) +// postfix-virtual.cf quelle@dom ziel1@dom,ziel2@dom (ein Alias pro Zeile) +// dovecot-quotas.cf email:10G (eine Quota pro Zeile) +// +// DMS erkennt Dateiänderungen automatisch und lädt neu — kein Docker-Socket nötig. +// --------------------------------------------------------------------------- +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { sha512crypt } from 'sha512crypt-node'; +import { readSettings } from './settings.js'; + +const CONFIG_DIR = process.env.CONFIG_DIR || '/config'; +const ACCOUNTS = path.join(CONFIG_DIR, 'postfix-accounts.cf'); +const VIRTUAL = path.join(CONFIG_DIR, 'postfix-virtual.cf'); +const QUOTAS = path.join(CONFIG_DIR, 'dovecot-quotas.cf'); + +// --- simpler Schreib-Mutex (Admin-Last ist niedrig) ----------------------- +let chain = Promise.resolve(); +function withLock(fn) { + const run = chain.then(fn, fn); + chain = run.catch(() => {}); + return run; +} + +// --- Datei-Helfer ---------------------------------------------------------- +async function readLines(file) { + try { + const txt = await fs.readFile(file, 'utf8'); + return txt.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + } catch (e) { + if (e.code === 'ENOENT') return []; + throw e; + } +} + +async function writeLines(file, lines) { + const tmp = `${file}.tmp-${process.pid}`; + await fs.writeFile(tmp, lines.length ? lines.join('\n') + '\n' : '', 'utf8'); + await fs.rename(tmp, file); // atomar +} + +// --- Passwort-Hash (SHA512-CRYPT), Format wie doveadm pw ------------------ +const SALTCHARS = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +function hashPassword(plain) { + let salt = ''; + for (let i = 0; i < 16; i++) salt += SALTCHARS[Math.floor(Math.random() * SALTCHARS.length)]; + return '{SHA512-CRYPT}' + sha512crypt(plain, '$6$' + salt); +} + +const isEmail = (s) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); +const isCatchAll = (s) => /^@[^@\s]+\.[^@\s]+$/.test(s); // @domain.tld +const isQuota = (q) => !q || /^\d+\s*[KMGT]?$/i.test(String(q).trim()); // 5G, 500M, leer + +// =========================================================================== +// ACCOUNTS (+ Quota wird mit eingelesen/geschrieben) +// =========================================================================== +async function readQuotaMap() { + const map = {}; + for (const line of await readLines(QUOTAS)) { + const idx = line.lastIndexOf(':'); + if (idx > 0) map[line.slice(0, idx)] = line.slice(idx + 1); + } + return map; +} + +export async function listAccounts() { + const quotas = await readQuotaMap(); + return (await readLines(ACCOUNTS)).map((line) => { + const idx = line.indexOf('|'); + const email = idx > 0 ? line.slice(0, idx) : line; + return { id: email, email, quota: quotas[email] || '' }; + }); +} + +export async function getAccount(email) { + return (await listAccounts()).find((a) => a.id === email) || null; +} + +export function createAccount({ email, password, quota }) { + return withLock(async () => { + if (!isEmail(email)) throw httpErr(400, 'Ungültige E-Mail-Adresse.'); + if (!password) throw httpErr(400, 'Passwort erforderlich.'); + if (password.length < 8) throw httpErr(400, 'Passwort muss mindestens 8 Zeichen haben.'); + if (!isQuota(quota)) throw httpErr(400, 'Ungültiges Quota-Format (z.B. 5G, 500M – leer = unbegrenzt).'); + const lines = await readLines(ACCOUNTS); + if (lines.some((l) => l.split('|')[0] === email)) throw httpErr(409, 'Konto existiert bereits.'); + lines.push(`${email}|${hashPassword(password)}`); + await writeLines(ACCOUNTS, lines); + await setQuotaUnlocked(email, quota); + return { id: email, email, quota: quota || '' }; + }); +} + +export function updateAccount(email, { password, quota }) { + return withLock(async () => { + const lines = await readLines(ACCOUNTS); + const i = lines.findIndex((l) => l.split('|')[0] === email); + if (i === -1) throw httpErr(404, 'Konto nicht gefunden.'); + if (password && password.length < 8) throw httpErr(400, 'Passwort muss mindestens 8 Zeichen haben.'); + if (quota !== undefined && !isQuota(quota)) throw httpErr(400, 'Ungültiges Quota-Format (z.B. 5G, 500M).'); + if (password) lines[i] = `${email}|${hashPassword(password)}`; + await writeLines(ACCOUNTS, lines); + if (quota !== undefined) await setQuotaUnlocked(email, quota); + return { id: email, email, quota: quota ?? (await readQuotaMap())[email] ?? '' }; + }); +} + +export function deleteAccount(email) { + return withLock(async () => { + const lines = await readLines(ACCOUNTS); + await writeLines(ACCOUNTS, lines.filter((l) => l.split('|')[0] !== email)); + await setQuotaUnlocked(email, ''); // Quota mit entfernen + return { id: email }; + }); +} + +// --- Quota (innerhalb eines bestehenden Locks aufrufen) ------------------- +async function setQuotaUnlocked(email, quota) { + const lines = await readLines(QUOTAS); + const rest = lines.filter((l) => l.slice(0, l.lastIndexOf(':')) !== email); + if (quota) rest.push(`${email}:${quota}`); + await writeLines(QUOTAS, rest); +} + +// =========================================================================== +// ALIASES +// =========================================================================== +export async function listAliases() { + return (await readLines(VIRTUAL)).map((line) => { + const m = line.split(/\s+/); + const source = m.shift(); + return { id: source, source, destination: m.join(' ').replace(/\s+/g, ',') }; + }); +} + +export async function getAlias(source) { + return (await listAliases()).find((a) => a.id === source) || null; +} + +export function createAlias({ source, destination }) { + return withLock(async () => { + if (!isEmail(source) && !isCatchAll(source)) { + throw httpErr(400, 'Ungültige Alias-Adresse (vollständige E-Mail oder @domain.tld für Catch-all).'); + } + if (!destination) throw httpErr(400, 'Ziel erforderlich.'); + const lines = await readLines(VIRTUAL); + if (lines.some((l) => l.split(/\s+/)[0] === source)) throw httpErr(409, 'Alias existiert bereits.'); + lines.push(`${source} ${destination.split(',').map((s) => s.trim()).filter(Boolean).join(',')}`); + await writeLines(VIRTUAL, lines); + return { id: source, source, destination }; + }); +} + +export function updateAlias(source, { destination }) { + return withLock(async () => { + const lines = await readLines(VIRTUAL); + const i = lines.findIndex((l) => l.split(/\s+/)[0] === source); + if (i === -1) throw httpErr(404, 'Alias nicht gefunden.'); + lines[i] = `${source} ${destination.split(',').map((s) => s.trim()).filter(Boolean).join(',')}`; + await writeLines(VIRTUAL, lines); + return { id: source, source, destination }; + }); +} + +export function deleteAlias(source) { + return withLock(async () => { + const lines = await readLines(VIRTUAL); + await writeLines(VIRTUAL, lines.filter((l) => l.split(/\s+/)[0] !== source)); + return { id: source }; + }); +} + +// =========================================================================== +// STATUS / DNS / DKIM +// =========================================================================== +export async function status() { + const s = await readSettings(); + const primary = s.primaryDomain; + const fqdn = s.fqdn; + const domains = s.domains; + const accounts = await listAccounts(); + const aliases = await listAliases(); + + // alle DKIM-DNS-Dateien einlesen (Dateiname enthält die Domain) + const dkimFiles = []; + try { + const dkimDir = path.join(CONFIG_DIR, 'rspamd', 'dkim'); + for (const f of await fs.readdir(dkimDir)) { + if (f.endsWith('.dns.txt')) { + dkimFiles.push({ f, txt: (await fs.readFile(path.join(dkimDir, f), 'utf8')).trim() }); + } + } + } catch { /* noch kein DKIM erzeugt */ } + + const records = domains.map((d) => ({ + domain: d, + mx: `${d}. IN MX 10 ${fqdn}.`, + spf: `${d}. IN TXT "v=spf1 mx ~all"`, + dmarc: `_dmarc.${d}. IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@${d}"`, + dkim: (dkimFiles.find((x) => x.f.includes(`-${d}.`) || x.f.includes(d)) || {}).txt || '', + })); + + return { + id: 'status', + fqdn, + domain: primary, + brand: s.brand, + webmailFqdn: s.webmailFqdn, + adminFqdn: s.adminFqdn, + domains, + accounts: accounts.length, + aliases: aliases.length, + host: { + a: `${fqdn}. IN A <öffentliche IP>`, + ptr: `<öffentliche IP> -> ${fqdn} (PTR/rDNS beim Hoster setzen)`, + }, + records, + }; +} + +// --- kleiner HTTP-Fehler-Helfer ------------------------------------------- +export function httpErr(status, message) { + const e = new Error(message); + e.status = status; + return e; +} diff --git a/stack/api/package.json b/stack/api/package.json new file mode 100644 index 0000000..84a0253 --- /dev/null +++ b/stack/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "dms-admin-api", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Admin API für docker-mailserver — verwaltet Konten/Aliase/Quotas über die DMS-Config-Dateien (Supabase-Auth).", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "dockerode": "^4.0.2", + "express": "^4.19.2", + "sha512crypt-node": "^1.0.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/stack/api/server.js b/stack/api/server.js new file mode 100644 index 0000000..8de1535 --- /dev/null +++ b/stack/api/server.js @@ -0,0 +1,163 @@ +// --------------------------------------------------------------------------- +// server.js — Admin-API für docker-mailserver +// +// REST-Schnittstelle im Format von react-admin (ra-data-simple-rest): +// GET /accounts Liste (mit Content-Range Header) +// GET /accounts/:id einzelnes Konto +// POST /accounts anlegen +// PUT /accounts/:id ändern (Passwort/Quota) +// DELETE /accounts/:id löschen +// analog /aliases, sowie GET /status (DNS/DKIM-Infos). +// --------------------------------------------------------------------------- +import express from 'express'; +import { requireAdmin } from './lib/auth.js'; +import * as store from './lib/store.js'; +import * as ms from './lib/mailserver.js'; +import * as settings from './lib/settings.js'; + +const app = express(); +app.use(express.json()); + +// --- ungeschützter Health-Check ------------------------------------------- +app.get('/health', (_req, res) => res.json({ ok: true })); + +// --- ab hier alles geschützt ---------------------------------------------- +app.use(requireAdmin); + +// Hilfsfunktion: Liste sortieren/filtern/paginieren wie ra-data-simple-rest +function sendList(res, resource, rows, query) { + let data = rows; + // filter + if (query.filter) { + try { + const f = JSON.parse(query.filter); + // getMany: { id: [...] } + if (Array.isArray(f.id)) data = data.filter((r) => f.id.includes(r.id)); + // Volltext-Filter "q" + if (f.q) { + const q = String(f.q).toLowerCase(); + data = data.filter((r) => JSON.stringify(r).toLowerCase().includes(q)); + } + } catch { /* ignore */ } + } + // sort + if (query.sort) { + try { + const [field, order] = JSON.parse(query.sort); + data = [...data].sort((a, b) => String(a[field]).localeCompare(String(b[field]))); + if (order === 'DESC') data.reverse(); + } catch { /* ignore */ } + } + const total = data.length; + // range + let [start, end] = [0, total - 1]; + if (query.range) { + try { [start, end] = JSON.parse(query.range); } catch { /* ignore */ } + } + const page = data.slice(start, end + 1); + res.set('Content-Range', `${resource} ${start}-${start + page.length - 1}/${total}`); + res.set('Access-Control-Expose-Headers', 'Content-Range'); + res.json(page); +} + +const id = (req) => decodeURIComponent(req.params.id); + +// ============================ ACCOUNTS ===================================== +app.get('/accounts', async (req, res, next) => { + try { sendList(res, 'accounts', await store.listAccounts(), req.query); } catch (e) { next(e); } +}); +app.get('/accounts/:id', async (req, res, next) => { + try { + const a = await store.getAccount(id(req)); + if (!a) return res.status(404).json({ error: 'Konto nicht gefunden.' }); + res.json(a); + } catch (e) { next(e); } +}); +app.post('/accounts', async (req, res, next) => { + try { res.status(201).json(await store.createAccount(req.body)); } catch (e) { next(e); } +}); +app.put('/accounts/:id', async (req, res, next) => { + try { res.json(await store.updateAccount(id(req), req.body)); } catch (e) { next(e); } +}); +app.delete('/accounts/:id', async (req, res, next) => { + try { res.json(await store.deleteAccount(id(req))); } catch (e) { next(e); } +}); + +// ============================ ALIASES ====================================== +app.get('/aliases', async (req, res, next) => { + try { sendList(res, 'aliases', await store.listAliases(), req.query); } catch (e) { next(e); } +}); +app.get('/aliases/:id', async (req, res, next) => { + try { + const a = await store.getAlias(id(req)); + if (!a) return res.status(404).json({ error: 'Alias nicht gefunden.' }); + res.json(a); + } catch (e) { next(e); } +}); +app.post('/aliases', async (req, res, next) => { + try { res.status(201).json(await store.createAlias(req.body)); } catch (e) { next(e); } +}); +app.put('/aliases/:id', async (req, res, next) => { + try { res.json(await store.updateAlias(id(req), req.body)); } catch (e) { next(e); } +}); +app.delete('/aliases/:id', async (req, res, next) => { + try { res.json(await store.deleteAlias(id(req))); } catch (e) { next(e); } +}); + +// ============================ STATUS ======================================= +app.get('/status', async (_req, res, next) => { + try { res.json(await store.status()); } catch (e) { next(e); } +}); +// react-admin erwartet bei getOne(status) ggf. /status/status +app.get('/status/:id', async (_req, res, next) => { + try { res.json(await store.status()); } catch (e) { next(e); } +}); + +// ============== MAILSERVER-BRIDGE (über docker-socket-proxy) =============== +app.get('/mailserver/overview', async (_req, res, next) => { + try { res.json(await ms.overview()); } catch (e) { next(e); } +}); +app.get('/mailserver/quota', async (_req, res, next) => { + try { res.json(await ms.quotaUsage()); } catch (e) { next(e); } +}); +app.get('/mailserver/queue', async (_req, res, next) => { + try { res.json(await ms.queue()); } catch (e) { next(e); } +}); +app.get('/mailserver/who', async (_req, res, next) => { + try { res.json(await ms.who()); } catch (e) { next(e); } +}); +app.post('/mailserver/dkim', async (req, res, next) => { + try { res.json(await ms.generateDkim((req.body || {}).domain)); } catch (e) { next(e); } +}); + +// ============== EINSTELLUNGEN (Domains / Webmail-Domain / Brand) ============ +app.post('/settings/domains', async (req, res, next) => { + try { + const domain = (req.body || {}).domain; + const result = await settings.addDomain(domain); + ms.generateDkim(domain).catch(() => {}); // DKIM best-effort über die Bridge + res.json(result); + } catch (e) { next(e); } +}); +app.delete('/settings/domains/:domain', async (req, res, next) => { + try { res.json(await settings.removeDomain(decodeURIComponent(req.params.domain))); } catch (e) { next(e); } +}); +app.get('/settings', async (_req, res, next) => { + try { res.json(await settings.readSettings()); } catch (e) { next(e); } +}); +app.get('/settings/:id', async (_req, res, next) => { + try { res.json(await settings.readSettings()); } catch (e) { next(e); } +}); +app.put('/settings/:id', async (req, res, next) => { + try { res.json(await settings.writeSettings(req.body || {})); } catch (e) { next(e); } +}); + +// --- Fehlerbehandlung ------------------------------------------------------ +app.use((err, _req, res, _next) => { + const code = err.status || 500; + if (code >= 500) console.error(err); + res.status(code).json({ error: err.message || 'Serverfehler' }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => console.log(`[dms-admin-api] läuft auf :${PORT}`)); diff --git a/stack/docker-compose.yml b/stack/docker-compose.yml new file mode 100644 index 0000000..02b7218 --- /dev/null +++ b/stack/docker-compose.yml @@ -0,0 +1,114 @@ +# ============================================================================ +# docker-mailserver Stack +# mailserver – Postfix/Dovecot/Rspamd (docker-mailserver) +# admin-api – Node.js API (Supabase-Auth) verwaltet DMS-Config-Dateien +# admin-ui – React-Admin Oberfläche (nginx, proxyt /api -> admin-api) +# snappymail – schlankes Webmail für die Mitarbeiter +# +# TLS/HTTPS für admin-ui & snappymail macht der Nginx Proxy Manager +# (separater Stack), der auf ADMIN_PORT / WEBMAIL_PORT dieses Hosts zeigt. +# ============================================================================ +services: + mailserver: + image: ghcr.io/docker-mailserver/docker-mailserver:${DMS_TAG:-latest} + container_name: mailserver + hostname: ${MAIL_FQDN} + env_file: mailserver.env + environment: + - OVERRIDE_HOSTNAME=${MAIL_FQDN} + - POSTMASTER_ADDRESS=postmaster@${MAIL_DOMAIN} + ports: + - "25:25" # SMTP (eingehender MX-Verkehr) + - "143:143" # IMAP (STARTTLS) + - "465:465" # SMTP Submission (implicit TLS) + - "587:587" # SMTP Submission (STARTTLS) + - "993:993" # IMAP (implicit TLS) + - "${RSPAMD_PORT:-11334}:11334" # Rspamd Web-UI — NUR über NPM/Firewall öffnen! + volumes: + - ./docker-data/dms/mail-data/:/var/mail/ + - ./docker-data/dms/mail-state/:/var/mail-state/ + - ./docker-data/dms/mail-logs/:/var/log/mail/ + - ./docker-data/dms/config/:/tmp/docker-mailserver/ + - ./docker-data/certs/:/etc/letsencrypt/:ro # Zertifikate (NPM DNS-Challenge) + - /etc/localtime:/etc/localtime:ro + restart: always + stop_grace_period: 1m + cap_add: + - NET_ADMIN # für Fail2ban + healthcheck: + test: "ss --listening --tcp | grep -P 'LISTEN.+:smtp' || exit 1" + timeout: 3s + retries: 0 + + # docker-socket-proxy: gibt der Admin-API NUR exec frei (kein create/delete/...) + socket-proxy: + image: tecnativa/docker-socket-proxy:latest + container_name: dms-socket-proxy + restart: always + security_opt: + - no-new-privileges:true + environment: + - CONTAINERS=1 + - EXEC=1 + - POST=1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + admin-api: + build: ./api + container_name: dms-admin-api + restart: always + security_opt: + - no-new-privileges:true + environment: + - CONFIG_DIR=/config + - MAIL_DOMAIN=${MAIL_DOMAIN} + - MAIL_DOMAINS=${MAIL_DOMAINS} + - MAIL_FQDN=${MAIL_FQDN} + - BRAND=${BRAND} + - WEBMAIL_FQDN=${WEBMAIL_FQDN} + - ADMIN_FQDN=${ADMIN_FQDN} + - SUPABASE_URL=${SUPABASE_URL} + - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} + - ADMIN_ALLOWED_EMAILS=${ADMIN_ALLOWED_EMAILS} + # Bridge zum Mailserver: nur exec über den socket-proxy, Whitelist in der API + - DOCKER_PROXY=socket-proxy:2375 + - MAILSERVER_CONTAINER=mailserver + depends_on: + - socket-proxy + volumes: + # Schreibzugriff auf die DMS-Config-Dateien (kein direkter Docker-Socket!) + - ./docker-data/dms/config/:/config/ + expose: + - "3000" + + admin-ui: + build: + context: ./admin + args: + # Werden zur Laufzeit via /config.js injiziert (siehe entrypoint) + - VITE_SUPABASE_URL=${SUPABASE_URL} + - VITE_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} + container_name: dms-admin-ui + restart: always + security_opt: + - no-new-privileges:true + depends_on: + - admin-api + environment: + - SUPABASE_URL=${SUPABASE_URL} + - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} + ports: + - "${ADMIN_PORT:-8080}:80" + + snappymail: + image: djmaze/snappymail:latest + container_name: snappymail + restart: always + security_opt: + - no-new-privileges:true + ports: + - "${WEBMAIL_PORT:-8888}:8888" + volumes: + - ./docker-data/snappymail/:/var/lib/snappymail/ # echter Datenpfad der djmaze-Image + - ./snappymail-theme/:/snappymail/themes/:ro # KGVA "Shibui"-Theme diff --git a/stack/mailserver.env b/stack/mailserver.env new file mode 100644 index 0000000..046d06d --- /dev/null +++ b/stack/mailserver.env @@ -0,0 +1,32 @@ +# ============================================================================ +# docker-mailserver — statische Feature-Flags +# (Hostname/Domain kommen über docker-compose aus der .env) +# Vollständige Referenz: https://docker-mailserver.github.io/docker-mailserver/latest/config/environment/ +# ============================================================================ + +TZ=Europe/Zurich +LOG_LEVEL=info + +# --- TLS --- +# Fester Cert-Pfad (gemountet aus ./docker-data/certs nach /etc/letsencrypt). +# Das Setup-Skript legt dort beim ersten Start ein SELF-SIGNED-Zertifikat ab, +# damit STARTTLS sofort funktioniert. Für ein echtes Zertifikat einfach diese +# zwei Dateien durch das NPM-Let's-Encrypt-Cert ersetzen (cert.pem=fullchain, +# key.pem=privkey) und `docker compose restart mailserver` — KEINE Änderung hier. +# Siehe README, Abschnitt "TLS mit Nginx Proxy Manager". +SSL_TYPE=manual +SSL_CERT_PATH=/etc/letsencrypt/cert.pem +SSL_KEY_PATH=/etc/letsencrypt/key.pem + +# --- Spam / Antivirus --- +ENABLE_RSPAMD=1 +RSPAMD_GREYLISTING=1 +ENABLE_OPENDKIM=0 +ENABLE_OPENDMARC=0 +ENABLE_CLAMAV=0 +ENABLE_FAIL2BAN=1 + +# --- Sicherheit / Submission --- +ONE_DIR=1 +PERMIT_DOCKER=none +SPOOF_PROTECTION=1 diff --git a/stack/snappymail-theme/Shibui/styles.css b/stack/snappymail-theme/Shibui/styles.css new file mode 100644 index 0000000..a116906 --- /dev/null +++ b/stack/snappymail-theme/Shibui/styles.css @@ -0,0 +1,2921 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400&display=swap"); + +/* ============================================================ + shibui (KGVA) für SnappyMail + Basis: SnappyMail-Theme "NextcloudV25+" (Nextcloud-Nachbildung, + liest die --color-* Variablen). Darunter folgt das Original- + Nextcloud-shibui-CSS von KGVA, das genau diese --color-* setzt. + ============================================================ */ + +/* ===== BASIS: NextcloudV25+ ===== */ +/** + * Nextcloud - SnappyMail mail plugin + * + * @author Rene Hampölz (@hampoelz), SnappyMail, Nextcloud Team + */ + +/* + * Default Nextcloud (v25+) theme variables and colors + * based on https://github.com/nextcloud/server/blob/master/core/css/variables.scss + */ +/* #region theme */ + +:root { + --border-color: var(--nc-color-border); + --dialog-border-clr: var(--nc-color-border); +} + +:not([data-themes~="dark"]) { + --nc-color-main-background: var(--color-main-background, #ffffff); + --nc-color-main-background-rgb: var(--color-main-background-rgb, 255, 255, 255); + --nc-color-main-background-blur: var(--color-main-background-blur, rgba(var(--nc-color-main-background-rgb), .8)); + --nc-filter-background-blur: var(--filter-background-blur, blur(25px)); + --nc-color-background-hover: var(--color-background-hover, #f5f5f5); + --nc-color-background-dark: var(--color-background-dark, #ededed); + --nc-color-background-darker: var(--color-background-darker, #dbdbdb); + --nc-color-placeholder-light: var(--color-placeholder-light, #e6e6e6); + --nc-color-placeholder-dark: var(--color-placeholder-dark, #cccccc); + --nc-color-main-text: var(--color-main-text, #222222); + --nc-color-text-light: var(--color-text-light, #222222); + --nc-color-text-lighter: var(--color-text-lighter, #767676); + --nc-color-text-maxcontrast: var(--color-text-maxcontrast, #767676); + --nc-color-scrollbar: var(--color-scrollbar, rgba(34, 34, 34, .15)); + --nc-color-error: var(--color-error, #e9322d); + --nc-color-error-rgb: var(--color-error-rgb, 233, 50, 45); + --nc-color-error-hover: var(--color-error-hover, #ed5a56); + --nc-color-warning: var(--color-warning, #eca700); + --nc-color-warning-rgb: var(--color-warning-rgb, 236, 167, 0); + --nc-color-warning-hover: var(--color-warning-hover, #efb832); + --nc-color-success: var(--color-success, #46ba61); + --nc-color-success-rgb: var(--color-success-rgb, 70, 186, 97); + --nc-color-success-hover: var(--color-success-hover, #6ac780); + --nc-color-loading-light: var(--color-loading-light, #cccccc); + --nc-color-loading-dark: var(--color-loading-dark, #444444); + --nc-color-box-shadow-rgb: var(--color-box-shadow-rgb, 77, 77, 77); + --nc-color-box-shadow: var(--color-box-shadow, rgba(var(--nc-color-box-shadow-rgb), 0.5)); + --nc-color-border: var(--color-border, #ededed); + --nc-color-border-dark: var(--color-border-dark, #dbdbdb); + --nc-color-border-maxcontrast: var(--color-border-maxcontrast, #949494); + --nc-font-face: var(--font-face, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, sans-serif, 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'); + --nc-default-font-size: var(--default-font-size, 15px); + --nc-default-line-height: var(--default-line-height, 24px); + --nc-animation-quick: var(--animation-quick, 100ms); + --nc-animation-slow: var(--animation-slow, 300ms); + --nc-border-radius: var(--border-radius, 3px); + --nc-border-radius-large: var(--border-radius-large, 8px); + --nc-border-radius-pill: var(--border-radius-pill, 100px); + --nc-default-grid-baseline: var(--default-grid-baseline, 4px); + --nc-navigation-width: var(--navigation-width, 300px); + --nc-sidebar-min-width: var(--sidebar-min-width, 300px); + --nc-sidebar-max-width: var(--sidebar-max-width, 500px); + --nc-list-min-width: var(--list-min-width, 200px); + --nc-list-max-width: var(--list-max-width, 300px); + --nc-breakpoint-mobile: var(--breakpoint-mobile, 1024px); + --nc-color-primary: var(--color-primary, #00639a); + --nc-color-primary-default: var(--color-primary-default, #0082c9); + --nc-color-primary-text: var(--color-primary-text, #ffffff); + --nc-color-primary-hover: var(--color-primary-hover, #3282ae); + --nc-color-primary-light: var(--color-primary-light, #e5eff4); + --nc-color-primary-light-text: var(--color-primary-light-text, #00273d); + --nc-color-primary-light-hover: var(--color-primary-light-hover, #dbe4e9); + --nc-color-primary-text-dark: var(--color-primary-text-dark, #ededed); + --nc-color-primary-element: var(--color-primary-element, #00639a); + --nc-color-primary-element-text: var(--color-primary-element-text, #ffffff); + --nc-color-primary-element-hover: var(--color-primary-element-hover, #3282ae); + --nc-color-primary-element-light: var(--color-primary-element-light, #e5eff4); + --nc-color-primary-element-light-text: var(--color-primary-element-light-text, #00273d); + --nc-color-primary-element-light-hover: var(--color-primary-element-light-hover, #dbe4e9); + --nc-color-primary-element-text-dark: var(--color-primary-element-text-dark, #ededed); +} + +@media (prefers-color-scheme: dark) { + :not([data-themes~="light"]) { + --nc-color-main-background: var(--color-main-background, #171717); + --nc-color-main-background-rgb: var(--color-main-background-rgb, 23, 23, 23); + --nc-color-main-background-blur: var(--color-main-background-blur, rgba(var(--nc-color-main-background-rgb), .8)); + --nc-filter-background-blur: var(--filter-background-blur, blur(25px)); + --nc-color-background-hover: var(--color-background-hover, #212121); + --nc-color-background-dark: var(--color-background-dark, #292929); + --nc-color-background-darker: var(--color-background-darker, #3b3b3b); + --nc-color-placeholder-light: var(--color-placeholder-light, #313131); + --nc-color-placeholder-dark: var(--color-placeholder-dark, #4a4a4a); + --nc-color-main-text: var(--color-main-text, #D8D8D8); + --nc-color-text-light: var(--color-text-light, #bfbfbf); + --nc-color-text-lighter: var(--color-text-lighter, #a5a5a5); + --nc-color-text-maxcontrast: var(--color-text-maxcontrast, #8c8c8c); + --nc-color-scrollbar: var(--color-scrollbar, #3d3d3d); + --nc-color-error: var(--color-error, #e9322d); + --nc-color-error-rgb: var(--color-error-rgb, 233, 50, 45); + --nc-color-error-hover: var(--color-error-hover, #ed5a56); + --nc-color-warning: var(--color-warning, #eca700); + --nc-color-warning-rgb: var(--color-warning-rgb, 236, 167, 0); + --nc-color-warning-hover: var(--color-warning-hover, #efb832); + --nc-color-success: var(--color-success, #46ba61); + --nc-color-success-rgb: var(--color-success-rgb, 70, 186, 97); + --nc-color-success-hover: var(--color-success-hover, #6ac780); + --nc-color-loading-light: var(--color-loading-light, #777); + --nc-color-loading-dark: var(--color-loading-dark, #CCC); + --nc-color-box-shadow-rgb: var(--color-box-shadow-rgb, 0, 0, 0); + --nc-color-box-shadow: var(--color-box-shadow, #000000); + --nc-color-border: var(--color-border, #292929); + --nc-color-border-dark: var(--color-border-dark, #3b3b3b); + --nc-color-border-maxcontrast: var(--color-border-maxcontrast, #646464); + --nc-font-face: var(--font-face, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', Arial, sans-serif, 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'); + --nc-default-font-size: var(--default-font-size, 15px); + --nc-default-line-height: var(--default-line-height, 24px); + --nc-animation-quick: var(--animation-quick, 100ms); + --nc-animation-slow: var(--animation-slow, 300ms); + --nc-border-radius: var(--border-radius, 3px); + --nc-border-radius-large: var(--border-radius-large, 8px); + --nc-border-radius-pill: var(--border-radius-pill, 100px); + --nc-default-grid-baseline: var(--default-grid-baseline, 4px); + --nc-navigation-width: var(--navigation-width, 300px); + --nc-sidebar-min-width: var(--sidebar-min-width, 300px); + --nc-sidebar-max-width: var(--sidebar-max-width, 500px); + --nc-list-min-width: var(--list-min-width, 200px); + --nc-list-max-width: var(--list-max-width, 300px); + --nc-breakpoint-mobile: var(--breakpoint-mobile, 1024px); + --nc-color-primary: var(--color-primary, #00639a); + --nc-color-primary-default: var(--color-primary-default, #0082c9); + --nc-color-primary-text: var(--color-primary-text, #ffffff); + --nc-color-primary-hover: var(--color-primary-hover, #04537f); + --nc-color-primary-light: var(--color-primary-light, #141e24); + --nc-color-primary-light-text: var(--color-primary-light-text, #99c0d6); + --nc-color-primary-light-hover: var(--color-primary-light-hover, #1d272d); + --nc-color-primary-text-dark: var(--color-primary-text-dark, #ededed); + --nc-color-primary-element: var(--color-primary-element, #00639a); + --nc-color-primary-element-text: var(--color-primary-element-text, #ffffff); + --nc-color-primary-element-hover: var(--color-primary-element-hover, #04537f); + --nc-color-primary-element-light: var(--color-primary-element-light, #141e24); + --nc-color-primary-element-light-text: var(--color-primary-element-light-text, #99c0d6); + --nc-color-primary-element-light-hover: var(--color-primary-element-light-hover, #1d272d); + --nc-color-primary-element-text-dark: var(--color-primary-element-text-dark, #ededed); + } +} + +/* #endregion */ + +/* + * Icons + */ +/* #region icons */ + +#rl-app .iconcolor-green { + color: var(--nc-color-success) !important; +} + +#rl-app .iconcolor-red { + color: var(--nc-color-error) !important; +} + +#rl-app i.fontastic { + line-height: unset; +} + +#rl-app [data-icon]::before { + vertical-align: -10%; +} + +#rl-app .drag-handle { + color: var(--nc-color-text-lighter); +} + +/* #endregion */ + +/* + * Elements + */ + +/* #region elements */ + +/* #region link */ + +:root { + --link-color: var(--nc-color-primary-element-light-text); +} + +#rl-app a[target="_blank"] { + color: var(--nc-color-primary-element-light-text); +} + +#rl-app a[target="_blank"]:visited, +#rl-app a[target="_blank"]:active { + color: var(--nc-color-primary-element); +} + +/* #endregion */ + +/* #region scrollbar */ + +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-thumb { + background: var(--nc-color-scrollbar); + border-radius: var(--nc-border-radius-large); + border: 2px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-corner, +::-webkit-scrollbar-track-piece { + background-color: transparent; +} + +/* #endregion */ + +/* #region resizer */ + +#rl-app .resizer { + opacity: 1; + background-color: unset; + border-right: solid 1px var(--nc-color-border); +} + +::-webkit-resizer { + border: 10px solid transparent; + border-radius: var(--nc-border-radius); + border-bottom-color: var(--nc-color-border-dark); +} + +::-webkit-resizer { + border-right-color: var(--nc-color-border-dark); +} + +[dir="rtl"] ::-webkit-resizer { + border-right-color: transparent; + border-left-color: var(--nc-color-border-dark); +} + +/* #endregion */ + +/* #region meter */ + +#rl-app meter::-webkit-meter-bar { + background-color: var(--nc-color-border-dark); + border-color: var(--nc-color-border-dark); + border-width: 2px; + height: 10px; +} + +/* #endregion */ + +/* #region horizontal rule */ + +:root { + --hr-color: var(--nc-color-border-dark); +} + +#rl-app hr { + border-color: var(--nc-color-border-dark); +} + +/* #endregion */ + +/* #region tooltip */ + +#rl-app [data-rainloopErrorTip]::before, +#rl-app .help-block span { + border: 1px solid var(--nc-color-border); + border-radius: var(--nc-border-radius); + box-shadow: 0 0 2px var(--nc-color-box-shadow); + background-color: var(--nc-color-background-dark); + font-size: 13px; + color: unset; + left: 10px; + right: unset; + padding: 5px; + margin: 7px; +} + +#rl-app [data-rainloopErrorTip]::before { + color: var(--nc-color-error); + font-weight: 700; +} + +/* #endregion */ + +/* #region alert */ + +#rl-app .alert { + text-shadow: unset; + border: unset; + color: var(--nc-color-main-text); + background-color: rgba(var(--nc-color-warning-rgb), 0.1); + border-inline-start: 4px solid var(--nc-color-warning); + border-radius: var(--nc-border-radius); + margin: 1rem; + padding: 1rem; +} + +#rl-app .alert h4 { + font-weight: bold; + font-size: 20px; + margin-top: unset; + margin-bottom: 12px; + line-height: 30px; + color: var(--nc-color-text-light); +} + +#rl-app .alert.alert-info { + background-color: var(--nc-color-background-dark); + border-color: var(--nc-color-background-darker); +} + +#rl-app .alert.alert-warning { + background-color: rgba(var(--nc-color-warning-rgb), 0.1); + border-color: var(--nc-color-warning); +} + +#rl-app .alert.alert-success { + background-color: rgba(var(--nc-color-success-rgb), 0.1); + border-color: var(--nc-color-success); +} + +#rl-app .alert.alert-error { + background-color: rgba(var(--nc-color-error-rgb), 0.1); + border-color: var(--nc-color-error); +} + +#rl-app .alert .close { + top: -0.6rem; + right: unset; +} + +#rl-app .alert br { + display: none; +} + +/* #endregion */ + +/* #region button */ + +#rl-app .btn, +#rl-app button, +#rl-app button.btn:not(.button-vue) { + min-height: 36px; + height: 36px; + box-sizing: border-box; + padding: 8px 14px; + line-height: 20px; + color: var(--nc-color-main-text); + background-color: var(--nc-color-background-dark); + border: 1px solid var(--nc-color-border-dark) !important; + border-radius: var(--nc-border-radius-large); + font-size: var(--nc-default-font-size); + text-shadow: none; + box-shadow: none; +} + +#rl-app .btn:hover, +#rl-app .btn:focus, +#rl-app .btn:active, +#rl-app .btn.active, +#rl-app button:hover, +#rl-app button:focus, +#rl-app button:active, +#rl-app button.active, +#rl-app span.fontastic:hover, +#rl-app span.fontastic:focus, +#rl-app span.fontastic:active { + outline: none; + opacity: unset; +} + +#rl-app .btn.fontastic, +#rl-app button.fontastic { + border-radius: var(--nc-border-radius-large); +} + +#rl-app .btn.btn-thin, +#rl-app button.btn-thin { + padding-right: 9px; + padding-left: 9px; +} + +#rl-app .btn.btn-transparent, +#rl-app button.btn-transparent { + background: unset !important; + border: unset !important; + opacity: 0.6; +} + +#rl-app .btn.btn-warning, +#rl-app button.btn-warning { + color: var(--nc-color-primary-element-text); + background-color: var(--nc-color-warning); + border-color: var(--nc-color-warning) !important; +} + +#rl-app .btn.btn-warning:hover, +#rl-app .btn.btn-warning:focus, +#rl-app button.btn-warning:hover, +#rl-app button.btn-warning:focus { + color: var(--nc-color-primary-element-text); + background-color: var(--nc-color-warning-hover); + border-color: var(--nc-color-warning-hover) !important; +} + +#rl-app .btn.btn-danger, +#rl-app button.btn-danger { + color: var(--nc-color-primary-element-text); + background-color: var(--nc-color-error); + border-color: var(--nc-color-error) !important; +} + +#rl-app .btn.btn-danger:hover, +#rl-app .btn.btn-danger:focus, +#rl-app button.btn-danger:hover, +#rl-app button.btn-danger:focus { + color: var(--nc-color-primary-element-text); + background-color: var(--nc-color-error-hover); + border-color: var(--nc-color-error-hover) !important; +} + +#rl-app .btn.btn-success, +#rl-app button.btn-success, +#rl-app button.btn.buttonLogin { + color: var(--nc-color-primary-element-text); + background-color: var(--nc-color-primary-element); + border-color: var(--nc-color-primary-element) !important; +} + +#rl-app .btn.btn-success:hover, +#rl-app .btn.btn-success:focus, +#rl-app button.btn-success:hover, +#rl-app button.btn-success:focus, +#rl-app button.btn.buttonLogin:hover, +#rl-app button.btn.buttonLogin:focus { + color: var(--nc-color-primary-element-text); + background-color: var(--nc-color-primary-element-hover); + border-color: var(--nc-color-primary-element-hover) !important; +} + +#rl-app span.fontastic { + padding: 6px; + height: fit-content; + border-radius: var(--nc-border-radius); +} + +#rl-app span.fontastic:hover { + background-color: var(--nc-color-background-hover); +} + +#rl-app span.fontastic:active { + background-color: var(--nc-color-primary-light); +} + +#rl-app .btn.disabled, .btn[disabled], +#rl-app .btn.disabled:hover, .btn[disabled]:hover, +#rl-app .btn.disabled:focus, .btn[disabled]:focus, +#rl-app .btn.disabled:active, .btn[disabled]:active, +#rl-app button.disabled, button[disabled], +#rl-app button.disabled:hover, button[disabled]:hover, +#rl-app button.disabled:focus, button[disabled]:focus, +#rl-app button.disabled:active, button[disabled]:active, +#rl-app span.fontastic.disabled, span.fontastic[disabled], +#rl-app span.fontastic.disabled:hover, span.fontastic[disabled]:hover, +#rl-app span.fontastic.disabled:focus, span.fontastic[disabled]:focus, +#rl-app span.fontastic.disabled:active, span.fontastic[disabled]:active { + opacity: .5; + filter: saturate(0.7); +} + +#rl-app .btn:not(.disabled, [disabled]):hover, +#rl-app .btn:not(.disabled, [disabled]):focus, +#rl-app button:not(.disabled, [disabled]):hover, +#rl-app button:not(.disabled, [disabled]):focus, +#rl-app span.fontastic:not(.disabled, [disabled]):hover, +#rl-app span.fontastic:not(.disabled, [disabled]):focus { + border-color: var(--nc-color-primary-element) !important; +} + +#rl-app .btn:not(.disabled, [disabled]):active, +#rl-app .btn:not(.disabled, [disabled]).active, +#rl-app button:not(.disabled, [disabled]):active, +#rl-app button:not(.disabled, [disabled]).active, +#rl-app span.fontastic:not(.disabled, [disabled]):active, +#rl-app span.fontastic:not(.disabled, [disabled]).active { + background-color: var(--nc-color-main-background); + color: var(--nc-color-text-light); +} + +/* #endregion */ + +/* #region button group */ + +#rl-app .btn-group .btn:nth-child(1 of :not([style*="display: none;"])) { + margin-right: 0.5px; +} + +/* fallback */ +#rl-app .btn-group .btn:first-of-type { + margin-right: 0.5px; +} + +#rl-app .btn-group .btn:nth-last-child(1 of :not([style*="display: none;"])) { + margin-left: 0.5px; +} + +/* fallback */ +#rl-app .btn-group .btn:last-of-type { + margin-left: 0.5px; +} + +#rl-app .btn-group .btn:nth-child(n+2 of :not([style*="display: none;"])):nth-last-child(n+2 of :not([style*="display: none;"])) { + border-radius: 0 !important; + margin-right: 0.5px; + margin-left: 0.5px; +} + +/* fallback */ +#rl-app .btn-group .btn:not(:first-of-type):not(:last-of-type) { + border-radius: 0; + margin: 0 0.5px; +} + +#rl-app .btn-group .btn:nth-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .btn-group .btn:nth-last-child(1 of :not([style*="display: none;"])) { + border-radius: 0 !important; + border-top-left-radius: var(--nc-border-radius-large) !important; + border-bottom-left-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .btn-group .btn:first-of-type, +[dir="rtl"] #rl-app .btn-group .btn:last-of-type { + border-radius: 0; + border-top-left-radius: var(--nc-border-radius-large); + border-bottom-left-radius: var(--nc-border-radius-large); +} + +#rl-app .btn-group .btn:nth-last-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .btn-group .btn:nth-child(1 of :not([style*="display: none;"])) { + border-radius: 0 !important; + border-top-right-radius: var(--nc-border-radius-large) !important; + border-bottom-right-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .btn-group .btn:last-of-type, +[dir="rtl"] #rl-app .btn-group .btn:first-of-type { + border-radius: 0; + border-top-right-radius: var(--nc-border-radius-large); + border-bottom-right-radius: var(--nc-border-radius-large); +} + +#rl-app .btn-group .btn:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .btn-group .btn:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])) { + border-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .btn-group .btn:first-of-type:last-of-type, +[dir="rtl"] #rl-app .btn-group .btn:first-of-type:last-of-type { + border-radius: var(--nc-border-radius-large) !important; +} + +#rl-app .btn-group .btn.fontastic:nth-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .btn-group .btn.fontastic:nth-last-child(1 of :not([style*="display: none;"])) { + border-radius: 0 !important; + border-top-left-radius: var(--nc-border-radius-large) !important; + border-bottom-left-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .btn-group .btn.fontastic:first-of-type, +[dir="rtl"] #rl-app .btn-group .btn.fontastic:last-of-type { + border-radius: 0; + border-top-left-radius: var(--nc-border-radius-large); + border-bottom-left-radius: var(--nc-border-radius-large); +} + +#rl-app .btn-group .btn.fontastic:nth-last-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .btn-group .btn.fontastic:nth-child(1 of :not([style*="display: none;"])) { + border-radius: 0 !important; + border-top-right-radius: var(--nc-border-radius-large) !important; + border-bottom-right-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .btn-group .btn.fontastic:last-of-type, +[dir="rtl"] #rl-app .btn-group .btn.fontastic:first-of-type { + border-radius: 0; + border-top-right-radius: var(--nc-border-radius-large); + border-bottom-right-radius: var(--nc-border-radius-large); +} + +#rl-app .btn-group .btn.fontastic:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .btn-group .btn.fontastic:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])) { + border-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .btn-group .btn.fontastic:first-of-type:last-of-type, +[dir="rtl"] #rl-app .btn-group .btn.fontastic:first-of-type:last-of-type { + border-radius: var(--nc-border-radius-large) !important; +} + +/* #endregion */ + +/* #region input & select */ + +#rl-app select, +#rl-app .select, +#rl-app .emailaddresses, +#rl-app input:not([type="checkbox"]) { + padding: 0 12px; + font-size: var(--nc-default-font-size); + color: var(--nc-color-main-text); + background-color: unset; + height: 36px; + border: 2px solid var(--nc-color-border-maxcontrast); + border-radius: var(--nc-border-radius-large); + line-height: 32px; + outline: none; +} + +#rl-app select:hover, +#rl-app select:active, +#rl-app select:focus, +#rl-app .select:hover, +#rl-app .select:active, +#rl-app .select:focus, +#rl-app .emailaddresses:hover, +#rl-app .emailaddresses:active, +#rl-app .emailaddresses:focus, +#rl-app input:hover, +#rl-app input:active, +#rl-app input:focus, +#rl-app input:not([type="checkbox"]):hover, +#rl-app input:not([type="checkbox"]):active, +#rl-app input:not([type="checkbox"]):focus { + border-color: var(--nc-color-primary-element); +} + +/* #endregion */ + +/* #region input */ + +#rl-app input { + box-shadow: none; +} + +#rl-app input[type="search"]::-webkit-search-decoration, +#rl-app input[type="search"]::-webkit-search-cancel-button, +#rl-app input[type="search"]::-webkit-search-results-button, +#rl-app input[type="search"]::-webkit-search-results-decoration { + -webkit-appearance: none; +} + +#rl-app input::-webkit-outer-spin-button, +#rl-app input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +#rl-app input[type=number] { + appearance: textfield; + margin: 0; + /* ? margin: 0 5px 0 0; */ +} + +#rl-app .search-input-wrp a { + position: absolute; + top: 2px; + right: 1px; + min-width: unset; + min-height: unset; + height: 32px; + font-size: 20px; + line-height: 30px; +} + +/* #endregion */ + +/* #region checkbox */ + +#rl-app .e-checkbox.material-design { + width: fit-content !important; + width: -moz-fit-content !important; + display: flex; + position: relative; + align-items: center; + user-select: none; + height: 44px; + border-radius: 44px; + padding: 0 14px; + margin: 0; +} + +#rl-app .e-checkbox.material-design:hover, +#rl-app .e-checkbox.material-design:focus-within { + background-color: var(--nc-color-primary-light); + outline: none; +} + +#rl-app .e-checkbox input[type="checkbox"] { + display: none; +} + +#rl-app .e-checkbox:focus { + outline: none; +} + +input[type="checkbox"] { + background-color: transparent; + -webkit-appearance: none; + appearance: none; +} + +#rl-app .e-checkbox.material-design>div { + position: relative; + width: 18px; + top: 2px; +} + +#rl-app input[type="checkbox"], +#rl-app .e-checkbox.material-design input+div { + position: unset; + height: 18px !important; + width: 18px !important; + min-width: 18px !important; + flex-shrink: 0 !important; + border: 2px solid var(--nc-color-primary-element); + border-radius: 2px; + box-sizing: border-box; + animation: none; + transform: unset; +} + +#rl-app input[type="checkbox"]:checked, +#rl-app .e-checkbox.material-design input:checked+div { + --nc-svg-checked: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='3 3 18 18'><path d='M10,17L5,12L6.41,10.58L10,14.17L17.59,6.58L19,8M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z'></path></svg>"); + -webkit-mask-image: var(--nc-svg-checked); + mask-image: var(--nc-svg-checked); + background: var(--nc-color-primary-element); + border-width: 0; + animation: none; + transform: unset; +} + +/* #endregion */ + +/* #region select */ + +#rl-app select option, +#rl-app select::-webkit-scrollbar-corner, +#rl-app select::-webkit-scrollbar-track-piece { + background-color: var(--nc-color-main-background); + color: var(--nc-color-main-text); +} + +/* #endregion */ + +/* #region select email address */ + +#rl-app .emailaddresses { + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 0 0 4px; + width: 100%; + height: auto; + min-height: 44px; + max-height: calc(44px*2); + line-height: var(--nc-default-line-height); + background-color: var(--nc-color-main-background); + white-space: normal; +} + +#rl-app .emailaddresses.emailaddresses-focused { + border: 2px solid var(--nc-color-primary-element); +} + +#rl-app .emailaddresses li { + margin: 4px 2px 0; +} + +#rl-app .emailaddresses li.emailaddresses-input>input[type="text"], +#rl-app .emailaddresses li.emailaddresses-input>input[type="text"]:focus, +#rl-app .emailaddresses li.emailaddresses-input>input[type="text"]:hover { + padding: 0 7px; + height: 36px; + max-width: 390px; + color: var(--nc-color-main-text); + font-size: var(--nc-default-font-size); + line-height: var(--nc-default-line-height); +} + +#rl-app .emailaddresses li[draggable] { + display: flex; + align-items: center; + padding: 0 0.5em; + min-height: 36px; + line-height: var(--nc-default-line-height); + color: var(--nc-color-main-text); + border: 0; + border-radius: var(--nc-border-radius-large); + background-color: var(--nc-color-background-dark); + box-shadow: none; +} + +#rl-app .emailaddresses li[draggable]>span { + max-width: calc(100vw - 140px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#rl-app .emailaddresses li[draggable]>a.ficon { + position: unset; + color: transparent; + text-shadow: 0 0 0 var(--nc-color-text-maxcontrast); + background: none; + border: 0; + cursor: pointer; + display: inline-flex; + margin-left: 4px; + padding: 0; +} + +@media screen and (max-width: 480px) { + #rl-app .emailaddresses li.emailaddresses-input>input[type="text"] { + max-width: calc(100vw - 116px) !important; + } +} + +/* #endregion */ + +/* #region textarea */ + +#rl-app .squire-toolbar { + height: 50px; + padding: 5px; + box-sizing: content-box; + border: none; +} + +#rl-app textarea, +#rl-app div[contenteditable], +#rl-app .squire-wysiwyg, +#rl-app .squire-plain { + padding: 8px 12px; + margin: unset; + font-size: var(--nc-default-font-size); + color: var(--nc-color-main-text); + background-color: unset; + outline: none; + border: 2px solid var(--nc-color-border-dark); + border-radius: var(--nc-border-radius-large); +} + +#rl-app textarea:hover, +#rl-app textarea:active, +#rl-app textarea:focus, +#rl-app div[contenteditable=true]:hover, +#rl-app div[contenteditable=true]:active, +#rl-app div[contenteditable=true]:focus, +#rl-app .squire-wysiwyg:hover, +#rl-app .squire-wysiwyg:active, +#rl-app .squire-wysiwyg:focus, +#rl-app .squire-plain:hover, +#rl-app .squire-plain:active, +#rl-app .squire-plain:focus { + border-color: var(--nc-color-primary-element); +} + +/* #endregion */ + +/* #region dropdown */ + +#rl-app .dropdown.show { + box-shadow: none; +} + +#rl-app .dropdown-menu { + background-color: var(--nc-color-main-background); + filter: drop-shadow(0 1px 5px var(--nc-color-box-shadow)); + border-radius: var(--nc-border-radius-large); + color: var(--nc-color-main-text); + padding: 4px; +} + +#rl-app .dropdown-menu a { + background-color: unset; + color: unset; + height: 22px; + line-height: 22px; + border-radius: 3px; + margin: 2px; + box-sizing: content-box; + text-align: start; +} + +#rl-app .dropdown-menu li.disabled>a, +#rl-app .dropdown-menu li.disabled>a:hover, +#rl-app .dropdown-menu li.disabled>a:focus, +#rl-app .dropdown-menu li.disabled>a:active { + opacity: .5; + filter: saturate(0.7); +} + +#rl-app .dropdown-menu li:not(.disabled)>a:hover, +#rl-app .dropdown-menu li:not(.disabled)>a:focus { + background-color: var(--nc-color-background-hover); + color: unset; + opacity: unset; +} + +#rl-app .dropdown-menu li:not(.disabled)>a:active { + background-color: var(--nc-color-primary-element-light); +} + +#rl-app .dropdown-menu *+.dividerbar { + border-color: var(--nc-color-border); +} + +#rl-app .dropdown-menu::-webkit-scrollbar-track-piece { + background-color: inherit; + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; +} + +.rl-left-panel-disabled #rl-app .dropdown-menu:not(.right-edge) { + position: fixed; + top: auto; + left: 5px; + right: auto; +} + +/* #endregion */ + +/* #region dropdown account */ + +#rl-app #V-SystemDropDown { + margin: 2px 5px 0 5px; +} + +#rl-app #V-SystemDropDown .btn-toolbar { + border: solid 1px var(--nc-color-border-dark); + border-radius: var(--nc-border-radius-large); + height: 36px; + padding: 2px; + display: flex; + flex-direction: row; + align-items: center; + box-sizing: content-box; +} + +#rl-app #V-SystemDropDown .accountPlace { + background: unset; + color: unset; + text-shadow: none; + border-color: var(--nc-color-border); +} + +#rl-app #V-SystemDropDown #top-system-dropdown-id { + background-color: unset; + border: unset !important; + border-radius: var(--nc-border-radius-large) !important; + height: 20px; +} + +#rl-app #V-SystemDropDown #top-system-dropdown-id:hover, +#rl-app #V-SystemDropDown #top-system-dropdown-id:focus { + background-color: var(--nc-color-background-hover); +} + +#rl-app #V-SystemDropDown #top-system-dropdown-id:active { + background-color: var(--nc-color-background-dark); +} + +/* #endregion */ + +/* #region tab */ + +#rl-app .tabs>[id^=tab]:checked+label { + background-color: var(--nc-color-main-background); + border-color: var(--nc-color-primary-element); + border-bottom-color: transparent; +} + +#rl-app .tabs>label { + margin: 0 2px -1px 2px; + border-radius: 0; + border-top-left-radius: var(--nc-border-radius); + border-top-right-radius: var(--nc-border-radius); + overflow-wrap: anywhere; +} + +#rl-app .tabs>label:first-of-type { + margin-left: 0px; +} + +#rl-app .tabs>label:last-of-type { + margin-right: 0px; +} + + +#rl-app .tabs>label:hover { + border-color: transparent; + border-bottom-color: var(--nc-color-primary-element); + background-color: var(--nc-color-background-hover) +} + +#rl-app .tabs .tab-content { + border-top: solid 1px; + border-radius: 4px; + border-color: var(--nc-color-primary-element); +} + +@media screen and (max-width: 480px) { + #rl-app .tabs>label>i, + #rl-app .tabs>label::before { + display: none; + } +} + +/* #endregion */ + +/* #region table */ + +#rl-app .table td, +#rl-app .table th, +#rl-app .table-bordered { + border-color: var(--nc-color-border-dark); +} + +#rl-app .table-hover tbody tr:hover td, +#rl-app .table-hover tbody tr:hover th { + background-color: var(--nc-color-background-hover); +} + +#rl-app .table-striped tbody tr:nth-child(odd) td, +#rl-app .table-striped tbody tr:nth-child(odd) th { + background-color: var(--nc-color-background-dark); +} + +/* #endregion */ + +/* #region attachment */ + +#rl-app .attachmentItem { + background-color: var(--nc-color-background-dark); + border-radius: var(--nc-border-radius); + box-shadow: none; + padding: 2px; +} + +#rl-app .attachmentListSimple { + flex-wrap: wrap; + display: flex; +} + +#rl-app .attachmentListSimple .attachmentItem { + display: flex; + width: min-content; + max-width: unset; +} + +#rl-app .attachmentListSimple .attachmentName { + max-width: 200px; + margin: 5px; +} + +#rl-app .attachmentListSimple .checkboxAttachment { + position: initial; + margin: 5px; + margin-left: 0; +} + +#rl-app .attachmentItem:hover, +#rl-app .attachmentItem:active { + background-color: var(--nc-color-background-darker); +} + +#rl-app .attachmentItem:focus { + outline: none; +} + +#rl-app .attachmentItem .iconProgress { + background-color: var(--nc-color-background-darker); +} + +#rl-app .attachmentItem .iconBG { + font-weight: 700; + line-height: 48px; + font-size: unset; + text-shadow: unset; +} + +#rl-app .attachmentItem .attachmentNameParent { + border-color: var(--nc-color-border-dark); +} + +#rl-app .attachmentItem .iconMain, +#rl-app .attachmentItem .iconPreview { + color: var(--nc-color-text-light); +} + +/* #endregion */ + +/* #endregion */ + +/* + * Views + */ + +/* #region views */ + +/* #region app */ + +#rl-app { + color: var(--nc-color-main-text); + background-color: transparent; + font-family: var(--nc-font-face); + font-size: var(--nc-default-font-size); +} + +#rl-app #rl-left { + background-color: var(--nc-color-main-background-blur, var(--nc-color-main-background)); + -webkit-backdrop-filter: var(--nc-filter-background-blur, none); + backdrop-filter: var(--nc-filter-background-blur, none); + border-color: var(--nc-color-border); + width: 300px; +} + +html:not(.rl-left-panel-disabled) #rl-left { + max-width: var(--nc-sidebar-max-width); +} + +html.rl-left-panel-disabled #rl-app #rl-left { + width: 65px !important; +} + +html.rl-mobile.rl-left-panel-disabled #rl-app #rl-left { + width: 0px !important; +} + +#rl-app #rl-right { + background-color: var(--nc-color-main-background); +} + +#rl-app::selection { + background-color: var(--nc-color-primary); + color: var(--nc-color-primary-text); +} + +#rl-app .control-group { + margin-bottom: 0.5em; +} + +#rl-app .form-horizontal .control-group { + display: flex; + align-items: center; + margin-left: 0; +} + +#rl-app .form-horizontal .control-group { + margin-bottom: 1em; +} + +@media screen and (max-width: 799px) { + #rl-app .form-horizontal .control-group>label { + text-align: right; + } +} + +/* #endregion */ + +/* #region login & loading */ + +#rl-app #V-Login, +#rl-app #rl-loading, +#rl-app #rl-loading-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + + top: unset; + position: unset; + min-width: unset; + max-width: unset; + transform: unset; + text-shadow: none; + transition: none; + + background-color: var(--nc-color-main-background-blur, var(--nc-color-main-background)); + -webkit-backdrop-filter: var(--nc-filter-background-blur, none); + backdrop-filter: var(--nc-filter-background-blur, none); + + color: var(--nc-color-main-text); + width: 100%; + height: 100%; +} + +#rl-app .LoginView .descWrapper { + color: var(--nc-color-main-text); +} + +#rl-app .LoginView .alert { + position: unset; +} + +#rl-app .LoginView form { + min-width: 250px; + max-width: 500px; + width: 90%; + + background-color: var(--nc-color-main-background); + border: unset; + border-radius: var(--nc-border-radius-large); + box-shadow: 0 0 10px var(--nc-color-box-shadow); + color: var(--nc-color-main-text); +} + +#rl-app .LoginView .controls>.fontastic:first-child { + padding: unset; + left: 10px; +} + +#rl-app .LoginView .controls span.fontastic { + user-select: none; +} + +#rl-app .LoginView .controls span.fontastic:active, +#rl-app .LoginView .controls span.fontastic:hover { + background: unset; +} + +#rl-app .LoginView .controls input:not([type="checkbox"]) { + padding-left: 30px; +} + +#rl-app #rl-loading[hidden], +#rl-app #rl-loading-error[hidden], +#rl-app .LoginView .alert[hidden] { + display: none; +} + +/* #endregion */ + +/* #region side panel */ + +html.rl-left-panel-disabled #rl-app #rl-left:has(#V-SettingsMenu:not([hidden])) { + width: 250px !important; +} + +#rl-app #V-AdminPane>.b-toolbar, +#rl-app #V-SettingsMenu>.b-toolbar { + top: 0; + left: 0; + height: auto !important; + padding-top: calc(var(--nc-default-grid-baseline) * 2) !important; + padding-bottom: calc(var(--nc-default-grid-baseline) * 2) !important; + font-weight: bold; +} + +#rl-app .b-folders .b-toolbar { + height: unset; + display: flex; + flex-direction: row; + padding: 8px; +} + +html.rl-left-panel-disabled #rl-app .b-folders .b-toolbar { + flex-direction: column; +} + +#rl-app .b-folders .b-toolbar .btn.buttonCompose, +#rl-app .b-folders .b-toolbar .btn.buttonContacts, +#rl-app .b-footer .btn { + box-sizing: border-box; + padding: 4px 14px; + line-height: 36px; + height: 44px; + min-width: fit-content; + width: stretch; + width: -moz-available; + width: -webkit-fill-available; + margin: 3px !important; + + font-size: var(--nc-default-font-size); +} + +#rl-app .b-folders .b-toolbar .btn.buttonCompose { + min-width: 16px; + max-width: 100%; + font-weight: bold; + box-sizing: content-box !important; +} + +#rl-app .b-folders .b-toolbar .btn.buttonContacts { + width: 44px; + min-width: 44px; +} + +#rl-app #V-AdminMenu nav, +#rl-app #V-SettingsMenu nav, +#rl-app .b-folders .b-content { + padding: calc(var(--nc-default-grid-baseline) * 2); +} + +#rl-app .b-folders .b-content { + top: 60px; + bottom: 162px; + color: unset; +} + +#rl-app .b-folders .b-content.show-on-panel-disabled { + bottom: 56px; +} + +#rl-app #V-AdminMenu nav a, +#rl-app #V-AdminPane>.b-toolbar, +#rl-app #V-SettingsMenu nav a, +#rl-app #V-SettingsMenu>.b-toolbar, +#rl-app .b-folders li a.selectable { + margin: 2px; + padding: 0 2em 0 15px; + height: 38px; + line-height: 38px !important; + border-radius: var(--nc-border-radius-large); + color: unset; + border: none; +} + +#rl-app #V-AdminMenu nav a, +#rl-app #V-AdminPane>.b-toolbar, +#rl-app #V-SettingsMenu nav a, +#rl-app #V-SettingsMenu>.b-toolbar { + height: 44px; + min-height: 44px; + line-height: 44px !important; +} + +#rl-app #V-AdminMenu nav a.selected, +#rl-app #V-SettingsMenu nav a.selected, +#rl-app .b-folders li a.selectable.selected { + color: unset; + background-color: var(--nc-color-primary-light); +} + +#rl-app #V-AdminMenu nav a:not(.selected):hover, +#rl-app #V-AdminMenu nav a:not(.selected):focus, +#rl-app #V-SettingsMenu nav a:not(.selected):hover, +#rl-app #V-SettingsMenu nav a:not(.selected):focus, +#rl-app .b-folders li a.selectable:not(.selected):hover, +#rl-app .b-folders li a.selectable:not(.selected):focus { + color: unset; + background-color: var(--nc-color-background-hover); +} + +#rl-app .b-folders .b-folders-system a[data-unread]::after, +#rl-app .b-folders .b-folders-user a[data-unread]::after { + display: block; + margin: 0; + padding: 2px 6px; + top: 50%; + transform: translateY(-50%); + + color: var(--nc-color-primary-element); + background-color: var(--nc-color-primary-element-light); + + font-size: 12px; + text-shadow: none; +} + +html.rl-left-panel-disabled #rl-app .b-folders .b-folders-system a.selectable { + text-overflow: clip; +} + +#rl-app .b-folders hr { + border-top: solid var(--nc-color-main-text); + border-radius: var(--nc-border-radius-large); + opacity: .1; +} + +#rl-app .b-folders .search-input-wrp { + width: calc(100% - 20px); + margin: 5px 10px; + opacity: .7; +} + +#rl-app .b-folders .search-input-wrp input { + width: 100%; +} + +#rl-app .b-footer.btn-toolbar.hide-mobile, +#rl-app .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled { + display: flex; + flex-direction: column; + align-content: stretch; + justify-content: flex-start; + height: unset; + box-sizing: border-box; +} + +#rl-app .b-footer.btn-toolbar.hide-mobile, +#rl-app .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled { + margin: 0; + padding: 0; + bottom: 0; +} + +#rl-app .b-footer.btn-toolbar { + overflow: unset; + padding: 3px; +} + +#rl-app .b-footer.btn-toolbar .btn { + background: transparent; + border: unset !important; +} + +#rl-app .b-footer.btn-toolbar .btn { + text-align: left; + font-size: 22px; + user-select: none; +} + +#rl-app .b-footer.btn-toolbar .btn-group .btn:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])), +#rl-app .b-footer.btn-toolbar .btn-group .btn.fontastic:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .b-footer.btn-toolbar .btn-group .btn:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])), +[dir="rtl"] #rl-app .b-footer.btn-toolbar .btn-group .btn.fontastic:nth-child(1 of :not([style*="display: none;"])):nth-last-child(1 of :not([style*="display: none;"])) { + border-radius: var(--nc-border-radius-large) !important; +} + +/* fallback */ +#rl-app .b-footer.btn-toolbar .btn-group .btn, +#rl-app .b-footer.btn-toolbar .btn-group .btn.fontastic, +[dir="rtl"] #rl-app .b-footer.btn-toolbar .btn-group .btn, +[dir="rtl"] #rl-app .b-footer.btn-toolbar .btn-group .btn.fontastic { + border-radius: var(--nc-border-radius-large) !important; +} + +#rl-app .b-footer.btn-toolbar .btn:hover, +#rl-app .b-footer.btn-toolbar .btn.fontastic:hover { + background-color: var(--nc-color-background-hover); +} + +#rl-app .b-footer.btn-toolbar .btn:active, +#rl-app .b-footer.btn-toolbar .btn.fontastic:active { + background-color: var(--nc-color-primary-light); +} + +#rl-app .b-footer.btn-toolbar .btn:focus, +#rl-app .b-footer.btn-toolbar .btn.fontastic:focus { + outline: none; +} + +#rl-app .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn { + width: -webkit-fill-available; + width: -moz-available; + width: fill-available; + overflow: hidden; +} + +#rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn.fontastic { + background-color: var(--nc-color-main-background); +} + +#rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn:after, +[dir="rtl"] #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn.fontastic:before { + font-family: var(--nc-font-face); + font-size: var(--nc-default-font-size); + margin-left: 15px; +} + +#rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn:after { + position: absolute; +} + +[dir="rtl"] #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn:after { + position: unset; +} + +#rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn.fontastic:after, +[dir="rtl"] #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn.fontastic:before { + content: attr(title); + width: inherit; + overflow: hidden; + text-overflow: ellipsis; +} + +[dir="rtl"] #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group.hide-on-panel-disabled .btn.fontastic:after { + content: unset; +} + +#rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group .btn.icon-folder-add:before { + line-height: unset; +} + +#rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group .btn.icon-folder-add:after { + content: attr(title); + width: inherit; + overflow: hidden; + text-overflow: ellipsis; +} + +html.rl-left-panel-disabled #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group .btn.fontastic:after, +html.rl-left-panel-disabled #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group .btn.icon-folder-add:after, +html.rl-left-panel-disabled[dir="rtl"] #rl-app #V-MailFolderList .b-footer.btn-toolbar .btn-group .btn.fontastic:before { + content: ''; +} + +#rl-app #V-MailFolderList .b-footer .btn[data-bind*="toggleLeftPanel"] { + width: unset !important; +} + +/* #endregion */ + +/* #region message list & view */ + +#rl-app .messageList, +#rl-app .messageView { + height: calc(100% - 65px); + margin: 0 5px; + border: 0; + border-top: 1px solid transparent; + box-shadow: none !important; + background: var(--nc-color-main-background); +} + +#rl-app #V-MailMessageList .btn-toolbar, +#rl-app #V-MailMessageView .btn-toolbar { + padding: 10px; + margin-top: 5px; +} + +/* fallback */ +#rl-app #V-MailMessageList .btn-toolbar .btn-group .btn, +#rl-app #V-MailMessageView .btn-toolbar .btn-group .btn { + border-radius: var(--nc-border-radius-large); +} + +/* #endregion */ + +/* #region message list */ + +#rl-app #V-MailMessageList { + overflow: unset; +} + +#rl-app #V-MailMessageList .btn-toolbar { + background: var(--nc-color-primary-light); + border-radius: var(--nc-border-radius-large); + margin: 5px 5px 0 5px; + text-align: start; +} + +html.sm-msgView-side #V-MailMessageList .btn-toolbar { + text-align: center !important; +} + +#rl-app #V-MailMessageList .btn-toolbar .btn:not(.btn-success) { + background: var(--nc-color-main-background); +} + +#rl-app #V-MailMessageList .messageList { + min-width: 370px; +} + +html.sm-msgView-bottom #rl-app .messageList { + height: 35vh; +} + +#rl-app .messageList .second-toolbar, +#rl-app .messageList .b-footer { + border-color: var(--nc-color-border); + background: unset !important; +} + +#rl-app .messageList .second-toolbar { + align-items: center; +} + +#rl-app .messageList .b-footer { + padding-bottom: 10px; +} + +#rl-app .messageList .inputSearch { + max-width: unset; +} + +#rl-app .messageList .checkboxCheckAll { + margin: 0 12px; +} + +#rl-app .messageList .checkboxCheckAll:hover, +#rl-app .messageList .checkboxCheckAll:focus { + color: var(--nc-color-primary-element); +} + +#rl-app .messageList .checkboxCheckAll:active { + color: var(--nc-color-primary-element-hover); +} + +html:not(rl-mobile) #rl-app .hideMessageListCheckbox .checkboxCheckAll { + display: none; +} + +#rl-app .messageList .btn.buttonMoreSearch { + background: unset; + border-radius: var(--nc-border-radius-large); + margin: 0 0 0 8px; + line-height: 20px !important; +} + +#rl-app .messageList .checkboxCheckAll, +#rl-app .messageList .btn.buttonMoreSearch { + font-size: var(--nc-default-font-size); + height: 28px; + line-height: 28px; +} + +#rl-app .messageList .b-content .listThreadUidDesc { + margin: 8px; + color: var(--nc-color-main-text); + background: unset; + border: solid 1px; + border-radius: var(--nc-border-radius-large); + border-color: var(--nc-color-border-dark); +} + +#rl-app .messageList .b-content .listThreadUidDesc:hover, +#rl-app .messageList .b-content .listThreadUidDesc:focus { + background: var(--nc-color-background-hover); +} + +#rl-app .messageList .b-content .listThreadUidDesc:active { + background-color: var(--nc-color-primary-element-light); +} + +#rl-app .messageList .b-content .listSearchDesc { + border-color: var(--nc-color-border); +} + +#rl-app .messageList .b-content .listError { + color: var(--nc-color-error); +} + +#rl-app .messageList .b-content .listDragOver.dragOverEnter { + background-color: var(--nc-color-background-dark); + border-radius: var(--nc-border-radius); + margin: 5px; +} + +#rl-app .messageList .b-content .listDragOver, +#rl-app .messageList .b-content .listEmptyMessage, +#rl-app .messageList .b-content .listError, +#rl-app .messageList .b-content .listLoading { + color: var(--nc-color-text-lighter); +} + +#rl-app .messageList .groupLabel { + background-color: var(--nc-color-background-dark); + border-radius: var(--nc-border-radius); + margin: 2px 0; +} + +/* #endregion */ + +/* #region message list item */ + +#rl-app .messageListItem .checkboxMessage { + border-color: var(--nc-color-text-lighter); + color: var(--nc-color-text-lighter); +} + +#rl-app .messageListItem .checkboxMessage:hover, +#rl-app .messageListItem .checkboxMessage:focus { + background-color: var(--nc-color-background-darker); +} + +#rl-app .messageListItem .checkboxMessage:active { + background-color: var(--nc-color-primary-light-hover); +} + +#rl-app .messageListItem .threads-len { + color: var(--nc-color-text-light); + border-color: var(--nc-color-text-light); + padding: 0 4px; + margin-right: 5px; +} + +#rl-app .messageListItem .threads-len:hover, +#rl-app .messageListItem .threads-len:focus { + background-color: var(--nc-color-background-darker); + border-color: unset; +} + +#rl-app .messageListItem .threads-len:active { + background-color: var(--nc-color-primary-element-light-hover); +} + +#rl-app .messageListItem { + padding: 8px; + margin: 2px 0; + border-bottom: unset; + border-radius: var(--nc-border-radius); +} + +#rl-app .messageListItem, +#rl-app .messageListItem.focused { + background-color: var(--nc-color-background); + border-color: var(--nc-color-border); +} + +#rl-app .messageListItem:hover, +#rl-app .messageListItem.focused:hover { + background-color: var(--nc-color-background-hover); + border-color: var(--nc-color-primary-light-hover); +} + +#rl-app .messageListItem.selected, +#rl-app .messageListItem.selected:hover { + background-color: var(--nc-color-primary-light) !important; + border-color: var(--nc-color-primary-light-text) !important; +} + +#rl-app .messageListItem.unseen { + background-color: unset; + border-color: var(--nc-color-primary); +} + +#rl-app .messageListItem.unseen.focused, +#rl-app .messageListItem.unseen.focused:hover { + background-color: var(--nc-color-primary-light); + border-color: var(--nc-color-primary); +} + +#rl-app .messageListItem.unseen:hover { + background-color: var(--nc-color-background-hover); + border-color: var(--nc-color-primary-hover); +} + +#rl-app .messageListItem [data-unseen] { + border-color: var(--nc-color-primary-element); + background-color: var(--nc-color-primary-element-light); + border-width: 2px; + font-weight: 700; +} + +#rl-app .messageListItem [data-unseen]:hover { + border-color: var(--nc-color-primary-element-hover); + background-color: var(--nc-color-primary-element-light-hover); +} + +#rl-app .messageListItem.checked { + background-color: unset; + border-color: var(--nc-color-primary-light-text); +} + +#rl-app .messageListItem.checked.focused { + background-color: inherit; + border-color: var(--nc-color-primary-light-text); +} + +#rl-app .messageListItem.checked:hover, +#rl-app .messageListItem.checked.focused:hover { + background-color: var(--nc-color-background-hover); + border-color: inherit; +} + +#rl-app #messagesDragImage { + padding: 5px 5px 5px 10px; + color: var(--nc-color-text-lighter); + background-color: var(--nc-color-background-darker); + border-radius: var(--nc-border-radius); + box-shadow: 0 0 4px var(--nc-color-box-shadow); + height: unset; + min-width: unset; +} + +/* #endregion */ + +/* #region message view */ + +#rl-app #V-MailMessageView { + bottom: 0; +} + +html.sm-msgView-bottom #rl-app .messageView { + height: 100%; +} + +#rl-app .messageView .b-message .message-fixed-button-toolbar .btn.buttonEdit { + padding: 4px 8px; + min-height: 28px; + height: 28px; + opacity: 0.8; +} + +#rl-app .messageView .b-message .message-fixed-button-toolbar .btn.buttonEdit:hover, +#rl-app .messageView .b-message .message-fixed-button-toolbar .btn.buttonEdit:focus, +#rl-app .messageView .b-message .message-fixed-button-toolbar .btn.buttonEdit:active { + opacity: unset; +} + +#rl-app .messageView .b-message-view-desc, +#rl-app .messageView .b-message-view-checked-helper { + color: var(--nc-color-text-lighter); +} + +#rl-app .messageView .b-message-view-desc.error { + color: var(--nc-color-error); +} + +#rl-app .messageView .messageItemHeader .informationShort meter::-webkit-meter-optimum-value { + background-color: var(--nc-color-success); + border-radius: var(--nc-border-radius-large); +} + +#rl-app .messageView .messageItemHeader .informationShort meter::-webkit-meter-suboptimum-value { + background-color: var(--nc-color-warning); + border-radius: var(--nc-border-radius-large); +} + +#rl-app .messageView .messageItemHeader .informationShort meter::-webkit-meter-even-less-good-value { + background-color: var(--nc-color-error); + border-radius: var(--nc-border-radius-large); +} + +#rl-app .messageView .messageItemHeader .hasVirus { + background: unset; + border: solid 2px var(--nc-color-error); + border-radius: var(--nc-border-radius); + margin: 5px; +} + +#rl-app .messageView .messageItemHeader .subject { + line-height: 30px; + height: 30px; +} + +#rl-app .messageView .crypto-control.encrypted, +#rl-app .messageView .crypto-control.signed { + border-radius: var(--nc-border-radius); +} + +#rl-app #V-MailMessageView .b-message-view-backdrop { + background-color: var(--nc-color-main-background-blur); + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); +} + +#rl-app #V-MailMessageView .b-message-view-backdrop .backdrop-message { + background-color: var(--nc-color-main-background); + border: solid 1px var(--nc-color-primary); + border-radius: var(--nc-border-radius-large); + text-shadow: none; + margin: 10px; + color: unset; +} + +#rl-app .messageView .messageAssignedTags { + margin: 0 5px; +} + +#rl-app .messageView .messageAssignedTags span { + border-radius: var(--nc-border-radius-large); + background: unset; + padding: 2px 5px; +} + +#rl-app #messageItem .bodySubHeader { + background-color: unset; +} + +#rl-app #messageItem .bodySubHeader>* { + border-color: var(--nc-color-border); +} + +.rl-left-panel-disabled #rl-app #messageItem .dropdown-menu { + position: absolute; +} + +#rl-app #messageItem .readReceipt, +#rl-app #messageItem .showImages { + margin: 8px; + padding: 10px; + color: var(--nc-color-main-text); + border: solid 1px var(--nc-color-primary); + background-color: var(--nc-color-primary-light); + border-radius: var(--nc-border-radius-large); +} + +#rl-app #messageItem .showImages>.btn, +#rl-app #messageItem .showImages>.btn-group { + margin: 2px; +} + +#rl-app #messageItem .readReceipt:hover { + background-color: var(--nc-color-primary-light-hover); +} + +#rl-app .messageView .messageItemHeader, +#rl-app #messageItem .attachmentsPlace { + background-color: unset; + border-color: var(--nc-color-border); + color: var(--nc-color-main-text); +} + +#rl-app #messageItem .attachmentsPlace { + border-top: 1px solid var(--nc-color-border); + top: -1px; +} + +#rl-app #messageItem .attachmentsPlace .controls-handle { + -webkit-user-select: none; + user-select: none; +} + +#rl-app #messageItem .attachmentsControls { + background-color: unset; + border-color: var(--nc-color-border); + -webkit-user-select: none; + user-select: none; +} + +#rl-app #messageItem .attachmentsControls .icon-file-archive, +#rl-app #messageItem .attachmentsControls .fontastic { + color: var(--nc-color-main-text); +} + +/* #endregion */ + +/* #region settings */ + +#rl-app #V-SettingsPane { + margin: 62px 0 8px 0; + height: calc(100% - 62px - 8px); + background-color: unset; + box-shadow: none; + border: none; +} + +#rl-app #V-SettingsPane .btn-toolbar { + padding: 8px; +} + +#rl-app .row { + margin-left: unset; +} + +#rl-app .legend, +#rl-app #V-AdminPane hr, +#rl-app #V-SettingsPane hr { + color: unset; + border-color: var(--nc-color-border); +} + +#rl-app #V-Settings-Themes figure, +#rl-app #V-Settings-Themes img { + border-radius: var(--nc-border-radius-large); +} + +#rl-app #V-Settings-Themes figure:not(.selected):hover { + background-color: var(--nc-color-background-hover); + border-color: transparent; +} + +#rl-app #V-Settings-Themes figure:not(.selected):active { + background-color: var(--nc-color-primary-element-light); +} + +#rl-app #V-Settings-Themes figure:not(.selected):focus { + outline: none; +} + +#rl-app #V-Settings-Themes figure.selected { + background-color: var(--nc-color-primary-element); + border-color: transparent; + color: var(--nc-color-primary-element-text); +} + +/* hide additional theme settings, as these are made via Nextcloud theming */ + +/* fonts */ +#rl-app #V-SettingsPane #V-Settings-Themes .form-horizontal:nth-of-type(3) { + display: none !important; +} + +/* background */ +#content.app-snappymail #rl-app #V-SettingsPane #V-Settings-Themes .form-horizontal:nth-of-type(4) { + display: none !important; +} + +/* #endregion */ + +/* #region settings admin */ + +#rl-app #V-AdminPane { + margin: 8px 0px; + height: calc(100% - 2*8px); + background-color: unset; + box-shadow: none; + border: none; +} + +#rl-app #V-AdminPane>.b-toolbar { + width: 300px; + text-shadow: none; +} + +html.rl-mobile #rl-app #V-AdminPane { + margin: 60px 0px 8px 0px; + height: calc(100% - 60px - 8px); +} + +html.rl-mobile #rl-app #V-AdminPane>.b-toolbar { + width: unset; +} + +#rl-app #V-Settings-Config th { + background-color: var(--nc-color-background-dark); +} + +#rl-app #V-Settings-Config em { + word-break: break-word; +} + +#rl-app #V-Settings-General label[data-i18n="TAB_GENERAL/LABEL_ATTACHMENT_SIZE_LIMIT"] { + align-self: self-start; +} + +#rl-app #V-Settings-General label[data-i18n="TAB_GENERAL/LABEL_ATTACHMENT_SIZE_LIMIT"]~div .alert.alert-info { + margin-left: -130px; +} + +#rl-app #V-Settings-Domains .btn[data-i18n="GLOBAL/TEST"] { + margin-left: 1rem; +} + +@media screen and (max-width: 1024px) { + #rl-app #V-AdminPane table { + width: 100%; + } + + #rl-app #V-AdminPane [class*="span"] { + width: unset; + } +} + +@media screen and (max-width: 480px) { + #rl-app #V-Settings-Config .table { + border-collapse: collapse; + } + + #rl-app #V-Settings-Config .table tr { + border-top: 10px solid transparent; + } + + #rl-app #V-Settings-Config .table td, + #rl-app #V-Settings-Config .table th { + display: block; + border-style: solid; + border-width: 1px 0px; + } +} + +/* #endregion */ + +/* #region popup */ + +#rl-app dialog { + background: var(--nc-color-main-background); + color: var(--nc-color-main-text); + border-radius: var(--nc-border-radius-large); + filter: drop-shadow(0 1px 5px var(--nc-color-box-shadow)); + box-shadow: none; + border-color: var(--nc-color-border); +} + +#rl-app dialog>header, +#rl-app dialog>footer { + border-color: var(--nc-color-border); +} + +#rl-app dialog>footer .btn+.btn, +#rl-app dialog>footer button+button { + margin: 0 0 0 5px; +} + +#rl-app #V-PopupsPlugin .help-block { + margin-left: 5px; +} + +#rl-app #V-PopupsIdentity .e-signature-place { + border: none; +} + +#rl-app #V-PopupsLanguages label { + background-color: var(--nc-color-background-dark) !important; + border-color: var(--nc-color-border); + border-radius: var(--nc-border-radius); + padding: 8px 15px; +} + +#rl-app #V-PopupsLanguages label:hover { + border-color: var(--nc-color-primary-element); + background-color: unset; +} + +#rl-app #V-PopupsLanguages label.user { + background-color: var(--nc-color-main-background) !important; + color: var(--nc-color-text-light); +} + +#rl-app #V-PopupsLanguages label.selected { + background-color: var(--nc-color-primary-element-light) !important; + border-color: var(--nc-color-primary-element); +} + +@media screen and (max-width: 480px) { + #rl-app #V-PopupsAdvancedSearch .e-component.e-checkbox { + margin-left: -100%; + } + + #rl-app #V-PopupsLanguages label { + margin-left: 0; + margin-right: 0; + } + + #rl-app #V-PopupsKeyboardShortcutsHelp .modal-body, + #rl-app #V-PopupsDomain .modal-body { + padding-left: 0; + padding-right: 0; + overflow-x: hidden; + } +} + +#rl-app #V-PopupsOpenPgpImport textarea { + width: 100%; +} + +/* #endregion */ + +/* #region popup contacts */ + +#rl-app #V-PopupsContacts .right, +#rl-app #V-PopupsContacts .b-list-toolbar, +#rl-app #V-PopupsContacts .b-list-footer-toolbar, +#rl-app #V-PopupsContacts .b-view-content-toolbar { + border-color: var(--nc-color-border); +} + +#rl-app #V-PopupsContacts .b-list-toolbar { + height: 55px; +} + +#rl-app #V-PopupsContacts .b-list-toolbar .e-search { + margin: 9px 5px; + width: calc(100% - 2*5px); +} + +#rl-app #V-PopupsContacts .b-view-content { + margin-top: 20px; + height: calc(100% - 72px); + overflow: hidden; +} + +#rl-app #V-PopupsContacts .b-list-content { + height: calc(100% - 35px - 72px); +} + +#rl-app #V-PopupsContacts .b-list-content .listClear { + box-shadow: none; +} + +#rl-app #V-PopupsContacts .tabs { + height: 100%; +} + +#rl-app #V-PopupsContacts .tab-content { + overflow: auto; +} + +#rl-app #V-PopupsContacts .e-contact-item { + padding: 4px; + margin: 2px 4px; + max-height: unset; + border-bottom: 0 !important; + border-radius: var(--nc-border-radius); +} + +#rl-app #V-PopupsContacts .e-contact-item, +#rl-app #V-PopupsContacts .e-contact-item.focused { + background-color: var(--nc-color-background); + border-color: var(--nc-color-border); +} + +#rl-app #V-PopupsContacts .e-contact-item:hover, +#rl-app #V-PopupsContacts .e-contact-item.focused:hover { + background-color: var(--nc-color-background-hover); + border-color: var(--nc-color-primary-light-hover); +} + +#rl-app #V-PopupsContacts .e-contact-item.selected, +#rl-app #V-PopupsContacts .e-contact-item.selected:hover { + background-color: var(--nc-color-primary-light); + border-color: var(--nc-color-primary-light-text); +} + +#rl-app #V-PopupsContacts .e-contact-item.checked { + background-color: unset; + border-color: var(--nc-color-primary-light-text); +} + +#rl-app #V-PopupsContacts .e-contact-item.checked.focused { + background-color: inherit; + border-color: var(--nc-color-primary-light-text); +} + +#rl-app #V-PopupsContacts .e-contact-item.checked:hover, +#rl-app #V-PopupsContacts .e-contact-item.checked.focused:hover { + background-color: var(--nc-color-background-hover); + border-color: inherit; +} + +@media screen and (max-width: 799px) { + #rl-app #V-PopupsContacts .modal-body { + flex-direction: column; + } + + #rl-app #V-PopupsContacts .left { + max-width: unset; + height: 40%; + } + + #rl-app #V-PopupsContacts .b-list-footer-toolbar { + display: none; + } + + #rl-app #V-PopupsContacts .b-list-content { + height: calc(100% - 55px); + border-bottom: 1px solid var(--nc-color-border); + } + + #rl-app #V-PopupsContacts .right { + height: 60%; + border-left: unset; + } + + #rl-app #V-PopupsContacts .control-group { + margin-right: 1em; + } +} + +/* #endregion */ + +/* #region popup compose */ + +#rl-app #V-PopupsCompose header { + background-color: unset; + color: unset; +} + +#rl-app #V-PopupsCompose header .close, +#rl-app #V-PopupsCompose header .minimize-custom { + border-color: unset; +} + +#rl-app #V-PopupsCompose .b-header { + background: unset; +} + +#rl-app #V-PopupsCompose .b-header .error-to { + color: var(--nc-color-error); +} + +#rl-app #V-PopupsCompose .pull-right>.btn, +#rl-app #V-PopupsCompose .pull-right>.btn-group { + margin-left: 3px; +} + +#rl-app #V-PopupsCompose .no-attachments-desc { + text-shadow: unset; +} + +#rl-app #V-PopupsCompose .attachmentAreaParent .attachmentItem:hover, +#rl-app #V-PopupsCompose .attachmentAreaParent .attachmentItem:active { + background-color: var(--nc-color-background-dark); +} + +#rl-app #V-PopupsCompose .b-attachment-place { + border-color: var(--nc-color-border); + background-color: var(--nc-color-background-dark); +} + +#rl-app #V-PopupsCompose .b-attachment-place.dragAndDropOver { + border-color: var(--nc-color-border); +} + +#rl-app #V-PopupsCompose .tabs label[for="tab-attachments"] b { + padding: 2px 6px; + border-radius: 1em; + color: var(--nc-color-primary-element); + background-color: var(--nc-color-primary-element-light); + font-size: 12px; +} + +/* #endregion */ + +/* #region popup nextcloud files */ + +#rl-app #V-PopupsCompose .btn[data-bind*="nextcloudAttach"] { + font-size: 0; +} + +#rl-app #V-PopupsCompose .btn[data-bind*="nextcloudAttach"]::before { + display: block; + content: ""; + height: 100%; + width: 30px; + --nc-logo: url("data:image/svg+xml,<svg height='29' width='64' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' version='1.1' xmlns='http://www.w3.org/2000/svg'><path d='m32.028 0c-6.6304 0-12.249 4.4952-13.991 10.564-1.5172-3.259-4.7762-5.5066-8.5971-5.5066-5.1694 0-9.4398 4.2704-9.4398 9.496s4.2704 9.4961 9.496 9.4961c3.7647 0 7.0799-2.2476 8.5971-5.5066 1.6856 6.0685 7.3046 10.564 13.935 10.564 6.5742 0 12.193-4.4389 13.991-10.451 1.5171 3.1466 4.7761 5.338 8.4846 5.338 5.2257 0 9.4961-4.2704 9.4961-9.4961s-4.2704-9.4398-9.4961-9.4398c-3.7085 0-6.9675 2.1914-8.4846 5.338-1.7981-5.9561-7.3608-10.395-13.991-10.395zm0 5.5628c5.0009 0 8.9903 3.9894 8.9903 8.9903s-3.9894 8.9904-8.9903 8.9904-8.9903-3.9895-8.9903-8.9904 3.9894-8.9903 8.9903-8.9903zm-22.532 5.057c2.1914 0 3.9333 1.7419 3.9333 3.9333s-1.7419 3.9333-3.9333 3.9333-3.9332-1.7419-3.9332-3.9333 1.7419-3.9333 3.9332-3.9333zm45.008 0c2.1915 0 3.9333 1.7419 3.9333 3.9333s-1.7418 3.9333-3.9333 3.9333-3.9332-1.7419-3.9332-3.9333c0.0562-2.1914 1.7418-3.9333 3.9332-3.9333z' fill-rule='nonzero'></path></svg>"); + -webkit-mask-image: var(--nc-logo); + mask-image: var(--nc-logo); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 30px; + mask-size: 30px; + background-color: var(--nc-color-main-text); +} + +#rl-app #V-PopupsNextcloudFiles { + user-select: none; +} + +#rl-app #V-PopupsNextcloudFiles li { + line-height: unset; +} + +#rl-app #V-PopupsNextcloudFiles li[data-icon], +#rl-app #V-PopupsNextcloudFiles li:has(>button[name="create"]), +#rl-app #V-PopupsNextcloudFiles li details summary { + padding: 5px 10px; + display: flex; + align-items: center !important; + overflow-wrap: anywhere; +} + +/* fallback (Firefox ignores complete selector list if :has is used) */ +#rl-app #V-PopupsNextcloudFiles li[data-icon], +#rl-app #V-PopupsNextcloudFiles li details summary { + padding: 5px 10px; + display: flex; + align-items: center !important; + overflow-wrap: anywhere; +} + +#rl-app #V-PopupsNextcloudFiles li button, +#rl-app #V-PopupsNextcloudFiles li input { + margin-left: auto !important; + align-self: center; +} + +#rl-app #V-PopupsNextcloudFiles li input { + min-width: 100px; + flex-shrink: 1; +} + +#rl-app #V-PopupsNextcloudFiles li button { + flex-shrink: 0; +} + +#rl-app #V-PopupsNextcloudFiles li button[name="create"] { + margin-left: 10px !important; +} + +#rl-app #V-PopupsNextcloudFiles li:not(:last-child), +#rl-app #V-PopupsNextcloudFiles li details[open]>summary { + border-bottom: 1px solid var(--nc-color-border); +} + +#rl-app #V-PopupsNextcloudFiles li details[open]>summary { + background-color: var(--nc-color-primary-element-light); + border-bottom-color: var(--nc-color-primary-element-hover); +} + +#rl-app #V-PopupsNextcloudFiles li:hover { + background-color: unset; +} + +#rl-app #V-PopupsNextcloudFiles li[data-icon]:hover, +#rl-app #V-PopupsNextcloudFiles li details:not([open]):hover, +#rl-app #V-PopupsNextcloudFiles li details[open]>summary:hover { + background-color: var(--nc-color-background-hover); +} + +#rl-app #V-PopupsNextcloudFiles li details summary[data-icon] { + list-style-type: none; +} + +#rl-app #V-PopupsNextcloudFiles li details summary[data-icon]::before { + color: var(--nc-color-primary); + font-size: 20px; +} + +#rl-app #V-PopupsNextcloudFiles li[data-icon]::before, +#rl-app #V-PopupsNextcloudFiles li details summary[data-icon]::before { + margin: 5px 10px 5px 0; +} + +/* #endregion */ + +/* #region popup nextcloud calendars */ + +#rl-app #V-PopupsNextcloudCalendars li { + padding: 5px; + display: flex; + align-items: center !important; + border-bottom: solid 1px var(--nc-color-border); +} + +#rl-app #V-PopupsNextcloudCalendars li button { + border: 0px !important; + margin-left: 8px !important; + color: var(--nc-color-primary-element-light-text) !important; +} + +/* #endregion */ + +/* #region popup openpgp generate */ + +#rl-app #openpgp-generate.form-horizontal .control-group:has(.e-checkbox) { + flex-direction: column; + align-items: flex-start; +} + +#rl-app #openpgp-generate.form-horizontal .control-group:has(.e-checkbox) br { + display: none; +} + +/* #endregion */ + +/* #endregion */ + +/* + * Extensions + */ + +/* #region extensions */ + +/* #region avatars */ + +#rl-app .messageCheckbox .fromPic { + margin: -5px 0 -9px 8px; + height: 32px; + width: 32px; +} + +/* #endregion */ + +/* #endregion */ + +/* + * Message Flags + */ + +/* #region message flags */ + +:root { + --msgflag-\$label1-color: #ae2c35; + --msgflag-\$label1-color-hover: #e13a45; + --msgflag-\$label2-color: #cc9827; + --msgflag-\$label2-color-hover: #ffbf31; + --msgflag-\$label3-color: #005235; + --msgflag-\$label3-color-hover: #008557; + --msgflag-\$label4-color: #005cc4; + --msgflag-\$label4-color-hover: #0074f8; + --msgflag-\$label5-color: #5631c0; + --msgflag-\$label5-color-hover: #6d3ff3; + + --msgflag-\\flagged-color: #ffca32; +} + +#rl-app .msgflag-\$label5:not(.focused) { border-color: var(--msgflag-\$label5-color); } +#rl-app .msgflag-\$label4:not(.focused) { border-color: var(--msgflag-\$label4-color); } +#rl-app .msgflag-\$label3:not(.focused) { border-color: var(--msgflag-\$label3-color); } +#rl-app .msgflag-\$label2:not(.focused) { border-color: var(--msgflag-\$label2-color); } +#rl-app .msgflag-\$label1:not(.focused) { border-color: var(--msgflag-\$label1-color); } + +#rl-app .msgflag-\$label5.focused:not(.selected) { border-color: var(--msgflag-\$label5-color) !important; } +#rl-app .msgflag-\$label4.focused:not(.selected) { border-color: var(--msgflag-\$label4-color) !important; } +#rl-app .msgflag-\$label3.focused:not(.selected) { border-color: var(--msgflag-\$label3-color) !important; } +#rl-app .msgflag-\$label2.focused:not(.selected) { border-color: var(--msgflag-\$label2-color) !important; } +#rl-app .msgflag-\$label1.focused:not(.selected) { border-color: var(--msgflag-\$label1-color) !important; } + +#rl-app .msgflag-\$label5:not(.focused):hover { border-color: var(--msgflag-\$label5-color-hover); } +#rl-app .msgflag-\$label4:not(.focused):hover { border-color: var(--msgflag-\$label4-color-hover); } +#rl-app .msgflag-\$label3:not(.focused):hover { border-color: var(--msgflag-\$label3-color-hover); } +#rl-app .msgflag-\$label2:not(.focused):hover { border-color: var(--msgflag-\$label2-color-hover); } +#rl-app .msgflag-\$label1:not(.focused):hover { border-color: var(--msgflag-\$label1-color-hover); } + +#rl-app .msgflag-\$label1:not(.focused), #rl-app .msgflag-\$label1 .checkboxMessage, +#rl-app .msgflag-\$label2:not(.focused), #rl-app .msgflag-\$label2 .checkboxMessage, +#rl-app .msgflag-\$label3:not(.focused), #rl-app .msgflag-\$label3 .checkboxMessage, +#rl-app .msgflag-\$label4:not(.focused), #rl-app .msgflag-\$label4 .checkboxMessage, +#rl-app .msgflag-\$label5:not(.focused), #rl-app .msgflag-\$label5 .checkboxMessage { + background-color: unset; + color: unset; +} + +#rl-app .messageListItem.msgflag-\$label1:hover, +#rl-app .messageListItem.msgflag-\$label2:hover, +#rl-app .messageListItem.msgflag-\$label3:hover, +#rl-app .messageListItem.msgflag-\$label4:hover, +#rl-app .messageListItem.msgflag-\$label5:hover { + background-color: var(--nc-color-background-hover); +} + +#rl-app a.msgflag-\$label1, +#rl-app a.msgflag-\$label2, +#rl-app a.msgflag-\$label3, +#rl-app a.msgflag-\$label4, +#rl-app a.msgflag-\$label5 { + border-left: solid 5px; +} + +#rl-app .btn-group.show .dropdown-menu div li a[class^='msgflag-']:not(.msgflag-\$label1, .msgflag-\$label2, .msgflag-\$label3, .msgflag-\$label4, .msgflag-\$label5), +#rl-app .btn-group.show .dropdown-menu div li a[class*='msgflag-']:not(.msgflag-\$label1, .msgflag-\$label2, .msgflag-\$label3, .msgflag-\$label4, .msgflag-\$label5) { + border-left: solid 5px gray; +} + +#rl-app .b-folders .is-flagged .flag-icon::after, +#rl-app .messageListItem.hasFlaggedSubMessage .flagParent::after, +#rl-app .messageListItem.msgflag-\\flagged .flagParent::after { + color: var(--msgflag-\\flagged-color); +} + +#rl-app #messageItem .b-text-part.swapColors { + background-color: var(--nc-color-primary); + color: var(--nc-color-primary-text); +} + +/* #endregion */ + +/* #endregion */ + +/* + * Tablet Layout + */ + +/* #region tablet layout */ + +@media screen and (min-width: 800px) and (max-width: 1024px) { + #rl-app #rl-left { + width: 250px; + } + + html:not(.rl-left-panel-disabled) #rl-left { + max-width: calc(var(--nc-sidebar-max-width) / 2); + } + + #rl-app #V-SystemDropDown .accountPlace { + display: none; + } +} + +/* #endregion */ + +/* + * Keyframes + */ + +/* #region keyframes */ + +@keyframes highlight-folder-row { + 0% { + transform: scale(1) + } + + 50% { + transform: scale(.95) + } + + 100% { + transform: scale(1) + } +} + + +/* ===== OVERLAY: KGVA shibui (Original-Nextcloud-CSS, setzt --color-*) ===== */ +/* ============================================================ + shibui — a Nextcloud theme + 渋い · restrained · unobtrusive · quietly refined + ============================================================ */ + +/* Nextcloud sets its theme variables on body[data-theme-*], which beats :root. + We must match that specificity or our overrides only apply for one frame. */ +:root, +body, +body[data-theme-default], +body[data-theme-light], +body[data-theme-light-highcontrast], +body[data-theme-dark], +body[data-theme-dark-highcontrast] { + /* ---------- palette ---------- + neutral paper (no green tint in the ground), petrol sits as the + only color note. dossier-style soft sage-teal as the accent. */ + --shibui-washi: #F0F0F0; /* neutral light, no undertone */ + --shibui-washi-soft: #E8E8E8; /* recessed surfaces */ + --shibui-washi-warm: #DCDCDC; /* hover wash — clearly distinct */ + --shibui-border: #C2C2C2; /* hairline */ + --shibui-border-soft: #D6D6D6; + --shibui-sumi: #1A1A1A; /* near-black neutral ink */ + --shibui-sumi-soft: #3D3D3D; /* secondary text */ + --shibui-sumi-mute: #666666; /* tertiary — readable against washi */ + --shibui-petrol: #7BA89B; /* sage-petrol, the only color note */ + --shibui-petrol-deep: #557A6D; /* hover / accent text */ + --shibui-petrol-light: #D4E4DE; /* light variant for soft fills */ + --shibui-kakishibu: #A85A3C; /* persimmon, signal */ + --shibui-matcha: #5A7050; /* moss green, success */ + --shibui-bengara: #8C3A2E; /* iron red, error */ + + /* ---------- nextcloud variable overrides ---------- + !important on each: Nextcloud's Theming app declares its own + variable values with !important on body[data-theme-*]. Without + !important here we tie on specificity and lose on source order. */ + --color-main-background: var(--shibui-washi) !important; + --color-main-background-rgb: 240, 240, 240 !important; + --color-main-background-translucent: rgba(240, 240, 240, 0.92) !important; + --color-main-background-blur: rgba(240, 240, 240, 0.78) !important; + --color-main-text: var(--shibui-sumi) !important; + + --color-background-plain: var(--shibui-washi) !important; + --color-background-hover: var(--shibui-washi-warm) !important; + --color-background-dark: var(--shibui-washi-soft) !important; + --color-background-darker: var(--shibui-border-soft) !important; + --color-background-translucent: rgba(240, 240, 240, 0.85) !important; + + --color-placeholder-light: var(--shibui-border-soft) !important; + --color-placeholder-dark: var(--shibui-border) !important; + + --color-text-light: var(--shibui-sumi-soft) !important; + --color-text-lighter: var(--shibui-sumi-mute) !important; + --color-text-maxcontrast: var(--shibui-sumi-soft) !important; + --color-text-maxcontrast-default: var(--shibui-sumi-soft) !important; + --color-text-maxcontrast-background-blur: var(--shibui-sumi) !important; + + --color-primary: var(--shibui-petrol) !important; + --color-primary-default: var(--shibui-petrol) !important; + --color-primary-text: var(--shibui-sumi) !important; + --color-primary-hover: var(--shibui-petrol-deep) !important; + --color-primary-light: var(--shibui-petrol-light) !important; + --color-primary-light-text: var(--shibui-petrol-deep) !important; + --color-primary-light-hover: #C2D7CF !important; + --color-primary-element: var(--shibui-petrol) !important; + --color-primary-element-default: var(--shibui-petrol) !important; + --color-primary-element-text: var(--shibui-sumi) !important; + --color-primary-element-hover: var(--shibui-petrol-deep) !important; + --color-primary-element-light: var(--shibui-petrol-light) !important; + --color-primary-element-light-text: var(--shibui-petrol-deep) !important; + --color-primary-element-light-hover: #C2D7CF !important; + + --color-border: var(--shibui-border) !important; + --color-border-dark: var(--shibui-border) !important; + --color-border-maxcontrast: var(--shibui-sumi-mute) !important; + + --color-loading-light: var(--shibui-washi-soft) !important; + --color-loading-dark: var(--shibui-border) !important; + + --color-box-shadow: rgba(0, 0, 0, 0.10) !important; + + --color-success: var(--shibui-matcha) !important; + --color-warning: var(--shibui-kakishibu) !important; + --color-error: var(--shibui-bengara) !important; + --color-favorite: var(--shibui-kakishibu) !important; + + /* ---------- typography ---------- + Inter for UI, Instrument Serif for quiet accents (login title, + headings on empty states), JetBrains Mono for code. */ + --font-face: + "Inter", + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif !important; + --font-face-serif: + "Instrument Serif", + "Iowan Old Style", + "Hoefler Text", + Georgia, + serif !important; + --font-face-monospace: + "JetBrains Mono", + ui-monospace, + SFMono-Regular, + Menlo, + monospace !important; + + /* ---------- geometry ---------- + softer corners; shibui prefers gentle curves over hard edges, + but never decorative roundness. */ + --border-radius: 4px; + --border-radius-large: 8px; + --border-radius-rounded: 12px; + --border-radius-pill: 999px; + + /* header sits flat on the page like a horizon line */ + --header-height: 50px; + --color-header: var(--shibui-washi) !important; + --color-header-text: var(--shibui-sumi) !important; + --color-header-contrast: var(--shibui-sumi) !important; + --color-header-primary: var(--shibui-washi) !important; + + /* Nextcloud's auto-invert system: tell it our header is bright, + so SVG masks that would otherwise be white get inverted to dark. */ + --background-invert-if-bright: invert(100%) !important; + --background-invert-if-dark: no-invert !important; + --background-image-invert-if-bright: invert(100%) !important; + --primary-invert-if-bright: invert(100%) !important; + --primary-invert-if-dark: no-invert !important; +} + +/* ============================================================ + base + ============================================================ */ +body, +input, +textarea, +select, +button, +.body-login-container { + font-family: var(--font-face); + font-weight: 400; + letter-spacing: 0.005em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + background-color: var(--shibui-washi); + color: var(--shibui-sumi); +} + +/* ---------- serif: every title is a held breath ---------- */ +h1, h2, h3, +.app-navigation-caption, +.section-title, +.empty-content h2, +.empty-content__title { + font-family: var(--font-face-serif) !important; + font-weight: 400 !important; + letter-spacing: 0; + font-feature-settings: "ss01", "liga"; +} + +h1, +.empty-content__title { + font-size: 1.75rem; + line-height: 1.15; +} + +/* ============================================================ + scrollbars — almost invisible + ============================================================ */ +* { + scrollbar-width: thin; + scrollbar-color: var(--shibui-border) transparent; +} +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background-color: var(--shibui-border); + border-radius: var(--border-radius-pill); + border: 2px solid var(--shibui-washi); +} +*::-webkit-scrollbar-thumb:hover { + background-color: var(--shibui-sumi-mute); +} + +/* ============================================================ + selection + ============================================================ */ +::selection { + background-color: rgba(123, 168, 155, 0.35); + color: var(--shibui-sumi); +} + +/* ============================================================ + dark mode — sumi night (only if explicitly opted in) + ============================================================ */ +body[data-theme-dark], +body[data-theme-dark-highcontrast] { + --shibui-washi: #0F0F0F; + --shibui-washi-soft: #161616; + --shibui-washi-warm: #1F1F1F; + --shibui-border: #2E2E2E; + --shibui-border-soft: #1F1F1F; + --shibui-sumi: #EAEAEA; + --shibui-sumi-soft: #B5B5B5; + --shibui-sumi-mute: #888888; + --shibui-petrol: #7BA89B; + --shibui-petrol-deep: #9BC4B7; + --shibui-petrol-light: #1F3331; + --shibui-kakishibu: #C97A5C; + --shibui-matcha: #8AA078; + --shibui-bengara: #C26A5C; + + --color-main-background-rgb: 15, 15, 15 !important; + --color-main-background-translucent: rgba(15, 15, 15, 0.92) !important; + --color-main-background-blur: rgba(15, 15, 15, 0.78) !important; + --color-box-shadow: rgba(0, 0, 0, 0.50) !important; + --color-primary-text: var(--shibui-sumi) !important; + --color-primary-element-text: var(--shibui-sumi) !important; +} + +/* ============================================================ + KGVA-Feinschliff: SnappyMail-Chrome -> echte Nextcloud-Optik + (nackte Toolbar-Icons, kein sage-Strip, Such-Pille) + ============================================================ */ + +/* Toolbar-Container: kein getönter Hintergrund, keine Linie */ +#rl-app .b-toolbar, +#rl-app .btn-toolbar, +#rl-app .b-folders .b-toolbar, +#rl-app .messageList .b-toolbar, +#rl-app .messageList .second-toolbar, +#rl-app .b-footer.btn-toolbar { + background: transparent !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; +} + +/* Toolbar-Buttons = NACKTE Icons (ohne Box/Rand), außer Compose/Contacts. + Dezenter runder Hover statt geschlossener/halb-offener Kästchen. */ +#rl-app .b-toolbar .btn:not(.buttonCompose):not(.buttonContacts), +#rl-app .btn-toolbar .btn:not(.buttonCompose):not(.buttonContacts), +#rl-app .messageList .second-toolbar .btn, +#rl-app .b-footer.btn-toolbar .btn, +#rl-app .b-toolbar .btn-group > .btn:not(.buttonCompose):not(.buttonContacts) { + background: transparent !important; + background-color: transparent !important; + border: none !important; + box-shadow: none !important; + border-radius: var(--border-radius-pill, 999px) !important; +} +#rl-app .b-toolbar .btn:not(.buttonCompose):not(.buttonContacts):hover, +#rl-app .btn-toolbar .btn:not(.buttonCompose):not(.buttonContacts):hover, +#rl-app .messageList .second-toolbar .btn:hover, +#rl-app .b-footer.btn-toolbar .btn:hover { + background-color: var(--shibui-washi-warm) !important; +} + +/* Such-Zeile als rundes Pill mit zarter Linie (wie Nextcloud-Suche) */ +#rl-app .messageList .inputSearch, +#rl-app .messageList .inputSearch input, +#rl-app .inputSearch input { + border-radius: var(--border-radius-pill, 999px) !important; + background-color: var(--shibui-washi-soft) !important; + border: 1px solid var(--shibui-border-soft) !important; + box-shadow: none !important; + padding-left: 14px !important; +} +#rl-app .inputSearch input:focus { + background-color: var(--shibui-washi) !important; + border-color: var(--shibui-petrol) !important; + box-shadow: 0 0 0 3px rgba(123, 168, 155, 0.28) !important; + outline: none !important; +} + +/* ----- Feinschliff 2: gerade Linie statt geschwungen + Nav-Pills ----- */ + +/* Listen-/Lese-Panel: gerade Kanten (durchgezogene Linie statt Rundung), + kein Box-Shadow im Fokus -> keine geschwungene Linie mehr */ +#rl-app .messageList, +#rl-app .messageView, +#rl-app .messageListPlace, +#rl-app #V-MailMessageList.focused .messageList, +#rl-app #V-MailMessageView.focused .messageView { + border-radius: 0 !important; + box-shadow: none !important; +} + +/* Folder-Nav: Einträge & Auswahl als Pills (wie Nextcloud-Sidebar) */ +#rl-app .b-folders li a.selectable { + border-radius: var(--border-radius-pill, 999px) !important; +} + +/* "New message" / Compose: volle Pill-Form (konsistent mit Nav-Pills) */ +#rl-app .b-folders .b-toolbar .btn.buttonCompose, +#rl-app .b-folders .b-toolbar .btn.buttonContacts, +#rl-app .buttonCompose { + border-radius: var(--border-radius-pill, 999px) !important; +}