diff --git a/.gitignore b/.gitignore index e746f2d..79474a9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ .dev __pycache__ *.zip +.vendor +.cargo +.xfer diff --git a/CHANGELOG.md b/CHANGELOG.md index 411de5b..2135ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.1.0 + +Logika dopasowana do PostfixAdmin 4.0.5 / master, przygotowanie do pracy produkcyjnej. + +### Zgodność z PostfixAdmin +- Limity domen: `-1` = wyłączone, `0` = bez limitu (wcześniej `-1` przy aliasach znaczyło „bez limitu”); licznik aliasów nie obejmuje aliasów skrzynek. +- Quota skrzynek liczona jak `check_quota()`/`allowed_quota()`; formularz pokazuje dozwolone maksimum. Wyłączona funkcja quota nie nadpisuje już quota skrzynek zerem. +- `alias_control`/`alias_control_admin` dotyczą wyłącznie aliasów skrzynek (przekierowań); wcześniej blokowały tworzenie wszystkich aliasów. +- Alias nie może wskazywać na siebie; dozwolone konta lokalne (`root`) i `@domena` dla catch-all; listy administratora pomijają aliasy skrzynek. +- Edycja skrzynki zawsze synchronizuje aktywność aliasu skrzynki; edycja częściowa nie resetuje pól (SMTP, telefon, e-mail). +- Edycja domeny bez podanych limitów zachowuje dotychczasowe wartości (wcześniej przywracała domyślne). +- Domena: walidacja `password_expiry` (0–36500), konflikt z `vacation_domain`, usuwanie w transakcji. +- Administrator domeny widzi tylko aktywne domeny bez backup MX (`list_domains_for_admin`). +- Administratorzy: zachowanie listy domen superadmina, ochrona przed odebraniem sobie uprawnień. +- Autoresponder: „Wracam” dezaktywuje zamiast usuwać, czyszczenie `vacation_notification`, godziny od/do, `vacation_choice_of_reply`. +- Fetchmail: hasło źródłowe zapisywane base64, jak oczekuje `fetchmail.pl`. +- `recipient_delimiter` traktowany jako zbiór znaków; status POP/IMAP jak w `gen_show_status()`. +- `edit_mailbox_name`: użytkownik skrzynki może zmienić własną nazwę (globalnie lub dla listy domen). +- Nowe akcje: przełączanie aktywności (domeny, skrzynki, aliasy, domeny aliasowe, administratorzy), przekierowania skrzynki, wyszukiwanie globalne i w domenie, stronicowanie, filtr aktywności aliasów, dziennik ze stronicowaniem. + +### Instalacja i utrzymanie +- `--init-schema [--dry-run]` (odpowiednik `setup.php`/`upgrade.php`), `--create-superadmin`, `--integrity-check`, `--refresh-dns`, `--help`. +- Baza developerska tworzona tym samym kodem co produkcyjny schemat. + +### Bezpieczeństwo +- `trusted_proxies` – poprawny adres klienta za reverse proxy (limit logowań, wyjątki TOTP). +- Rewalidacja sesji, wylogowanie innych sesji po zmianie hasła, czyszczenie pamięci sesji/limitów, opcjonalny HSTS. +- Logowanie z automatycznym rozpoznaniem konta (administrator, następnie skrzynka). +- Wiadomość powitalna wysyłana w tle, limit czasu SMTP 15 s. + +### Poprawki +- PostgreSQL: logowanie i listy nie działały (`CAST(boolean AS BIGINT)`, sumy `NUMERIC`, parametry `timestamp`). +- MySQL/PostgreSQL: sumy quota rzutowane na liczbę całkowitą. + +### Interfejs +- Nowy interfejs: adresy URL widoków (odświeżenie/zakładki/przycisk wstecz), lista wirtualna domeny, wyszukiwarka, stronicowanie, formularze z potwierdzeniem hasła i generatorem, błędy wyświetlane w oknie formularza, widok na telefonie, panel użytkownika (przekierowania, autoresponder, nazwa, 2FA). +- Teksty źródłowe po angielsku, kompletne tłumaczenie polskie (łącznie z komunikatami API). + ## 0.0.3 - Alias status is shown as a simple `Alias` badge; catch-all aliases follow PostfixAdmin semantics (`*` in the form, `@domain` in the database), including edit/delete compatibility and catch-all domain forwarding to `@target-domain`. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 02423e2..dbef55f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,73 +1,17 @@ -# Zgodnosc z PostfixAdmin +# Zgodność techniczna -Stan: **0.0.3**. Celem jest replacement runtime PHP z zachowaniem istniejacej bazy PostfixAdmin i SQL map uzywanych przez Postfix/Dovecot. +Pełne porównanie logiki z PostfixAdmin: [PORT-PARITY.md](PORT-PARITY.md). -| Obszar | Stan | Uwagi | -|---|---|---| -| Admin / global-admin / domain-admin | gotowe | Uprawnienia wymuszane w backendzie | -| Mailbox user login | gotowe | Tabela `mailbox` | -| Domeny CRUD | gotowe | Limity, quota, transport, backup MX | -| Mailbox CRUD | gotowe | Maildir, quota, self-alias, welcome mail, hooki | -| Aliasy CRUD | gotowe | Store-and-forward, special aliases, limity | -| Bulk delete inactive forwards | gotowe | Z ochrona mailbox self-alias i special aliases | -| Alias domains | gotowe | Kontrola lancuchow/cykli | -| Admin preferences | gotowe | W tym domyslny cel aliasu | -| Status aliasow | gotowe | undeliverable/external/POP-IMAP/vacation/disabled/expired/custom | -| Fetchmail | gotowe | Stary i nowszy wariant tabeli; MDA/extra options tylko gdy wlaczone | -| Vacation | gotowe | User/admin control | -| Logging | gotowe | Tabela `log` | -| Send mail / broadcast | gotowe | Mailbox-only lub aktywne cele aliasow | -| Backup | gotowe | `mysqldump` / `pg_dump` / SQLite; sekret MySQL poza argv | -| Quota / domain quota | gotowe | Zgodne z multiplierem | -| Used quota | gotowe warunkowo | `quota` / `quota2`, jesli istnieje | -| Password hashes | gotowe | Natywne formaty PA + Courier/Authlib/php_crypt + kontrolowany Dovecot fallback | -| Mixed legacy hashes | gotowe | Format rekordu ma pierwszenstwo przy weryfikacji | -| Password recovery e-mail | gotowe warunkowo | Wymaga kolumn token/email | -| SMS recovery | brak | Do dodania jako jawny provider, bez callbackow PHP | -| TOTP | gotowe warunkowo | Wymaga `totp_secret` | -| TOTP exceptions | gotowe warunkowo | Wymaga tabeli `totp_exception_address` | -| Application passwords | gotowe warunkowo | Wymaga `mailbox_app_password` | -| DKIM keys/signing | gotowe warunkowo | Wymaga tabel `dkim`, `dkim_signing`; OpenSSL bez shella | -| Password expiration | gotowe warunkowo | Wymaga odpowiednich kolumn | -| `smtp_active` | gotowe warunkowo | Wymaga kolumny | -| DNS status domeny | gotowe warunkowo | Wymaga `dns_active` i `dns_checked` | -| DNS validation adresow/domen | gotowe | A/AAAA/MX/NS zgodnie z logika PA | -| MySQL/MariaDB | gotowe | Podstawowy wariant produkcyjny | -| PostgreSQL | gotowe | Placeholdery transliterowane przez DB layer | -| SQLite | gotowe | Takze izolowany mock developerski | -| Dev mock DB | gotowe | Superset tabel PA + seedy; tylko `environment=development` | -| Dev SSH tunnel do produkcyjnej DB | gotowe | Domyslnie read-only; zapis wymaga `POSTFIXADMIN_RS_DEV_DB_WRITES=1` | -| UI i18n | gotowe | EN/PL, domyslnie English, przelaczanie z loginu/topbara; API pozostaje angielskie | -| Post-create/edit/delete hooks | gotowe inaczej | Program + argumenty, bez powloki | -| `maildir_name_hook` | zastapione natywnie | `maildir_template` z `{domain}`, `{local_part}`, `{username}` | -| Auto-create subfolders | gotowe dla Dovecot | `doveadm mailbox create`; generic IMAP/Courier jeszcze brak | -| PHP `*_struct_hook` | brak celowo | Nie uruchamiamy PHP; docelowo natywne rozszerzenia/config | -| `language_hook` PHP | brak celowo | Zastapione natywnymi katalogami i18n EN/PL; bez wykonywania callbackow PHP | -| XML-RPC | brak | W przekazanym configu jest wylaczone | -| Setup wizard / DB upgrade | brak | Runtime wykrywa schema capability, nie migruje jeszcze produkcyjnej DB | -| Update checker | brak | Nie jest potrzebny do runtime poczty | -| Stary theme PostfixAdmin | brak celowo | Zastapiony UI RustBox-like | - -## Funkcje publicznego PA sprawdzone przy porownaniu - -Odwzorowane odpowiedniki istnieja dla: login/MFA, list/edit/delete/editactive, list-virtual, vacation, viewlog, sendmail, broadcast, backup, refresh-domain-dns, delete-inactive i delete-preference. - -Nieodwzorowane jeszcze publiczne wejscia: `setup.php`, `upgrade.php`, `update-check.php`, `xmlrpc.php`. - -## Hasla - -Lista kompatybilnosci jest oparta na dostarczonych `DOCUMENTS/HASHING.md`, `functions.inc.php` i `tests/PacryptTest.php`. W kodzie sa referencyjne wektory PA dla SHA, crypt, bcrypt, SSHA, Argon2, Courier oraz wariantow B64. - -Weryfikacja nie wymusza aktualnego `password.scheme`, jesli hash sam identyfikuje algorytm (`{SCHEME}`, `$1$`, `$2...`, `$5$`, `$6$`). To pozwala na stopniowa migracje. Generowanie nowego hasla nadal uzywa jawnie skonfigurowanego schematu, aby nie zerwac zgodnosci z Dovecot/SASL. - -## Przekazany config - -Aktywne elementy istotne dla tego wdrozenia (MySQL/MariaDB, `php_crypt:MD5`, alias-domain, sendmail, logging, fetchmail, user recovery) maja odpowiednik. XML-RPC jest w nim wylaczone, SMS callback pusty, `maildir_name_hook=NO`, a lista automatycznych subfolderow pusta, wiec pozostale luki nie blokuja obecnego profilu. - -## Audit PostfixAdmin 4.0.1 - -Pelniejszy audyt funkcji z dostarczonego PHP 4.0.1 jest w [PORT-PARITY.md](PORT-PARITY.md). 0.0.3 nadal **nie jest 100% parity**; dokument rozdziela funkcje runtime juz przeportowane od pozostalych narzedzi instalacyjnych, API, CLI i legacy hookow/UI. - -## Rspamd DKIM - -Dodano osobna integracje hostowa Rspamd: generowanie przez `rspamadm dkim_keygen`, `selector_map`, `path_map`, atomowe zapisy, `rspamadm configtest`, opcjonalny reload i podpinanie istniejacych kluczy. Szczegoly: [docs/RSPAMD_DKIM.md](docs/RSPAMD_DKIM.md). +| Element | Stan | +|---|---| +| MySQL / MariaDB | wspierane (SQLx, `TINYINT(1)`, `TEXT`/`DECIMAL` normalizowane w zapytaniach) | +| PostgreSQL | wspierane i testowane na PostgreSQL 16 (`boolean`, `timestamptz`) | +| SQLite | wspierane, również jako izolowana baza developerska | +| Prefiks tabel / własne nazwy tabel | `database.prefix`, `[database.tables]` | +| Schemat | PostfixAdmin 1857; `--init-schema` tworzy/uzupełnia, `config.version` ustawiane na 1857 | +| Kolumny opcjonalne starszych schematów | wykrywane w runtime (`smtp_active`, `password_expiry`, `totp_secret`, `description`, `dns_*`, `phone`, `email_other`, `token*`) | +| Hasła | wszystkie formaty PostfixAdmin; format rekordu ma pierwszeństwo | +| `vacation.pl` | alias transportu `user#domena@vacation_domain`, tabela `vacation`, `vacation_notification` | +| `fetchmail.pl` | `src_password` base64, kolumny starego i nowego schematu | +| Dovecot quota | `quota` (`path='quota/storage'`) lub `quota2` (`new_quota_table`) | +| Postfix SQL maps | bez zmian (`alias`, `mailbox`, `domain`, `alias_domain`) | diff --git a/Cargo.lock b/Cargo.lock index bd3f240..f8162e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "postfixadmin-rs" -version = "0.0.3" +version = "0.1.0" dependencies = [ "argon2", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index d0ee10d..5457463 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postfixadmin-rs" -version = "0.0.3" +version = "0.1.0" edition = "2024" rust-version = "1.99.1" default-run = "postfixadmin-rs" diff --git a/PORT-PARITY.md b/PORT-PARITY.md index 8bfe1db..2d96b8e 100644 --- a/PORT-PARITY.md +++ b/PORT-PARITY.md @@ -1,80 +1,52 @@ -# PostfixAdmin 4.0.1 -> PostfixAdmin-RS 0.0.3 parity audit +# PostfixAdmin → PostfixAdmin-RS: zgodność -Audit basis: the supplied `postfixadmin-master` source identifies itself as PostfixAdmin **4.0.1** in `config.inc.php`. -The goal of 0.0.3 is runtime replacement for normal mail administration, not binary/API compatibility with PHP. +Referencja: PostfixAdmin **4.0.5** oraz gałąź `master` (listopad 2026), schemat bazy **1857**. +Cel: zastąpienie runtime PHP przy zachowaniu bazy, map SQL Postfixa/Dovecota i skryptów +`vacation.pl` / `fetchmail.pl`. -## Runtime features already ported +## Logika przeniesiona 1:1 -| PostfixAdmin area | Rust status | +| PostfixAdmin | PostfixAdmin-RS | |---|---| -| Admin/global-admin/domain-admin authentication and authorization | Ported | -| Mailbox user login / own mailbox overview / self-alias editing | Ported; overview uses DB/quota tables only, no IMAP | -| Domains CRUD, limits, quota, transport, backup MX | Ported | -| Mailboxes CRUD, quota, maildir, self-alias, welcome mail | Ported | -| Aliases CRUD, store-and-forward, special alias protection | Ported | -| Delete inactive forwards | Ported | -| Alias domains | Ported | -| Fetchmail | Ported, with old/new schema detection | -| Vacation/autoreply | Ported using PostfixAdmin `vacation` table and controls; mail-server Vacation transport must already be configured | -| Logging/audit table | Ported | -| Send mail and broadcast | Ported | -| Database backup | Ported | -| DNS validation and domain DNS refresh | Ported | -| Used quota (`quota`/`quota2`) | Ported when schema exposes it | -| Password expiration / `smtp_active` | Ported when schema exposes it | -| Password recovery by e-mail | Ported when schema exposes required fields | -| TOTP and TOTP exceptions | Ported when schema exposes required fields/tables | -| Application passwords | Ported when schema exposes required table | -| PostfixAdmin DKIM and DKIM signing tables | Ported | -| Admin preferences / alias default target | Ported | -| Post-create/edit/delete/password/TOTP/app-password hooks | Ported as argv-only native hooks | -| `maildir_name_hook` use case | Replaced by safe native `maildir_template` | -| Mailbox subfolders | Ported for Dovecot via `doveadm` | -| Rspamd file-based DKIM | Added beyond PHP PA: per-domain keygen/maps, configtest, optional reload, adopt existing key, mailbox-user public DNS read-only view | -| ManageSieve filter scripts | Added beyond PHP PA: mailbox-user list/get/check/save/activate/rename/delete, optional TLS, basic visual rule builder + raw editor, no IMAP | +| `list_domains_for_admin()` – admin domeny widzi tylko aktywne, nie-backupMX domeny | tak samo; rewalidacja co `session_revalidate_seconds` | +| `DomainHandler` – limity `-1/0/n`, `password_expiry` 0..36500, konflikt z `vacation_domain`, domyślne aliasy, kasowanie z `domain_admins/alias/mailbox/alias_domain/vacation/vacation_notification/quota/quota2/fetchmail/log`, blokada gdy domena jest celem domeny aliasowej | tak samo (w transakcji) | +| `alias_count` = aliasy − skrzynki | tak samo (licznik i limit) | +| `MailboxHandler::check_quota()` / `allowed_quota()` | tak samo, łącznie z komunikatem „maks. N MB” | +| `MailboxHandler` – alias skrzynki tworzony razem ze skrzynką, aktywność aliasu = aktywność skrzynki, kasowanie `fetchmail/vacation/vacation_notification/quota/quota2/alias` | tak samo (+ hasła aplikacji, wyjątki TOTP) | +| `generate_password` / `show_password` | tak samo (wygenerowane hasło pokazywane raz) | +| `mailbox_postpassword_script` – blokuje zmianę hasła przez admina, wywoływany przy tworzeniu z hasłem na stdin | tak samo (hook argv) | +| `edit_mailbox_name` (master) – użytkownik zmienia swoją nazwę, globalnie lub dla listy domen | tak samo | +| `AliasHandler` – limit aliasów, catch-all `@domena`, `@domena` → `@inna`, konto lokalne `^[a-z0-9]+$`, „alias wskazuje na siebie”, `emailcheck_localaliasonly`, `special_alias_control`, ukrywanie aliasów skrzynek na listach admina | tak samo | +| `alias_control` / `alias_control_admin` dotyczą **aliasów skrzynek** (przekierowań), `edit_alias` – użytkownika | tak samo (wcześniejsza wersja RS błędnie blokowała nimi wszystkie aliasy) | +| `gen_show_status()` – niedostarczalny/zewnętrzny w zakresie domen admina, domeny aliasowe, `recipient_delimiter` jako **zbiór znaków** (master), vacation, disabled, expired, POP/IMAP, custom | tak samo | +| `AliasdomainHandler` | tak samo + ochrona przed łańcuchami | +| `AdminHandler` – domeny + znacznik `ALL` dla superadmina | tak samo; nie można zdegradować/wyłączyć samego siebie | +| `VacationHandler` – `set_away` czyści `vacation_notification`, `remove` tylko dezaktywuje, alias transportu | tak samo; daty z godziną, `vacation_choice_of_reply` | +| `FetchmailHandler` – `src_password` zapisywane base64 (`b64p`) | tak samo (wcześniejsza wersja RS zapisywała jawny tekst) | +| `editactive.php` | `POST /api/{domains,mailboxes,aliases,alias-domains,admins}/{id}/active` | +| `list-virtual.php` – wyszukiwanie, stronicowanie, filtr aktywności, `delete-inactive.php` | tak samo | +| `viewlog.php` – „wszystkie domeny”, stronicowanie | tak samo + filtr tekstowy | +| `setup.php` / `upgrade.php` / `DatabaseIntegrityChecker` | `--init-schema [--dry-run]`, `--integrity-check` | +| `scripts/domain-dns-check.php` | `--refresh-dns` | +| logowanie admin/`users/` | jeden formularz z automatycznym wyborem (admin, potem skrzynka) lub ręcznym | +| TOTP, wyjątki TOTP, hasła aplikacji, odzyskiwanie hasła e-mailem | tak samo | +| DKIM (`dkim`, `dkim_signing`) | tak samo + opcjonalnie Rspamd | -## Not yet 1:1 with PHP PostfixAdmin +## Świadome różnice -These are real parity gaps; 0.0.3 must not be described as 100% complete while they remain. - -| Area | Gap / intended Rust replacement | +| Obszar | Powód | |---|---| -| `setup.php` | No web installer yet. Development mock exists; production schema must already exist. | -| `upgrade.php` / DB migrations | No PostfixAdmin schema migration engine yet. Runtime capability detection exists only. | -| `update-check.php` | No upstream update checker. | -| `xmlrpc.php` | Not implemented. The supplied deployment config has it disabled. | -| SMS password recovery callback | Not implemented. Needs an explicit provider interface, not arbitrary PHP callbacks. | -| PHP `*_struct_hook` | Intentionally not executable. Needs a typed Rust extension/config mechanism. | -| PHP `language_hook` | Intentionally not executable. UI uses native EN/PL catalogs with runtime switching; English is the default. | -| Generic IMAP/Courier mailbox subfolder creation | Dovecot path is implemented; generic IMAP/Courier parity remains. | -| CLI (`scripts/postfixadmin-cli`) | No full CLI CRUD frontend yet. | -| `scripts/domain-dns-check.php` cron equivalent | DNS refresh exists through API/UI; no dedicated batch CLI/cron command yet. | -| `scripts/examples/vacation-cron.php` expiry job | Vacation dates and CRUD are ported; no dedicated scheduled/batch command yet to deactivate expired vacation rows automatically. | -| Database Unix socket | Not yet exposed in Rust DB config. | -| Client-certificate DB TLS (`ssl_key`, `ssl_cert`, `ssl_ca`, `ssl_cipher`) | Only TLS enable/verify policy is currently exposed. | -| Arbitrary regex `password_validation` | Rust currently uses typed minimum length/letter/digit policy. | -| Configurable `username_legal_chars` | Rust uses a fixed RFC-style safe local-part validator. | -| `generate_password` / `show_password` UI helpers | Ported for mailbox/admin forms; generated passwords use Web Crypto and optional post-create reveal remains disabled by default. | -| `edit_mailbox_name` toggle | Boolean policy is ported in the Rust UI. PostfixAdmin's per-domain array policy remains a parity gap. | -| Header/footer/MOTD options | MOTD is ported per user/admin/global-admin. Legacy header/footer chrome remains intentionally replaced by the RustBox-style UI. | -| Legacy theme/favicon/custom CSS options | Intentionally replaced by RustBox-style embedded UI. | -| Legacy status colors | Semantic status badges replace legacy color-name configuration. Quota warning thresholds (`quota_level_med_pct` / `quota_level_high_pct`) are ported to progress bars. | -| `domain_list_hide_maxquota` / `domain_list_hide_password_expiry` | Ported as configuration flags for the Domains table. | -| `site_url` behavior | No equivalent configuration yet. | -| `DatabaseIntegrityChecker` breadth | `/system/schema` performs compatibility checks, but is not yet a full 1:1 integrity checker. | +| Hooki PHP (`*_struct_hook`, `maildir_name_hook`, `language_hook`) | nie wykonujemy PHP; zamiast tego `maildir_template` i hooki jako program + argumenty | +| `password_validation` z dowolnymi wyrażeniami regularnymi | typowane `min_length` / `min_letters` / `min_digits` (odpowiada domyślnej konfiguracji PA) | +| Wygląd, `theme_*`, `header_text`, `footer_*` | nowy interfejs; MOTD obsługiwane | +| Wiadomość powitalna | wysyłana w tle – brak SMTP nie blokuje tworzenia skrzynki | -## Public PHP entry points checked +## Czego brak -Equivalent runtime functionality exists for login/MFA, list/edit/delete/edit-active behavior, virtual list, vacation, view log, sendmail, broadcast, backup, DNS refresh, delete-inactive and preference deletion. - -Still missing as direct equivalents: setup, upgrade, update-check and XML-RPC. User pages for password/TOTP/app-passwords/recovery are represented by Rust API/UI routes rather than PHP-compatible URLs. - -## Rspamd DKIM design - -Rspamd integration is separate from the PostfixAdmin `dkim` / `dkim_signing` database tables. It manages the actual signing keys used by Rspamd on the host. - -The integration uses Rspamd-supported `selector_map` + `path_map` files and keeps only a small marked block in `local.d/dkim_signing.conf`. Existing private keys can be adopted as long as they live below the configured DKIM directory. The legacy importer creates a backup and migrates only valid `domain { path/selector }` entries; imported blocks are removed from the active config only after the generated map configuration passes `rspamadm configtest`, with rollback on failure. - -## Definition of "full parity" for later releases - -A future release can be called full runtime parity only after the migration/setup path, XML-RPC decision/replacement, SMS provider abstraction, CLI/batch tools, advanced DB connectivity and remaining user-visible config toggles are either implemented or explicitly declared unsupported with a migration alternative. +| Obszar | Uwagi | +|---|---| +| XML-RPC (`xmlrpc.php`) | wyłączone w typowych wdrożeniach; brak planów | +| Odzyskiwanie hasła SMS | wymaga interfejsu dostawcy SMS | +| `update-check.php` | niepotrzebne w runtime | +| CLI CRUD (`postfixadmin-cli`) | jest API JSON; CLI tylko do instalacji/utrzymania | +| Połączenie z bazą przez gniazdo Unix i certyfikaty klienta TLS | tylko host:port i TLS z weryfikacją serwera | diff --git a/README.md b/README.md index 680efc0..3450c62 100644 --- a/README.md +++ b/README.md @@ -1,194 +1,141 @@ # PostfixAdmin-RS -Rustowy panel administracyjny dla Postfix/Dovecot, rozwijany jako zamiennik runtime PHP PostfixAdmin i zgodny z jego istniejaca baza danych. +Panel administracyjny serwera poczty Postfix/Dovecot napisany w Rust — zamiennik +PHP-owego [PostfixAdmin](https://github.com/postfixadmin/postfixadmin), działający +na **tej samej bazie danych** (MySQL/MariaDB, PostgreSQL, SQLite). Postfix, Dovecot, +`vacation.pl` i `fetchmail.pl` korzystają z tabel dokładnie tak jak przy PHP-owym +PostfixAdminie, więc panel można podmienić bez migracji danych. -Aktualna linia developerska: **0.0.3**. Projekt jest przypiety do **Rust 1.99.1**, edition 2024. Frontend jest osadzony w pojedynczym binarnym pliku; bundlowanie assetow, sprite SVG i workflow developerski sa wzorowane na przekazanym RustBox. Stary wyglad PostfixAdmin nie jest kopiowany. +Wersja: **0.1.0** · referencja logiki: PostfixAdmin 4.0.5 + gałąź `master` (schemat bazy 1857). -## Development bez produkcyjnej bazy +Jeden statyczny plik binarny z wbudowanym interfejsem (EN/PL, jasny/ciemny motyw, +działa na telefonie), bez PHP, bez serwera aplikacji, bez zależności w runtime. -Domyslny `dev.sh` nie potrzebuje MySQL/PostgreSQL. Uzywa izolowanego SQLite w `.dev/postfixadmin-rs.sqlite` i przy pierwszym starcie tworzy mock schematu PostfixAdmin z danymi testowymi: +## Funkcje + +| Obszar | Zachowanie | +|---|---| +| Domeny | limity aliasów/skrzynek (`-1` = wyłączone, `0` = bez limitu), quota domeny i maks. quota skrzynki, transport, backup MX, wygasanie haseł, status DNS, domyślne aliasy przy tworzeniu, kasowanie domeny z wszystkimi danymi | +| Lista wirtualna | widok domeny jak `list-virtual.php`: domeny aliasowe, aliasy, skrzynki, wyszukiwanie, stronicowanie, filtr aktywne/nieaktywne, przełączanie aktywności jednym kliknięciem, „usuń nieaktywne”, statusy (niedostarczalny, zewnętrzny, autoresponder, wyłączona, hasło wygasło, POP/IMAP) | +| Skrzynki | reguły quota identyczne z `MailboxHandler::check_quota()`/`allowed_quota()` (podpowiedź „maks. N MB” w formularzu), generowanie hasła, potwierdzenie hasła, SMTP on/off, e-mail do odzyskiwania, wiadomość powitalna (w tle), maildir zgodny z `domain_path`/`domain_in_mailbox`, hooki po utworzeniu/edycji/usunięciu/zmianie hasła | +| Przekierowania | alias skrzynki („przekaż i zachowaj”), z uprawnieniami `alias_control` / `alias_control_admin` / `edit_alias` jak w PostfixAdminie | +| Aliasy | catch-all (`*` → `@domena`), przekierowanie całej domeny, konto lokalne (`root`), blokada aliasu wskazującego na siebie, `emailcheck_localaliasonly`, chronione aliasy domyślne (`special_alias_control`), zapamiętany domyślny cel | +| Domeny aliasowe | z ochroną przed łańcuchami i pętlami | +| Administratorzy | globalni i domenowi, przypisanie domen, aktywacja, reset hasła | +| Autoresponder | tabela `vacation` + alias transportu `user#domena@vacation_domain`, data i godzina od/do, `vacation_choice_of_reply`, czyszczenie `vacation_notification` | +| Panel użytkownika | podgląd skrzynki i quota, przekierowania, autoresponder, zmiana hasła, zmiana nazwy (`edit_mailbox_name`), 2FA, hasła aplikacji, filtry Sieve | +| Bezpieczeństwo | TOTP + wyjątki IP, hasła aplikacji, odzyskiwanie hasła e-mailem, limit prób logowania per IP/konto, CSRF, ciasteczka `HttpOnly`/`SameSite=Strict`/`Secure`, nagłówki CSP/HSTS, wylogowanie innych sesji po zmianie hasła, rewalidacja sesji (wyłączone konto lub odebrane uprawnienia działają natychmiast) | +| Inne | fetchmail (hasło base64 jak w PostfixAdmin), dziennik zmian z filtrem domen i stronicowaniem, wysyłanie poczty / wiadomość do wszystkich, kopia zapasowa bazy, DKIM (tabele PostfixAdmin/OpenDKIM oraz Rspamd), ManageSieve | +| Hasła | wszystkie formaty PostfixAdmin (ARGON2ID/I, BLF-CRYPT, SHA512-CRYPT, MD5-CRYPT, `php_crypt:*`, `md5crypt`, SSHA, Courier, Authlib, `dovecot:*` …) — format zapisany w rekordzie ma pierwszeństwo, więc stare i nowe hashe mogą współistnieć | + +Szczegółowe porównanie z PHP: [PORT-PARITY.md](PORT-PARITY.md). + +## Instalacja produkcyjna + +### 1. Budowanie ```bash -bash dev.sh +cargo build --release --locked # wynik: target/release/postfixadmin-rs +# albo pakiet Debiana (w Dockerze): +./scripts/build-deb.sh # wynik: packages/postfixadmin-rs_*.deb ``` -Konta testowe: - -```text -admin@example.test / admin123 # global-admin -domain-admin@example.test / domain123 # admin example.test -user@example.test / user123 # mailbox user -``` - -Reset mock DB oraz developerskiego mocka Rspamd DKIM: +### 2. Konfiguracja ```bash +install -d -m 0750 -g postfixadmin /etc/postfixadmin-rs +install -m 0640 -g postfixadmin config.example.toml /etc/postfixadmin-rs/config.toml +$EDITOR /etc/postfixadmin-rs/config.toml +``` + +Najważniejsze ustawienia: + +- `[database]` – dane bazy używanej przez Postfix/Dovecot. Hasło można podać w + `/etc/default/postfixadmin-rs` jako `POSTFIXADMIN_RS__DATABASE__PASSWORD=...`. +- `[password] scheme` – **musi** odpowiadać temu, co obsługuje Dovecot + (`default_pass_scheme`). Dla nowych instalacji: `ARGON2ID` lub `SHA512-CRYPT`. +- `[defaults] vacation_domain` – domena transportu autorespondera z `master.cf`. +- `[security]` – za nginx ustaw `trusted_proxies = ["127.0.0.1", "::1"]`, + `cookie_secure = true`, `hsts = true`. + +### 3. Baza danych + +```bash +export POSTFIXADMIN_RS_CONFIG=/etc/postfixadmin-rs/config.toml +postfixadmin-rs --init-schema --dry-run # co zostałoby zmienione +postfixadmin-rs --init-schema # tworzy brakujące tabele/kolumny/indeksy +postfixadmin-rs --create-superadmin admin@example.com # hasło ze stdin lub POSTFIXADMIN_RS_ADMIN_PASSWORD +postfixadmin-rs --check-config +``` + +`--init-schema` zastępuje `setup.php`/`upgrade.php`: na pustej bazie tworzy pełny +schemat PostfixAdmin (wersja 1857), na istniejącej tylko dodaje brakujące elementy — +nigdy niczego nie usuwa. Jest idempotentne. + +### 4. Usługa i reverse proxy + +```bash +install -m 0644 packaging/postfixadmin-rs.service /etc/systemd/system/ +systemctl daemon-reload && systemctl enable --now postfixadmin-rs +``` + +Przykład nginx z TLS, HSTS i limitem logowań: [packaging/nginx.conf.example](packaging/nginx.conf.example). +Aplikacja nasłuchuje tylko na `127.0.0.1:8080`; publicznie wystawiony jest wyłącznie nginx. + +### 5. Zadania okresowe (opcjonalnie) + +```cron +# odświeżanie statusu DNS domen (odpowiednik scripts/domain-dns-check.php) +17 3 * * * postfixadmin POSTFIXADMIN_RS_CONFIG=/etc/postfixadmin-rs/config.toml /usr/bin/postfixadmin-rs --refresh-dns +``` + +## Migracja z PHP PostfixAdmin + +1. Kopia zapasowa bazy oraz konfiguracji Postfix/Dovecot/PostfixAdmin. +2. Przenieś ustawienia z `config.local.php` do `config.toml` + (`$CONF['encrypt']` → `password.scheme`, `$CONF['vacation_domain']`, `default_aliases`, limity, `quota_multiplier` …). + Przykład odwzorowania: [config.from-user.toml](config.from-user.toml). +3. `postfixadmin-rs --init-schema --dry-run`, a potem bez `--dry-run` + (dodaje tylko brakujące kolumny nowszych wersji PostfixAdmin). +4. Uruchom panel równolegle (inny vhost), sprawdź logowanie, listy i jedną domenę testową. +5. Przełącz reverse proxy, a PHP/FPM wyłącz na końcu. + +Konta administratorów i skrzynek oraz ich hasła działają bez zmian. + +## Polecenia + +``` +postfixadmin-rs uruchamia serwer +postfixadmin-rs --check-config sprawdza konfigurację, połączenie i schemat +postfixadmin-rs --init-schema tworzy/aktualizuje schemat (--dry-run: tylko raport) +postfixadmin-rs --integrity-check osierocone rekordy (tylko odczyt) +postfixadmin-rs --create-superadmin EMAIL +postfixadmin-rs --refresh-dns odświeża status DNS wszystkich domen +postfixadmin-rs --version | --help +``` + +## Rozwój + +```bash +bash dev.sh # izolowana baza SQLite z danymi testowymi POSTFIXADMIN_RS_DEV_RESET=1 bash dev.sh ``` -`database.dev_mock=true` jest dozwolone tylko z `environment="development"` i backendem SQLite. Produkcyjna baza nie jest potrzebna do pracy nad UI/API. +Konta testowe: `admin@example.test / admin123` (globalny), `domain-admin@example.test / domain123` +(admin `example.test`), `user@example.test / user123` (skrzynka). Tryb SSH do prawdziwej bazy +(domyślnie tylko do odczytu) opisuje `.env.example`. -Jesli chcesz testowac bezposrednio na produkcyjnej bazie przez SSH, `dev.sh` moze zestawic lokalny tunnel. Domyslnie taka sesja jest **read-only**. Najprosciej skopiowac gotowy przyklad dla `host.linuxiarz.pl`: +Testy: ```bash -cp .env.example .env -$EDITOR .env -bash dev.sh +cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test +python3 scripts/api-smoke.py http://127.0.0.1:8080 admin@example.test admin123 # test API na działającej instancji +python3 scripts/i18n/build.py # przebudowa katalogu PL + kontrola brakujących tłumaczeń ``` -W `.env` uzupelnij co najmniej `POSTFIXADMIN_RS_DEV_DB_PASSWORD`. Przyklad jest dopasowany do testowego hosta: `root@host.linuxiarz.pl:11922`, MySQL/MariaDB, baza `postfix`, uzytkownik DB `postfix` i lokalny port tunelu `13306`. +Interfejs jest w `src/web` (zwykły JS/CSS, wbudowywany przez `build.rs`); teksty źródłowe +są po angielsku, tłumaczenia polskie w `scripts/i18n/pl_messages.py`. -Domyslny forward dla MySQL to `127.0.0.1:13306 -> 127.0.0.1:3306`, a dla PostgreSQL `127.0.0.1:15432 -> 127.0.0.1:5432`. Mozesz zmienic host/port po stronie serwera przez `POSTFIXADMIN_RS_DEV_DB_HOST` i `POSTFIXADMIN_RS_DEV_DB_PORT`. Weryfikacja host key SSH pozostaje wlaczona. Tunel uzywa `BatchMode=yes`, wiec przygotuj klucz/ssh-agent; opcjonalnie ustaw `POSTFIXADMIN_RS_SSH_IDENTITY` lub `POSTFIXADMIN_RS_SSH_KNOWN_HOSTS`. - -Zapisy na produkcyjnej bazie wymagaja dodatkowego, jawnego opt-in: - -```bash -POSTFIXADMIN_RS_DEV_DB_MODE=ssh POSTFIXADMIN_RS_DEV_DB_WRITES=1 bash dev.sh -``` - -W tym trybie aplikacja moze modyfikowac realne skrzynki/aliasy. Do zwyklych testow UI i odczytu schematu nie ustawiaj `POSTFIXADMIN_RS_DEV_DB_WRITES=1`. - -## Co jest gotowe - -- logowanie admin/mailbox, sesje HttpOnly/SameSite, CSRF i rate limit, -- global-admin, domain-admin i mailbox user, -- domeny, skrzynki, aliasy, alias-domains i masowe usuwanie nieaktywnych forwardow, -- limity domen, quota/domain quota, transport, backup MX i self-alias, -- fetchmail, vacation/autoreply zgodny z tabela `vacation`, log, sendmail/broadcast i backup DB, -- panel mailbox-user: wlasna quota/usage z DB, dane skrzynki, self-alias, bez odczytu IMAP, -- opcjonalny ManageSieve dla mailbox-user: lista/edycja/walidacja/aktywacja skryptow filtrow, prosty wizualny builder reguly + edytor raw, bez IMAP, -- admin preferences (m.in. domyslny cel aliasu), -- DKIM keys/signing, TOTP, TOTP exceptions, application passwords, -- Rspamd DKIM: generowanie kluczy, selector/path maps, configtest, podpinanie istniejacych kluczy i opcjonalny reload, -- `smtp_active`, password expiration i DNS status, gdy schema je udostepnia, -- bezpieczny natywny `maildir_template` zamiast PHP `maildir_name_hook`, -- automatyczne subfoldery przez `doveadm mailbox create`, jesli lista jest skonfigurowana, -- MySQL/MariaDB, PostgreSQL i SQLite przez SQLx Any, -- hooki wykonywane jako program + argumenty, bez `sh -c`, -- UI EN/PL z English jako domyslnym jezykiem; wybor z interfejsu jest zapamietywany w przegladarce, API pozostaje po angielsku. - -Pelna macierz: [COMPATIBILITY.md](COMPATIBILITY.md). Audyt 1:1 wobec dostarczonego PHP 4.0.1: [PORT-PARITY.md](PORT-PARITY.md). - -## Mailbox user, Vacation i ManageSieve - -Panel zwyklego uzytkownika nie pokazuje administracyjnej listy modulow ani cudzych skrzynek. Overview pobiera tylko rekord zalogowanej skrzynki i jej self-alias. Quota/usage pochodzi z tabel PostfixAdmin/Dovecot (`mailbox`, opcjonalnie `quota`/`quota2`); aplikacja nie loguje sie do IMAP, zeby zbudowac ten widok. - -Vacation pozostaje zgodny z PostfixAdmin: korzysta z tabeli `vacation` i ustawien `features.vacation` / `features.vacation_control`. Samo wlaczenie UI nie konfiguruje transportu/autorespondera po stronie Postfix/Dovecot, dlatego na istniejacym serwerze wlacz te flagi tylko wtedy, gdy mechanizm Vacation jest faktycznie skonfigurowany. - -ManageSieve jest dodatkiem niezaleznym od mechanizmu Vacation PA. Konfiguracja: - -```toml -[sieve] -enabled = true -host = "127.0.0.1" -port = 4190 -tls_mode = "starttls" # starttls | implicit | none -allow_insecure_auth = false -timeout_secs = 10 -read_only = false -``` - -Haslo mailbox-user jest przechowywane tylko w pamieci jego sesji, i tylko gdy Sieve jest wlaczone; nie trafia do JSON API ani logow. Po zmianie hasla credential sesji jest aktualizowany. Dla developerskiego testu przez SSH mozna wlaczyc drugi forward tym samym `dev.sh`: - -```bash -POSTFIXADMIN_RS_DEV_SIEVE_ENABLED=true bash dev.sh -``` - -Domyslnie zewnetrzny ManageSieve przez dev tunnel jest read-only. Zapis wymaga osobnego `POSTFIXADMIN_RS_DEV_SIEVE_WRITES=1`; jest to niezalezne od `POSTFIXADMIN_RS_DEV_DB_WRITES`. - -## Hasla zgodne z PostfixAdmin - -Weryfikacja najpierw rozpoznaje format juz zapisany w rekordzie. Dzieki temu jedna baza moze zawierac stare i nowe hashe podczas migracji, bez masowego przepisywania kont. - -Obslugiwane sa m.in.: - -- `ARGON2ID`, `ARGON2ID.B64`, `ARGON2I`, `ARGON2I.B64`, -- `BLF-CRYPT`, `BLF-CRYPT.B64`, `CRYPT`, -- `SHA512-CRYPT`, `SHA512-CRYPT.B64`, legacy `SHA512.B64`, `SHA512`, -- `SHA256-CRYPT`, `SHA256-CRYPT.B64`, `SHA256`, -- `SHA1-CRYPT`, `SHA1`, `SHA1.B64`, `SHA`, `SSHA`, -- `MD5-CRYPT`, legacy `md5crypt`, `PLAIN-MD5`, `MD5`, `MD5RAW`, -- `AUTHLIB` (`md5raw`, `md5`, `SHA`, `crypt`), -- `courier:md5`, `courier:md5raw`, `courier:sha1`, `courier:ssha`, `courier:sha256`, -- `php_crypt:DES|MD5|BLOWFISH|SHA256|SHA512` z kompatybilnym difficulty/prefix, -- `dovecot:METHOD` jako kontrolowany fallback do `doveadm pw`, bez shella i bez hasla w argv, -- `PLAIN`, `CLEAR`, `cleartext` tylko dla zgodnosci/testow. - -Testy zawieraja referencyjne wektory z dostarczonego `tests/PacryptTest.php`. Slabe formaty pozostaja tylko dla kompatybilnosci; nowych kont nie nalezy na nie migrowac bez potrzeby. - -Dla przekazanego configu produkcyjnego pozostaje `php_crypt:MD5`, dopoki konfiguracja Dovecot/Postfix SASL nie zostanie zmieniona razem z panelem. - -## Konfiguracja przekazanego serwera - -`config.from-user.toml` odwzorowuje przekazany config PostfixAdmin, ale nie zawiera hasla bazy. Sekret podaj przez ENV: - -```bash -export POSTFIXADMIN_RS_CONFIG="$PWD/config.from-user.toml" -export POSTFIXADMIN_RS__DATABASE__PASSWORD='...' -``` - -## Jezyki interfejsu - -Frontend ma katalogi `src/web/i18n/en.json` i `src/web/i18n/pl.json`. `default_language = "en"` ustawia English jako pierwszy jezyk, a przelacznik w loginie i gornej belce zapisuje wybor w `localStorage`. Lokalizacja dotyczy tylko UI; endpointy, JSON i kody bledow API pozostaja angielskie. - -## Build / release gate - -```bash -./scripts/build-release.sh -``` - -Gate wykonuje walidacje statyczna, `cargo fmt --check`, testy, Clippy z `-D warnings` i release build z `Cargo.lock`. - -## Migracja z PHP - -1. Zrob backup bazy i konfiguracji Postfix/Dovecot/PostfixAdmin. -2. Najpierw testuj na kopii DB lub na mocku developerskim. -3. Na realnej DB uruchom `--check-config`, sprawdz logowanie i CRUD na domenie testowej. -4. Nie zmieniaj `password.scheme`, dopoki Dovecot/Postfix SASL nie obsluguje tego samego formatu. -5. Reverse proxy przelacz dopiero po testach regresyjnych; PHP/FPM usun na koncu. - -## Status 0.0.3 - -To nadal wersja developerska, nie deklaruje jeszcze 100% parity PostfixAdmin 4.x. Pozostale luki to przede wszystkim webowy setup/upgrade schematu, XML-RPC, SMS provider, update checker oraz generic IMAP/Courier do tworzenia folderow. Dynamiczne hooki PHP (`*_struct_hook`, `language_hook`) nie beda uruchamiane jako PHP — wymagaja natywnego mechanizmu rozszerzen. - -W tym srodowisku nie ma `cargo`/`rustc`; proba pobrania toolchaina 1.99.1 jest dodatkowo blokowana przez brak DNS/network, wiec realny `cargo check/test/clippy` nie mogl zostac wykonany tutaj. Statyczny gate jest uruchamiany przed spakowaniem, ale developerski build trzeba ostatecznie zweryfikowac przez `bash dev.sh` na maszynie z Rust 1.99.1. Checklista testowa: [TESTING.md](TESTING.md). - -## Rspamd DKIM - -Integracja Rspamd nie rozbudowuje recznie `domain { ... }`. Uzywa map `selector_map` i `path_map`, generuje klucze przez `rspamadm`, wykonuje `rspamadm configtest` przed zaakceptowaniem zmian i moze wykonac jawnie skonfigurowany argv-only reload. Instrukcja: [docs/RSPAMD_DKIM.md](docs/RSPAMD_DKIM.md). - -### DKIM backends - -The UI exposes PostfixAdmin database DKIM and Rspamd DKIM as separate backends. They can be enabled independently: - -```toml -[features] -dkim = true - -[dkim] -default_backend = "postfixadmin" # postfixadmin | rspamd - -[rspamd_dkim] -enabled = true -``` - -When both are enabled, `dkim.default_backend` selects the tab opened first. Disabling one backend removes that tab from the UI. - -Mailbox users can optionally see DKIM as a **read-only DNS view** for their own domain (`[dkim].user_read_only = true`). The view merges only public records from the PostfixAdmin DKIM tables and Rspamd. `default_backend` is only a UI preference for administrators; there is no shared/global DKIM key in this project. Every key/selector remains associated with a concrete domain. - -When development uses the SSH database mode, `dev.sh` also forwards ManageSieve (`14190 -> 4190`) and, by default, builds a public-only Rspamd DKIM cache over SSH. The helper reads mapping/config metadata and derives the public key on the mail host; it never downloads private-key bytes. Disable this with `POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC=false`. - -### Domain-focused UI - -`Domains` is the main entry point for domain administration. Clicking a domain opens a workspace with mailbox and alias usage, quota progress, mailboxes, aliases and alias-domain relationships. The standalone Mailboxes and Aliases pages show one domain at a time instead of rendering all domains at once. - -## Local Debian package - -For Debian-like systems, build a development `.deb` inside Docker: - -```bash -./scripts/build-deb.sh -``` - -The builder uses Debian 12 as the compatibility baseline, installs the Rust toolchain pinned by `rust-toolchain.toml`, runs static validation, `cargo fmt --check`, tests and clippy, then writes the package and SHA256 file to `./packages/`. The resulting package installs the binary, a systemd service, `/etc/postfixadmin-rs/config.toml` and `/etc/default/postfixadmin-rs`. During installation it creates the unprivileged system user/group `postfixadmin`, runs the service as that identity, keeps `/etc/postfixadmin-rs` root-owned with group-read access for `postfixadmin`, and creates `/var/lib/postfixadmin-rs` for runtime state. It does not start the service automatically because database/Rspamd permissions must be configured first. Rspamd DKIM integration must be granted narrowly scoped write access to the configured key/map/config paths; do not run the whole service as root. +## Licencja +GPL-2.0-or-later, jak PostfixAdmin. Zob. [LICENSE](LICENSE) i [NOTICE.md](NOTICE.md). diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 5cdda7b..282760f 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,90 +1,25 @@ -PostfixAdmin-RS 0.0.3 +PostfixAdmin-RS 0.1.0 – validation of this release -Validated in the current workspace: -- TOML parsing: PASS -- JSON i18n parsing: PASS -- English is the default UI language: PASS -- EN/PL catalog and interface language switch markers: PASS -- API/backend messages remain English (no backend localization layer added): PASS -- CSS/JS manifest completeness: PASS -- JavaScript syntax with Node: PASS -- shell syntax (bash -n): PASS -- dev.sh mock-mode smoke test with command shims: PASS -- dev.sh SSH-tunnel mode smoke test with command shims: PASS -- SSH DB mode defaults to READ-ONLY: PASS -- SSH DB writes require POSTFIXADMIN_RS_DEV_DB_WRITES=1: PASS -- SSH host-key verification is not disabled: PASS -- no PHP files in project: PASS -- obsolete UI branding "· bez PHP" / "Panel poczty bez PHP": REMOVED -- no native browser prompt/confirm/alert dialogs: PASS -- modal/theme SVG integration markers: PASS -- no shell-based hook execution markers: PASS -- Rspamd DKIM route/config/static integration markers: PASS -- Rspamd selector_map/path_map config present in dev/example/user configs: PASS -- CSP static policy gate: PASS -- dev mock safety markers: PASS -- production DB read-only mutation guard: PASS (static source check) -- PostfixAdmin password compatibility markers/tests: PASS (static source check) -- config.from-user.toml contains no database password: PASS -- PostfixAdmin 4.0.1 entry points/handlers reviewed against PORT-PARITY.md: PASS (manual source audit) +Compiler gate (rustc 1.97 offline with vendored crates, edition 2024): +- cargo build: PASS +- cargo clippy --all-targets -- -D warnings: PASS +- cargo test: PASS (unit tests incl. quota/limit rules, CIDR/proxy resolution, + recipient delimiter, vacation dates, fetchmail base64, password vectors) +- scripts/validate-release-static.py: PASS +- scripts/i18n/build.py: PASS (no missing Polish strings) -Not validated in the current workspace: -- cargo check / cargo test / cargo clippy / cargo build -Reason: Rust/Cargo toolchain is not installed in the execution environment. +Database: +- PostgreSQL 16: --init-schema on an empty database, second run idempotent, + --create-superadmin, --check-config, --integrity-check: PASS +- SQLite (development mock built by the same schema code): PASS +- MySQL/MariaDB: not available in the build environment; queries use the same + portable layer (flag/sum casts, LIKE ... ESCAPE '!', LIMIT/OFFSET binds). + Run scripts/api-smoke.py against a MySQL staging copy before going live. -Readiness conclusion: -- READY FOR DEVELOPMENT TESTING with the SQLite mock. -- READY FOR READ-ONLY production-schema testing through the guarded SSH tunnel. -- NOT YET PRODUCTION-READY until the Rust compiler/test/clippy gate declared by Cargo.toml and the regression checklist in TESTING.md pass. - -Developer gate on a machine with the Rust toolchain declared by Cargo.toml/rust-toolchain.toml: -POSTFIXADMIN_RS_DEV_RESET=1 bash dev.sh - -Read-only production DB test through SSH: -POSTFIXADMIN_RS_DEV_DB_MODE=ssh bash dev.sh - -Full release gate: -./scripts/build-release.sh - -Current iteration additions: -- content-hashed SVG sprite (fixes stale cached theme icons): PASS (static) -- domain-switch loading bar/spinner: PASS (JS/CSS static) -- domain-scoped mailbox/alias API loading: PASS (static) -- per-mailbox quota progress with PA 55/90 thresholds: PASS (static) -- PA generate_password/show_password helpers: PASS (static) -- PA MOTD and domain-list hide flags: PASS (static) -- Docker Debian package builder syntax/static markers: PASS -- Docker .deb build itself: NOT RUN (Docker is unavailable in this execution environment) - -Current mailbox-user iteration: -- user navigation hides administrative modules: PASS (static) -- user Overview shows only own mailbox/self-alias/quota data: PASS (backend scope + static UI) -- password page opens directly on the change-password form; no cookie/CSRF prose: PASS (static) -- user Aliases domain is read-only: PASS (static UI + backend scope) -- PostfixAdmin-compatible Vacation user form: PASS (static; requires features.vacation + existing mail-server vacation setup) -- optional ManageSieve API/client/UI, no IMAP dependency: PASS (static) -- ManageSieve login credential held only in in-memory mailbox session when enabled: PASS (static) -- dev ManageSieve SSH tunnel defaults to READ-ONLY and has separate write opt-in: PASS (shell/static) -- quota progress uses CSP-safe native , no inline style mutations: PASS (static) -- Rust compiler gate: NOT RUN; the required toolchain is absent in this execution environment. - -Compatibility regression checks: -- mailbox/alias local-part-only create UX with read-only selected domain: PASS (static) -- admin Vacation mailbox picker/editor: PASS (JS syntax/static) -- Vacation alias transport synchronization: PASS (source review + unit-test markers; Rust test gate not runnable here) -- mailbox-user alias field restriction in UI and backend: PASS (static) -- technical alias separation: PASS (static) -- Rspamd contextual filesystem errors + packaged permission helper: PASS (static + helper smoke test with isolated paths) - -Domain alias UI regression checks: -- domain workspace separates user aliases from technical/default aliases: PASS (static) -- mailbox self-aliases are excluded from the domain alias sections: PASS (static) -- alias tables retain PostfixAdmin-RS fields (address, destination, description, status, active, actions): PASS (static) -- technical alias actions honor special_alias_control/global-admin permissions: PASS (static) -- domain mailbox/alias sections use the full content width for readability: PASS (static) - -Current corrections: -- version-agnostic release validation (semantic package version + Cargo/toolchain consistency): PASS -- MySQL/MariaDB TEXT/LONGTEXT fallback decoding in row_string(): PASS (static) -- alias `goto` reads the real PostfixAdmin database value and uses the Blob fallback when required: PASS (static) -- frontend accepts alias destinations as array or comma-separated text and renders an explicit dash only when empty: PASS (static) +End-to-end: +- scripts/api-smoke.py against PostgreSQL and SQLite: 0 failures +- DKIM keys/signing, fetchmail, TOTP setup + TOTP login, TOTP exceptions, + application passwords against PostgreSQL: PASS +- Browser run (Chromium/Playwright) as global admin, domain admin and mailbox + user, English and Polish, desktop and phone width: no JavaScript errors, + no horizontal page overflow on phones. diff --git a/SECURITY.md b/SECURITY.md index 7a7985a..c7bdc6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,6 +9,11 @@ - Logowanie i recovery sa limitowane per IP + konto. - CSP nie zawiera `unsafe-inline` ani `unsafe-eval`; dodatkowo nosniff, frame deny, no-referrer i ograniczony Permissions-Policy. - Request body i timeout sa limitowane. +- Za reverse proxy adres klienta brany jest z `X-Forwarded-For`/`X-Real-IP` tylko od adresow z `security.trusted_proxies` (prawy skrajny niezaufany adres) – klient nie moze podszyc sie pod inny IP. +- Sesje sa co `session_revalidate_seconds` sprawdzane w bazie: wylaczone/usuniete konto oraz odebrane domeny dzialaja natychmiast. +- Zmiana hasla wylogowuje pozostale sesje konta; zmiana hasla/dezaktywacja przez administratora wylogowuje wszystkie sesje tego konta. +- Wygasle sesje, wyzwania MFA i liczniki prob sa okresowo czyszczone z pamieci. +- `hsts = true` dodaje `Strict-Transport-Security`. ## Development mock DB diff --git a/TESTING.md b/TESTING.md index d06b820..38cc9b8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,25 +1,39 @@ -# PostfixAdmin-RS 0.0.3 - test checklist +# PostfixAdmin-RS 0.1.0 – testy -## 1. Static / compiler gate - -Run on Rust 1.99.1: +## 1. Bramka kompilacji ```bash +python3 scripts/validate-release-static.py +python3 scripts/i18n/build.py # katalog PL + kontrola brakujących tłumaczeń cargo fmt --check -cargo check --all-targets -cargo test --all-targets cargo clippy --all-targets -- -D warnings +cargo test ``` -Then start the isolated development database: +## 2. Test API (logika PostfixAdmin) + +Na działającej instancji testowej (mock z `dev.sh` albo kopia bazy): ```bash -POSTFIXADMIN_RS_DEV_RESET=1 bash dev.sh +python3 scripts/api-smoke.py http://127.0.0.1:8080 admin@example.test admin123 ``` -Expected test accounts are printed by `dev.sh`. +Skrypt tworzy domeny `rs-smoke-a.test`/`rs-smoke-b.test` i sprawdza m.in.: limity `-1/0/n`, +quota (`check_quota`/`allowed_quota`), alias wskazujący na siebie, catch-all, konto lokalne, +ukrywanie aliasów skrzynek, przekierowania, przełączanie aktywności, „usuń nieaktywne”, +wyszukiwanie, autoresponder, uprawnienia administratora domeny, wylogowanie po zmianie hasła, +panel użytkownika i domeny aliasowe. Na końcu wszystko usuwa. -## 2. UI / i18n +## 3. Nowa baza od zera + +```bash +postfixadmin-rs --init-schema --dry-run +postfixadmin-rs --init-schema # drugi raz: "schema is up to date" +postfixadmin-rs --create-superadmin admin@example.com +postfixadmin-rs --check-config +``` + +## 4. UI / i18n - Login page starts in English when there is no `pa_language` value in localStorage. - Language selector is available on login and in the top bar. diff --git a/config.example.toml b/config.example.toml index c7936b3..71dc8b1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,3 +1,15 @@ +# PostfixAdmin-RS production configuration. +# Copy to /etc/postfixadmin-rs/config.toml (mode 0640, group postfixadmin). +# Secrets can be supplied via environment instead, e.g. +# POSTFIXADMIN_RS__DATABASE__PASSWORD=... +# +# First start on an empty database: +# postfixadmin-rs --init-schema +# postfixadmin-rs --create-superadmin admin@example.com +# Existing PostfixAdmin database: `postfixadmin-rs --init-schema --dry-run` +# shows which (additive) changes would be made. + +# Listen only on localhost and publish the panel through a TLS reverse proxy. host = "127.0.0.1" port = 8080 environment = "production" @@ -76,7 +88,8 @@ domain_dns_status_check = 1 smtp_active_flag = false generate_password = true show_password = false -edit_mailbox_name = true +# Mailbox users may change their own display name: false | true | ["example.com", ...] +edit_mailbox_name = false domain_list_hide_maxquota = false domain_list_hide_password_expiry = false @@ -97,6 +110,8 @@ create_mailbox_subdirs_prefix = "" transport_default = "virtual" transport_options = ["virtual", "local", "relay"] vacation_domain = "autoreply.change-this-to-your.domain.tld" +# Reply intervals offered for the auto-reply (seconds): 0 = once, 1 = every mail, 604800 = weekly +vacation_choice_of_reply = [0, 604800] default_aliases = { abuse = "abuse@change-this-to-your.domain.tld", hostmaster = "hostmaster@change-this-to-your.domain.tld", postmaster = "postmaster@change-this-to-your.domain.tld", webmaster = "webmaster@change-this-to-your.domain.tld" } welcome_text = "Hi,\n\nWelcome to your new account.\n" recipient_delimiter = "" @@ -107,10 +122,19 @@ motd_admin = "" motd_superadmin = "" [security] -session_timeout_minutes = 1440 +session_timeout_minutes = 480 +# Keep true in production: the session cookie is then only sent over HTTPS. cookie_secure = true +# Send Strict-Transport-Security (only when the panel is served over HTTPS). +hsts = true +# Reverse proxies whose X-Forwarded-For / X-Real-IP headers are trusted. +# Required behind nginx, otherwise rate limiting and TOTP exceptions see 127.0.0.1. +trusted_proxies = ["127.0.0.1", "::1"] +# Failed logins per client IP and account within the window. login_attempts = 8 login_window_seconds = 300 +# Re-check sessions against the database (disabled accounts, changed rights). +session_revalidate_seconds = 30 request_body_limit_mb = 2 allow_legacy_shell_hooks = false allow_unsafe_alias_destinations = false diff --git a/config.from-user.toml b/config.from-user.toml index 3a6b5e6..f101245 100644 --- a/config.from-user.toml +++ b/config.from-user.toml @@ -76,7 +76,8 @@ domain_dns_status_check = 1 smtp_active_flag = false generate_password = true show_password = false -edit_mailbox_name = true +# PostfixAdmin 4.x master: mailbox users may change their own display name. +edit_mailbox_name = false domain_list_hide_maxquota = false domain_list_hide_password_expiry = false @@ -109,6 +110,9 @@ motd_superadmin = "" [security] session_timeout_minutes = 1440 cookie_secure = true +hsts = true +# nginx on the same host forwards the client address. +trusted_proxies = ["127.0.0.1", "::1"] login_attempts = 8 login_window_seconds = 300 request_body_limit_mb = 2 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 2b80e6d..0d223ad 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -5,6 +5,7 @@ 3. Ustaw haslo DB w pliku env z prawami `0600`. 4. Zbuduj przez `./scripts/build-release.sh` na Rust 1.99.1. 5. Uruchom `POSTFIXADMIN_RS_CONFIG=/etc/postfixadmin-rs/config.toml postfixadmin-rs --check-config`. +5a. `postfixadmin-rs --init-schema --dry-run` pokaze brakujace kolumny/tabele nowszych wersji PostfixAdmin; `--init-schema` je doda (tylko dodawanie, nic nie jest usuwane). 6. Wystartuj panel tylko na localhost i wykonaj testy read-only. 7. Utworz testowa skrzynke/alias i sprawdz logowanie SMTP/IMAP oraz dostarczanie. 8. Sprawdz reset hasla, jezeli korzystasz z `email_other`/tokenow. diff --git a/packages/postfixadmin-rs_0.1.0_amd64.deb b/packages/postfixadmin-rs_0.1.0_amd64.deb new file mode 100644 index 0000000..2b9d8bf Binary files /dev/null and b/packages/postfixadmin-rs_0.1.0_amd64.deb differ diff --git a/packages/postfixadmin-rs_0.1.0_amd64.deb.sha256 b/packages/postfixadmin-rs_0.1.0_amd64.deb.sha256 new file mode 100644 index 0000000..d2f3f5b --- /dev/null +++ b/packages/postfixadmin-rs_0.1.0_amd64.deb.sha256 @@ -0,0 +1 @@ +8da3efee31740887838f1a4304cfb23d31bdd0de603f3140f1357c0fcfab2d68 /packages/postfixadmin-rs_0.1.0_amd64.deb diff --git a/packaging/nginx.conf.example b/packaging/nginx.conf.example index bcf8924..4efea9e 100644 --- a/packaging/nginx.conf.example +++ b/packaging/nginx.conf.example @@ -1,7 +1,57 @@ -location /postfixadmin/ { - proxy_pass http://127.0.0.1:8080/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# PostfixAdmin-RS behind nginx with TLS. +# In config.toml set: trusted_proxies = ["127.0.0.1", "::1"], cookie_secure = true, hsts = true +# +# Serving under a sub-path (e.g. https://mail.example.com/postfixadmin/) also works: +# set base_path = "/postfixadmin" in config.toml and use `location /postfixadmin/` +# with `proxy_pass http://127.0.0.1:8080;` (no trailing slash). + +server { + listen 80; + listen [::]:80; + server_name admin.example.com; + return 301 https://$host$request_uri; } + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name admin.example.com; + + ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + + client_max_body_size 4m; + server_tokens off; + + # Optional: restrict the panel to known networks. + # allow 203.0.113.0/24; + # deny all; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90s; + } + + # Slow down credential stuffing in addition to the built-in rate limit. + location = /api/auth/login { + limit_req zone=pa_login burst=5 nodelay; + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Put this into the http {} block (e.g. /etc/nginx/conf.d/postfixadmin-rs-limits.conf): +# limit_req_zone $binary_remote_addr zone=pa_login:10m rate=10r/m; diff --git a/packaging/postfixadmin-rs.service b/packaging/postfixadmin-rs.service index cfff81b..ade048d 100644 --- a/packaging/postfixadmin-rs.service +++ b/packaging/postfixadmin-rs.service @@ -1,6 +1,7 @@ [Unit] Description=PostfixAdmin-RS mail administration panel -After=network-online.target +Documentation=https://github.com/postfixadmin/postfixadmin +After=network-online.target mariadb.service mysql.service postgresql.service Wants=network-online.target [Service] @@ -10,22 +11,39 @@ Group=postfixadmin WorkingDirectory=/var/lib/postfixadmin-rs Environment=POSTFIXADMIN_RS_CONFIG=/etc/postfixadmin-rs/config.toml EnvironmentFile=-/etc/default/postfixadmin-rs +ExecStartPre=/usr/bin/postfixadmin-rs --check-config ExecStart=/usr/bin/postfixadmin-rs Restart=on-failure RestartSec=3s UMask=0077 StateDirectory=postfixadmin-rs StateDirectoryMode=0750 + +# Hardening. Hooks (doveadm, maildir scripts) run with the same restrictions; +# grant write access to additional paths with ReadWritePaths= if a hook needs it, +# e.g. ReadWritePaths=/var/lib/rspamd/dkim /etc/rspamd/local.d NoNewPrivileges=true PrivateTmp=true PrivateDevices=true ProtectHome=true -ProtectSystem=true +ProtectSystem=full ProtectKernelTunables=true ProtectKernelModules=true +ProtectKernelLogs=true ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX LockPersonality=true +MemoryDenyWriteExecute=true +CapabilityBoundingSet= +AmbientCapabilities= +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources [Install] WantedBy=multi-user.target diff --git a/scripts/api-smoke.py b/scripts/api-smoke.py new file mode 100755 index 0000000..b1b51f8 --- /dev/null +++ b/scripts/api-smoke.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""End-to-end API check of a running PostfixAdmin-RS instance. + + python3 scripts/api-smoke.py http://127.0.0.1:8080 admin@example.test admin123 + +Uses a global administrator, creates the domains rs-smoke-a.test and +rs-smoke-b.test, exercises the PostfixAdmin business rules (limits, quota, +aliases, forwarding, vacation, permissions) and deletes everything again. +Run it only against a development or staging database. With +features.emailcheck_resolve_domain = true the .test domains are rejected, +disable that check for the test instance. +""" +import http.cookiejar +import json +import sys +import urllib.error +import urllib.request + +BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:8080" +ADMIN = sys.argv[2] if len(sys.argv) > 2 else "admin@example.test" +PASSWORD = sys.argv[3] if len(sys.argv) > 3 else "admin123" +A, B = "rs-smoke-a.test", "rs-smoke-b.test" +failures = 0 + + +class Client: + def __init__(self): + self.jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar)) + self.csrf = "" + + def req(self, method, path, body=None, expect=None, quiet=False, silent=False): + global failures + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(BASE + "/api" + path, data=data, method=method) + if data: + request.add_header("content-type", "application/json") + if self.csrf: + request.add_header("x-csrf-token", self.csrf) + try: + response = self.opener.open(request) + code, text = response.status, response.read().decode() + except urllib.error.HTTPError as error: + code, text = error.code, error.read().decode() + ok = (expect is None and code < 300) or code == expect + if silent: + return json.loads(text) if text else None + if not ok: + failures += 1 + if not ok or not quiet: + detail = "" if ok and code < 300 else " " + text[:160] + print(f"{'ok ' if ok else 'FAIL'} {method:6} {path} -> {code}{detail}") + return json.loads(text) if text else None + + def login(self, username, password, kind=None): + body = {"username": username, "password": password} + if kind: + body["kind"] = kind + result = self.req("POST", "/auth/login", body) + self.csrf = (result or {}).get("csrf", "") + return result + + +def main(): + global failures + admin = Client() + admin.login(ADMIN, PASSWORD) + runtime = admin.req("GET", "/system/features", quiet=True) + features = runtime["features"] + quota = features.get("quota") and features.get("domain_quota") + + # remove leftovers of an interrupted run (result ignored) + admin.req("DELETE", f"/admins/da@{A}", silent=True) + admin.req("DELETE", f"/alias-domains/{B}", silent=True) + for domain in (A, B): + admin.req("DELETE", f"/domains/{domain}", silent=True) + admin.req("POST", "/domains", {"domain": A, "description": "smoke", "aliases": 6, "mailboxes": 2, + "maxquota": 100, "quota": 150, "default_aliases": True}) + admin.req("POST", "/domains", {"domain": B, "aliases": 0, "mailboxes": 0, "maxquota": 0, "quota": 0, + "default_aliases": False}) + admin.req("POST", "/domains", {"domain": A}, expect=409) + admin.req("POST", "/domains", {"domain": "bad-limit.test", "aliases": -2}, expect=400) + + pw = "Smoke12pass" + if quota: + admin.req("POST", "/mailboxes", {"domain": A, "local_part": "u1", "password": pw, "quota": 0}, expect=400) + admin.req("POST", "/mailboxes", {"domain": A, "local_part": "u1", "password": pw, "quota": 101}, expect=400) + admin.req("POST", "/mailboxes", {"domain": A, "local_part": "u1", "password": pw, "quota": 100, "name": "User One"}) + if quota: + admin.req("POST", "/mailboxes", {"domain": A, "local_part": "u2", "password": pw, "quota": 60}, expect=400) + hint = admin.req("GET", f"/mailboxes/quota-hint?domain={A}", quiet=True) + if hint["allowed_mb"] != 50: + print("FAIL quota hint", hint) + admin.req("POST", "/mailboxes", {"domain": A, "local_part": "u2", "password": pw, "quota": 50}) + admin.req("POST", "/mailboxes", {"domain": A, "local_part": "u3", "password": pw, "quota": 0}, expect=409) + admin.req("POST", "/mailboxes", {"domain": B, "local_part": "weak", "password": "abc"}, expect=400) + + # aliases (4 default aliases + limit 6) + admin.req("POST", "/aliases", {"domain": A, "local_part": "info", "goto": [f"u1@{A}"]}) + admin.req("POST", "/aliases", {"domain": A, "local_part": "sales", "goto": [f"u1@{A}"]}) + admin.req("POST", "/aliases", {"domain": A, "local_part": "more", "goto": [f"u1@{A}"]}, expect=409) + admin.req("POST", "/aliases", {"domain": B, "local_part": "loop", "goto": [f"loop@{B}"]}, expect=400) + admin.req("POST", "/aliases", {"domain": B, "local_part": "*", "goto": [f"@{A}"]}) + admin.req("POST", "/aliases", {"domain": B, "local_part": "root", "goto": ["root"]}) + listing = admin.req("GET", f"/aliases?domain={A}", quiet=True) + if any(item["is_mailbox"] for item in listing["items"]): + print("FAIL mailbox aliases must not be listed for admins") + failures += 1 + + # forwarding / activity / search + admin.req("PUT", f"/mailboxes/u1@{A}/forwarding", {"forward_to": ["ext@example.org"], "deliver_to_mailbox": True}) + mailbox = admin.req("GET", f"/mailboxes/u1@{A}", quiet=True) + if mailbox["forward_to"] != ["ext@example.org"] or not mailbox["deliver_to_mailbox"]: + print("FAIL forwarding", mailbox) + admin.req("POST", f"/aliases/sales@{A}/active", {"active": False}) + admin.req("POST", f"/aliases/delete-inactive/{A}") + found = admin.req("GET", f"/mailboxes?domain={A}&q=one", quiet=True) + if found["total"] != 1: + print("FAIL search", found) + + if features.get("vacation"): + admin.req("PUT", f"/vacation/u1@{A}", {"email": f"u1@{A}", "subject": "Away", "body": "Back soon", + "interval_time": (runtime["defaults"].get("vacation_choice_of_reply") or [0])[0], + "active_from": "2026-01-01", "active_until": "2026-01-31", "active": True}) + mailbox = admin.req("GET", f"/mailboxes/u1@{A}", quiet=True) + if not mailbox["vacation_active"]: + print("FAIL vacation flag") + admin.req("DELETE", f"/vacation/u1@{A}") + + # domain administrator + admin.req("POST", "/admins", {"username": f"da@{A}", "password": pw, "domains": [A]}) + dadmin = Client() + dadmin.login(f"da@{A}", pw) + dadmin.req("POST", "/domains", {"domain": "x.test"}, expect=403) + dadmin.req("GET", f"/mailboxes?domain={B}", expect=403) + dadmin.req("POST", f"/mailboxes/u2@{A}/active", {"active": False}) + admin.req("PUT", f"/admins/da@{A}", {"username": f"da@{A}", "password": "Changed12pw", "domains": [A], "active": True}) + dadmin.req("GET", "/domains", expect=401) + + # mailbox user + user = Client() + user.login(f"u1@{A}", pw) + user.req("GET", f"/mailboxes/u2@{A}", expect=403) + user.req("PUT", f"/mailboxes/u1@{A}/forwarding", {"forward_to": [], "deliver_to_mailbox": True}) + user.req("POST", "/security/password", {"current_password": pw, "new_password": pw}, expect=400) + user.req("POST", "/security/password", {"current_password": pw, "new_password": "Newer12pass"}) + + # alias domain + deletion rules + admin.req("POST", "/alias-domains", {"alias_domain": B, "target_domain": A}) + admin.req("DELETE", f"/domains/{A}", expect=409) + admin.req("DELETE", f"/alias-domains/{B}") + admin.req("DELETE", f"/admins/da@{A}") + admin.req("DELETE", f"/domains/{B}") + admin.req("DELETE", f"/domains/{A}") + print(f"\n{failures} failure(s)") + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/i18n/build.py b/scripts/i18n/build.py new file mode 100755 index 0000000..a01b7e9 --- /dev/null +++ b/scripts/i18n/build.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Build src/web/i18n/pl.json from scripts/i18n/pl_messages.py and report +UI strings that have no Polish translation.""" +import json, re, sys +from pathlib import Path + +root = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).parent)) +import pl_messages as pl # noqa: E402 + +src = "".join((root / "src/web/js" / f).read_text() for f in ("00-core.js", "10-pages.js")) +keys = set(m.group(1).replace("\\'", "'") for m in re.finditer(r"\bte?\(\s*'((?:[^'\\]|\\.)*)'", src)) +for m in re.finditer(r"\['\w+', '([^']+)', '(Management|Services|Account|Mailbox)'", src): + keys.update(m.groups()) +for m in re.finditer(r"(?:STATUS_LABELS|STATUS_HINTS|INTERVAL_LABELS) = \{([^}]*)\}", src): + keys.update(re.findall(r":\s*'([^']+)'", m.group(1))) +for m in re.finditer(r"(?:showToast\(|message: )'((?:[^'\\]|\\.)*)'", src): + keys.add(m.group(1)) + +messages = {**pl.SERVER, **pl.UI} +missing = sorted(k for k in keys if k not in messages) +catalog = { + "language": "pl", + "messages": dict(sorted(messages.items())), + "patterns": [{"match": m, "replace": r} for m, r in pl.PATTERNS], +} +(root / "src/web/i18n/pl.json").write_text(json.dumps(catalog, ensure_ascii=False, indent=1) + "\n") +(root / "src/web/i18n/en.json").write_text(json.dumps({"language": "en", "messages": {}, "patterns": []}, indent=1) + "\n") +print(f"pl.json: {len(messages)} messages, {len(pl.PATTERNS)} patterns") +if missing: + print("MISSING translations:") + for k in missing: + print(" ", k) + sys.exit(1) diff --git a/scripts/i18n/pl_messages.py b/scripts/i18n/pl_messages.py new file mode 100644 index 0000000..3438f44 --- /dev/null +++ b/scripts/i18n/pl_messages.py @@ -0,0 +1,593 @@ +# Polish UI catalog source. Run scripts/i18n/build.py to regenerate +# src/web/i18n/pl.json. Keys are the English source strings used in the UI +# (t('...')) and the English API error messages. + +UI = { + "-1 = disabled, 0 = unlimited": "-1 = wyłączone, 0 = bez limitu", + "0 = passwords never expire": "0 = hasła nie wygasają", + "0 = unlimited": "0 = bez limitu", + "6-digit code": "Kod 6-cyfrowy", + "6-digit code from your authenticator app": "6-cyfrowy kod z aplikacji uwierzytelniającej", + "@{domain} — use * for a catch-all alias": "@{domain} — wpisz *, aby utworzyć alias catch-all", + "A catch-all may forward to a whole domain: @otherdomain.tld": "Alias catch-all może przekazywać na całą domenę: @innadomena.pl", + "A one-time code will be sent to the alternative e-mail address stored for the account.": "Jednorazowy kod zostanie wysłany na alternatywny adres e-mail zapisany dla konta.", + "Account": "Konto", + "Account (empty = all your accounts)": "Konto (puste = wszystkie Twoje konta)", + "Account type": "Typ konta", + "Action": "Akcja", + "Activate": "Aktywuj", + "Active": "Aktywne", + "Add": "Dodaj", + "Add alias": "Dodaj alias", + "Add alias domain": "Dodaj domenę aliasową", + "Add alias to {domain}": "Nowy alias w {domain}", + "Add default aliases": "Dodaj domyślne aliasy", + "Add mailbox": "Dodaj skrzynkę", + "Add mailbox to {domain}": "Nowa skrzynka w {domain}", + "Add rule": "Dodaj regułę", + "Add the key to your authenticator app (e.g. by opening the link on your phone), then enter the code it shows.": "Dodaj klucz do aplikacji uwierzytelniającej (np. otwierając link na telefonie), a następnie wpisz wyświetlony kod.", + "Administrator": "Administrator", + "Administrator saved": "Administrator zapisany", + "Administrators": "Administratorzy", + "Alias": "Alias", + "Alias created": "Alias utworzony", + "Alias domain": "Domena aliasowa", + "Alias domain saved": "Domena aliasowa zapisana", + "Alias domains": "Domeny aliasowe", + "Alias updated": "Alias zaktualizowany", + "Aliases": "Aliasy", + "All": "Wszystkie", + "All domains": "Wszystkie domeny", + "Allow sending mail (SMTP)": "Zezwalaj na wysyłanie poczty (SMTP)", + "Alternative e-mail (password recovery)": "Alternatywny e-mail (odzyskiwanie hasła)", + "Application password": "Hasło aplikacji", + "Application password created": "Hasło aplikacji utworzone", + "Application passwords": "Hasła aplikacji", + "At least one local destination does not exist": "Co najmniej jeden lokalny adres docelowy nie istnieje", + "At least {n} characters, {l} letters and {d} digits": "Co najmniej {n} znaków, w tym {l} litery i {d} cyfry", + "Authentication": "Uwierzytelnianie", + "Author (domain or address)": "Nadawca (domena lub adres)", + "Auto-reply": "Autoresponder", + "Auto-reply activated": "Autoresponder włączony", + "Auto-reply is active": "Autoresponder jest aktywny", + "Auto-reply is switched off": "Autoresponder jest wyłączony", + "Auto-reply is switched on": "Autoresponder jest włączony", + "Auto-reply switched off": "Autoresponder wyłączony", + "Automatic": "Automatycznie", + "Automatic reply while you are away": "Automatyczna odpowiedź podczas Twojej nieobecności", + "Back": "Wstecz", + "Backup MX": "Zapasowy MX", + "Broadcast message": "Wiadomość do wszystkich", + "Cancel": "Anuluj", + "Change name": "Zmień nazwę", + "Change password": "Zmień hasło", + "Changes made through the administration interface": "Zmiany wprowadzone w panelu administracyjnym", + "Check": "Sprawdź", + "Check DNS now": "Sprawdź DNS teraz", + "Choose the mailbox whose auto-reply (vacation) you want to edit": "Wybierz skrzynkę, której autoresponder chcesz edytować", + "Close": "Zamknij", + "Coming back": "Wracam", + "Configure maps": "Skonfiguruj mapy", + "Continue": "Dalej", + "Copied": "Skopiowano", + "Copy": "Kopiuj", + "Copy to folder": "Kopiuj do folderu", + "Could not copy to the clipboard": "Nie udało się skopiować do schowka", + "Create": "Utwórz", + "Create a DKIM key first.": "Najpierw utwórz klucz DKIM.", + "Create a script to start filtering your mail.": "Utwórz skrypt, aby zacząć filtrować pocztę.", + "Current password": "Obecne hasło", + "Currently stored: {v}": "Obecnie zapisane: {v}", + "DKIM": "DKIM", + "DKIM DNS records of your domain (read-only)": "Rekordy DNS DKIM Twojej domeny (tylko do odczytu)", + "DKIM key saved": "Klucz DKIM zapisany", + "DNS check failed": "Sprawdzenie DNS nie powiodło się", + "DNS check passed": "Sprawdzenie DNS zakończone powodzeniem", + "DNS record": "Rekord DNS", + "Data": "Dane", + "Database": "Baza danych", + "Database connection is read-only": "Połączenie z bazą jest tylko do odczytu", + "Database schema and runtime configuration": "Schemat bazy danych i konfiguracja", + "Deactivate all": "Dezaktywuj wszystkie", + "Default alias": "Alias domyślny", + "Delete": "Usuń", + "Delete DKIM key": "Usuń klucz DKIM", + "Delete administrator": "Usuń administratora", + "Delete alias": "Usuń alias", + "Delete alias domain": "Usuń domenę aliasową", + "Delete all inactive aliases of {domain}? Mailbox aliases and protected aliases are skipped.": "Usunąć wszystkie nieaktywne aliasy domeny {domain}? Aliasy skrzynek i aliasy chronione zostaną pominięte.", + "Delete application password": "Usuń hasło aplikacji", + "Delete domain": "Usuń domenę", + "Delete exception": "Usuń wyjątek", + "Delete fetchmail entry": "Usuń wpis fetchmail", + "Delete inactive": "Usuń nieaktywne", + "Delete inactive aliases": "Usuń nieaktywne aliasy", + "Delete key": "Usuń klucz", + "Delete mailbox": "Usuń skrzynkę", + "Delete script": "Usuń skrypt", + "Delete signing entry": "Usuń wpis podpisywania", + "Delete the administrator {a}?": "Usunąć administratora {a}?", + "Delete the alias domain {a} → {b}?": "Usunąć domenę aliasową {a} → {b}?", + "Delete the alias {a}?": "Usunąć alias {a}?", + "Delete the application password “{d}”?": "Usunąć hasło aplikacji „{d}”?", + "Delete the exception for {ip}?": "Usunąć wyjątek dla {ip}?", + "Delete the fetchmail entry {u}@{s}?": "Usunąć wpis fetchmail {u}@{s}?", + "Delete the key {d} / {s}?": "Usunąć klucz {d} / {s}?", + "Delete the mailbox {m}? Its alias, auto-reply, fetchmail entries and quota data are removed as well. Mail files on the server are only removed by a configured deletion hook.": "Usunąć skrzynkę {m}? Zostaną usunięte także jej alias, autoresponder, wpisy fetchmail i dane quota. Pliki poczty na serwerze usuwa tylko skonfigurowany hook usuwania.", + "Delete the script {s}?": "Usunąć skrypt {s}?", + "Delete the signing entry for {a}?": "Usunąć wpis podpisywania dla {a}?", + "Delete {domain} together with all mailboxes, aliases, alias domains and log entries? This cannot be undone.": "Usunąć {domain} razem ze wszystkimi skrzynkami, aliasami, domenami aliasowymi i wpisami dziennika? Tej operacji nie można cofnąć.", + "Deleted: {d}, skipped: {s}": "Usunięto: {d}, pominięto: {s}", + "Delivered to the mailbox (POP/IMAP)": "Dostarczane do skrzynki (POP/IMAP)", + "Delivery": "Dostarczanie", + "Description": "Opis", + "Description (e.g. phone)": "Opis (np. telefon)", + "Disable": "Wyłącz", + "Disable two-factor authentication": "Wyłącz uwierzytelnianie dwuskładnikowe", + "Disabled": "Wyłączone", + "Discard": "Odrzuć", + "Domain": "Domena", + "Domain administrator": "Administrator domen", + "Domain deleted": "Domena usunięta", + "Domain quota": "Quota domeny", + "Domain quota (MB)": "Quota domeny (MB)", + "Domains": "Domeny", + "Download backup": "Pobierz kopię zapasową", + "E-mail address": "Adres e-mail", + "Edit": "Edytuj", + "Edit DKIM key": "Edytuj klucz DKIM", + "Edit administrator": "Edytuj administratora", + "Edit alias": "Edytuj alias", + "Edit alias domain": "Edytuj domenę aliasową", + "Edit auto-reply": "Edytuj autoresponder", + "Edit domain": "Edytuj domenę", + "Edit fetchmail entry": "Edytuj wpis fetchmail", + "Edit mailbox": "Edytuj skrzynkę", + "Edit signing entry": "Edytuj wpis podpisywania", + "Empty = protocol default": "Puste = domyślny port protokołu", + "Enabled": "Włączone", + "Enabled features": "Włączone funkcje", + "Enter a search term in the search field above.": "Wpisz szukaną frazę w polu wyszukiwania powyżej.", + "Enter a valid header name": "Podaj poprawną nazwę nagłówka", + "Enter a value for the condition": "Podaj wartość warunku", + "Enter the forwarding address": "Podaj adres przekierowania", + "Enter the target folder": "Podaj folder docelowy", + "Error": "Błąd", + "External": "Zewnętrzny", + "Extra options": "Dodatkowe opcje", + "Fetch all messages (also already seen)": "Pobieraj wszystkie wiadomości (także przeczytane)", + "Fetchmail": "Fetchmail", + "Fetchmail entry saved": "Wpis fetchmail zapisany", + "File": "Plik", + "Filter": "Filtruj", + "Filters": "Filtry", + "Filter…": "Filtruj…", + "Folder": "Folder", + "Folder or address": "Folder lub adres", + "Forgot password": "Nie pamiętam hasła", + "Forgot password?": "Nie pamiętasz hasła?", + "Forward incoming mail to other addresses": "Przekazuj przychodzącą pocztę na inne adresy", + "Forward only": "Tylko przekazywanie", + "Forward to (one address per line)": "Przekazuj na (jeden adres w wierszu)", + "Forwarding": "Przekierowanie", + "Forwarding for {m}": "Przekierowanie dla {m}", + "Forwarding saved": "Przekierowanie zapisane", + "Forwards to a domain outside your administration": "Przekazuje do domeny spoza Twojej administracji", + "From": "Od", + "Generate": "Generuj", + "Generate DKIM key": "Wygeneruj klucz DKIM", + "Generate key": "Wygeneruj klucz", + "Global administrator": "Administrator globalny", + "Global administrator (all domains)": "Administrator globalny (wszystkie domeny)", + "Global and domain administrators": "Administratorzy globalni i administratorzy domen", + "Header name (for “Other header”)": "Nazwa nagłówka (dla „Inny nagłówek”)", + "I am currently out of the office and will reply after my return.": "Jestem obecnie poza biurem i odpowiem po powrocie.", + "I have a code": "Mam kod", + "IP address": "Adres IP", + "If": "Jeżeli", + "If the account is eligible, a recovery code has been sent to its alternative e-mail address.": "Jeśli konto spełnia warunki, kod odzyskiwania został wysłany na jego alternatywny adres e-mail.", + "Import": "Importuj", + "Import Rspamd configuration": "Import konfiguracji Rspamd", + "Import existing configuration": "Importuj istniejącą konfigurację", + "Import result": "Wynik importu", + "Imported: {n}": "Zaimportowano: {n}", + "Inactive": "Nieaktywne", + "Interval": "Interwał", + "Keep a copy in my mailbox (forward and store)": "Zachowaj kopię w mojej skrzynce (przekaż i zachowaj)", + "Keep a copy in the mailbox (forward and store)": "Zachowaj kopię w skrzynce (przekaż i zachowaj)", + "Keep in inbox": "Zostaw w skrzynce odbiorczej", + "Keep messages on the server": "Pozostaw wiadomości na serwerze", + "Key": "Klucz", + "Key mapped": "Klucz podłączony", + "Key size": "Długość klucza", + "Keys": "Klucze", + "Keys and signing table stored in the PostfixAdmin database (OpenDKIM)": "Klucze i tabela podpisywania w bazie PostfixAdmin (OpenDKIM)", + "Language": "Język", + "Last result": "Ostatni wynik", + "Leave empty to generate a password": "Pozostaw puste, aby wygenerować hasło", + "Legacy domain blocks found: {d}. Import them so that the maps can manage these domains.": "Znaleziono stare bloki domain: {d}. Zaimportuj je, aby mapy mogły obsługiwać te domeny.", + "Link for the authenticator app": "Link dla aplikacji uwierzytelniającej", + "Loading…": "Ładowanie…", + "Log": "Dziennik", + "Mail server administration": "Administracja serwerem poczty", + "Mail server is backup MX": "Serwer jest zapasowym MX", + "Mail to any address of this domain is delivered to the same address in the target domain.": "Poczta na dowolny adres tej domeny trafia na ten sam adres w domenie docelowej.", + "Mailbox": "Skrzynka", + "Mailbox created": "Skrzynka utworzona", + "Mailbox deleted": "Skrzynka usunięta", + "Mailbox is disabled": "Skrzynka jest wyłączona", + "Mailbox name": "Nazwa skrzynki", + "Mailbox updated": "Skrzynka zaktualizowana", + "Mailbox user": "Użytkownik skrzynki", + "Mailbox {m} created": "Utworzono skrzynkę {m}", + "Mailboxes": "Skrzynki", + "Mailboxes, aliases and alias domains": "Skrzynki, aliasy i domeny aliasowe", + "Management": "Zarządzanie", + "Maps active": "Mapy aktywne", + "Mark as read": "Oznacz jako przeczytane", + "Match": "Dopasowanie", + "Max. mailbox quota": "Maks. quota skrzynki", + "Max. mailbox quota (MB)": "Maks. quota skrzynki (MB)", + "Maximum: {n} MB": "Maksymalnie: {n} MB", + "Menu": "Menu", + "Message": "Treść", + "Message sent": "Wiadomość wysłana", + "Modified": "Zmieniono", + "Move to folder": "Przenieś do folderu", + "Move valid domain { path/selector } blocks into the managed maps? A backup is created and the change is rolled back if the configuration test fails.": "Przenieść poprawne bloki domain { path/selector } do zarządzanych map? Zostanie utworzona kopia zapasowa, a zmiana zostanie wycofana, jeśli test konfiguracji się nie powiedzie.", + "My mailbox": "Moja skrzynka", + "Name": "Nazwa", + "Name changed": "Nazwa zmieniona", + "Negate the condition": "Zaneguj warunek", + "Never": "Nigdy", + "New DKIM key": "Nowy klucz DKIM", + "New administrator": "Nowy administrator", + "New application password": "Nowe hasło aplikacji", + "New domain": "Nowa domena", + "New entry": "Nowy wpis", + "New fetchmail entry": "Nowy wpis fetchmail", + "New key": "Nowy klucz", + "New name": "Nowa nazwa", + "New password": "Nowe hasło", + "New password (leave empty to keep)": "Nowe hasło (puste = bez zmian)", + "New private key (leave empty to keep)": "Nowy klucz prywatny (puste = bez zmian)", + "New public key (leave empty to keep)": "Nowy klucz publiczny (puste = bez zmian)", + "New script": "Nowy skrypt", + "New signing entry": "Nowy wpis podpisywania", + "No": "Nie", + "No DKIM keys.": "Brak kluczy DKIM.", + "No DKIM record is configured for your domain.": "Dla Twojej domeny nie skonfigurowano rekordu DKIM.", + "No Rspamd DKIM keys.": "Brak kluczy DKIM Rspamd.", + "No administrators.": "Brak administratorów.", + "No aliases found.": "Nie znaleziono aliasów.", + "No aliases match the search.": "Żaden alias nie pasuje do wyszukiwania.", + "No application passwords.": "Brak haseł aplikacji.", + "No domains are assigned to you.": "Nie masz przypisanych domen.", + "No domains found.": "Nie znaleziono domen.", + "No domains yet.": "Brak domen.", + "No exceptions.": "Brak wyjątków.", + "No fetchmail entries.": "Brak wpisów fetchmail.", + "No log entries.": "Brak wpisów w dzienniku.", + "No mailboxes found.": "Nie znaleziono skrzynek.", + "No mailboxes match the search.": "Żadna skrzynka nie pasuje do wyszukiwania.", + "No scripts yet.": "Brak skryptów.", + "No signing entries.": "Brak wpisów podpisywania.", + "None": "Brak", + "Not configured": "Nieskonfigurowane", + "Nothing to select.": "Brak pozycji do wyboru.", + "Nothing to show.": "Brak danych.", + "Only after the new DNS record has been published.": "Dopiero po opublikowaniu nowego rekordu DNS.", + "Only mailboxes (not alias destinations)": "Tylko skrzynki (bez celów aliasów)", + "Other header": "Inny nagłówek", + "Out of office": "Poza biurem", + "Overview": "Przegląd", + "POP/IMAP": "POP/IMAP", + "Password": "Hasło", + "Password (leave empty to keep)": "Hasło (puste = bez zmian)", + "Password changed": "Hasło zmienione", + "Password expired": "Hasło wygasło", + "Password expires": "Hasło wygasa", + "Password expiry": "Wygasanie hasła", + "Password expiry (days)": "Ważność hasła (dni)", + "Password scheme": "Schemat haseł", + "Password – copy it now, it will not be shown again.": "Hasło – skopiuj je teraz, nie zostanie pokazane ponownie.", + "Password, two-factor authentication and application passwords": "Hasło, uwierzytelnianie dwuskładnikowe i hasła aplikacji", + "Path of the private key": "Ścieżka klucza prywatnego", + "Poll every (minutes)": "Sprawdzaj co (minuty)", + "Port": "Port", + "Private key": "Klucz prywatny", + "Private key (leave empty to generate)": "Klucz prywatny (puste = wygeneruj)", + "Protect the sign-in with a one-time code from an authenticator app.": "Zabezpiecz logowanie jednorazowym kodem z aplikacji uwierzytelniającej.", + "Protocol": "Protokół", + "Public key": "Klucz publiczny", + "Public key (leave empty to generate)": "Klucz publiczny (puste = wygeneruj)", + "Publish this DNS record.": "Opublikuj ten rekord DNS.", + "Quota": "Quota", + "Quota (MB)": "Quota (MB)", + "Quota is disabled for this domain (use -1)": "Quota jest wyłączona dla tej domeny (użyj -1)", + "Recipient (To/Cc)": "Odbiorca (To/Cc)", + "Recovery code": "Kod odzyskiwania", + "Recovery e-mail": "E-mail do odzyskiwania", + "Redirect to": "Przekieruj na", + "Reload Rspamd": "Przeładuj Rspamd", + "Reload Rspamd afterwards": "Przeładuj Rspamd po zapisie", + "Remember these destinations for new aliases": "Zapamiętaj te adresy dla nowych aliasów", + "Remove the mapping and delete the private key {d} / {s} from disk?": "Usunąć mapowanie i skasować z dysku klucz prywatny {d} / {s}?", + "Remove {d} from the Rspamd maps? The key file stays on disk.": "Usunąć {d} z map Rspamd? Plik klucza pozostanie na dysku.", + "Rename": "Zmień nazwę", + "Rename script": "Zmień nazwę skryptu", + "Repeat password": "Powtórz hasło", + "Replace an existing mapping": "Zastąp istniejące mapowanie", + "Reply at most every {n} seconds": "Odpowiadaj nie częściej niż co {n} s", + "Reply once (per sender)": "Odpowiedz raz (każdemu nadawcy)", + "Reply once per day": "Odpowiadaj raz dziennie", + "Reply once per week": "Odpowiadaj raz w tygodniu", + "Reply to every message": "Odpowiadaj na każdą wiadomość", + "Reply type": "Sposób odpowiedzi", + "Retrieve mail from remote POP3/IMAP accounts": "Pobieranie poczty ze zdalnych kont POP3/IMAP", + "Role": "Rola", + "Rspamd maps configured": "Mapy Rspamd skonfigurowane", + "Rule added – check and save the script": "Reguła dodana – sprawdź i zapisz skrypt", + "Rule name": "Nazwa reguły", + "Run “postfixadmin-rs --init-schema” on the server to create missing tables and columns.": "Uruchom na serwerze „postfixadmin-rs --init-schema”, aby utworzyć brakujące tabele i kolumny.", + "Save": "Zapisz", + "Saved": "Zapisano", + "Schema": "Schemat", + "Schema check": "Kontrola schematu", + "Script name": "Nazwa skryptu", + "Script saved": "Skrypt zapisany", + "Scripts": "Skrypty", + "Search": "Szukaj", + "Search in this domain…": "Szukaj w tej domenie…", + "Search mailboxes and aliases…": "Szukaj skrzynek i aliasów…", + "Search results for “{q}”": "Wyniki wyszukiwania „{q}”", + "Searches addresses, names and forwarding targets in your domains": "Przeszukuje adresy, nazwy i cele przekierowań w Twoich domenach", + "Secret key": "Klucz tajny", + "Security": "Bezpieczeństwo", + "Select at least one domain": "Wybierz co najmniej jedną domenę", + "Selector": "Selektor", + "Send": "Wyślij", + "Send a copy to": "Wyślij kopię na", + "Send a message through the configured SMTP server": "Wysyłanie wiadomości przez skonfigurowany serwer SMTP", + "Send code": "Wyślij kod", + "Send e-mail": "Wyślij e-mail", + "Send mail": "Wyślij pocztę", + "Send this message to all recipients in {n} domain(s)?": "Wysłać tę wiadomość do wszystkich odbiorców w domenach: {n}?", + "Send to all": "Wyślij do wszystkich", + "Send welcome e-mail": "Wyślij wiadomość powitalną", + "Sender": "Nadawca", + "Sender domain": "Domena nadawcy", + "Sender name": "Nazwa nadawcy", + "Sending (SMTP)": "Wysyłanie (SMTP)", + "Sending disabled": "Wysyłanie wyłączone", + "Sent: {s}, failed: {f}": "Wysłano: {s}, błędy: {f}", + "Server": "Serwer", + "Server-side mail filters (Sieve)": "Filtry poczty po stronie serwera (Sieve)", + "Services": "Usługi", + "Set a new password": "Ustaw nowe hasło", + "Set up": "Skonfiguruj", + "Set up two-factor authentication": "Konfiguracja uwierzytelniania dwuskładnikowego", + "Show": "Pokaż", + "Show all ({n})": "Pokaż wszystkie ({n})", + "Show password": "Pokaż hasło", + "Sign in": "Zaloguj", + "Sign out": "Wyloguj", + "Signed in as {user}": "Zalogowano jako {user}", + "Signing entry saved": "Wpis podpisywania zapisany", + "Signing keys used by Rspamd on this host": "Klucze podpisujące używane przez Rspamd na tym serwerze", + "Signing table": "Tabela podpisywania", + "Skipped: {n}": "Pominięto: {n}", + "Source": "Źródło", + "Status": "Status", + "Stop processing further rules": "Zatrzymaj przetwarzanie kolejnych reguł", + "Stored in mailbox": "Zapisywana w skrzynce", + "Subject": "Temat", + "System": "System", + "TOTP exception": "Wyjątek TOTP", + "TOTP exceptions (trusted IP addresses)": "Wyjątki TOTP (zaufane adresy IP)", + "Target domain": "Domena docelowa", + "Target mailbox": "Skrzynka docelowa", + "Test configuration": "Testuj konfigurację", + "The database schema is complete.": "Schemat bazy danych jest kompletny.", + "The mailbox password has expired": "Hasło skrzynki wygasło", + "The password has been changed. You can sign in now.": "Hasło zostało zmienione. Możesz się zalogować.", + "The passwords do not match.": "Hasła nie są identyczne.", + "The script contains an error": "Skrypt zawiera błąd", + "The script is valid": "Skrypt jest poprawny", + "Then": "Wtedy", + "This domain has no alias domains.": "Ta domena nie ma domen aliasowych.", + "This domain has no aliases.": "Ta domena nie ma aliasów.", + "This domain has no mailboxes.": "Ta domena nie ma skrzynek.", + "Time": "Czas", + "To": "Do", + "To (one address per line)": "Do (jeden adres w wierszu)", + "Transport": "Transport", + "Two-factor authentication": "Uwierzytelnianie dwuskładnikowe", + "Two-factor authentication (TOTP)": "Uwierzytelnianie dwuskładnikowe (TOTP)", + "Two-factor authentication disabled": "Uwierzytelnianie dwuskładnikowe wyłączone", + "Two-factor authentication enabled": "Uwierzytelnianie dwuskładnikowe włączone", + "Undeliverable": "Niedostarczalny", + "Unmap": "Odłącz", + "Unmap key": "Odłącz klucz", + "Until": "Do", + "Use SSL/TLS": "Użyj SSL/TLS", + "Use existing key": "Użyj istniejącego klucza", + "Use this password in the application instead of your mailbox password.": "Użyj tego hasła w aplikacji zamiast hasła do skrzynki.", + "User": "Użytkownik", + "Vacation": "Autoresponder", + "Value": "Wartość", + "Verify": "Zweryfikuj", + "Verify the server certificate": "Weryfikuj certyfikat serwera", + "Version": "Wersja", + "Virtual list": "Lista wirtualna", + "Warnings": "Ostrzeżenia", + "Yes": "Tak", + "Your domains": "Twoje domeny", + "Your session has ended. Please sign in again.": "Sesja wygasła. Zaloguj się ponownie.", + "active": "aktywny", + "all": "wszystkie", + "catch-all": "catch-all", + "contains": "zawiera", + "disabled": "wyłączone", + "expired": "wygasło", + "forward only": "tylko przekazywanie", + "is exactly": "jest równe", + "matches (wildcards)": "pasuje do wzorca (*, ?)", + "max. {q}": "maks. {q}", + "missing": "brak", + "system": "systemowy", + "unlimited": "bez limitu", + "{from}–{to} of {total}": "{from}–{to} z {total}", + "{n} address(es)": "Adresy: {n}", + "{n} days": "{n} dni", + "{n} domain(s)": "Domeny: {n}", + "{n} min": "{n} min", + "Going away": "Wyjeżdżam", + "Switch to dark theme": "Przełącz na ciemny motyw", + "Switch to light theme": "Przełącz na jasny motyw", + "Click to activate": "Kliknij, aby aktywować", + "Click to deactivate": "Kliknij, aby dezaktywować", + "Two-factor authentication enabled ": "Uwierzytelnianie dwuskładnikowe włączone", + "The mailbox password script failed": "Skrypt hasła skrzynki zakończył się błędem", + "The mailbox post-creation script failed": "Skrypt po utworzeniu skrzynki zakończył się błędem", + "Mailbox folders could not be created": "Nie udało się utworzyć folderów skrzynki", + "Rspamd reloaded": "Rspamd przeładowany", +} + +SERVER = { + "A stale alias-domain entry with this name exists; remove it first": "Istnieje osierocony wpis domeny aliasowej o tej nazwie; najpierw go usuń", + "Account is disabled or no longer exists": "Konto jest wyłączone lub już nie istnieje", + "Account no longer exists": "Konto już nie istnieje", + "Account not found": "Nie znaleziono konta", + "Administrator already exists": "Administrator już istnieje", + "Administrator not found": "Nie znaleziono administratora", + "Administrator role required": "Wymagana rola administratora", + "Alias does not exist": "Alias nie istnieje", + "Alias domain does not exist": "Domena aliasowa nie istnieje", + "Alias domains are disabled": "Domeny aliasowe są wyłączone", + "Alias is outside this account": "Alias nie należy do tego konta", + "Aliases are disabled for this domain": "Aliasy są wyłączone dla tej domeny", + "An alias domain cannot point to itself": "Domena aliasowa nie może wskazywać na samą siebie", + "Application password not found": "Nie znaleziono hasła aplikacji", + "Application passwords are created by mailbox users": "Hasła aplikacji tworzą użytkownicy skrzynek", + "Application passwords are disabled": "Hasła aplikacji są wyłączone", + "At least one destination is required": "Wymagany jest co najmniej jeden adres docelowy", + "Backup is disabled": "Kopia zapasowa jest wyłączona", + "Broadcast is restricted to global administrators": "Wiadomości do wszystkich mogą wysyłać tylko administratorzy globalni", + "Broadcast mail is disabled": "Wiadomości do wszystkich są wyłączone", + "Changing the display name is not enabled": "Zmiana nazwy nie jest włączona", + "Current password is incorrect": "Obecne hasło jest nieprawidłowe", + "DKIM key not found": "Nie znaleziono klucza DKIM", + "DKIM management is disabled": "Zarządzanie DKIM jest wyłączone", + "DNS status checks are disabled": "Sprawdzanie DNS jest wyłączone", + "Database is read-only in this development session": "Baza danych jest w tej sesji tylko do odczytu", + "Default/special aliases can only be edited by a global administrator": "Aliasy domyślne/specjalne może edytować tylko administrator globalny", + "Description is required": "Opis jest wymagany", + "Description is too long": "Opis jest za długi", + "Domain already exists": "Domena już istnieje", + "Domain does not exist": "Domena nie istnieje", + "Domain is outside this administrator's scope": "Domena jest poza zakresem tego administratora", + "Editing mailbox aliases is disabled for your role (alias_control / alias_control_admin)": "Edycja aliasów skrzynek jest wyłączona dla Twojej roli (alias_control / alias_control_admin)", + "Editing the forwarding is not allowed": "Edycja przekierowania jest niedozwolona", + "Enter at least one forwarding address or keep delivery to the mailbox": "Podaj co najmniej jeden adres przekierowania albo pozostaw dostarczanie do skrzynki", + "Fetchmail is disabled": "Fetchmail jest wyłączony", + "Fetchmail record not found": "Nie znaleziono wpisu fetchmail", + "Global administrator role required": "Wymagana rola administratora globalnego", + "Invalid CSRF token": "Nieprawidłowy token CSRF – odśwież stronę", + "Invalid IP address": "Nieprawidłowy adres IP", + "Invalid TOTP code": "Nieprawidłowy kod TOTP", + "Invalid address": "Nieprawidłowy adres", + "Invalid alias destination": "Nieprawidłowy adres docelowy aliasu", + "Invalid catch-all alias address": "Nieprawidłowy adres aliasu catch-all", + "Invalid credentials": "Nieprawidłowy login lub hasło", + "Invalid domain": "Nieprawidłowa domena", + "Invalid email address": "Nieprawidłowy adres e-mail", + "Invalid email local part": "Nieprawidłowa część lokalna adresu e-mail", + "Invalid mailbox/alias local part": "Nieprawidłowa nazwa skrzynki/aliasu", + "Invalid or expired recovery code": "Nieprawidłowy lub wygasły kod odzyskiwania", + "Invalid phone number": "Nieprawidłowy numer telefonu", + "Login required": "Wymagane logowanie", + "MFA challenge expired": "Weryfikacja dwuskładnikowa wygasła – zaloguj się ponownie", + "Mailbox does not exist": "Skrzynka nie istnieje", + "Mailbox domain does not match domain": "Domena skrzynki nie zgadza się z domeną", + "Mailbox is outside this account": "Skrzynka nie należy do tego konta", + "Mailbox not found": "Nie znaleziono skrzynki", + "Mailboxes are disabled for this domain": "Skrzynki są wyłączone dla tej domeny", + "ManageSieve credentials are not available; sign in again": "Dane logowania ManageSieve są niedostępne – zaloguj się ponownie", + "ManageSieve is disabled": "ManageSieve jest wyłączony", + "ManageSieve is read-only": "ManageSieve jest tylko do odczytu", + "Missing CSRF token": "Brak tokenu CSRF – odśwież stronę", + "Only mailbox users can use this endpoint": "Funkcja dostępna tylko dla użytkowników skrzynek", + "Password is required": "Hasło jest wymagane", + "Password recovery is disabled": "Odzyskiwanie hasła jest wyłączone", + "Passwords are managed by the mailbox_postpassword hook and cannot be changed here": "Hasła są zarządzane przez hook mailbox_postpassword i nie można ich tu zmienić", + "Pipe/file alias destinations are disabled by the Rust security profile": "Cele aliasów typu potok/plik są wyłączone ze względów bezpieczeństwa", + "Quota -1 (disabled) is only allowed when the domain maximum quota is disabled": "Quota -1 (wyłączona) jest dozwolona tylko, gdy maks. quota domeny jest wyłączona", + "Quota must be -1 or greater": "Quota musi wynosić co najmniej -1", + "Renaming administrators is not supported": "Zmiana nazwy administratora nie jest obsługiwana", + "Renaming alias domains is not supported": "Zmiana nazwy domeny aliasowej nie jest obsługiwana", + "Renaming aliases is not supported": "Zmiana nazwy aliasu nie jest obsługiwana", + "Renaming domains is not supported; create a new domain instead": "Zmiana nazwy domeny nie jest obsługiwana; utwórz nową domenę", + "Renaming mailboxes is not supported": "Zmiana nazwy skrzynki nie jest obsługiwana", + "Select at least one domain": "Wybierz co najmniej jedną domenę", + "Send mail is disabled": "Wysyłanie poczty jest wyłączone", + "Send mail is restricted to global administrators": "Wysyłanie poczty jest dostępne tylko dla administratorów globalnych", + "Session expired": "Sesja wygasła", + "Source password is required": "Hasło źródłowe jest wymagane", + "Source server and user are required": "Serwer i użytkownik źródłowy są wymagani", + "TOTP is disabled": "TOTP jest wyłączone", + "TOTP is not configured": "TOTP nie jest skonfigurowane", + "Target mailbox does not exist": "Skrzynka docelowa nie istnieje", + "The alias name (local part) is required": "Nazwa aliasu jest wymagana", + "The alias of a mailbox can only be deleted together with the mailbox": "Alias skrzynki można usunąć tylko razem ze skrzynką", + "The alias of a mailbox is activated together with the mailbox": "Alias skrzynki jest aktywowany razem ze skrzynką", + "The local part must not contain an @ sign": "Nazwa nie może zawierać znaku @", + "The mailbox name (local part) is required": "Nazwa skrzynki jest wymagana", + "The maximum mailbox quota cannot be larger than the domain quota": "Maks. quota skrzynki nie może być większa niż quota domeny", + "The name may contain up to 255 characters and no control characters": "Nazwa może mieć do 255 znaków i nie może zawierać znaków sterujących", + "The new password must differ from the current password": "Nowe hasło musi być inne niż obecne", + "The target domain is itself an alias domain; chained alias domains are not supported": "Domena docelowa sama jest domeną aliasową; łańcuchy domen aliasowych nie są obsługiwane", + "This address already exists": "Ten adres już istnieje", + "This domain is already an alias domain": "Ta domena jest już domeną aliasową", + "This domain is already the target of another alias domain": "Ta domena jest już celem innej domeny aliasowej", + "This domain is configured as the vacation (autoreply) domain": "Ta domena jest skonfigurowana jako domena autorespondera", + "Too many login attempts": "Zbyt wiele prób logowania – spróbuj później", + "Too many password change attempts": "Zbyt wiele prób zmiany hasła – spróbuj później", + "Too many recovery requests": "Zbyt wiele próśb o odzyskanie hasła – spróbuj później", + "Too many reset attempts": "Zbyt wiele prób – spróbuj później", + "Unlimited quota is not allowed because the domain has a total quota": "Quota bez limitu jest niedozwolona, ponieważ domena ma quotę całkowitą", + "Unsupported vacation reply interval": "Nieobsługiwany interwał odpowiedzi", + "Vacation control is disabled for domain administrators": "Zarządzanie autoresponderem jest wyłączone dla administratorów domen", + "Vacation control is disabled for this user": "Zarządzanie autoresponderem jest wyłączone dla tego użytkownika", + "Vacation end date cannot be before start date": "Data końcowa nie może być wcześniejsza niż początkowa", + "Vacation is disabled": "Autoresponder jest wyłączony", + "Vacation message is too long": "Treść autorespondera jest za długa", + "You cannot deactivate yourself": "Nie możesz dezaktywować własnego konta", + "You cannot deactivate yourself or remove your own global administrator role": "Nie możesz dezaktywować własnego konta ani odebrać sobie roli administratora globalnego", + "You cannot delete the currently logged-in administrator": "Nie możesz usunąć zalogowanego administratora", + "fetchmail extra options/MDA are disabled": "Dodatkowe opcje fetchmail/MDA są wyłączone", + "poll_time must be positive": "Interwał musi być dodatni", +} + +# Server messages that contain values. +PATTERNS = [ + (r"^The mailbox limit of this domain has been reached \((\d+)\)$", "Osiągnięto limit skrzynek tej domeny ($1)"), + (r"^The alias limit of this domain has been reached \((\d+)\)$", "Osiągnięto limit aliasów tej domeny ($1)"), + (r"^Unlimited quota is not allowed – the maximum mailbox quota of this domain is (\d+) MB$", "Quota bez limitu jest niedozwolona – maks. quota skrzynki w tej domenie to $1 MB"), + (r"^The quota exceeds the maximum mailbox quota of this domain \((\d+) MB\)$", "Quota przekracza maks. quotę skrzynki tej domeny ($1 MB)"), + (r"^The domain quota would be exceeded \((\d+) MB of (\d+) MB already allocated\)$", "Quota domeny zostałaby przekroczona (przydzielono już $1 MB z $2 MB)"), + (r"^(.+): an alias must not point to itself$", "$1: alias nie może wskazywać na samego siebie"), + (r"^Invalid destination address: (.+)$", "Nieprawidłowy adres docelowy: $1"), + (r"^You may only make aliases to domains hosted on this server\. (.+) is a remote domain name\.$", "Aliasy mogą wskazywać tylko na domeny obsługiwane przez ten serwer. $1 jest domeną zewnętrzną."), + (r"^Domain does not exist: (.+)$", "Domena nie istnieje: $1"), + (r"^Unknown domain: (.+)$", "Nieznana domena: $1"), + (r"^Unsupported transport: (.+)$", "Nieobsługiwany transport: $1"), + (r"^Password must contain at least (\d+) characters$", "Hasło musi mieć co najmniej $1 znaków"), + (r"^Password must contain at least (\d+) letters$", "Hasło musi zawierać co najmniej $1 litery"), + (r"^Password must contain at least (\d+) digits$", "Hasło musi zawierać co najmniej $1 cyfry"), + (r"^(.+) must be -1 \(disabled\), 0 \(unlimited\) or a positive number$", "$1: dozwolone -1 (wyłączone), 0 (bez limitu) lub liczba dodatnia"), + (r"^Password expiration must be between 0 \(never\) and (\d+) days$", "Ważność hasła musi wynosić od 0 (nigdy) do $1 dni"), + (r"^The domain is the target of the alias domain (.+); delete that alias domain first$", "Domena jest celem domeny aliasowej $1; najpierw ją usuń"), + (r"^Domain has no A, AAAA, MX or NS record: (.+)$", "Domena nie ma rekordu A, AAAA, MX ani NS: $1"), + (r"^Invalid vacation (start|end) date$", "Nieprawidłowa data autorespondera"), +] diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index cb70113..94ea0bf 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -138,9 +138,13 @@ for marker in ['/rspamd-dkim/generate', 'selector_map', 'path_map', 'RspamdDkimC fail(f'missing Rspamd DKIM integration marker: {marker}') compatibility_regression_markers = { - 'src/routes/vacation.rs': ['sync_vacation_alias', 'vacation_address', 'validate_dates'], - 'src/routes/aliases.rs': ['technical', 'internal_vacation', 'can_edit_description'], - 'src/web/js/10-pages.js': ['vacationMailboxPicker', 'Aliasy techniczne', 'local_part'], + 'src/routes/vacation.rs': ['sync_vacation_alias', 'vacation_address', 'normalize_dates'], + 'src/routes/aliases.rs': ['technical', 'internal_vacation', 'require_mailbox_alias_control', 'recipient_delimiter_base'], + 'src/routes/mailboxes.rs': ['check_quota', 'allowed_quota', 'set_forwarding', 'set_own_name'], + 'src/limits.rs': ['ensure_mailbox_allowed', 'ensure_alias_allowed'], + 'src/schema.rs': ['PFA_SCHEMA_VERSION', 'integrity_check'], + 'src/routes/fetchmail.rs': ['encode_src_password'], + 'src/web/js/10-pages.js': ['vacationEditor', 'technical', 'local_part'], 'packaging/postfixadmin-rs-rspamd-setup': ['postfixadmin-rspamd', 'systemctl restart postfixadmin-rs rspamd'], } for relative, markers in compatibility_regression_markers.items(): @@ -151,14 +155,14 @@ for relative, markers in compatibility_regression_markers.items(): domain_ui_regression_markers = { 'src/web/js/10-pages.js': [ - 'aliasRows.filter(item => !item.is_mailbox)', - 'regularAliases = indexedAliases.filter(({ item }) => !item.technical)', - 'technicalAliases = indexedAliases.filter(({ item }) => item.technical)', - 'Aliasy użytkowników', - 'Aliasy techniczne', - "['Adres', 'Cel', 'Opis', 'Status', 'Aktywny', '']", - 'aliasDestinations', - 'aliasTargetsCell', + 'async function virtualPage(', + 'row.catch_all', + 'function aliasForm(', + 'function mailboxForm(', + 'function forwardingForm(', + 'pager(aliases.total', + 'pager(mailboxes.total', + 'deleteInactive', ], 'src/web/css/20-app.css': ['.workspace-grid{display:grid;grid-template-columns:1fr;'], 'src/routes/aliases.rs': ['a.goto AS goto_value', 'row_string(row, "goto_value")'], @@ -248,7 +252,7 @@ core_js = (ROOT / 'src/web/js/00-core.js').read_text(encoding='utf-8') for marker in ['/sieve/status', '/sieve/scripts/{name}', '/sieve/check']: if marker not in routes_mod: fail(f'missing ManageSieve route: {marker}') -for marker in ['openSieveRuleBuilder', 'buildSieveRule', 'mergeSieveRequirements']: +for marker in ['newRule', 'buildSieveRule', 'mergeSieveRequirements']: if marker not in pages_js: fail(f'missing ManageSieve visual rule builder marker: {marker}') for marker in ['SieveConfig', 'allow_insecure_auth', 'timeout_secs', 'read_only']: @@ -275,7 +279,7 @@ else: result = subprocess.run([sys.executable, '-m', 'py_compile', str(sync_script)], capture_output=True, text=True) if result.returncode: fail(f'{sync_script.relative_to(ROOT)}: py_compile failed: {result.stderr.strip()}') -for marker in ["['mailboxes', 'mail', 'Skrzynki', S.me.kind === 'admin']", "S.me.kind === 'user'", 'readonlyDomain(selected)', "api('/mailboxes')", "api('/aliases')", 'vacationUserForm', 'passwordForm']: +for marker in ['const isUser = ', 'async function userOverview(', 'async function forwardingPage(', "['forwarding', 'Forwarding', 'Mailbox'", 'passwordForm', 'edit_own_name']: if marker not in core_js + '\n' + pages_js: fail(f'missing mailbox-user UX marker: {marker}') if 'Sesja: HttpOnly' in branding_text or 'Session: HttpOnly' in branding_text or 'SameSite=Strict, Secure w produkcji' in branding_text: diff --git a/src/auth.rs b/src/auth.rs index c218e78..20ae533 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -28,6 +28,7 @@ pub struct Session { pub csrf: String, mail_password: Option>, expires_at: Instant, + pub validated_at: Instant, } impl Session { @@ -123,11 +124,49 @@ impl AuthStore { csrf: random_token(32), mail_password, expires_at: Instant::now() + self.timeout, + validated_at: Instant::now(), }; self.sessions.insert(token.clone(), session.clone()); (token, session) } + /// Store refreshed authorization data (role, domain scope) for a session + /// after it was re-validated against the database. + pub fn refresh_session(&self, token: &str, role: Role, allowed_domains: Arc<[String]>) { + if let Some(mut session) = self.sessions.get_mut(token) { + session.role = role; + session.allowed_domains = allowed_domains; + session.validated_at = Instant::now(); + } + } + + /// Revoke every session of an account (password change by admin, + /// account deletion). The current session can be kept with `keep`. + pub fn revoke_user(&self, kind: AccountKind, username: &str, keep: Option<&str>) { + self.sessions.retain(|token, session| { + Some(token.as_str()) == keep + || session.kind != kind + || !session.username.eq_ignore_ascii_case(username) + }); + } + + /// Drop expired sessions, MFA challenges and stale rate-limit buckets. + pub fn purge_expired(&self) { + let now = Instant::now(); + self.sessions.retain(|_, session| session.expires_at > now); + self.pending.retain(|_, pending| pending.expires_at > now); + let window = self.login_window; + self.attempts.retain(|_, attempts| { + while attempts + .front() + .is_some_and(|t| now.duration_since(*t) > window) + { + attempts.pop_front(); + } + !attempts.is_empty() + }); + } + pub fn get_session(&self, token: &str) -> Option { let item = self.sessions.get(token)?; if Instant::now() >= item.expires_at { diff --git a/src/config.rs b/src/config.rs index a262bc5..3a2158d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -476,7 +476,7 @@ pub struct FeatureConfig { #[serde(default)] pub show_password: bool, #[serde(default)] - pub edit_mailbox_name: bool, + pub edit_mailbox_name: NamePolicy, #[serde(default)] pub domain_list_hide_maxquota: bool, #[serde(default)] @@ -524,13 +524,31 @@ impl Default for FeatureConfig { smtp_active_flag: false, generate_password: false, show_password: false, - edit_mailbox_name: false, + edit_mailbox_name: NamePolicy::Enabled(false), domain_list_hide_maxquota: false, domain_list_hide_password_expiry: false, } } } +/// `edit_mailbox_name`: `false` (default), `true` (all domains) or a list of +/// domains whose mailbox users may change their own display name. +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum NamePolicy { + Enabled(bool), + Domains(Vec), +} + +impl NamePolicy { + pub fn allows(&self, domain: &str) -> bool { + match self { + Self::Enabled(value) => *value, + Self::Domains(list) => list.iter().any(|d| d.eq_ignore_ascii_case(domain)), + } + } +} + #[derive(Debug, Deserialize, Clone)] pub struct DefaultsConfig { #[serde(default = "default_aliases")] @@ -568,6 +586,10 @@ pub struct DefaultsConfig { pub transport_options: Vec, #[serde(default = "default_vacation_domain")] pub vacation_domain: String, + /// Allowed reply intervals in seconds (`vacation_choice_of_reply`): + /// 0 = reply once, 1 = every mail, 604800 = once per week. + #[serde(default = "default_vacation_choices")] + pub vacation_choice_of_reply: Vec, #[serde(default = "default_alias_map")] pub default_aliases: BTreeMap, #[serde(default = "default_welcome")] @@ -585,6 +607,9 @@ pub struct DefaultsConfig { #[serde(default)] pub motd_superadmin: String, } +fn default_vacation_choices() -> Vec { + vec![0, 604_800] +} fn default_transport_options() -> Vec { vec!["virtual".into(), "local".into(), "relay".into()] } @@ -627,6 +652,7 @@ impl Default for DefaultsConfig { transport_default: default_transport(), transport_options: default_transport_options(), vacation_domain: default_vacation_domain(), + vacation_choice_of_reply: default_vacation_choices(), default_aliases: default_alias_map(), welcome_text: default_welcome(), recipient_delimiter: String::new(), @@ -655,6 +681,22 @@ pub struct SecurityConfig { pub allow_legacy_shell_hooks: bool, #[serde(default)] pub allow_unsafe_alias_destinations: bool, + /// Reverse proxies (IP addresses or CIDR ranges) whose `X-Forwarded-For` / + /// `X-Real-IP` headers are trusted to carry the real client address. Without + /// this, every request behind nginx appears to come from 127.0.0.1 and the + /// login rate limit / TOTP exceptions cannot work per client. + #[serde(default)] + pub trusted_proxies: Vec, + /// Send `Strict-Transport-Security` (only enable when served over HTTPS). + #[serde(default)] + pub hsts: bool, + /// How often (seconds) an active session is re-checked against the database + /// (account still active, admin role and domain assignments unchanged). + #[serde(default = "default_revalidate")] + pub session_revalidate_seconds: u64, +} +fn default_revalidate() -> u64 { + 30 } impl Default for SecurityConfig { fn default() -> Self { @@ -666,6 +708,9 @@ impl Default for SecurityConfig { request_body_limit_mb: 2, allow_legacy_shell_hooks: false, allow_unsafe_alias_destinations: false, + trusted_proxies: Vec::new(), + hsts: false, + session_revalidate_seconds: default_revalidate(), } } } @@ -866,6 +911,18 @@ impl AppConfig { )); } } + for proxy in &self.security.trusted_proxies { + if crate::net::parse_cidr(proxy).is_none() { + return Err(AppError::config(format!( + "security.trusted_proxies contains an invalid IP/CIDR: {proxy}" + ))); + } + } + if self.environment == "production" && !self.security.cookie_secure { + tracing::warn!( + "security.cookie_secure=false in production: session cookies will be sent over plain HTTP" + ); + } if self.security.session_timeout_minutes == 0 || self.security.login_attempts == 0 { return Err(AppError::config( "session timeout and login attempts must be non-zero", @@ -1099,3 +1156,9 @@ impl AppConfig { } } } + +impl Default for NamePolicy { + fn default() -> Self { + Self::Enabled(false) + } +} diff --git a/src/db.rs b/src/db.rs index 071b9c7..b756b2f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -73,6 +73,14 @@ impl Db { }) } + pub fn prefix(&self) -> &str { + &self.prefix + } + + pub fn forget_column_cache(&self) { + self.column_cache.clear(); + } + pub fn pool(&self) -> &AnyPool { &self.pool } @@ -145,11 +153,21 @@ impl Db { pub fn cast_i64(&self, expr: &str) -> String { match self.dialect { Dialect::MySql => format!("CAST({expr} AS SIGNED)"), - Dialect::Postgres => format!("CAST({expr} AS BIGINT)"), + // PostgreSQL has no boolean -> bigint cast; boolean -> integer exists. + Dialect::Postgres => format!("CAST(CAST({expr} AS INTEGER) AS BIGINT)"), Dialect::Sqlite => format!("CAST({expr} AS INTEGER)"), } } + /// Placeholder for a timestamp value bound as text. PostgreSQL does not + /// coerce a text parameter into a timestamp column implicitly. + pub fn ts_param(&self) -> &'static str { + match self.dialect { + Dialect::Postgres => "CAST(? AS TIMESTAMP)", + Dialect::MySql | Dialect::Sqlite => "?", + } + } + pub fn flag(&self, expr: &str, alias: &str) -> String { format!("{} AS {alias}", self.cast_i64(expr)) } @@ -222,311 +240,79 @@ impl Db { Ok(()) } - /// Creates an isolated SQLite schema used only by `dev.sh`. - /// It intentionally mirrors a superset of PostfixAdmin columns so UI/API - /// development does not require a copy of the production mail database. + /// Creates an isolated SQLite database used only by `dev.sh`: the regular + /// PostfixAdmin schema (same code path as `--init-schema`) plus test data. pub async fn initialize_dev_mock(&self, config: &AppConfig) -> AppResult<()> { if !config.database.dev_mock || self.dialect != Dialect::Sqlite { return Ok(()); } - + crate::schema::upgrade(self, false).await?; let admin = self.table(Table::Admin); - let alias = self.table(Table::Alias); - let alias_domain = self.table(Table::AliasDomain); - let config_table = self.table(Table::Config); + let existing = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {admin}")) + .fetch_one(&self.pool) + .await?; + if row_i64(&existing, "n") > 0 { + tracing::info!("development PostfixAdmin mock database is ready"); + return Ok(()); + } let domain = self.table(Table::Domain); let domain_admins = self.table(Table::DomainAdmins); - let fetchmail = self.table(Table::Fetchmail); - let log = self.table(Table::Log); let mailbox = self.table(Table::Mailbox); - let vacation = self.table(Table::Vacation); - let vacation_notification = self.table(Table::VacationNotification); - let quota = self.table(Table::Quota); - let quota2 = self.table(Table::Quota2); - let dkim = self.table(Table::Dkim); - let dkim_signing = self.table(Table::DkimSigning); - let totp_exception = self.table(Table::TotpException); - let app_password = self.table(Table::MailboxAppPassword); + let alias = self.table(Table::Alias); + let alias_domain = self.table(Table::AliasDomain); let admin_preferences = self.table(Table::AdminPreferences); - - let statements = vec![ + let quota2 = self.table(Table::Quota2); + let now = "CURRENT_TIMESTAMP"; + let seeds = [ format!( - r#"CREATE TABLE IF NOT EXISTS {admin} ( - username TEXT PRIMARY KEY, - password TEXT NOT NULL, - superadmin INTEGER NOT NULL DEFAULT 0, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1, - phone TEXT NOT NULL DEFAULT '', - email_other TEXT NOT NULL DEFAULT '', - token TEXT NOT NULL DEFAULT '', - token_validity TEXT NOT NULL DEFAULT '2000-01-01 00:00:00', - totp_secret TEXT - )"# + "INSERT INTO {domain} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active,password_expiry,dns_active,dns_checked) VALUES ('example.test','Development mock domain',100,100,1024,10240,'virtual',0,{now},{now},1,90,1,{now})" ), format!( - r#"CREATE TABLE IF NOT EXISTS {domain} ( - domain TEXT PRIMARY KEY, - description TEXT NOT NULL DEFAULT '', - aliases INTEGER NOT NULL DEFAULT 10, - mailboxes INTEGER NOT NULL DEFAULT 10, - maxquota INTEGER NOT NULL DEFAULT 10, - quota INTEGER NOT NULL DEFAULT 2048, - transport TEXT NOT NULL DEFAULT 'virtual', - backupmx INTEGER NOT NULL DEFAULT 0, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1, - password_expiry INTEGER NOT NULL DEFAULT 0, - dns_active INTEGER, - dns_checked TEXT - )"# + "INSERT INTO {domain} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active,password_expiry) VALUES ('second.test','Second development domain',10,5,512,2048,'virtual',0,{now},{now},1,0)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {domain_admins} ( - username TEXT NOT NULL, - domain TEXT NOT NULL, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (username, domain) - )"# + "INSERT INTO {domain} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active,password_expiry) VALUES ('alias.example.test','Alias domain for example.test',0,0,0,0,'virtual',0,{now},{now},1,0)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {mailbox} ( - username TEXT PRIMARY KEY, - password TEXT NOT NULL, - name TEXT NOT NULL DEFAULT '', - maildir TEXT NOT NULL DEFAULT '', - quota INTEGER NOT NULL DEFAULT 0, - local_part TEXT NOT NULL DEFAULT '', - domain TEXT NOT NULL, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1, - smtp_active INTEGER NOT NULL DEFAULT 1, - password_expiry TEXT NOT NULL DEFAULT '9999-12-31 23:59:59', - phone TEXT NOT NULL DEFAULT '', - email_other TEXT NOT NULL DEFAULT '', - token TEXT NOT NULL DEFAULT '', - token_validity TEXT NOT NULL DEFAULT '2000-01-01 00:00:00', - totp_secret TEXT - )"# + "INSERT INTO {admin} (username,password,superadmin,created,modified,active,email_other) VALUES ('admin@example.test','{{PLAIN}}admin123',1,{now},{now},1,'admin@example.test')" ), format!( - r#"CREATE TABLE IF NOT EXISTS {alias} ( - address TEXT PRIMARY KEY, - goto TEXT NOT NULL, - domain TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1 - )"# + "INSERT INTO {domain_admins} (username,domain,created,active) VALUES ('admin@example.test','ALL',{now},1)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {alias_domain} ( - alias_domain TEXT PRIMARY KEY, - target_domain TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1 - )"# + "INSERT INTO {admin} (username,password,superadmin,created,modified,active,email_other) VALUES ('domain-admin@example.test','{{PLAIN}}domain123',0,{now},{now},1,'domain-admin@example.test')" ), format!( - r#"CREATE TABLE IF NOT EXISTS {fetchmail} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain TEXT NOT NULL DEFAULT '', - mailbox TEXT NOT NULL, - src_server TEXT NOT NULL, - src_port INTEGER, - src_auth TEXT, - src_user TEXT NOT NULL, - src_password TEXT NOT NULL, - src_folder TEXT NOT NULL DEFAULT '', - poll_time INTEGER NOT NULL DEFAULT 10, - fetchall INTEGER NOT NULL DEFAULT 0, - keep INTEGER NOT NULL DEFAULT 0, - protocol TEXT NOT NULL DEFAULT 'POP3', - usessl INTEGER NOT NULL DEFAULT 0, - sslcertck INTEGER NOT NULL DEFAULT 0, - sslcertpath TEXT NOT NULL DEFAULT '', - sslfingerprint TEXT NOT NULL DEFAULT '', - extra_options TEXT NOT NULL DEFAULT '', - returned_text TEXT NOT NULL DEFAULT '', - mda TEXT NOT NULL DEFAULT '', - date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1 - )"# + "INSERT INTO {domain_admins} (username,domain,created,active) VALUES ('domain-admin@example.test','example.test',{now},1)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {vacation} ( - email TEXT PRIMARY KEY, - subject TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL DEFAULT '', - activefrom TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - activeuntil TEXT NOT NULL DEFAULT '9999-12-31 23:59:59', - cache TEXT NOT NULL DEFAULT '', - domain TEXT NOT NULL DEFAULT '', - interval_time INTEGER NOT NULL DEFAULT 0, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - active INTEGER NOT NULL DEFAULT 1 - )"# + "INSERT INTO {mailbox} (username,password,name,maildir,quota,local_part,domain,created,modified,active,smtp_active,email_other,password_expiry) VALUES ('user@example.test','{{PLAIN}}user123','Development User','example.test/user/',104857600,'user','example.test',{now},{now},1,1,'user@example.test','9999-12-31 23:59:59')" ), format!( - r#"CREATE TABLE IF NOT EXISTS {vacation_notification} ( - on_vacation TEXT NOT NULL, - notified TEXT NOT NULL, - notified_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (on_vacation, notified) - )"# + "INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES ('user@example.test','user@example.test','example.test','',{now},{now},1)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {log} ( - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - username TEXT NOT NULL DEFAULT '', - domain TEXT NOT NULL DEFAULT '', - action TEXT NOT NULL DEFAULT '', - data TEXT NOT NULL DEFAULT '' - )"# + "INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES ('support@example.test','user@example.test','example.test','Development alias',{now},{now},1)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {quota} ( - username TEXT NOT NULL, - path TEXT NOT NULL, - current INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (username, path) - )"# + "INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES ('old-forward@example.test','user@example.test','example.test','Inactive alias for bulk-delete testing',{now},{now},0)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {quota2} ( - username TEXT PRIMARY KEY, - bytes INTEGER NOT NULL DEFAULT 0, - messages INTEGER NOT NULL DEFAULT 0 - )"# + "INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES ('postmaster@example.test','admin@example.test','example.test','',{now},{now},1)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {dkim} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - domain_name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - selector TEXT NOT NULL DEFAULT 'default', - private_key TEXT, - public_key TEXT, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - )"# + "INSERT INTO {alias_domain} (alias_domain,target_domain,description,created,modified,active) VALUES ('alias.example.test','example.test','Development alias domain',{now},{now},1)" ), format!( - r#"CREATE TABLE IF NOT EXISTS {dkim_signing} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - author TEXT NOT NULL, - dkim_id INTEGER NOT NULL, - created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - )"# + "INSERT INTO {admin_preferences} (username,pref_key,pref_value) VALUES ('admin@example.test','alias_goto_default','user@example.test')" ), format!( - r#"CREATE TABLE IF NOT EXISTS {totp_exception} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT NOT NULL, - username TEXT, - description TEXT NOT NULL DEFAULT '' - )"# - ), - format!( - r#"CREATE TABLE IF NOT EXISTS {app_password} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - password_hash TEXT NOT NULL - )"# - ), - format!( - r#"CREATE TABLE IF NOT EXISTS {admin_preferences} ( - username TEXT NOT NULL, - pref_key TEXT NOT NULL, - pref_value TEXT NOT NULL DEFAULT '', - PRIMARY KEY (username, pref_key) - )"# - ), - format!( - r#"CREATE TABLE IF NOT EXISTS {config_table} ( - name TEXT PRIMARY KEY, - value TEXT NOT NULL DEFAULT '' - )"# + "INSERT INTO {quota2} (username,bytes,messages) VALUES ('user@example.test',12582912,42)" ), ]; - - for statement in statements { + for statement in seeds { sqlx::query(&statement).execute(&self.pool).await?; } - - sqlx::query(&format!( - "INSERT OR IGNORE INTO {domain} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,active,password_expiry,dns_active,dns_checked) VALUES ('example.test','Development mock domain',100,100,1024,10240,'virtual',0,1,90,1,CURRENT_TIMESTAMP)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {admin} (username,password,superadmin,active,email_other) VALUES ('admin@example.test','{{PLAIN}}admin123',1,1,'admin@example.test')" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {domain_admins} (username,domain,active) VALUES ('admin@example.test','ALL',1)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {admin} (username,password,superadmin,active,email_other) VALUES ('domain-admin@example.test','{{PLAIN}}domain123',0,1,'domain-admin@example.test')" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {domain_admins} (username,domain,active) VALUES ('domain-admin@example.test','example.test',1)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {mailbox} (username,password,name,maildir,quota,local_part,domain,active,smtp_active,email_other) VALUES ('user@example.test','{{PLAIN}}user123','Development User','example.test/user/',104857600,'user','example.test',1,1,'user@example.test')" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {alias} (address,goto,domain,description,active) VALUES ('user@example.test','user@example.test','example.test','Mailbox self alias',1)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {alias} (address,goto,domain,description,active) VALUES ('support@example.test','user@example.test','example.test','Development alias',1)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {alias} (address,goto,domain,description,active) VALUES ('old-forward@example.test','user@example.test','example.test','Inactive alias for bulk-delete testing',0)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {alias_domain} (alias_domain,target_domain,description,active) VALUES ('alias.example.test','example.test','Development alias domain',1)" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {admin_preferences} (username,pref_key,pref_value) VALUES ('admin@example.test','alias_goto_default','user@example.test')" - )) - .execute(&self.pool) - .await?; - sqlx::query(&format!( - "INSERT OR IGNORE INTO {quota2} (username,bytes,messages) VALUES ('user@example.test',12582912,42)" - )) - .execute(&self.pool) - .await?; - tracing::info!("development PostfixAdmin mock database is ready"); Ok(()) } @@ -678,6 +464,10 @@ impl Db { })) } + /// Domains a (non-global) administrator may manage. Mirrors PostfixAdmin's + /// `list_domains_for_admin()`: only assigned domains that are active and + /// not backup MX. Global administrators get the raw assignment list (which + /// contains `ALL`). pub async fn allowed_domains(&self, username: &str) -> AppResult> { let table = self.table(Table::DomainAdmins); let sql = self.q(&format!( @@ -688,6 +478,23 @@ impl Db { .bind(true) .fetch_all(&self.pool) .await?; + let assigned: Vec = rows.iter().map(|r| row_string(r, "domain")).collect(); + if assigned.iter().any(|d| d == "ALL") { + return Ok(assigned); + } + let domain = self.table(Table::Domain); + let sql = self.q(&format!( + "SELECT d.domain FROM {domain} d INNER JOIN {table} da ON da.domain = d.domain \ + WHERE da.username = ? AND da.active = ? AND d.active = ? AND d.backupmx = ? \ + AND d.domain <> 'ALL' ORDER BY d.domain" + )); + let rows = sqlx::query(&sql) + .bind(username) + .bind(true) + .bind(true) + .bind(false) + .fetch_all(&self.pool) + .await?; Ok(rows.iter().map(|r| row_string(r, "domain")).collect()) } @@ -760,6 +567,21 @@ impl Db { } } +/// Build a portable `LIKE` pattern (escape character `!`) for a free-text +/// search term. Use together with `ESCAPE '!'`. +pub fn like_pattern(term: &str) -> String { + let mut out = String::with_capacity(term.len() + 2); + out.push('%'); + for ch in term.trim().to_lowercase().chars() { + if matches!(ch, '%' | '_' | '!') { + out.push('!'); + } + out.push(ch); + } + out.push('%'); + out +} + pub fn row_string(row: &AnyRow, name: &str) -> String { if let Ok(value) = row.try_get::(name) { return value; diff --git a/src/limits.rs b/src/limits.rs new file mode 100644 index 0000000..c655f94 --- /dev/null +++ b/src/limits.rs @@ -0,0 +1,284 @@ +//! Domain limits and quota rules, ported from PostfixAdmin's +//! `get_domain_properties()`, `MailboxHandler::check_quota()`, +//! `MailboxHandler::allowed_quota()` and the `create_allowed()` checks of the +//! mailbox and alias handlers. +//! +//! Limit semantics (identical to PostfixAdmin): +//! * `0` – unlimited +//! * `-1` – disabled (nothing may be created / quota disabled) +//! * `n` – at most `n` + +use crate::{ + db::{Table, row_i64}, + error::{AppError, AppResult}, + state::AppState, +}; + +#[derive(Debug, Clone)] +pub struct DomainProps { + pub aliases: i64, + pub mailboxes: i64, + /// Maximum mailbox quota in MB. + pub maxquota: i64, + /// Domain quota in MB. + pub quota: i64, + /// Aliases that do not belong to a mailbox (PostfixAdmin's `alias_count`). + pub alias_count: i64, + pub mailbox_count: i64, + /// Sum of all mailbox quotas of the domain, in MB. + pub total_quota: i64, +} + +impl DomainProps { + pub async fn load(state: &AppState, domain: &str) -> AppResult> { + let d = state.db.table(Table::Domain); + let a = state.db.table(Table::Alias); + let m = state.db.table(Table::Mailbox); + let quota_sum = state.db.cast_i64(&format!( + "(SELECT COALESCE(SUM(CASE WHEN z.quota > 0 THEN z.quota ELSE 0 END),0) FROM {m} z WHERE z.domain=d.domain)" + )); + let sql = state.db.q(&format!( + "SELECT d.aliases,d.mailboxes,d.maxquota,d.quota, \ + (SELECT COUNT(*) FROM {a} x WHERE x.domain=d.domain) AS all_aliases, \ + (SELECT COUNT(*) FROM {m} y WHERE y.domain=d.domain) AS mailbox_count, \ + {quota_sum} AS quota_bytes \ + FROM {d} d WHERE d.domain=?" + )); + let Some(row) = sqlx::query(&sql) + .bind(domain) + .fetch_optional(state.db.pool()) + .await? + else { + return Ok(None); + }; + let mailbox_count = row_i64(&row, "mailbox_count"); + let multiplier = state.config.defaults.quota_multiplier.max(1); + Ok(Some(Self { + aliases: row_i64(&row, "aliases"), + mailboxes: row_i64(&row, "mailboxes"), + maxquota: row_i64(&row, "maxquota"), + quota: row_i64(&row, "quota"), + alias_count: (row_i64(&row, "all_aliases") - mailbox_count).max(0), + mailbox_count, + total_quota: row_i64(&row, "quota_bytes") / multiplier, + })) + } + + pub async fn require(state: &AppState, domain: &str) -> AppResult { + Self::load(state, domain) + .await? + .ok_or_else(|| AppError::not_found(format!("Domain does not exist: {domain}"))) + } + + /// `MailboxHandler::create_allowed()` + pub fn ensure_mailbox_allowed(&self) -> AppResult<()> { + match limit_state(self.mailboxes, self.mailbox_count) { + LimitState::Ok => Ok(()), + LimitState::Disabled => Err(AppError::forbidden( + "Mailboxes are disabled for this domain", + )), + LimitState::Reached => Err(AppError::conflict(format!( + "The mailbox limit of this domain has been reached ({})", + self.mailboxes + ))), + } + } + + /// `AliasHandler::create_allowed()` (mailbox aliases are always allowed and + /// never counted). + pub fn ensure_alias_allowed(&self) -> AppResult<()> { + match limit_state(self.aliases, self.alias_count) { + LimitState::Ok => Ok(()), + LimitState::Disabled => { + Err(AppError::forbidden("Aliases are disabled for this domain")) + } + LimitState::Reached => Err(AppError::conflict(format!( + "The alias limit of this domain has been reached ({})", + self.aliases + ))), + } + } +} + +enum LimitState { + Ok, + Disabled, + Reached, +} + +fn limit_state(limit: i64, count: i64) -> LimitState { + if limit == 0 { + LimitState::Ok + } else if limit < 0 { + LimitState::Disabled + } else if count >= limit { + LimitState::Reached + } else { + LimitState::Ok + } +} + +/// Validate a requested mailbox quota (in MB) – `MailboxHandler::check_quota()`. +/// +/// `others_total` is the sum (MB) of the quotas of all *other* mailboxes of +/// the domain. +pub fn check_quota( + quota_enabled: bool, + domain_quota_enabled: bool, + props: &DomainProps, + quota: i64, + others_total: i64, +) -> AppResult<()> { + if !quota_enabled { + return Ok(()); + } + if quota < -1 { + return Err(AppError::bad_request("Quota must be -1 or greater")); + } + let limit = props.maxquota; + if limit < 0 && quota < 0 { + return Ok(()); + } + if quota < 0 { + return Err(AppError::bad_request( + "Quota -1 (disabled) is only allowed when the domain maximum quota is disabled", + )); + } + if limit > 0 && quota == 0 { + return Err(AppError::bad_request(format!( + "Unlimited quota is not allowed – the maximum mailbox quota of this domain is {limit} MB" + ))); + } + if limit != 0 && quota > limit { + return Err(AppError::bad_request(format!( + "The quota exceeds the maximum mailbox quota of this domain ({limit} MB)" + ))); + } + if !domain_quota_enabled || props.quota <= 0 { + return Ok(()); + } + if quota == 0 { + return Err(AppError::bad_request( + "Unlimited quota is not allowed because the domain has a total quota", + )); + } + if quota + others_total > props.quota { + return Err(AppError::bad_request(format!( + "The domain quota would be exceeded ({} MB of {} MB already allocated)", + others_total, props.quota + ))); + } + Ok(()) +} + +/// Maximum quota (MB) a mailbox may get – `MailboxHandler::allowed_quota()`. +/// `0` means unlimited, a negative value means quota is disabled. +pub fn allowed_quota( + quota_enabled: bool, + domain_quota_enabled: bool, + props: &DomainProps, + current_mailbox_quota_mb: i64, +) -> i64 { + if !quota_enabled { + return 0; + } + let mut max = props.maxquota; + if domain_quota_enabled && props.quota != 0 { + let free = props.quota - props.total_quota + current_mailbox_quota_mb.max(0); + if free < max || max == 0 { + max = free; + } + } + max +} + +/// Sum (MB) of all mailbox quotas of a domain except `exclude`. +pub async fn others_total_quota( + state: &AppState, + domain: &str, + exclude: Option<&str>, +) -> AppResult { + let m = state.db.table(Table::Mailbox); + let total = state + .db + .cast_i64("COALESCE(SUM(CASE WHEN quota > 0 THEN quota ELSE 0 END),0)"); + let mut sql = format!("SELECT {total} AS total FROM {m} WHERE domain=?"); + if exclude.is_some() { + sql.push_str(" AND username<>?"); + } + let sql = state.db.q(&sql); + let mut query = sqlx::query(&sql).bind(domain); + if let Some(exclude) = exclude { + query = query.bind(exclude); + } + let row = query.fetch_one(state.db.pool()).await?; + Ok(row_i64(&row, "total") / state.config.defaults.quota_multiplier.max(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn props(maxquota: i64, quota: i64) -> DomainProps { + DomainProps { + aliases: 10, + mailboxes: 10, + maxquota, + quota, + alias_count: 0, + mailbox_count: 0, + total_quota: 0, + } + } + + #[test] + fn quota_rules_match_postfixadmin() { + // maxquota limited: unlimited mailbox not allowed, above max not allowed + let p = props(100, 0); + assert!(check_quota(true, true, &p, 0, 0).is_err()); + assert!(check_quota(true, true, &p, 101, 0).is_err()); + assert!(check_quota(true, true, &p, 100, 0).is_ok()); + // unlimited maxquota, unlimited domain quota + let p = props(0, 0); + assert!(check_quota(true, true, &p, 0, 0).is_ok()); + assert!(check_quota(true, true, &p, 5000, 0).is_ok()); + // domain quota + let p = props(0, 1000); + assert!(check_quota(true, true, &p, 0, 0).is_err()); + assert!(check_quota(true, true, &p, 600, 500).is_err()); + assert!(check_quota(true, true, &p, 500, 500).is_ok()); + assert!(check_quota(true, false, &p, 600, 500).is_ok()); + // disabled quota + let p = props(-1, 0); + assert!(check_quota(true, true, &p, -1, 0).is_ok()); + assert!(check_quota(false, true, &props(1, 1), 99999, 0).is_ok()); + } + + #[test] + fn allowed_quota_rules() { + let mut p = props(100, 1000); + p.total_quota = 950; + assert_eq!(allowed_quota(true, true, &p, 0), 50); + assert_eq!(allowed_quota(true, true, &p, 30), 80); + let mut p = props(0, 1000); + p.total_quota = 200; + assert_eq!(allowed_quota(true, true, &p, 0), 800); + assert_eq!(allowed_quota(true, false, &p, 0), 0); + assert_eq!(allowed_quota(false, true, &p, 0), 0); + } + + #[test] + fn limits() { + let mut p = props(0, 0); + p.mailboxes = 0; + assert!(p.ensure_mailbox_allowed().is_ok()); + p.mailboxes = -1; + assert!(p.ensure_mailbox_allowed().is_err()); + p.mailboxes = 2; + p.mailbox_count = 2; + assert!(p.ensure_mailbox_allowed().is_err()); + p.aliases = 3; + p.alias_count = 2; + assert!(p.ensure_alias_allowed().is_ok()); + } +} diff --git a/src/mailer.rs b/src/mailer.rs index 4db90fe..e012aa0 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -18,7 +18,8 @@ fn transport(config: &AppConfig) -> AppResult .map_err(|e| AppError::Mail(e.to_string()))?, _ => AsyncSmtpTransport::::builder_dangerous(&smtp.host), } - .port(smtp.port); + .port(smtp.port) + .timeout(Some(std::time::Duration::from_secs(15))); if !smtp.client_name.trim().is_empty() { builder = builder.hello_name(ClientId::Domain(smtp.client_name.trim().to_string())); diff --git a/src/main.rs b/src/main.rs index 3a71aeb..95c99c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,15 +3,72 @@ mod config; mod db; mod error; mod hooks; +mod limits; mod mailer; mod models; +mod net; mod passwords; mod routes; +mod schema; mod sieve; mod state; mod ui; mod validation; +async fn create_superadmin( + config: &AppConfig, + db: &Db, + username: &str, + password: &str, +) -> AppResult<()> { + use crate::db::Table; + let hash = passwords::hash(config, password, username).await?; + let admin = db.table(Table::Admin); + let domain_admins = db.table(Table::DomainAdmins); + let exists = sqlx::query(&db.q(&format!("SELECT 1 FROM {admin} WHERE username=?"))) + .bind(username) + .fetch_optional(db.pool()) + .await? + .is_some(); + let sql = if exists { + db.q(&format!("UPDATE {admin} SET password=?,superadmin=?,active=?,modified=CURRENT_TIMESTAMP WHERE username=?")) + } else { + db.q(&format!("INSERT INTO {admin} (password,superadmin,active,username,created,modified) VALUES (?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)")) + }; + sqlx::query(&sql) + .bind(hash) + .bind(true) + .bind(true) + .bind(username) + .execute(db.pool()) + .await?; + let has_all = sqlx::query(&db.q(&format!( + "SELECT 1 FROM {domain_admins} WHERE username=? AND domain='ALL'" + ))) + .bind(username) + .fetch_optional(db.pool()) + .await? + .is_some(); + if !has_all { + sqlx::query(&db.q(&format!( + "INSERT INTO {domain_admins} (username,domain,created,active) VALUES (?,'ALL',CURRENT_TIMESTAMP,?)" + ))) + .bind(username) + .bind(true) + .execute(db.pool()) + .await?; + } + db.log( + config, + "CLI", + "", + if exists { "edit_admin" } else { "create_admin" }, + username, + ) + .await; + Ok(()) +} + use std::{net::SocketAddr, sync::Arc, time::Duration}; use axum::{ @@ -39,11 +96,38 @@ async fn main() -> Result<(), Box> { Ok(()) } +const HELP: &str = "postfixadmin-rs - PostfixAdmin compatible mail administration panel + +Usage: postfixadmin-rs [COMMAND] + +Without a command the web server is started. + +Commands: + --check-config validate configuration, database connection and schema + --init-schema [--dry-run] create/upgrade the PostfixAdmin database schema + (replacement for setup.php / upgrade.php) + --integrity-check report orphaned rows (read-only) + --refresh-dns re-check the DNS status of all domains (cron job, + like PostfixAdmin's scripts/domain-dns-check.php) + --create-superadmin EMAIL create (or reset) a global administrator; the password + is read from POSTFIXADMIN_RS_ADMIN_PASSWORD or stdin + -V, --version print version + -h, --help print this help + +Configuration: POSTFIXADMIN_RS_CONFIG=/etc/postfixadmin-rs/config.toml, +overrides via POSTFIXADMIN_RS__SECTION__KEY environment variables."; + async fn run() -> AppResult<()> { - if std::env::args().any(|arg| arg == "--version" || arg == "-V") { + let args: Vec = std::env::args().skip(1).collect(); + let has = |flag: &str| args.iter().any(|arg| arg == flag); + if has("--version") || has("-V") { println!("postfixadmin-rs {}", env!("CARGO_PKG_VERSION")); return Ok(()); } + if has("--help") || has("-h") { + println!("{HELP}"); + return Ok(()); + } tracing_subscriber::fmt() .with_env_filter( @@ -54,29 +138,156 @@ async fn run() -> AppResult<()> { let config = Arc::new(AppConfig::load()?); let db = Db::connect(&config).await?; + db.ping().await?; + + if has("--init-schema") { + let dry_run = has("--dry-run"); + if config.database.read_only && !dry_run { + return Err(AppError::config( + "database.read_only is set; refusing to change the schema", + )); + } + let report = schema::upgrade(&db, dry_run).await?; + let verb = if dry_run { "would create" } else { "created" }; + for table in &report.created_tables { + println!("{verb} table {table}"); + } + for column in &report.added_columns { + println!( + "{} column {column}", + if dry_run { "would add" } else { "added" } + ); + } + for index in &report.created_indexes { + println!("{verb} index {index}"); + } + for warning in &report.warnings { + println!("warning: {warning}"); + } + if !report.changed() { + println!("schema is up to date"); + } + if let Some(version) = report.version { + println!("PostfixAdmin schema version: {version}"); + } + return Ok(()); + } + + if let Some(position) = args.iter().position(|arg| arg == "--create-superadmin") { + if config.database.read_only { + return Err(AppError::config("database.read_only is set")); + } + let username = args + .get(position + 1) + .ok_or_else(|| AppError::config("--create-superadmin needs an e-mail address"))?; + let username = validation::email(username)?.to_ascii_lowercase(); + let password = match std::env::var("POSTFIXADMIN_RS_ADMIN_PASSWORD") { + Ok(value) if !value.is_empty() => value, + _ => { + eprintln!("Password for {username}: "); + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + line.trim_end_matches(['\r', '\n']).to_string() + } + }; + create_superadmin(&config, &db, &username, &password).await?; + println!("global administrator {username} is ready"); + return Ok(()); + } + + if has("--refresh-dns") { + let mode = config.features.domain_dns_status_check; + if mode == 0 { + return Err(AppError::config( + "features.domain_dns_status_check is 0 (disabled)", + )); + } + let table = db.table(crate::db::Table::Domain); + let rows = sqlx::query(&format!( + "SELECT domain FROM {table} WHERE domain <> 'ALL' ORDER BY domain" + )) + .fetch_all(db.pool()) + .await?; + let update = db.q(&format!( + "UPDATE {table} SET dns_active=?, dns_checked=CURRENT_TIMESTAMP WHERE domain=?" + )); + let mut failed = 0usize; + for row in rows { + let domain = crate::db::row_string(&row, "domain"); + let active = routes::domains::check_dns(&domain, mode).await; + if !config.database.read_only { + sqlx::query(&update) + .bind(active) + .bind(&domain) + .execute(db.pool()) + .await?; + } + if !active { + failed += 1; + } + println!("{domain}: {}", if active { "ok" } else { "FAILED" }); + } + if failed > 0 { + std::process::exit(2); + } + return Ok(()); + } + + if has("--integrity-check") { + let issues = schema::integrity_check(&db).await?; + if issues.is_empty() { + println!("no orphaned rows found"); + } + for (relation, count) in issues { + println!("{count} orphaned row(s): {relation}"); + } + return Ok(()); + } + if config.database.dev_mock { db.initialize_dev_mock(&config).await?; } - db.ping().await?; let warnings = db.check_schema(&config).await?; + let missing_core = warnings + .iter() + .any(|w| w.starts_with("missing required table")); for warning in &warnings { tracing::warn!(%warning, "schema compatibility warning"); } - if std::env::args().any(|arg| arg == "--check-config") { + if has("--check-config") { println!("configuration: ok"); - println!("database: ok"); + println!("database: ok ({})", config.database.backend); + match schema::current_version(&db).await { + Ok(Some(version)) => println!("PostfixAdmin schema version: {version}"), + _ => println!("PostfixAdmin schema version: unknown"), + } if config.database.dev_mock { println!("database mode: development mock"); } if config.database.read_only { println!("database access: read-only"); } - for warning in warnings { + for warning in &warnings { println!("warning: {warning}"); } + if missing_core { + println!("hint: run `postfixadmin-rs --init-schema` to create the database schema"); + } return Ok(()); } + if missing_core { + return Err(AppError::config( + "the PostfixAdmin database schema is missing; run `postfixadmin-rs --init-schema` first", + )); + } + if config.environment == "production" + && let Ok(issues) = schema::integrity_check(&db).await + { + for (relation, count) in issues { + tracing::warn!(%relation, count, "orphaned rows in database"); + } + } let state = Arc::new(AppState { auth: AuthStore::new(&config), @@ -84,6 +295,17 @@ async fn run() -> AppResult<()> { db, }); + { + let auth = state.auth.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(300)); + loop { + tick.tick().await; + auth.purge_expired(); + } + }); + } + let api = routes::router(&state); let inner = Router::>::new() .nest("/api", api) @@ -114,6 +336,14 @@ async fn run() -> AppResult<()> { HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), )) .layer(TraceLayer::new_for_http()); + let inner = if config.security.hsts { + inner.layer(SetResponseHeaderLayer::overriding( + HeaderName::from_static("strict-transport-security"), + HeaderValue::from_static("max-age=31536000; includeSubDomains"), + )) + } else { + inner + }; let base = config.normalized_base_path(); let app: Router = if base == "/" { diff --git a/src/models.rs b/src/models.rs index 1e2d248..402f5a8 100644 --- a/src/models.rs +++ b/src/models.rs @@ -48,10 +48,16 @@ pub struct DomainSummary { pub dns_checked: Option, pub alias_count: i64, pub mailbox_count: i64, + /// Sum of the mailbox quotas (MB). + pub quota_allocated_mb: i64, + /// Used storage reported by Dovecot (MB), if quota tracking is enabled. + pub quota_used_mb: Option, + pub can_edit: bool, } #[derive(Debug, Deserialize)] pub struct DomainInput { + #[serde(default)] pub domain: String, #[serde(default)] pub description: String, @@ -69,6 +75,49 @@ pub struct DomainInput { pub default_aliases: bool, } +/// One page of a list result. +#[derive(Debug, Serialize)] +pub struct Page { + pub items: Vec, + pub total: i64, + pub page: usize, + pub page_size: usize, +} + +/// Common list query parameters (`list-virtual.php` style). +#[derive(Debug, Default, Deserialize)] +pub struct ListQuery { + pub domain: Option, + /// Free-text search. + pub q: Option, + /// 1-based page number. + pub page: Option, + /// Page size; `0` returns everything. + pub page_size: Option, + /// Optional status filter (aliases): active | inactive | forward | catchall + pub status: Option, +} + +impl ListQuery { + /// Returns `(limit, offset, page, page_size)`; `limit == None` = unlimited. + pub fn paging(&self, default_size: usize) -> (Option, i64, usize, usize) { + let size = self.page_size.unwrap_or(default_size).min(500); + let page = self.page.unwrap_or(1).max(1); + if size == 0 { + return (None, 0, 1, 0); + } + (Some(size as i64), ((page - 1) * size) as i64, page, size) + } + + pub fn search(&self) -> Option { + self.q + .as_deref() + .map(str::trim) + .filter(|q| !q.is_empty()) + .map(|q| q.chars().take(128).collect()) + } +} + #[derive(Debug, Serialize)] pub struct MailboxSummary { pub username: String, @@ -86,24 +135,31 @@ pub struct MailboxSummary { pub vacation_active: bool, pub created: Option, pub modified: Option, + /// Quota in MB (`-1` = disabled, `0` = unlimited). + pub quota_mb: i64, + /// Forwarding targets of the mailbox alias (without the mailbox itself and + /// without the internal vacation address). + pub forward_to: Vec, + /// Mail is delivered to the mailbox (alias contains the mailbox itself). + pub deliver_to_mailbox: bool, + pub totp_enabled: bool, + pub password_expired: bool, } #[derive(Debug, Deserialize)] pub struct MailboxInput { pub username: Option, pub local_part: Option, + #[serde(default)] pub domain: String, - #[serde(default)] - pub name: String, + pub name: Option, pub password: Option, + /// Quota in MB. pub quota: Option, - #[serde(default = "default_true")] - pub active: bool, + pub active: Option, pub smtp_active: Option, - #[serde(default)] - pub phone: String, - #[serde(default)] - pub email_other: String, + pub phone: Option, + pub email_other: Option, #[serde(default)] pub send_welcome: bool, } @@ -118,20 +174,21 @@ pub struct AliasSummary { pub is_mailbox: bool, pub technical: bool, pub status: Vec, + pub catch_all: bool, + pub can_edit: bool, + pub can_delete: bool, + pub modified: Option, } #[derive(Debug, Deserialize)] pub struct AliasInput { pub address: Option, pub local_part: Option, + #[serde(default)] pub domain: String, - pub goto: Vec, - #[serde(default)] - pub description: String, - #[serde(default = "default_true")] - pub active: bool, - #[serde(default)] - pub store_and_forward: bool, + pub goto: Option>, + pub description: Option, + pub active: Option, #[serde(default)] pub save_goto_default: bool, } @@ -152,6 +209,7 @@ pub struct AliasDomainSummary { pub target_domain: String, pub description: String, pub active: bool, + pub modified: Option, } #[derive(Debug, Serialize)] diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..c652150 --- /dev/null +++ b/src/net.rs @@ -0,0 +1,161 @@ +//! Client address resolution behind trusted reverse proxies. + +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, +}; + +use axum::{ + extract::{ConnectInfo, FromRequestParts}, + http::{HeaderMap, request::Parts}, +}; + +use crate::{error::AppError, state::AppState}; + +/// Parse `a.b.c.d`, `a.b.c.d/nn`, `::1` or `fd00::/8`. +pub fn parse_cidr(value: &str) -> Option<(IpAddr, u8)> { + let value = value.trim(); + let (addr, bits) = match value.split_once('/') { + Some((addr, bits)) => (addr, Some(bits.parse::().ok()?)), + None => (value, None), + }; + let ip: IpAddr = addr.parse().ok()?; + let max = if ip.is_ipv4() { 32 } else { 128 }; + let bits = bits.unwrap_or(max); + if bits > max { + return None; + } + Some((ip, bits)) +} + +fn normalize(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + other => other, + } +} + +pub fn cidr_contains(network: (IpAddr, u8), ip: IpAddr) -> bool { + let ip = normalize(ip); + match (normalize(network.0), ip) { + (IpAddr::V4(net), IpAddr::V4(ip)) => { + let bits = u32::from(network.1.min(32)); + if bits == 0 { + return true; + } + let mask = u32::MAX << (32 - bits); + (u32::from(net) & mask) == (u32::from(ip) & mask) + } + (IpAddr::V6(net), IpAddr::V6(ip)) => { + let bits = u32::from(network.1.min(128)); + if bits == 0 { + return true; + } + let mask = u128::MAX << (128 - bits); + (u128::from(net) & mask) == (u128::from(ip) & mask) + } + _ => false, + } +} + +fn is_trusted(proxies: &[String], ip: IpAddr) -> bool { + proxies + .iter() + .filter_map(|p| parse_cidr(p)) + .any(|net| cidr_contains(net, ip)) +} + +/// Resolve the real client address. Forwarding headers are honoured only when +/// the TCP peer is a configured trusted proxy; the right-most untrusted hop of +/// `X-Forwarded-For` wins, so a client cannot spoof its address by prepending +/// values. +pub fn resolve_client_ip(proxies: &[String], peer: IpAddr, headers: &HeaderMap) -> IpAddr { + let peer = normalize(peer); + if proxies.is_empty() || !is_trusted(proxies, peer) { + return peer; + } + if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { + let hops: Vec = xff + .split(',') + .filter_map(|part| part.trim().parse::().ok()) + .map(normalize) + .collect(); + for hop in hops.iter().rev() { + if !is_trusted(proxies, *hop) { + return *hop; + } + } + if let Some(first) = hops.first() { + return *first; + } + } + if let Some(real) = headers + .get("x-real-ip") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + { + return normalize(real); + } + peer +} + +/// Extractor yielding the effective client IP. +pub struct ClientIp(pub IpAddr); + +impl FromRequestParts> for ClientIp { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let peer = parts + .extensions + .get::>() + .map(|ConnectInfo(addr)| addr.ip()) + .unwrap_or(IpAddr::from([127, 0, 0, 1])); + Ok(ClientIp(resolve_client_ip( + &state.config.security.trusted_proxies, + peer, + &parts.headers, + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cidr_matching() { + let net = parse_cidr("10.0.0.0/8").unwrap(); + assert!(cidr_contains(net, "10.1.2.3".parse().unwrap())); + assert!(!cidr_contains(net, "11.1.2.3".parse().unwrap())); + let v6 = parse_cidr("fd00::/8").unwrap(); + assert!(cidr_contains(v6, "fd12::1".parse().unwrap())); + assert!(parse_cidr("1.2.3.4/33").is_none()); + assert!(cidr_contains( + parse_cidr("127.0.0.1").unwrap(), + "::ffff:127.0.0.1".parse().unwrap() + )); + } + + #[test] + fn forwarded_for_only_from_trusted_proxy() { + let proxies = vec!["127.0.0.1".to_string()]; + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "6.6.6.6, 203.0.113.9".parse().unwrap()); + assert_eq!( + resolve_client_ip(&proxies, "127.0.0.1".parse().unwrap(), &headers), + "203.0.113.9".parse::().unwrap() + ); + assert_eq!( + resolve_client_ip(&proxies, "198.51.100.1".parse().unwrap(), &headers), + "198.51.100.1".parse::().unwrap() + ); + assert_eq!( + resolve_client_ip(&[], "127.0.0.1".parse().unwrap(), &headers), + "127.0.0.1".parse::().unwrap() + ); + } +} diff --git a/src/passwords.rs b/src/passwords.rs index eded814..7aecdc7 100644 --- a/src/passwords.rs +++ b/src/passwords.rs @@ -99,6 +99,34 @@ pub fn validate_new_password(config: &AppConfig, password: &str) -> AppResult<() Ok(()) } +/// Generate a random password that satisfies the configured policy +/// (PostfixAdmin `generate_password()`). +pub fn generate_password(config: &AppConfig) -> String { + const LETTERS: &[u8] = b"abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; + const DIGITS: &[u8] = b"23456789"; + let length = config + .password + .min_length + .max(config.password.min_letters + config.password.min_digits) + .max(12); + let mut rng = rand::rng(); + let mut pick = |set: &[u8]| set[(rng.next_u32() as usize) % set.len()] as char; + let mut chars: Vec = Vec::with_capacity(length); + for _ in 0..config.password.min_digits.max(2) { + chars.push(pick(DIGITS)); + } + while chars.len() < length { + chars.push(pick(LETTERS)); + } + // Fisher-Yates shuffle so the digits are not always in front. + let mut rng = rand::rng(); + for i in (1..chars.len()).rev() { + let j = (rng.next_u32() as usize) % (i + 1); + chars.swap(i, j); + } + chars.into_iter().collect() +} + /// Verify an existing PostfixAdmin hash. The format stored in the database wins /// over the currently configured format whenever it identifies itself. pub async fn verify(config: &AppConfig, password: &str, stored: &str, username: &str) -> bool { diff --git a/src/routes/admins.rs b/src/routes/admins.rs index 965a698..2030187 100644 --- a/src/routes/admins.rs +++ b/src/routes/admins.rs @@ -20,7 +20,7 @@ pub async fn list( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; session.require_global()?; let admin = state.db.table(Table::Admin); let da = state.db.table(Table::DomainAdmins); @@ -58,10 +58,12 @@ pub async fn list( .await? .iter() .map(|r| row_string(r, "domain")) - .collect(); + .collect::>(); + let has_all = domains.iter().any(|d| d == "ALL"); + let domains = domains.into_iter().filter(|d| d != "ALL").collect(); result.push(AdminSummary { username, - superadmin: row_bool(&row, "superadmin"), + superadmin: row_bool(&row, "superadmin") || has_all, active: row_bool(&row, "active"), phone: row_string(&row, "phone"), email_other: row_string(&row, "email_other"), @@ -78,7 +80,7 @@ pub async fn create( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; input.username = validation::resolvable_email(&state.config, &input.username).await?; let password = input @@ -158,7 +160,7 @@ pub async fn update( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; let username = validation::email(&path_user)?; input.username = validation::email(&input.username)?; @@ -168,6 +170,11 @@ pub async fn update( )); } validate_domains(&state, &input.domains, input.superadmin).await?; + if username.eq_ignore_ascii_case(&session.username) && (!input.active || !input.superadmin) { + return Err(AppError::conflict( + "You cannot deactivate yourself or remove your own global administrator role", + )); + } let admin = state.db.table(Table::Admin); let exists = state .db @@ -182,7 +189,8 @@ pub async fn update( } let has_phone = state.db.has_column(Table::Admin, "phone").await?; let has_other = state.db.has_column(Table::Admin, "email_other").await?; - let new_hash = if let Some(password) = input.password.as_deref() { + let password_changed = input.password.as_deref().is_some_and(|p| !p.is_empty()); + let new_hash = if let Some(password) = input.password.as_deref().filter(|p| !p.is_empty()) { Some(passwords::hash(&state.config, password, &username).await?) } else { None @@ -217,6 +225,11 @@ pub async fn update( } q.bind(&username).execute(state.db.pool()).await?; sync_domains(&state, &username, &input.domains, input.superadmin).await?; + if password_changed || !input.active { + state + .auth + .revoke_user(crate::models::AccountKind::Admin, &username, None); + } state .db .log( @@ -236,7 +249,7 @@ pub async fn remove( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; let username = validation::email(&path_user)?; if username.eq_ignore_ascii_case(&session.username) { @@ -261,6 +274,17 @@ pub async fn remove( { return Err(AppError::not_found("Administrator not found")); } + if state.db.has_table(Table::AdminPreferences).await { + let prefs = state.db.table(Table::AdminPreferences); + let sql = state.db.q(&format!("DELETE FROM {prefs} WHERE username=?")); + sqlx::query(&sql) + .bind(&username) + .execute(state.db.pool()) + .await?; + } + state + .auth + .revoke_user(crate::models::AccountKind::Admin, &username, None); state .db .log( @@ -274,16 +298,15 @@ pub async fn remove( Ok(Json(serde_json::json!({"ok":true}))) } -async fn validate_domains(state: &AppState, domains: &[String], superadmin: bool) -> AppResult<()> { - if superadmin { - return Ok(()); - } +async fn validate_domains( + state: &AppState, + domains: &[String], + _superadmin: bool, +) -> AppResult<()> { let table = state.db.table(Table::Domain); for raw in domains { - if raw == "ALL" { - return Err(AppError::bad_request( - "ALL can only be assigned to a global administrator", - )); + if raw.eq_ignore_ascii_case("ALL") { + continue; } let domain = validation::domain(raw)?; let sql = state @@ -309,15 +332,18 @@ async fn sync_domains( ) -> AppResult<()> { let table = state.db.table(Table::DomainAdmins); let sql = state.db.q(&format!("DELETE FROM {table} WHERE username=?")); - sqlx::query(&sql) - .bind(username) - .execute(state.db.pool()) - .await?; - let assigned: Vec = if superadmin { - vec!["ALL".into()] - } else { - domains.iter().map(|d| d.to_ascii_lowercase()).collect() - }; + let mut tx = state.db.pool().begin().await?; + sqlx::query(&sql).bind(username).execute(&mut *tx).await?; + let mut assigned: Vec = domains + .iter() + .map(|d| d.trim().to_ascii_lowercase()) + .filter(|d| !d.is_empty() && d != "all") + .collect(); + assigned.sort(); + assigned.dedup(); + if superadmin { + assigned.push("ALL".into()); + } for domain in assigned { let sql = state.db.q(&format!( "INSERT INTO {table} (username,domain,created,active) VALUES (?,?,CURRENT_TIMESTAMP,?)" @@ -326,8 +352,60 @@ async fn sync_domains( .bind(username) .bind(domain) .bind(true) - .execute(state.db.pool()) + .execute(&mut *tx) .await?; } + tx.commit().await?; Ok(()) } + +#[derive(serde::Deserialize)] +pub struct ActiveInput { + pub active: bool, +} + +/// `editactive.php?table=admin` +pub async fn set_active( + State(state): State>, + Path(path_user): Path, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_global()?; + let username = validation::email(&path_user)?; + if username.eq_ignore_ascii_case(&session.username) && !input.active { + return Err(AppError::conflict("You cannot deactivate yourself")); + } + let admin = state.db.table(Table::Admin); + let sql = state.db.q(&format!( + "UPDATE {admin} SET active=?,modified=CURRENT_TIMESTAMP WHERE username=?" + )); + if sqlx::query(&sql) + .bind(input.active) + .bind(&username) + .execute(state.db.pool()) + .await? + .rows_affected() + == 0 + { + return Err(AppError::not_found("Administrator not found")); + } + if !input.active { + state + .auth + .revoke_user(crate::models::AccountKind::Admin, &username, None); + } + state + .db + .log( + &state.config, + &session.username, + "", + "edit_admin", + &username, + ) + .await; + Ok(Json(serde_json::json!({"ok":true}))) +} diff --git a/src/routes/aliases.rs b/src/routes/aliases.rs index 07b90cb..d68ed24 100644 --- a/src/routes/aliases.rs +++ b/src/routes/aliases.rs @@ -1,118 +1,419 @@ -use std::sync::Arc; +//! Aliases and alias domains – port of PostfixAdmin's `AliasHandler` and +//! `AliasdomainHandler`. + +use std::{collections::HashSet, sync::Arc}; use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::HeaderMap, }; use axum_extra::extract::cookie::CookieJar; +use once_cell::sync::Lazy; +use regex::Regex; use serde::Deserialize; use crate::{ - db::{Table, row_bool, row_i64, row_string}, + auth::Session, + db::{Table, like_pattern, row_bool, row_i64, row_opt_string, row_string}, error::{AppError, AppResult}, - models::{AccountKind, AliasDomainInput, AliasDomainSummary, AliasInput, AliasSummary}, + limits::DomainProps, + models::{ + AccountKind, AliasDomainInput, AliasDomainSummary, AliasInput, AliasSummary, ListQuery, + Page, + }, routes, state::AppState, validation, }; -#[derive(Deserialize)] -pub struct DomainFilter { - pub domain: Option, +/// PostfixAdmin accepts a bare local system account (e.g. `root`) as target. +static LOCAL_ACCOUNT: Lazy = Lazy::new(|| Regex::new(r"^[a-z0-9]+$").expect("valid regex")); + +// --------------------------------------------------------------------------- +// shared helpers (also used by mailboxes/vacation) +// --------------------------------------------------------------------------- + +pub(crate) fn split_goto(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() } +/// `user@example.com` -> `user#example.com@` +pub(crate) fn vacation_destination(state: &AppState, address: &str) -> String { + format!( + "{}@{}", + address.replace('@', "#"), + state.config.defaults.vacation_domain + ) +} + +/// Who may edit the alias of a mailbox (its forwarding)? +/// * mailbox user: `edit_alias`, own mailbox only +/// * global admin: `alias_control` +/// * domain admin: `alias_control_admin` +pub(crate) fn require_mailbox_alias_control( + state: &AppState, + session: &Session, + username: &str, +) -> AppResult<()> { + let f = &state.config.features; + if session.kind == AccountKind::User { + if !f.edit_alias || !session.username.eq_ignore_ascii_case(username) { + return Err(AppError::forbidden("Editing the forwarding is not allowed")); + } + return Ok(()); + } + let (_, domain) = username + .rsplit_once('@') + .ok_or_else(|| AppError::bad_request("Invalid address"))?; + session.require_domain(domain)?; + let allowed = if session.role.is_global() { + f.alias_control + } else { + f.alias_control_admin + }; + if !allowed { + return Err(AppError::forbidden( + "Editing mailbox aliases is disabled for your role (alias_control / alias_control_admin)", + )); + } + Ok(()) +} + +/// Validate a list of alias targets (`AliasHandler::_validate_goto`). +/// With `reject_self` an alias pointing at itself is an error; otherwise +/// (mailbox forwarding) the own address is dropped silently. +pub(crate) async fn validate_destinations( + state: &AppState, + address: &str, + raw: &[String], + reject_self: bool, +) -> AppResult> { + let internal_vacation = vacation_destination(state, address); + let catch_all_source = address.starts_with('@'); + let mut out: Vec = Vec::new(); + let mut local_domains: Option> = None; + for item in raw.iter().flat_map(|v| v.split([',', '\n', ' '])) { + let item = item.trim(); + if item.is_empty() || item.eq_ignore_ascii_case(&internal_vacation) { + continue; + } + let value = if catch_all_source && item.starts_with('@') { + // domain-wide forward, only allowed for catch-all aliases + let domain = validation::resolvable_domain(&state.config, &item[1..]).await?; + format!("@{domain}") + } else if item.contains('@') { + validation::alias_destination_checked( + &state.config, + item, + state.config.security.allow_unsafe_alias_destinations, + ) + .await? + .to_ascii_lowercase() + } else if LOCAL_ACCOUNT.is_match(item) { + item.to_string() + } else { + validation::alias_destination( + item, + state.config.security.allow_unsafe_alias_destinations, + ) + .map_err(|_| AppError::bad_request(format!("Invalid destination address: {item}")))? + }; + if value.eq_ignore_ascii_case(address) { + if reject_self { + return Err(AppError::bad_request(format!( + "{value}: an alias must not point to itself" + ))); + } + continue; + } + if state.config.features.emailcheck_localaliasonly + && let Some((_, domain)) = value.rsplit_once('@') + { + if local_domains.is_none() { + local_domains = Some(all_domains(state).await?.into_iter().collect()); + } + if !local_domains + .as_ref() + .is_some_and(|set| set.contains(&domain.to_ascii_lowercase())) + { + return Err(AppError::bad_request(format!( + "You may only make aliases to domains hosted on this server. {domain} is a remote domain name." + ))); + } + } + if !out + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&value)) + { + out.push(value); + } + } + Ok(out) +} + +/// Keep the internal vacation target if the mailbox has an active vacation. +pub(crate) async fn append_vacation_if_active( + state: &AppState, + address: &str, + goto: &mut Vec, +) -> AppResult<()> { + if !state.config.features.vacation || !state.db.has_table(Table::Vacation).await { + return Ok(()); + } + let vacation = state.db.table(Table::Vacation); + let sql = state.db.q(&format!( + "SELECT 1 FROM {vacation} WHERE email=? AND active=? LIMIT 1" + )); + let active = sqlx::query(&sql) + .bind(address) + .bind(true) + .fetch_optional(state.db.pool()) + .await? + .is_some(); + let target = vacation_destination(state, address); + if active && !goto.iter().any(|g| g.eq_ignore_ascii_case(&target)) { + goto.push(target); + } + Ok(()) +} + +async fn all_domains(state: &AppState) -> AppResult> { + let domain = state.db.table(Table::Domain); + let rows = sqlx::query(&format!( + "SELECT domain FROM {domain} WHERE domain <> 'ALL'" + )) + .fetch_all(state.db.pool()) + .await?; + Ok(rows + .iter() + .map(|r| row_string(r, "domain").to_ascii_lowercase()) + .collect()) +} + +fn is_special_alias(state: &AppState, local: &str) -> bool { + state + .config + .defaults + .default_aliases + .keys() + .any(|name| name.eq_ignore_ascii_case(local)) +} + +/// Aliases listed in `default_aliases` are read-only for domain admins unless +/// `special_alias_control` is enabled. +fn can_edit_special(state: &AppState, session: &Session, local: &str) -> bool { + session.role.is_global() + || state.config.features.special_alias_control + || !is_special_alias(state, local) +} + +fn protect_special_alias(state: &AppState, session: &Session, local: &str) -> AppResult<()> { + if can_edit_special(state, session, local) { + Ok(()) + } else { + Err(AppError::forbidden( + "Default/special aliases can only be edited by a global administrator", + )) + } +} + +async fn is_mailbox(state: &AppState, address: &str) -> AppResult { + let mailbox = state.db.table(Table::Mailbox); + let sql = state + .db + .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); + Ok(sqlx::query(&sql) + .bind(address) + .fetch_optional(state.db.pool()) + .await? + .is_some()) +} + +// --------------------------------------------------------------------------- +// aliases +// --------------------------------------------------------------------------- + pub async fn list( State(state): State>, jar: CookieJar, - axum::extract::Query(filter): axum::extract::Query, -) -> AppResult>> { - let session = routes::session(&state, &jar)?; - let alias = state.db.table(Table::Alias); - let mailbox = state.db.table(Table::Mailbox); - let has_description = state.db.has_column(Table::Alias, "description").await?; + Query(query): Query, +) -> AppResult>> { + let session = routes::session(&state, &jar).await?; + let (limit, offset, page, page_size) = query.paging(state.config.defaults.page_size); + let empty = || { + Json(Page { + items: Vec::new(), + total: 0, + page, + page_size, + }) + }; + let db = &state.db; + let alias = db.table(Table::Alias); + let mailbox = db.table(Table::Mailbox); + let has_description = db.has_column(Table::Alias, "description").await?; + let has_modified = db.has_column(Table::Alias, "modified").await?; let description = if has_description { "a.description" } else { "'' AS description" }; - let active = state.db.flag("a.active", "active"); - let is_mailbox = state.db.flag( + let modified = if has_modified { + format!("{} AS modified", db.cast_text("a.modified")) + } else { + "NULL AS modified".into() + }; + let active = db.flag("a.active", "active"); + let is_mailbox = db.flag( "CASE WHEN m.username IS NULL THEN 0 ELSE 1 END", "is_mailbox", ); - let mut sql = format!( - "SELECT a.address,a.goto AS goto_value,a.domain,{active},{description},\ - {is_mailbox} \ - FROM {alias} a LEFT JOIN {mailbox} m ON m.username=a.address WHERE 1=1" - ); - let mut binds = Vec::new(); + let mut where_sql = String::from(" WHERE 1=1"); + let mut binds: Vec = Vec::new(); + let mut bool_filter: Option = None; if session.kind == AccountKind::User { if !state.config.features.edit_alias { - return Ok(Json(Vec::new())); + return Ok(empty()); } - sql.push_str(" AND a.address=?"); + where_sql.push_str(" AND a.address=?"); binds.push(session.username.clone()); - } else if let Some(raw) = filter.domain { - let domain = validation::domain(&raw)?; - session.require_domain(&domain)?; - sql.push_str(" AND a.domain=?"); - binds.push(domain); - } else if !session.role.is_global() { - let domains: Vec<_> = session - .allowed_domains - .iter() - .filter(|domain| domain.as_str() != "ALL") - .cloned() - .collect(); - if domains.is_empty() { - return Ok(Json(Vec::new())); + } else { + // Admin alias lists exclude mailbox aliases (PostfixAdmin). + where_sql.push_str(" AND m.username IS NULL"); + let search = query.search(); + if let Some(raw) = query.domain.as_deref().filter(|d| !d.is_empty()) { + let domain = validation::domain(raw)?; + session.require_domain(&domain)?; + where_sql.push_str(" AND a.domain=?"); + binds.push(domain); + } else if !session.role.is_global() { + let domains = routes::scoped_domains(&session); + if domains.is_empty() { + return Ok(empty()); + } + where_sql.push_str(" AND a.domain IN ("); + where_sql.push_str(&vec!["?"; domains.len()].join(",")); + where_sql.push(')'); + binds.extend(domains); + } + if let Some(search) = search { + let pattern = like_pattern(&search); + where_sql.push_str( + " AND (LOWER(a.address) LIKE ? ESCAPE '!' OR LOWER(a.goto) LIKE ? ESCAPE '!')", + ); + binds.push(pattern.clone()); + binds.push(pattern); + } + match query.status.as_deref() { + Some("active") => { + where_sql.push_str(" AND a.active=?"); + bool_filter = Some(true); + } + Some("inactive") => { + where_sql.push_str(" AND a.active=?"); + bool_filter = Some(false); + } + _ => {} } - sql.push_str(" AND a.domain IN ("); - sql.push_str(&vec!["?"; domains.len()].join(",")); - sql.push(')'); - binds.extend(domains); } - sql.push_str(" ORDER BY a.address"); - let sql = state.db.q(&sql); - let mut query = sqlx::query(&sql); - for bind in binds { - query = query.bind(bind); + let from = format!("FROM {alias} a LEFT JOIN {mailbox} m ON m.username=a.address"); + let count_sql = db.q(&format!("SELECT COUNT(*) AS n {from}{where_sql}")); + let mut count_query = sqlx::query(&count_sql); + for b in &binds { + count_query = count_query.bind(b.as_str()); } - let rows = query.fetch_all(state.db.pool()).await?; - let mut result = Vec::with_capacity(rows.len()); + if let Some(flag) = bool_filter { + count_query = count_query.bind(flag); + } + let total = row_i64(&count_query.fetch_one(db.pool()).await?, "n"); + + let mut sql = format!( + "SELECT a.address,a.goto AS goto_value,a.domain,{active},{description},{modified},{is_mailbox} {from}{where_sql} ORDER BY a.domain, a.address" + ); + if limit.is_some() { + sql.push_str(" LIMIT ? OFFSET ?"); + } + let sql = db.q(&sql); + let mut list_query = sqlx::query(&sql); + for b in &binds { + list_query = list_query.bind(b.as_str()); + } + if let Some(flag) = bool_filter { + list_query = list_query.bind(flag); + } + if let Some(limit) = limit { + list_query = list_query.bind(limit).bind(offset); + } + let rows = list_query.fetch_all(db.pool()).await?; + let ctx = StatusContext::load(&state, &session).await?; + let mut items = Vec::with_capacity(rows.len()); for row in &rows { let address = row_string(row, "address"); - let raw_gotos = split_goto(&row_string(row, "goto_value")); + let raw_goto = split_goto(&row_string(row, "goto_value")); + let status = alias_status(&state, &ctx, &address, &raw_goto).await?; let internal_vacation = vacation_destination(&state, &address); - let gotos: Vec = raw_gotos + let goto: Vec = raw_goto .into_iter() .filter(|value| !value.eq_ignore_ascii_case(&internal_vacation)) .collect(); - // The PostfixAdmin vacation transport is implementation detail, not a - // user-visible alias destination and must not affect delivery status. - let status = alias_status(&state, &session, &address, &gotos).await?; - let local = address.split('@').next().unwrap_or(""); - let technical = state - .config - .defaults - .default_aliases - .keys() - .any(|name| name.eq_ignore_ascii_case(local)); - result.push(AliasSummary { - address, - goto: gotos, + let local = address.split('@').next().unwrap_or("").to_string(); + let mailbox_alias = row_bool(row, "is_mailbox"); + let editable = + session.kind == AccountKind::User || can_edit_special(&state, &session, &local); + items.push(AliasSummary { + goto, domain: row_string(row, "domain"), description: row_string(row, "description"), active: row_bool(row, "active"), - is_mailbox: row_bool(row, "is_mailbox"), - technical, + is_mailbox: mailbox_alias, + technical: is_special_alias(&state, &local), status, + catch_all: local.is_empty(), + can_edit: editable, + can_delete: editable && !mailbox_alias && session.kind != AccountKind::User, + modified: row_opt_string(row, "modified"), + address, }); } - Ok(Json(result)) + Ok(Json(Page { + items, + total, + page, + page_size, + })) +} + +fn parse_alias_address(input: &AliasInput) -> AppResult<(String, String)> { + match input.address.as_deref().filter(|a| !a.trim().is_empty()) { + Some(address) => { + let address = validation::alias_address(&address.to_ascii_lowercase())?; + let (local, domain) = address.rsplit_once('@').expect("validated alias address"); + Ok((local.to_string(), domain.to_string())) + } + None => { + let domain = validation::domain(&input.domain)?; + let raw = input + .local_part + .as_deref() + .ok_or_else(|| AppError::bad_request("The alias name (local part) is required"))? + .trim() + .to_ascii_lowercase(); + if raw == "*" || raw.is_empty() { + // catch-all: PostfixAdmin stores `@domain`, not `*@domain` + Ok((String::new(), domain)) + } else { + Ok((validation::local_part(&raw)?, domain)) + } + } + } } pub async fn create( @@ -121,47 +422,15 @@ pub async fn create( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; - let domain = validation::resolvable_domain(&state.config, &input.domain).await?; + let (local, domain) = parse_alias_address(&input)?; + let domain = validation::resolvable_domain(&state.config, &domain).await?; session.require_domain(&domain)?; - require_alias_control(&state, &session)?; - - let local = match input.address.as_deref() { - Some(address) => { - let alias_address = validation::alias_address(address)?; - let (local, address_domain) = alias_address - .rsplit_once('@') - .expect("validated alias address"); - if address_domain != domain { - return Err(AppError::bad_request( - "Address domain does not match domain", - )); - } - local.to_string() - } - None => { - let raw = input - .local_part - .as_deref() - .ok_or_else(|| AppError::bad_request("local_part or address is required"))? - .trim(); - if raw == "*" { - String::new() - } else { - validation::local_part(raw)? - } - } - }; - // PostfixAdmin stores a catch-all entered as `*` in canonical Postfix form - // `@domain`, not `*@domain`. Keep the database representation compatible. - let address = if local.is_empty() { - format!("@{domain}") - } else { - format!("{local}@{domain}") - }; + let address = format!("{local}@{domain}"); protect_special_alias(&state, &session, &local)?; - enforce_alias_limit(&state, &domain).await?; + let props = DomainProps::require(&state, &domain).await?; + props.ensure_alias_allowed()?; let table = state.db.table(Table::Alias); let check = state @@ -172,11 +441,26 @@ pub async fn create( .fetch_optional(state.db.pool()) .await? .is_some() + || is_mailbox(&state, &address).await? { - return Err(AppError::conflict("Alias already exists")); + return Err(AppError::conflict("This address already exists")); + } + let goto = + validate_destinations(&state, &address, input.goto.as_deref().unwrap_or(&[]), true).await?; + if goto.is_empty() { + return Err(AppError::bad_request( + "At least one destination is required", + )); + } + let description = input + .description + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + if description.chars().count() > 255 { + return Err(AppError::bad_request("Description is too long")); } - - let goto = build_goto(&state, &address, &input).await?; let has_description = state.db.has_column(Table::Alias, "description").await?; let sql = if has_description { state.db.q(&format!( @@ -190,16 +474,17 @@ pub async fn create( )) }; let goto_value = goto.join(","); - // Rebuild and execute with the same values while retaining the canonical goto string - // for the optional per-admin default preference. let mut query = sqlx::query(&sql) .bind(&address) .bind(&goto_value) .bind(&domain); if has_description { - query = query.bind(&input.description); + query = query.bind(&description); } - query.bind(input.active).execute(state.db.pool()).await?; + query + .bind(input.active.unwrap_or(true)) + .execute(state.db.pool()) + .await?; if input.save_goto_default && state.db.has_table(Table::AdminPreferences).await { state .db @@ -226,56 +511,102 @@ pub async fn update( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; - let address = validation::alias_address(&path_address)?; - let (_, domain) = address.rsplit_once('@').expect("validated email"); - if session.kind == AccountKind::User { - if !state.config.features.edit_alias || !address.eq_ignore_ascii_case(&session.username) { - return Err(AppError::forbidden("Alias is outside this account")); - } - } else { - session.require_domain(domain)?; - require_alias_control(&state, &session)?; - } - protect_special_alias(&state, &session, address.split('@').next().unwrap_or(""))?; - let table = state.db.table(Table::Alias); - let exists = state - .db - .q(&format!("SELECT 1 FROM {table} WHERE address=? LIMIT 1")); - if sqlx::query(&exists) - .bind(&address) - .fetch_optional(state.db.pool()) - .await? - .is_none() - { - return Err(AppError::not_found("Alias not found")); - } - if let Some(new_address) = input.address.as_deref() - && validation::alias_address(new_address)? != address + let session = routes::mutation_session(&state, &jar, &headers).await?; + let address = validation::alias_address(&path_address.to_ascii_lowercase())?; + let (local, domain) = address.rsplit_once('@').expect("validated alias"); + if let Some(new_address) = input.address.as_deref().filter(|a| !a.is_empty()) + && validation::alias_address(&new_address.to_ascii_lowercase())? != address { return Err(AppError::bad_request("Renaming aliases is not supported")); } - - let goto = build_goto(&state, &address, &input).await?; + let table = state.db.table(Table::Alias); let has_description = state.db.has_column(Table::Alias, "description").await?; - // Mailbox users may only change forwarding, store-and-forward and active state. - // Preserve description even if a crafted request tries to modify it. - let can_edit_description = has_description && session.kind != AccountKind::User; - let sql = if can_edit_description { - state.db.q(&format!( - "UPDATE {table} SET goto=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?" - )) + let description_expr = if has_description { + "description" } else { - state.db.q(&format!( - "UPDATE {table} SET goto=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?" - )) + "'' AS description" }; - let mut query = sqlx::query(&sql).bind(goto.join(",")); - if can_edit_description { - query = query.bind(&input.description); + let active_expr = state.db.flag("active", "active"); + let sql = state.db.q(&format!( + "SELECT goto,{description_expr},{active_expr} FROM {table} WHERE address=?" + )); + let current = sqlx::query(&sql) + .bind(&address) + .fetch_optional(state.db.pool()) + .await? + .ok_or_else(|| AppError::not_found("Alias does not exist"))?; + let mailbox_alias = is_mailbox(&state, &address).await?; + + if mailbox_alias { + // The alias of a mailbox is its forwarding; it has its own permission + // model and keeps "deliver to mailbox" as stored unless changed. + require_mailbox_alias_control(&state, &session, &address)?; + let current_goto = split_goto(&row_string(¤t, "goto")); + let keep_mailbox = current_goto + .iter() + .any(|g| g.eq_ignore_ascii_case(&address)); + let wanted = input.goto.clone().unwrap_or_else(|| current_goto.clone()); + let deliver = keep_mailbox || wanted.iter().any(|g| g.eq_ignore_ascii_case(&address)); + let mut goto = validate_destinations(&state, &address, &wanted, false).await?; + if deliver { + goto.insert(0, address.clone()); + } + if goto.is_empty() { + return Err(AppError::bad_request( + "At least one destination is required", + )); + } + append_vacation_if_active(&state, &address, &mut goto).await?; + let sql = state.db.q(&format!( + "UPDATE {table} SET goto=?,modified=CURRENT_TIMESTAMP WHERE address=?" + )); + sqlx::query(&sql) + .bind(goto.join(",")) + .bind(&address) + .execute(state.db.pool()) + .await?; + } else { + if session.kind == AccountKind::User { + return Err(AppError::forbidden("Alias is outside this account")); + } + session.require_domain(domain)?; + protect_special_alias(&state, &session, local)?; + let goto = match &input.goto { + Some(list) => validate_destinations(&state, &address, list, true).await?, + None => split_goto(&row_string(¤t, "goto")), + }; + if goto.is_empty() { + return Err(AppError::bad_request( + "At least one destination is required", + )); + } + let description = input + .description + .clone() + .unwrap_or_else(|| row_string(¤t, "description")); + if description.chars().count() > 255 { + return Err(AppError::bad_request("Description is too long")); + } + let active = input.active.unwrap_or_else(|| row_bool(¤t, "active")); + let sql = if has_description { + state.db.q(&format!( + "UPDATE {table} SET goto=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?" + )) + } else { + state.db.q(&format!( + "UPDATE {table} SET goto=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?" + )) + }; + let mut query = sqlx::query(&sql).bind(goto.join(",")); + if has_description { + query = query.bind(description.trim().to_string()); + } + query + .bind(active) + .bind(&address) + .execute(state.db.pool()) + .await?; } - query = query.bind(input.active).bind(&address); - query.execute(state.db.pool()).await?; state .db .log( @@ -289,49 +620,98 @@ pub async fn update( Ok(Json(serde_json::json!({"ok": true}))) } +#[derive(Deserialize)] +pub struct ActiveInput { + pub active: bool, +} + +/// `editactive.php?table=alias` +pub async fn set_active( + State(state): State>, + Path(path_address): Path, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + let address = validation::alias_address(&path_address.to_ascii_lowercase())?; + let (local, domain) = address.rsplit_once('@').expect("validated alias"); + session.require_domain(domain)?; + protect_special_alias(&state, &session, local)?; + if is_mailbox(&state, &address).await? { + return Err(AppError::conflict( + "The alias of a mailbox is activated together with the mailbox", + )); + } + let table = state.db.table(Table::Alias); + let sql = state.db.q(&format!( + "UPDATE {table} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=?" + )); + if sqlx::query(&sql) + .bind(input.active) + .bind(&address) + .execute(state.db.pool()) + .await? + .rows_affected() + == 0 + { + return Err(AppError::not_found("Alias does not exist")); + } + state + .db + .log( + &state.config, + &session.username, + domain, + "edit_alias_state", + &format!( + "{address}: {}", + if input.active { "active" } else { "inactive" } + ), + ) + .await; + Ok(Json(serde_json::json!({"ok": true}))) +} + pub async fn remove( State(state): State>, Path(path_address): Path, jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; - let address = validation::alias_address(&path_address)?; - let (local, domain) = address.rsplit_once('@').expect("validated email"); - if session.kind == AccountKind::User { - return Err(AppError::forbidden( - "Mailbox self-alias cannot be deleted by a user", - )); - } + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + let address = validation::alias_address(&path_address.to_ascii_lowercase())?; + let (local, domain) = address.rsplit_once('@').expect("validated alias"); session.require_domain(domain)?; - require_alias_control(&state, &session)?; - protect_special_alias(&state, &session, local)?; + delete_one(&state, &session, &address, local, domain).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} - let mailbox = state.db.table(Table::Mailbox); - let check = state - .db - .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); - if sqlx::query(&check) - .bind(&address) - .fetch_optional(state.db.pool()) - .await? - .is_some() - { +async fn delete_one( + state: &AppState, + session: &Session, + address: &str, + local: &str, + domain: &str, +) -> AppResult<()> { + protect_special_alias(state, session, local)?; + if is_mailbox(state, address).await? { return Err(AppError::conflict( - "This alias belongs to a mailbox and is managed with the mailbox", + "The alias of a mailbox can only be deleted together with the mailbox", )); } - let table = state.db.table(Table::Alias); let sql = state.db.q(&format!("DELETE FROM {table} WHERE address=?")); if sqlx::query(&sql) - .bind(&address) + .bind(address) .execute(state.db.pool()) .await? .rows_affected() != 1 { - return Err(AppError::not_found("Alias not found")); + return Err(AppError::not_found("Alias does not exist")); } state .db @@ -340,26 +720,25 @@ pub async fn remove( &session.username, domain, "delete_alias", - &address, + address, ) .await; - Ok(Json(serde_json::json!({"ok": true}))) + Ok(()) } +/// `delete-inactive.php` – delete all inactive (non-mailbox, non-protected) +/// aliases of one domain. pub async fn delete_inactive( State(state): State>, Path(path_domain): Path, jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; let domain = validation::domain(&path_domain)?; session.require_domain(&domain)?; - require_alias_control(&state, &session)?; - let alias = state.db.table(Table::Alias); - let mailbox = state.db.table(Table::Mailbox); let sql = state.db.q(&format!( "SELECT address FROM {alias} WHERE domain=? AND active=? ORDER BY address" )); @@ -368,555 +747,68 @@ pub async fn delete_inactive( .bind(false) .fetch_all(state.db.pool()) .await?; - let mut deleted = 0u64; let mut skipped = 0u64; for row in rows { let address = row_string(&row, "address"); - let local = address.split('@').next().unwrap_or(""); - if protect_special_alias(&state, &session, local).is_err() { - skipped += 1; - continue; - } - let check = state - .db - .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); - if sqlx::query(&check) - .bind(&address) - .fetch_optional(state.db.pool()) - .await? - .is_some() - { - skipped += 1; - continue; - } - let remove = state.db.q(&format!( - "DELETE FROM {alias} WHERE address=? AND domain=? AND active=?" - )); - let affected = sqlx::query(&remove) - .bind(&address) - .bind(&domain) - .bind(false) - .execute(state.db.pool()) - .await? - .rows_affected(); - if affected == 1 { - deleted += 1; - state - .db - .log( - &state.config, - &session.username, - &domain, - "delete_alias", - &address, - ) - .await; - } else { - skipped += 1; + let local = address.split('@').next().unwrap_or("").to_string(); + match delete_one(&state, &session, &address, &local, &domain).await { + Ok(()) => deleted += 1, + Err(_) => skipped += 1, } } - Ok(Json( serde_json::json!({"ok": true, "deleted": deleted, "skipped": skipped}), )) } -pub async fn list_domains( - State(state): State>, - jar: CookieJar, -) -> AppResult>> { - let session = routes::session(&state, &jar)?; - session.require_admin()?; - if !state.config.features.alias_domain || !state.db.has_table(Table::AliasDomain).await { - return Ok(Json(Vec::new())); - } - let has_description = state - .db - .has_column(Table::AliasDomain, "description") - .await?; - let description = if has_description { - "description" - } else { - "'' AS description" - }; - let table = state.db.table(Table::AliasDomain); - let active = state.db.flag("active", "active"); - let rows = sqlx::query(&format!( - "SELECT alias_domain,target_domain,{description},{active} FROM {table} ORDER BY alias_domain" - )) - .fetch_all(state.db.pool()) - .await?; - let values = rows - .iter() - .filter_map(|row| { - let target = row_string(row, "target_domain"); - if session.can_manage_domain(&target) { - Some(AliasDomainSummary { - alias_domain: row_string(row, "alias_domain"), - target_domain: target, - description: row_string(row, "description"), - active: row_bool(row, "active"), - }) - } else { - None - } - }) - .collect(); - Ok(Json(values)) +// --------------------------------------------------------------------------- +// status (gen_show_status) +// --------------------------------------------------------------------------- + +struct StatusContext { + /// Domains the current admin may see (PostfixAdmin `$allowed_domains`). + allowed: HashSet, } -pub async fn create_domain( - State(state): State>, - jar: CookieJar, - headers: HeaderMap, - Json(mut input): Json, -) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; - session.require_admin()?; - if !state.config.features.alias_domain { - return Err(AppError::forbidden("Alias domains are disabled")); - } - input.alias_domain = validation::resolvable_domain(&state.config, &input.alias_domain).await?; - input.target_domain = - validation::resolvable_domain(&state.config, &input.target_domain).await?; - if input.alias_domain == input.target_domain { - return Err(AppError::bad_request( - "Alias and target domains must differ", - )); - } - session.require_domain(&input.alias_domain)?; - session.require_domain(&input.target_domain)?; - ensure_real_domain(&state, &input.alias_domain).await?; - ensure_real_domain(&state, &input.target_domain).await?; - ensure_alias_domain_chain_safe(&state, &input.alias_domain, &input.target_domain, None).await?; - - let table = state.db.table(Table::AliasDomain); - let has_description = state - .db - .has_column(Table::AliasDomain, "description") - .await?; - let sql = if has_description { - state.db.q(&format!( - "INSERT INTO {table} (alias_domain,target_domain,description,created,modified,active) \ - VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)" - )) - } else { - state.db.q(&format!( - "INSERT INTO {table} (alias_domain,target_domain,created,modified,active) \ - VALUES (?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)" - )) - }; - let mut query = sqlx::query(&sql) - .bind(&input.alias_domain) - .bind(&input.target_domain); - if has_description { - query = query.bind(&input.description); - } - query - .bind(input.active) - .execute(state.db.pool()) - .await - .map_err(|error| { - if is_unique(&error) { - AppError::conflict("Alias domain already exists") - } else { - error.into() - } - })?; - state - .db - .log( - &state.config, - &session.username, - &input.alias_domain, - "create_alias_domain", - &input.target_domain, - ) - .await; - Ok(Json(serde_json::json!({"ok": true}))) -} - -pub async fn update_domain( - State(state): State>, - Path(path): Path, - jar: CookieJar, - headers: HeaderMap, - Json(mut input): Json, -) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; - session.require_admin()?; - let alias_domain = validation::domain(&path)?; - input.alias_domain = validation::domain(&input.alias_domain)?; - input.target_domain = - validation::resolvable_domain(&state.config, &input.target_domain).await?; - if input.alias_domain != alias_domain { - return Err(AppError::bad_request( - "Renaming alias domains is not supported", - )); - } - if alias_domain == input.target_domain { - return Err(AppError::bad_request( - "Alias and target domains must differ", - )); - } - session.require_domain(&alias_domain)?; - session.require_domain(&input.target_domain)?; - let table = state.db.table(Table::AliasDomain); - let exists = state.db.q(&format!( - "SELECT 1 FROM {table} WHERE alias_domain=? LIMIT 1" - )); - if sqlx::query(&exists) - .bind(&alias_domain) - .fetch_optional(state.db.pool()) - .await? - .is_none() - { - return Err(AppError::not_found("Alias domain not found")); - } - ensure_real_domain(&state, &alias_domain).await?; - ensure_real_domain(&state, &input.target_domain).await?; - ensure_alias_domain_chain_safe( - &state, - &alias_domain, - &input.target_domain, - Some(&alias_domain), - ) - .await?; - - let has_description = state - .db - .has_column(Table::AliasDomain, "description") - .await?; - let sql = if has_description { - state.db.q(&format!( - "UPDATE {table} SET target_domain=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE alias_domain=?" - )) - } else { - state.db.q(&format!( - "UPDATE {table} SET target_domain=?,active=?,modified=CURRENT_TIMESTAMP WHERE alias_domain=?" - )) - }; - let mut query = sqlx::query(&sql).bind(&input.target_domain); - if has_description { - query = query.bind(&input.description); - } - query - .bind(input.active) - .bind(&alias_domain) - .execute(state.db.pool()) - .await?; - state - .db - .log( - &state.config, - &session.username, - &alias_domain, - "edit_alias_domain", - &input.target_domain, - ) - .await; - Ok(Json(serde_json::json!({"ok": true}))) -} - -pub async fn remove_domain( - State(state): State>, - Path(path): Path, - jar: CookieJar, - headers: HeaderMap, -) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; - session.require_admin()?; - let alias_domain = validation::domain(&path)?; - let table = state.db.table(Table::AliasDomain); - let get = state.db.q(&format!( - "SELECT target_domain FROM {table} WHERE alias_domain=?" - )); - let row = sqlx::query(&get) - .bind(&alias_domain) - .fetch_optional(state.db.pool()) - .await? - .ok_or_else(|| AppError::not_found("Alias domain not found"))?; - let target = row_string(&row, "target_domain"); - session.require_domain(&target)?; - let sql = state - .db - .q(&format!("DELETE FROM {table} WHERE alias_domain=?")); - sqlx::query(&sql) - .bind(&alias_domain) - .execute(state.db.pool()) - .await?; - state - .db - .log( - &state.config, - &session.username, - &alias_domain, - "delete_alias_domain", - &target, - ) - .await; - Ok(Json(serde_json::json!({"ok": true}))) -} - -async fn ensure_real_domain(state: &AppState, domain: &str) -> AppResult<()> { - let table = state.db.table(Table::Domain); - let sql = state.db.q(&format!( - "SELECT 1 FROM {table} WHERE domain=? AND active=? LIMIT 1" - )); - if sqlx::query(&sql) - .bind(domain) - .bind(true) - .fetch_optional(state.db.pool()) - .await? - .is_none() - { - return Err(AppError::bad_request(format!( - "Unknown or inactive domain: {domain}" - ))); - } - Ok(()) -} - -async fn ensure_alias_domain_chain_safe( - state: &AppState, - alias_domain: &str, - target_domain: &str, - current: Option<&str>, -) -> AppResult<()> { - let table = state.db.table(Table::AliasDomain); - let target_is_alias = state.db.q(&format!( - "SELECT 1 FROM {table} WHERE alias_domain=? LIMIT 1" - )); - if sqlx::query(&target_is_alias) - .bind(target_domain) - .fetch_optional(state.db.pool()) - .await? - .is_some() - { - return Err(AppError::conflict( - "Target domain is itself an alias domain; chained alias domains are not supported", - )); - } - - let mut alias_is_target = format!("SELECT alias_domain FROM {table} WHERE target_domain=?"); - if current.is_some() { - alias_is_target.push_str(" AND alias_domain<>?"); - } - alias_is_target.push_str(" LIMIT 1"); - let alias_is_target = state.db.q(&alias_is_target); - let mut query = sqlx::query(&alias_is_target).bind(alias_domain); - if let Some(current) = current { - query = query.bind(current); - } - if query.fetch_optional(state.db.pool()).await?.is_some() { - return Err(AppError::conflict( - "Alias domain is currently used as a target domain; chained alias domains are not supported", - )); - } - Ok(()) -} - -fn require_alias_control(state: &AppState, session: &crate::auth::Session) -> AppResult<()> { - if session.role.is_global() { - if state.config.features.alias_control { - Ok(()) +impl StatusContext { + async fn load(state: &AppState, session: &Session) -> AppResult { + let allowed = if session.role.is_global() { + all_domains(state).await?.into_iter().collect() } else { - Err(AppError::forbidden("Alias control is disabled")) - } - } else if state.config.features.alias_control_admin { - Ok(()) - } else { - Err(AppError::forbidden( - "Alias control for domain administrators is disabled", - )) - } -} - -fn protect_special_alias( - state: &AppState, - session: &crate::auth::Session, - local: &str, -) -> AppResult<()> { - if !session.role.is_global() - && !state.config.features.special_alias_control - && state.config.defaults.default_aliases.contains_key(local) - { - Err(AppError::forbidden( - "Default/special aliases can only be edited by a global administrator", - )) - } else { - Ok(()) - } -} - -fn split_goto(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .filter(|item| !item.is_empty()) - .map(str::to_string) - .collect() -} - -fn vacation_destination(state: &AppState, address: &str) -> String { - format!( - "{}@{}", - address.replace('@', "#"), - state.config.defaults.vacation_domain - ) -} - -async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppResult> { - let mut output = Vec::new(); - let internal_vacation = vacation_destination(state, address); - for raw in &input.goto { - let raw = raw.trim(); - // PostfixAdmin also permits a catch-all to forward the whole domain to - // another domain using `@target.example`. Keep that compatibility only - // for catch-all source aliases; regular aliases still require a normal - // address or another explicitly allowed destination. - let value = if address.starts_with('@') && raw.starts_with('@') { - let target_domain = validation::resolvable_domain( - &state.config, - raw.trim_start_matches('@'), - ) - .await?; - format!("@{target_domain}") - } else { - validation::alias_destination_checked( - &state.config, - raw, - state.config.security.allow_unsafe_alias_destinations, - ) - .await? - }; - // The vacation transport is an internal PostfixAdmin detail. Never let - // alias editing expose, duplicate or remove it directly. - if value.eq_ignore_ascii_case(&internal_vacation) { - continue; - } - if state.config.features.emailcheck_localaliasonly - && let Some((_, domain)) = value.rsplit_once('@') - { - let table = state.db.table(Table::Domain); - let sql = state.db.q(&format!( - "SELECT 1 FROM {table} WHERE domain=? AND active=? LIMIT 1" - )); - if sqlx::query(&sql) - .bind(domain) - .bind(true) - .fetch_optional(state.db.pool()) - .await? - .is_none() - { - return Err(AppError::bad_request(format!( - "Alias destinations must be hosted locally: {domain}" - ))); - } - } - if !output.contains(&value) { - output.push(value); - } - } - - let mailbox = state.db.table(Table::Mailbox); - let sql = state - .db - .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); - let is_mailbox = sqlx::query(&sql) - .bind(address) - .fetch_optional(state.db.pool()) - .await? - .is_some(); - if is_mailbox - && (input.store_and_forward || output.is_empty()) - && !output.iter().any(|value| value == address) - { - output.insert(0, address.to_string()); - } - if output.is_empty() { - return Err(AppError::bad_request( - "At least one destination is required", - )); - } - - if state.config.features.vacation && state.db.has_table(Table::Vacation).await { - let vacation = state.db.table(Table::Vacation); - let sql = state.db.q(&format!( - "SELECT 1 FROM {vacation} WHERE email=? AND active=? LIMIT 1" - )); - if sqlx::query(&sql) - .bind(address) - .bind(true) - .fetch_optional(state.db.pool()) - .await? - .is_some() - && !output + session + .allowed_domains .iter() - .any(|value| value.eq_ignore_ascii_case(&internal_vacation)) - { - output.push(internal_vacation); - } + .map(|d| d.to_ascii_lowercase()) + .collect() + }; + Ok(Self { allowed }) } - Ok(output) -} - -async fn enforce_alias_limit(state: &AppState, domain: &str) -> AppResult<()> { - let domain_table = state.db.table(Table::Domain); - let alias_table = state.db.table(Table::Alias); - let sql = state.db.q(&format!( - "SELECT aliases FROM {domain_table} WHERE domain=?" - )); - let row = sqlx::query(&sql) - .bind(domain) - .fetch_optional(state.db.pool()) - .await? - .ok_or_else(|| AppError::not_found("Domain not found"))?; - let limit = row_i64(&row, "aliases"); - if limit <= 0 { - return Ok(()); - } - let sql = state.db.q(&format!( - "SELECT COUNT(*) AS n FROM {alias_table} WHERE domain=?" - )); - let row = sqlx::query(&sql) - .bind(domain) - .fetch_one(state.db.pool()) - .await?; - if row_i64(&row, "n") >= limit { - return Err(AppError::conflict("Domain alias limit reached")); - } - Ok(()) } async fn alias_status( state: &AppState, - session: &crate::auth::Session, + ctx: &StatusContext, address: &str, gotos: &[String], ) -> AppResult> { - if !state.config.features.show_status { + let f = &state.config.features; + if !f.show_status { return Ok(Vec::new()); } - let mut status = Vec::new(); let alias_table = state.db.table(Table::Alias); - let domain_table = state.db.table(Table::Domain); + let delimiter = &state.config.defaults.recipient_delimiter; - if state.config.features.show_undeliverable { + if f.show_undeliverable { let mut undeliverable = false; let mut external = false; for raw in gotos { - let Ok(mut destination) = validation::email(raw) else { + let Some((local, domain)) = raw.trim().rsplit_once('@') else { continue; }; - let (local, initial_domain) = destination - .rsplit_once('@') - .expect("validated email has domain"); - let local = local.to_string(); - let mut domain = initial_domain.to_string(); - if domain == state.config.defaults.vacation_domain + let mut domain = domain.to_ascii_lowercase(); + if domain == state.config.defaults.vacation_domain.to_ascii_lowercase() || state .config .defaults @@ -926,30 +818,15 @@ async fn alias_status( { continue; } - - if !session.role.is_global() && !session.can_manage_domain(&domain) { + if !ctx.allowed.contains(&domain) { external = true; continue; } - if session.role.is_global() { + let mut destination = raw.trim().to_ascii_lowercase(); + if f.alias_domain && state.db.has_table(Table::AliasDomain).await { + let ad = state.db.table(Table::AliasDomain); let sql = state.db.q(&format!( - "SELECT 1 FROM {domain_table} WHERE domain=? LIMIT 1" - )); - if sqlx::query(&sql) - .bind(&domain) - .fetch_optional(state.db.pool()) - .await? - .is_none() - { - external = true; - continue; - } - } - - if state.config.features.alias_domain && state.db.has_table(Table::AliasDomain).await { - let alias_domain_table = state.db.table(Table::AliasDomain); - let sql = state.db.q(&format!( - "SELECT target_domain FROM {alias_domain_table} WHERE alias_domain=? AND active=? LIMIT 1" + "SELECT target_domain FROM {ad} WHERE alias_domain=? AND active=? LIMIT 1" )); if let Some(row) = sqlx::query(&sql) .bind(&domain) @@ -957,32 +834,27 @@ async fn alias_status( .fetch_optional(state.db.pool()) .await? { - domain = row_string(&row, "target_domain"); - if !session.role.is_global() && !session.can_manage_domain(&domain) { + domain = row_string(&row, "target_domain").to_ascii_lowercase(); + if !ctx.allowed.contains(&domain) { external = true; continue; } destination = format!("{local}@{domain}"); } } - let catch_all = format!("@{domain}"); - let base = - recipient_delimiter_base(&destination, &state.config.defaults.recipient_delimiter); - let sql = if base.is_some() { - state.db.q(&format!( - "SELECT 1 FROM {alias_table} WHERE address=? OR address=? OR address=? LIMIT 1" - )) - } else { - state.db.q(&format!( - "SELECT 1 FROM {alias_table} WHERE address=? OR address=? LIMIT 1" - )) - }; - let mut query = sqlx::query(&sql).bind(&destination).bind(&catch_all); - if let Some(base) = base { - query = query.bind(base); - } - if query.fetch_optional(state.db.pool()).await?.is_none() { + let base = recipient_delimiter_base(&destination, delimiter); + let sql = state.db.q(&format!( + "SELECT 1 FROM {alias_table} WHERE address=? OR address=? OR address=? LIMIT 1" + )); + if sqlx::query(&sql) + .bind(&destination) + .bind(&catch_all) + .bind(&base) + .fetch_optional(state.db.pool()) + .await? + .is_none() + { undeliverable = true; } } @@ -994,7 +866,7 @@ async fn alias_status( } } - if state.config.features.show_vacation && state.db.has_table(Table::Vacation).await { + if f.show_vacation && state.db.has_table(Table::Vacation).await { let table = state.db.table(Table::Vacation); let sql = state.db.q(&format!( "SELECT 1 FROM {table} WHERE email=? AND active=? LIMIT 1" @@ -1010,7 +882,7 @@ async fn alias_status( } } - if state.config.features.show_disabled { + if f.show_disabled { let table = state.db.table(Table::Mailbox); let sql = state.db.q(&format!( "SELECT 1 FROM {table} WHERE username=? AND active=? LIMIT 1" @@ -1026,8 +898,8 @@ async fn alias_status( } } - if state.config.features.show_expired - && state.config.features.password_expiration + if f.show_expired + && f.password_expiration && state .db .has_column(Table::Mailbox, "password_expiry") @@ -1058,40 +930,397 @@ async fn alias_status( } } - if state.config.features.show_popimap { - let table = state.db.table(Table::Mailbox); - let sql = state - .db - .q(&format!("SELECT 1 FROM {table} WHERE username=? LIMIT 1")); - if sqlx::query(&sql) - .bind(address) - .fetch_optional(state.db.pool()) - .await? - .is_some() - { - status.push("popimap".into()); - } + // POP/IMAP: the address appears in its own goto (also via recipient delimiter). + if f.show_popimap + && gotos.iter().any(|g| { + g.eq_ignore_ascii_case(address) + || recipient_delimiter_base(g, delimiter).eq_ignore_ascii_case(address) + }) + { + status.push("popimap".into()); } for custom_domain in &state.config.defaults.show_custom_domains { - if gotos.iter().any(|goto| goto.contains(custom_domain)) { + if gotos + .iter() + .any(|goto| goto.contains(custom_domain.as_str())) + { status.push(format!("custom:{custom_domain}")); } } Ok(status) } -fn recipient_delimiter_base(address: &str, delimiter: &str) -> Option { - if delimiter.is_empty() { - return None; +/// Remove a recipient extension. Each character of `delimiters` is an +/// alternative delimiter (Postfix `recipient_delimiter` semantics, as in +/// PostfixAdmin master). +pub(crate) fn recipient_delimiter_base(address: &str, delimiters: &str) -> String { + if delimiters.is_empty() { + return address.to_string(); } - let (local, domain) = address.rsplit_once('@')?; - let (base, _) = local.split_once(delimiter)?; - if base.is_empty() { - None + let Some((local, domain)) = address.rsplit_once('@') else { + return address.to_string(); + }; + match local.find(|c: char| delimiters.contains(c)) { + Some(position) => format!("{}@{domain}", &local[..position]), + None => address.to_string(), + } +} + +// --------------------------------------------------------------------------- +// alias domains +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +pub struct AliasDomainQuery { + pub domain: Option, +} + +pub async fn list_domains( + State(state): State>, + jar: CookieJar, + Query(query): Query, +) -> AppResult>> { + let session = routes::session(&state, &jar).await?; + session.require_admin()?; + if !state.config.features.alias_domain || !state.db.has_table(Table::AliasDomain).await { + return Ok(Json(Vec::new())); + } + let has_description = state + .db + .has_column(Table::AliasDomain, "description") + .await?; + let description = if has_description { + "description" } else { - Some(format!("{base}@{domain}")) + "'' AS description" + }; + let table = state.db.table(Table::AliasDomain); + let active = state.db.flag("active", "active"); + let modified = format!("{} AS modified", state.db.cast_text("modified")); + let filter = match query.domain.as_deref().filter(|d| !d.is_empty()) { + Some(d) => Some(validation::domain(d)?), + None => None, + }; + let mut sql = + format!("SELECT alias_domain,target_domain,{description},{active},{modified} FROM {table}"); + if filter.is_some() { + sql.push_str(" WHERE alias_domain=? OR target_domain=?"); } + sql.push_str(" ORDER BY alias_domain"); + let sql = state.db.q(&sql); + let mut q = sqlx::query(&sql); + if let Some(d) = &filter { + q = q.bind(d.as_str()).bind(d.as_str()); + } + let rows = q.fetch_all(state.db.pool()).await?; + let values = rows + .iter() + .filter_map(|row| { + let alias_domain = row_string(row, "alias_domain"); + let target = row_string(row, "target_domain"); + if session.can_manage_domain(&alias_domain) || session.can_manage_domain(&target) { + Some(AliasDomainSummary { + alias_domain, + target_domain: target, + description: row_string(row, "description"), + active: row_bool(row, "active"), + modified: row_opt_string(row, "modified"), + }) + } else { + None + } + }) + .collect(); + Ok(Json(values)) +} + +pub async fn create_domain( + State(state): State>, + jar: CookieJar, + headers: HeaderMap, + Json(mut input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + if !state.config.features.alias_domain { + return Err(AppError::forbidden("Alias domains are disabled")); + } + input.alias_domain = validation::domain(&input.alias_domain)?; + input.target_domain = validation::domain(&input.target_domain)?; + if input.alias_domain == input.target_domain { + return Err(AppError::bad_request( + "An alias domain cannot point to itself", + )); + } + // Both must be domains of this administrator (enum options in PostfixAdmin). + session.require_domain(&input.alias_domain)?; + session.require_domain(&input.target_domain)?; + ensure_domain_exists(&state, &input.alias_domain).await?; + ensure_domain_exists(&state, &input.target_domain).await?; + ensure_alias_domain_chain_safe(&state, &input.alias_domain, &input.target_domain, None).await?; + if input.description.chars().count() > 255 { + return Err(AppError::bad_request("Description is too long")); + } + + let table = state.db.table(Table::AliasDomain); + let has_description = state + .db + .has_column(Table::AliasDomain, "description") + .await?; + let sql = if has_description { + state.db.q(&format!( + "INSERT INTO {table} (alias_domain,target_domain,description,created,modified,active) \ + VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)" + )) + } else { + state.db.q(&format!( + "INSERT INTO {table} (alias_domain,target_domain,created,modified,active) \ + VALUES (?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)" + )) + }; + let mut query = sqlx::query(&sql) + .bind(&input.alias_domain) + .bind(&input.target_domain); + if has_description { + query = query.bind(input.description.trim().to_string()); + } + query + .bind(input.active) + .execute(state.db.pool()) + .await + .map_err(|error| { + if is_unique(&error) { + AppError::conflict("This domain is already an alias domain") + } else { + error.into() + } + })?; + state + .db + .log( + &state.config, + &session.username, + &input.alias_domain, + "create_alias_domain", + &input.target_domain, + ) + .await; + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn update_domain( + State(state): State>, + Path(path): Path, + jar: CookieJar, + headers: HeaderMap, + Json(mut input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + let alias_domain = validation::domain(&path)?; + input.target_domain = validation::domain(&input.target_domain)?; + if !input.alias_domain.is_empty() && validation::domain(&input.alias_domain)? != alias_domain { + return Err(AppError::bad_request( + "Renaming alias domains is not supported", + )); + } + if alias_domain == input.target_domain { + return Err(AppError::bad_request( + "An alias domain cannot point to itself", + )); + } + session.require_domain(&alias_domain)?; + session.require_domain(&input.target_domain)?; + let table = state.db.table(Table::AliasDomain); + let exists = state.db.q(&format!( + "SELECT 1 FROM {table} WHERE alias_domain=? LIMIT 1" + )); + if sqlx::query(&exists) + .bind(&alias_domain) + .fetch_optional(state.db.pool()) + .await? + .is_none() + { + return Err(AppError::not_found("Alias domain does not exist")); + } + ensure_domain_exists(&state, &input.target_domain).await?; + ensure_alias_domain_chain_safe( + &state, + &alias_domain, + &input.target_domain, + Some(&alias_domain), + ) + .await?; + let has_description = state + .db + .has_column(Table::AliasDomain, "description") + .await?; + let sql = if has_description { + state.db.q(&format!( + "UPDATE {table} SET target_domain=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE alias_domain=?" + )) + } else { + state.db.q(&format!( + "UPDATE {table} SET target_domain=?,active=?,modified=CURRENT_TIMESTAMP WHERE alias_domain=?" + )) + }; + let mut query = sqlx::query(&sql).bind(&input.target_domain); + if has_description { + query = query.bind(input.description.trim().to_string()); + } + query + .bind(input.active) + .bind(&alias_domain) + .execute(state.db.pool()) + .await?; + state + .db + .log( + &state.config, + &session.username, + &alias_domain, + "edit_alias_domain", + &input.target_domain, + ) + .await; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// `editactive.php?table=aliasdomain` +pub async fn set_domain_active( + State(state): State>, + Path(path): Path, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + let alias_domain = validation::domain(&path)?; + session.require_domain(&alias_domain)?; + let table = state.db.table(Table::AliasDomain); + let sql = state.db.q(&format!( + "UPDATE {table} SET active=?,modified=CURRENT_TIMESTAMP WHERE alias_domain=?" + )); + if sqlx::query(&sql) + .bind(input.active) + .bind(&alias_domain) + .execute(state.db.pool()) + .await? + .rows_affected() + == 0 + { + return Err(AppError::not_found("Alias domain does not exist")); + } + state + .db + .log( + &state.config, + &session.username, + &alias_domain, + "edit_alias_domain_state", + &format!( + "{alias_domain}: {}", + if input.active { "active" } else { "inactive" } + ), + ) + .await; + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn remove_domain( + State(state): State>, + Path(path): Path, + jar: CookieJar, + headers: HeaderMap, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + let alias_domain = validation::domain(&path)?; + session.require_domain(&alias_domain)?; + let table = state.db.table(Table::AliasDomain); + let get = state.db.q(&format!( + "SELECT target_domain FROM {table} WHERE alias_domain=?" + )); + let row = sqlx::query(&get) + .bind(&alias_domain) + .fetch_optional(state.db.pool()) + .await? + .ok_or_else(|| AppError::not_found("Alias domain does not exist"))?; + let target = row_string(&row, "target_domain"); + let sql = state + .db + .q(&format!("DELETE FROM {table} WHERE alias_domain=?")); + sqlx::query(&sql) + .bind(&alias_domain) + .execute(state.db.pool()) + .await?; + state + .db + .log( + &state.config, + &session.username, + &alias_domain, + "delete_alias_domain", + &target, + ) + .await; + Ok(Json(serde_json::json!({"ok": true}))) +} + +async fn ensure_domain_exists(state: &AppState, domain: &str) -> AppResult<()> { + let table = state.db.table(Table::Domain); + let sql = state + .db + .q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1")); + if sqlx::query(&sql) + .bind(domain) + .fetch_optional(state.db.pool()) + .await? + .is_none() + { + return Err(AppError::bad_request(format!("Unknown domain: {domain}"))); + } + Ok(()) +} + +async fn ensure_alias_domain_chain_safe( + state: &AppState, + alias_domain: &str, + target_domain: &str, + current: Option<&str>, +) -> AppResult<()> { + let table = state.db.table(Table::AliasDomain); + let target_is_alias = state.db.q(&format!( + "SELECT 1 FROM {table} WHERE alias_domain=? LIMIT 1" + )); + if sqlx::query(&target_is_alias) + .bind(target_domain) + .fetch_optional(state.db.pool()) + .await? + .is_some() + { + return Err(AppError::conflict( + "The target domain is itself an alias domain; chained alias domains are not supported", + )); + } + let mut alias_is_target = format!("SELECT alias_domain FROM {table} WHERE target_domain=?"); + if current.is_some() { + alias_is_target.push_str(" AND alias_domain<>?"); + } + alias_is_target.push_str(" LIMIT 1"); + let alias_is_target = state.db.q(&alias_is_target); + let mut query = sqlx::query(&alias_is_target).bind(alias_domain); + if let Some(current) = current { + query = query.bind(current); + } + if query.fetch_optional(state.db.pool()).await?.is_some() { + return Err(AppError::conflict( + "This domain is already the target of another alias domain", + )); + } + Ok(()) } fn is_unique(error: &sqlx::Error) -> bool { @@ -1099,3 +1328,28 @@ fn is_unique(error: &sqlx::Error) -> bool { .as_database_error() .is_some_and(|database_error| database_error.is_unique_violation()) } + +#[cfg(test)] +mod tests { + use super::recipient_delimiter_base; + + #[test] + fn recipient_delimiter_is_a_character_set() { + assert_eq!( + recipient_delimiter_base("john+tag@example.com", "+-"), + "john@example.com" + ); + assert_eq!( + recipient_delimiter_base("john-tag+x@example.com", "+-"), + "john@example.com" + ); + assert_eq!( + recipient_delimiter_base("john@example.com", "+"), + "john@example.com" + ); + assert_eq!( + recipient_delimiter_base("john+x@example.com", ""), + "john+x@example.com" + ); + } +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index e432a21..9ba68df 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,8 +1,8 @@ -use std::{net::SocketAddr, sync::Arc}; +use std::sync::Arc; use axum::{ Json, - extract::{ConnectInfo, State}, + extract::State, http::StatusCode, response::{IntoResponse, Response}, }; @@ -13,6 +13,7 @@ use crate::{ auth::verify_totp, error::{AppError, AppResult}, models::{AccountKind, Role}, + net::ClientIp, passwords, routes, state::AppState, }; @@ -21,12 +22,27 @@ use crate::{ pub struct LoginInput { pub username: String, pub password: String, - #[serde(default = "admin_kind")] - pub kind: AccountKind, + /// `admin`, `user` or omitted/`auto` (administrator first, then mailbox). + #[serde(default, deserialize_with = "deserialize_kind")] + pub kind: Option, } -fn admin_kind() -> AccountKind { - AccountKind::Admin + +fn deserialize_kind<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value: Option = Option::deserialize(deserializer)?; + Ok(match value.as_deref() { + Some("admin") => Some(AccountKind::Admin), + Some("user") => Some(AccountKind::User), + _ => None, + }) } +struct ResolvedLogin { + kind: AccountKind, + password: String, +} + #[derive(Deserialize)] pub struct MfaInput { pub challenge: String, @@ -52,11 +68,12 @@ pub(crate) struct MeResponse { csrf: String, domains: Vec, language: String, + totp_enabled: bool, } pub async fn login( State(state): State>, - ConnectInfo(peer): ConnectInfo, + ClientIp(client_ip): ClientIp, jar: CookieJar, Json(input): Json, ) -> AppResult { @@ -64,15 +81,30 @@ pub async fn login( if username.is_empty() || input.password.is_empty() { return Err(AppError::unauthorized("Invalid credentials")); } - if !state.auth.login_allowed(peer.ip(), &username) { + if !state.auth.login_allowed(client_ip, &username) { return Err(AppError::too_many("Too many login attempts")); } - let record = state.db.login_record(input.kind, &username).await?; - let valid = if let Some(ref record) = record { - record.active + let candidates = match input.kind { + Some(kind) => vec![kind], + None => vec![AccountKind::Admin, AccountKind::User], + }; + let mut authenticated = None; + let mut any_record = false; + for kind in candidates { + let Some(record) = state.db.login_record(kind, &username).await? else { + continue; + }; + any_record = true; + if record.active && passwords::verify(&state.config, &input.password, &record.password, &username).await - } else { + { + authenticated = Some((kind, record)); + break; + } + } + if !any_record { + // Equalise timing for unknown accounts. let _ = passwords::verify( &state.config, &input.password, @@ -80,13 +112,15 @@ pub async fn login( &username, ) .await; - false - }; - if !valid { - return Err(AppError::unauthorized("Invalid credentials")); } - let record = record.expect("valid implies record"); - state.auth.clear_attempts(peer.ip(), &username); + let Some((kind, record)) = authenticated else { + return Err(AppError::unauthorized("Invalid credentials")); + }; + state.auth.clear_attempts(client_ip, &username); + let input = ResolvedLogin { + kind, + password: input.password, + }; let domains = if input.kind == AccountKind::Admin { state.db.allowed_domains(&username).await? @@ -114,7 +148,7 @@ pub async fn login( && record.totp_secret.as_deref().is_some_and(|s| !s.is_empty()) && !state .db - .totp_exception(&peer.ip().to_string(), &username) + .totp_exception(&client_ip.to_string(), &username) .await .unwrap_or(false); if needs_mfa { @@ -123,7 +157,7 @@ pub async fn login( input.kind, role, domains, - peer.ip(), + client_ip, mail_password.clone(), ); return Ok(( @@ -163,13 +197,13 @@ pub async fn login( pub async fn mfa( State(state): State>, - ConnectInfo(peer): ConnectInfo, + ClientIp(client_ip): ClientIp, jar: CookieJar, Json(input): Json, ) -> AppResult { let pending = state .auth - .take_pending(&input.challenge, peer.ip()) + .take_pending(&input.challenge, client_ip) .ok_or_else(|| AppError::unauthorized("MFA challenge expired"))?; let record = state .db @@ -215,8 +249,15 @@ pub async fn logout(State(state): State>, jar: CookieJar) -> Respo } pub async fn me(State(state): State>, jar: CookieJar) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; + let totp_enabled = state + .db + .login_record(session.kind, &session.username) + .await? + .and_then(|record| record.totp_secret) + .is_some_and(|secret| !secret.is_empty()); Ok(Json(MeResponse { + totp_enabled, username: session.username, role: session.role, kind: session.kind, @@ -255,7 +296,7 @@ pub struct RecoveryResetInput { pub async fn recovery_request( State(state): State>, - ConnectInfo(peer): ConnectInfo, + ClientIp(client_ip): ClientIp, Json(input): Json, ) -> AppResult> { routes::require_writable(&state)?; @@ -274,7 +315,7 @@ pub async fn recovery_request( } if !state .auth - .login_allowed(peer.ip(), &format!("recovery:{username}")) + .login_allowed(client_ip, &format!("recovery:{username}")) { return Err(AppError::too_many("Too many recovery requests")); } @@ -371,14 +412,14 @@ pub async fn recovery_request( pub async fn recovery_reset( State(state): State>, - ConnectInfo(peer): ConnectInfo, + ClientIp(client_ip): ClientIp, Json(input): Json, ) -> AppResult> { routes::require_writable(&state)?; let username = input.username.trim().to_ascii_lowercase(); if !state .auth - .login_allowed(peer.ip(), &format!("reset:{username}")) + .login_allowed(client_ip, &format!("reset:{username}")) { return Err(AppError::too_many("Too many reset attempts")); } @@ -437,7 +478,7 @@ pub async fn recovery_reset( } state .auth - .clear_attempts(peer.ip(), &format!("reset:{username}")); + .clear_attempts(client_ip, &format!("reset:{username}")); let domain = username.rsplit_once('@').map(|(_, d)| d).unwrap_or(""); state .db diff --git a/src/routes/backup.rs b/src/routes/backup.rs index 45c8c16..5509d46 100644 --- a/src/routes/backup.rs +++ b/src/routes/backup.rs @@ -15,7 +15,7 @@ use std::{process::Stdio, sync::Arc}; use tokio::process::Command; pub async fn download(State(state): State>, jar: CookieJar) -> AppResult { - let s = routes::session(&state, &jar)?; + let s = routes::session(&state, &jar).await?; s.require_global()?; if !state.config.features.backup { return Err(AppError::forbidden("Backup is disabled")); diff --git a/src/routes/dkim.rs b/src/routes/dkim.rs index 1af4d9f..a5cada4 100644 --- a/src/routes/dkim.rs +++ b/src/routes/dkim.rs @@ -67,7 +67,7 @@ pub async fn dns_records( State(state): State>, jar: CookieJar, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; if !session.role.is_admin() && !state.config.dkim.user_read_only { return Err(AppError::forbidden( "DKIM read-only view is disabled for mailbox users", @@ -133,7 +133,7 @@ pub async fn list_keys( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; dkim_role(&state, &session)?; if !state.db.has_table(Table::Dkim).await { return Ok(Json(Vec::new())); @@ -165,7 +165,7 @@ pub async fn create_key( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; dkim_role(&state, &session)?; input.domain_name = validation::domain(&input.domain_name)?; session.require_domain(&input.domain_name)?; @@ -214,7 +214,7 @@ pub async fn update_key( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; dkim_role(&state, &session)?; let table = state.db.table(Table::Dkim); let get = state.db.q(&format!( @@ -258,7 +258,7 @@ pub async fn remove_key( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; dkim_role(&state, &session)?; let table = state.db.table(Table::Dkim); let get = state.db.q(&format!( @@ -293,7 +293,7 @@ pub async fn list_signing( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; dkim_role(&state, &session)?; let signing = state.db.table(Table::DkimSigning); let keys = state.db.table(Table::Dkim); @@ -335,7 +335,7 @@ pub async fn create_signing( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; dkim_role(&state, &session)?; let author = validate_author(&input.author)?; let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author); @@ -370,7 +370,7 @@ pub async fn update_signing( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; dkim_role(&state, &session)?; let table = state.db.table(Table::DkimSigning); let get = state @@ -406,7 +406,7 @@ pub async fn remove_signing( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; dkim_role(&state, &session)?; let table = state.db.table(Table::DkimSigning); let get = state diff --git a/src/routes/domains.rs b/src/routes/domains.rs index aa4e149..c6e6345 100644 --- a/src/routes/domains.rs +++ b/src/routes/domains.rs @@ -10,7 +10,7 @@ use axum::{ http::HeaderMap, }; use axum_extra::extract::cookie::CookieJar; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tokio::{net::UdpSocket, time::timeout}; use trust_dns_resolver::TokioAsyncResolver; @@ -24,12 +24,49 @@ use crate::{ validation, }; +/// PostfixAdmin `PASSWORD_EXPIRATION_MAX_DAYS`. +const PASSWORD_EXPIRATION_MAX_DAYS: i64 = 36_500; + pub async fn list( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; session.require_admin()?; + let domains: Option> = if session.role.is_global() { + None + } else { + Some(routes::scoped_domains(&session)) + }; + if domains.as_ref().is_some_and(|d| d.is_empty()) { + return Ok(Json(Vec::new())); + } + Ok(Json( + load_domains(&state, domains.as_deref(), session.role.is_global()).await?, + )) +} + +pub async fn get( + State(state): State>, + Path(domain_path): Path, + jar: CookieJar, +) -> AppResult> { + let session = routes::session(&state, &jar).await?; + let domain = validation::domain(&domain_path)?; + session.require_domain(&domain)?; + load_domains(&state, Some(&[domain]), session.role.is_global()) + .await? + .into_iter() + .next() + .map(Json) + .ok_or_else(|| AppError::not_found("Domain does not exist")) +} + +pub(crate) async fn load_domains( + state: &AppState, + only: Option<&[String]>, + can_edit: bool, +) -> AppResult> { let domain = state.db.table(Table::Domain); let alias = state.db.table(Table::Alias); let mailbox = state.db.table(Table::Mailbox); @@ -39,11 +76,17 @@ pub async fn list( .await?; let has_dns = state.db.has_column(Table::Domain, "dns_active").await?; let has_dns_checked = state.db.has_column(Table::Domain, "dns_checked").await?; + let has_description = state.db.has_column(Table::Domain, "description").await?; let expiry_expr = if has_expiry { "d.password_expiry" } else { "NULL AS password_expiry" }; + let description = if has_description { + "d.description" + } else { + "'' AS description" + }; let backupmx_expr = state.db.flag("d.backupmx", "backupmx"); let active_expr = state.db.flag("d.active", "active"); let dns_expr = match (has_dns, has_dns_checked) { @@ -58,45 +101,47 @@ pub async fn list( ), (false, _) => "NULL AS dns_active, NULL AS dns_checked".into(), }; + let used_expr = match used_quota_source(state).await { + Some((Table::Quota2, t)) => format!( + "{} AS used_bytes", + state.db.cast_i64(&format!("(SELECT COALESCE(SUM(q.bytes),0) FROM {t} q INNER JOIN {mailbox} mq ON mq.username=q.username WHERE mq.domain=d.domain)")) + ), + Some((_, t)) => format!( + "{} AS used_bytes", + state.db.cast_i64(&format!("(SELECT COALESCE(SUM(q.current),0) FROM {t} q INNER JOIN {mailbox} mq ON mq.username=q.username WHERE mq.domain=d.domain AND q.path='quota/storage')")) + ), + None => "NULL AS used_bytes".into(), + }; + let allocated = state.db.cast_i64(&format!( + "(SELECT COALESCE(SUM(CASE WHEN m2.quota > 0 THEN m2.quota ELSE 0 END),0) FROM {mailbox} m2 WHERE m2.domain=d.domain)" + )); let mut sql = format!( - "SELECT d.domain,d.description,d.aliases,d.mailboxes,d.maxquota,d.quota,d.transport,{backupmx_expr},{active_expr},{expiry_expr},{dns_expr},\n (SELECT COUNT(*) FROM {alias} a WHERE a.domain=d.domain) AS alias_count,\n (SELECT COUNT(*) FROM {mailbox} m WHERE m.domain=d.domain) AS mailbox_count\n FROM {domain} d WHERE d.domain <> 'ALL'" + "SELECT d.domain,{description},d.aliases,d.mailboxes,d.maxquota,d.quota,d.transport,{backupmx_expr},{active_expr},{expiry_expr},{dns_expr},{used_expr}, \ + (SELECT COUNT(*) FROM {alias} a WHERE a.domain=d.domain) AS all_alias_count, \ + (SELECT COUNT(*) FROM {mailbox} m WHERE m.domain=d.domain) AS mailbox_count, \ + {allocated} AS allocated_bytes \ + FROM {domain} d WHERE d.domain <> 'ALL'" ); - let mut query_domains = false; - if !session.role.is_global() { - if !session.allowed_domains.iter().any(|d| d.as_str() != "ALL") { - return Ok(Json(Vec::new())); - } + if let Some(only) = only { sql.push_str(" AND d.domain IN ("); - for (i, _) in session - .allowed_domains - .iter() - .filter(|d| d.as_str() != "ALL") - .enumerate() - { - if i > 0 { - sql.push(','); - } - sql.push('?'); - } + sql.push_str(&vec!["?"; only.len()].join(",")); sql.push(')'); - query_domains = true; } sql.push_str(" ORDER BY d.domain"); let sql = state.db.q(&sql); let mut query = sqlx::query(&sql); - if query_domains { - for d in session - .allowed_domains - .iter() - .filter(|d| d.as_str() != "ALL") - { + if let Some(only) = only { + for d in only { query = query.bind(d.as_str()); } } let rows = query.fetch_all(state.db.pool()).await?; - Ok(Json( - rows.iter() - .map(|r| DomainSummary { + let multiplier = state.config.defaults.quota_multiplier.max(1); + Ok(rows + .iter() + .map(|r| { + let mailbox_count = row_i64(r, "mailbox_count"); + DomainSummary { domain: row_string(r, "domain"), description: row_string(r, "description"), aliases: row_i64(r, "aliases"), @@ -109,11 +154,138 @@ pub async fn list( password_expiry: row_opt_i64(r, "password_expiry"), dns_active: row_opt_bool(r, "dns_active"), dns_checked: row_opt_string(r, "dns_checked"), - alias_count: row_i64(r, "alias_count"), - mailbox_count: row_i64(r, "mailbox_count"), - }) - .collect(), - )) + alias_count: (row_i64(r, "all_alias_count") - mailbox_count).max(0), + mailbox_count, + quota_allocated_mb: row_i64(r, "allocated_bytes") / multiplier, + quota_used_mb: row_opt_i64(r, "used_bytes").map(|b| b / multiplier), + can_edit, + } + }) + .collect()) +} + +/// Table holding Dovecot's used-quota values, if enabled and present. +pub(crate) async fn used_quota_source(state: &AppState) -> Option<(Table, String)> { + if !state.config.features.used_quotas { + return None; + } + let table = if state.config.features.new_quota_table { + Table::Quota2 + } else { + Table::Quota + }; + if state.db.has_table(table).await { + Some((table, state.db.table(table))) + } else { + None + } +} + +fn validate_limit(name: &str, value: i64) -> AppResult { + if value < -1 { + return Err(AppError::bad_request(format!( + "{name} must be -1 (disabled), 0 (unlimited) or a positive number" + ))); + } + Ok(value) +} + +struct DomainValues { + description: String, + aliases: i64, + mailboxes: i64, + maxquota: i64, + quota: i64, + transport: String, + backupmx: bool, + active: bool, + password_expiry: i64, +} + +fn domain_values( + state: &AppState, + input: &DomainInput, + current: Option<&DomainSummary>, +) -> AppResult { + let d = &state.config.defaults; + let f = &state.config.features; + let aliases = validate_limit( + "Aliases", + input + .aliases + .or(current.map(|c| c.aliases)) + .unwrap_or(d.aliases), + )?; + let mailboxes = validate_limit( + "Mailboxes", + input + .mailboxes + .or(current.map(|c| c.mailboxes)) + .unwrap_or(d.mailboxes), + )?; + let maxquota = validate_limit( + "Max quota", + input + .maxquota + .or(current.map(|c| c.maxquota)) + .unwrap_or(d.maxquota_mb), + )?; + let quota = validate_limit( + "Domain quota", + input + .quota + .or(current.map(|c| c.quota)) + .unwrap_or(d.domain_quota_mb), + )?; + if f.quota && f.domain_quota && quota > 0 && maxquota > quota { + return Err(AppError::bad_request( + "The maximum mailbox quota cannot be larger than the domain quota", + )); + } + let transport = if f.transport { + let value = input + .transport + .clone() + .filter(|t| !t.trim().is_empty()) + .or(current.map(|c| c.transport.clone())) + .unwrap_or_else(|| d.transport_default.clone()); + if !d.transport_options.iter().any(|x| x == &value) + && current.is_none_or(|c| c.transport != value) + { + return Err(AppError::bad_request(format!( + "Unsupported transport: {value}" + ))); + } + value + } else { + current + .map(|c| c.transport.clone()) + .unwrap_or_else(|| d.transport_default.clone()) + }; + let password_expiry = input + .password_expiry + .or(current.and_then(|c| c.password_expiry)) + .unwrap_or(0); + if !(0..=PASSWORD_EXPIRATION_MAX_DAYS).contains(&password_expiry) { + return Err(AppError::bad_request(format!( + "Password expiration must be between 0 (never) and {PASSWORD_EXPIRATION_MAX_DAYS} days" + ))); + } + let description = input.description.trim().to_string(); + if description.chars().count() > 255 { + return Err(AppError::bad_request("Description is too long")); + } + Ok(DomainValues { + description, + aliases, + mailboxes, + maxquota, + quota, + transport, + backupmx: input.backupmx, + active: input.active, + password_expiry, + }) } pub async fn create( @@ -122,21 +294,18 @@ pub async fn create( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; input.domain = validation::resolvable_domain(&state.config, &input.domain).await?; - if state.config.features.transport - && input.transport.as_deref().is_some_and(|v| { - !state - .config - .defaults - .transport_options - .iter() - .any(|x| x == v) - }) + if input + .domain + .eq_ignore_ascii_case(state.config.defaults.vacation_domain.trim()) { - return Err(AppError::bad_request("Unsupported transport")); + return Err(AppError::bad_request( + "This domain is configured as the vacation (autoreply) domain", + )); } + let values = domain_values(&state, &input, None)?; let table = state.db.table(Table::Domain); let exists_sql = state .db @@ -149,46 +318,87 @@ pub async fn create( { return Err(AppError::conflict("Domain already exists")); } - let aliases = input.aliases.unwrap_or(state.config.defaults.aliases); - let mailboxes = input.mailboxes.unwrap_or(state.config.defaults.mailboxes); - let maxquota = input.maxquota.unwrap_or(state.config.defaults.maxquota_mb); - let quota = input.quota.unwrap_or(state.config.defaults.domain_quota_mb); - let transport = if state.config.features.transport { - input - .transport - .clone() - .unwrap_or_else(|| state.config.defaults.transport_default.clone()) - } else { - state.config.defaults.transport_default.clone() - }; + if state.config.features.alias_domain && state.db.has_table(Table::AliasDomain).await { + let ad = state.db.table(Table::AliasDomain); + let sql = state + .db + .q(&format!("SELECT 1 FROM {ad} WHERE alias_domain=? LIMIT 1")); + if sqlx::query(&sql) + .bind(&input.domain) + .fetch_optional(state.db.pool()) + .await? + .is_some() + { + return Err(AppError::conflict( + "A stale alias-domain entry with this name exists; remove it first", + )); + } + } let has_expiry = state .db .has_column(Table::Domain, "password_expiry") .await?; - let sql = if has_expiry { - state.db.q(&format!("INSERT INTO {table} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active,password_expiry) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?,?)")) - } else { - state.db.q(&format!("INSERT INTO {table} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)")) - }; + let has_description = state.db.has_column(Table::Domain, "description").await?; + let mut cols = vec![ + "domain", + "aliases", + "mailboxes", + "maxquota", + "quota", + "transport", + "backupmx", + "created", + "modified", + "active", + ]; + let mut vals = vec![ + "?", + "?", + "?", + "?", + "?", + "?", + "?", + "CURRENT_TIMESTAMP", + "CURRENT_TIMESTAMP", + "?", + ]; + if has_description { + cols.push("description"); + vals.push("?"); + } + if has_expiry { + cols.push("password_expiry"); + vals.push("?"); + } + let sql = state.db.q(&format!( + "INSERT INTO {table} ({}) VALUES ({})", + cols.join(","), + vals.join(",") + )); + let mut tx = state.db.pool().begin().await?; let mut q = sqlx::query(&sql) .bind(&input.domain) - .bind(&input.description) - .bind(aliases) - .bind(mailboxes) - .bind(maxquota) - .bind(quota) - .bind(&transport) - .bind(input.backupmx) - .bind(input.active); - if has_expiry { - q = q.bind(input.password_expiry.unwrap_or(0)); + .bind(values.aliases) + .bind(values.mailboxes) + .bind(values.maxquota) + .bind(values.quota) + .bind(&values.transport) + .bind(values.backupmx) + .bind(values.active); + if has_description { + q = q.bind(&values.description); } - q.execute(state.db.pool()).await?; + if has_expiry { + q = q.bind(values.password_expiry); + } + q.execute(&mut *tx).await?; if input.default_aliases { let alias_table = state.db.table(Table::Alias); for (local, target) in &state.config.defaults.default_aliases { - let address = format!("{local}@{}", input.domain); + let address = format!("{}@{}", local.to_ascii_lowercase(), input.domain); + // PostfixAdmin: if the target has no '@', it points into the new domain. let goto = if target.contains('@') { target.clone() } else { @@ -200,16 +410,20 @@ pub async fn create( .bind(goto) .bind(&input.domain) .bind(true) - .execute(state.db.pool()) + .execute(&mut *tx) .await?; } } - hooks::run( + tx.commit().await?; + if let Err(error) = hooks::run( &state.config.hooks.domain_postcreation, &[("domain", &input.domain)], None, ) - .await?; + .await + { + tracing::warn!(%error, domain = %input.domain, "domain post-creation hook failed"); + } state .db .log( @@ -220,30 +434,12 @@ pub async fn create( &input.domain, ) .await; - Ok(Json(DomainSummary { - domain: input.domain, - description: input.description, - aliases, - mailboxes, - maxquota, - quota, - transport, - backupmx: input.backupmx, - active: input.active, - password_expiry: if has_expiry { - Some(input.password_expiry.unwrap_or(0)) - } else { - None - }, - dns_active: None, - dns_checked: None, - alias_count: if input.default_aliases { - state.config.defaults.default_aliases.len() as i64 - } else { - 0 - }, - mailbox_count: 0, - })) + load_domains(&state, Some(std::slice::from_ref(&input.domain)), true) + .await? + .into_iter() + .next() + .map(Json) + .ok_or_else(|| AppError::not_found("Domain does not exist")) } pub async fn update( @@ -253,62 +449,62 @@ pub async fn update( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; let domain_name = validation::domain(&domain_path)?; - if validation::domain(&input.domain)? != domain_name { + if !input.domain.trim().is_empty() && validation::domain(&input.domain)? != domain_name { return Err(AppError::bad_request( "Renaming domains is not supported; create a new domain instead", )); } - let table = state.db.table(Table::Domain); - let exists = state - .db - .q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1")); - if sqlx::query(&exists) - .bind(&domain_name) - .fetch_optional(state.db.pool()) + let current = load_domains(&state, Some(std::slice::from_ref(&domain_name)), true) .await? - .is_none() - { - return Err(AppError::not_found("Domain not found")); - } + .into_iter() + .next() + .ok_or_else(|| AppError::not_found("Domain does not exist"))?; + let values = domain_values(&state, &input, Some(¤t))?; + let table = state.db.table(Table::Domain); let has_expiry = state .db .has_column(Table::Domain, "password_expiry") .await?; - let sql = if has_expiry { - state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,password_expiry=?,modified=CURRENT_TIMESTAMP WHERE domain=?")) - } else { - state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,modified=CURRENT_TIMESTAMP WHERE domain=?")) - }; - let mut q = sqlx::query(&sql) - .bind(&input.description) - .bind(input.aliases.unwrap_or(state.config.defaults.aliases)) - .bind(input.mailboxes.unwrap_or(state.config.defaults.mailboxes)) - .bind(input.maxquota.unwrap_or(state.config.defaults.maxquota_mb)) - .bind(input.quota.unwrap_or(state.config.defaults.domain_quota_mb)) - .bind(if state.config.features.transport { - input - .transport - .as_deref() - .unwrap_or(&state.config.defaults.transport_default) - } else { - &state.config.defaults.transport_default - }) - .bind(input.backupmx) - .bind(input.active); - if has_expiry { - q = q.bind(input.password_expiry.unwrap_or(0)); + let has_description = state.db.has_column(Table::Domain, "description").await?; + let mut sets = vec![ + "aliases=?", + "mailboxes=?", + "maxquota=?", + "quota=?", + "transport=?", + "backupmx=?", + "active=?", + "modified=CURRENT_TIMESTAMP", + ]; + if has_description { + sets.push("description=?"); } - q = q.bind(&domain_name); - q.execute(state.db.pool()).await?; - hooks::run( - &state.config.hooks.domain_postedit, - &[("domain", &domain_name)], - None, - ) - .await?; + if has_expiry { + sets.push("password_expiry=?"); + } + let sql = state.db.q(&format!( + "UPDATE {table} SET {} WHERE domain=?", + sets.join(",") + )); + let mut q = sqlx::query(&sql) + .bind(values.aliases) + .bind(values.mailboxes) + .bind(values.maxquota) + .bind(values.quota) + .bind(&values.transport) + .bind(values.backupmx) + .bind(values.active); + if has_description { + q = q.bind(&values.description); + } + if has_expiry { + q = q.bind(values.password_expiry); + } + q.bind(&domain_name).execute(state.db.pool()).await?; + run_postedit(&state, &domain_name).await; state .db .log( @@ -322,33 +518,105 @@ pub async fn update( Ok(Json(serde_json::json!({"ok":true}))) } +#[derive(Deserialize)] +pub struct ActiveInput { + pub active: bool, +} + +/// `editactive.php?table=domain` +pub async fn set_active( + State(state): State>, + Path(domain_path): Path, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_global()?; + let domain_name = validation::domain(&domain_path)?; + let table = state.db.table(Table::Domain); + let sql = state.db.q(&format!( + "UPDATE {table} SET active=?,modified=CURRENT_TIMESTAMP WHERE domain=?" + )); + if sqlx::query(&sql) + .bind(input.active) + .bind(&domain_name) + .execute(state.db.pool()) + .await? + .rows_affected() + == 0 + { + return Err(AppError::not_found("Domain does not exist")); + } + run_postedit(&state, &domain_name).await; + state + .db + .log( + &state.config, + &session.username, + &domain_name, + "edit_domain_state", + &format!( + "{domain_name}: {}", + if input.active { "active" } else { "inactive" } + ), + ) + .await; + Ok(Json(serde_json::json!({"ok":true}))) +} + +async fn run_postedit(state: &AppState, domain: &str) { + if let Err(error) = hooks::run( + &state.config.hooks.domain_postedit, + &[("domain", domain)], + None, + ) + .await + { + tracing::warn!(%error, %domain, "domain post-edit hook failed"); + } +} + pub async fn remove( State(state): State>, Path(domain_path): Path, jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; let domain_name = validation::domain(&domain_path)?; + let table = state.db.table(Table::Domain); + let exists = state + .db + .q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1")); + if sqlx::query(&exists) + .bind(&domain_name) + .fetch_optional(state.db.pool()) + .await? + .is_none() + { + return Err(AppError::not_found("Domain does not exist")); + } if state.config.features.alias_domain && state.db.has_table(Table::AliasDomain).await { let ad = state.db.table(Table::AliasDomain); - let sql = state - .db - .q(&format!("SELECT 1 FROM {ad} WHERE target_domain=? LIMIT 1")); - if sqlx::query(&sql) + let sql = state.db.q(&format!( + "SELECT alias_domain FROM {ad} WHERE target_domain=? LIMIT 1" + )); + if let Some(row) = sqlx::query(&sql) .bind(&domain_name) .fetch_optional(state.db.pool()) .await? - .is_some() { - return Err(AppError::conflict( - "Domain is still the target of an alias domain", - )); + return Err(AppError::conflict(format!( + "The domain is the target of the alias domain {}; delete that alias domain first", + row_string(&row, "alias_domain") + ))); } } + // PostfixAdmin deletes dependent rows directly on the database level. let like = format!("%@{domain_name}"); - for (table, col, extra_like) in [ + let mut targets = vec![ (Table::DomainAdmins, "domain", false), (Table::Alias, "domain", false), (Table::Mailbox, "domain", false), @@ -358,11 +626,17 @@ pub async fn remove( (Table::Quota, "username", true), (Table::Quota2, "username", true), (Table::Fetchmail, "mailbox", true), + (Table::MailboxAppPassword, "username", true), (Table::Log, "domain", false), - ] { - if !state.db.has_table(table).await { - continue; + ]; + let mut present = Vec::new(); + for item in targets.drain(..) { + if state.db.has_table(item.0).await { + present.push(item); } + } + let mut tx = state.db.pool().begin().await?; + for (table, col, extra_like) in present { let t = state.db.table(table); let sql = if extra_like { state @@ -375,25 +649,23 @@ pub async fn remove( if extra_like { q = q.bind(&like); } - q.execute(state.db.pool()).await?; + q.execute(&mut *tx).await?; } - let table = state.db.table(Table::Domain); let sql = state.db.q(&format!("DELETE FROM {table} WHERE domain=?")); - if sqlx::query(&sql) + sqlx::query(&sql) .bind(&domain_name) - .execute(state.db.pool()) - .await? - .rows_affected() - != 1 - { - return Err(AppError::not_found("Domain not found")); - } - hooks::run( + .execute(&mut *tx) + .await?; + tx.commit().await?; + if let Err(error) = hooks::run( &state.config.hooks.domain_postdeletion, &[("domain", &domain_name)], None, ) - .await?; + .await + { + tracing::warn!(%error, domain = %domain_name, "domain post-deletion hook failed"); + } state .db .log( @@ -419,7 +691,7 @@ pub async fn dns_refresh( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; let domain = validation::domain(&domain_path)?; session.require_domain(&domain)?; if state.config.features.domain_dns_status_check == 0 { @@ -445,7 +717,7 @@ pub async fn dns_refresh( Ok(Json(DnsRefreshResult { domain, active })) } -async fn check_dns(domain: &str, mode: u8) -> bool { +pub(crate) async fn check_dns(domain: &str, mode: u8) -> bool { let resolver = match TokioAsyncResolver::tokio_from_system_conf() { Ok(r) => r, Err(_) => return false, diff --git a/src/routes/fetchmail.rs b/src/routes/fetchmail.rs index b76f8c7..1460413 100644 --- a/src/routes/fetchmail.rs +++ b/src/routes/fetchmail.rs @@ -52,7 +52,7 @@ pub async fn list( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; if !state.config.features.fetchmail { return Ok(Json(Vec::new())); } @@ -124,7 +124,7 @@ pub async fn create( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; require(&state, &session, &input.mailbox)?; let mailbox = validation::email(&input.mailbox)?; let (_, domain) = mailbox.rsplit_once('@').expect("validated email"); @@ -203,7 +203,7 @@ pub async fn create( query = query .bind(input.src_auth.as_deref().unwrap_or("password")) .bind(&input.src_user) - .bind(&input.src_password) + .bind(encode_src_password(&input.src_password)) .bind(&input.src_folder) .bind(input.poll_time) .bind(input.fetchall) @@ -247,7 +247,7 @@ pub async fn update( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; let table = state.db.table(Table::Fetchmail); let get = state .db @@ -320,7 +320,7 @@ pub async fn update( .bind(input.src_auth.as_deref().unwrap_or("password")) .bind(&input.src_user); if !input.src_password.is_empty() { - query = query.bind(&input.src_password); + query = query.bind(encode_src_password(&input.src_password)); } query = query .bind(&input.src_folder) @@ -354,7 +354,7 @@ pub async fn remove( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; let table = state.db.table(Table::Fetchmail); let get = state .db @@ -446,3 +446,18 @@ fn map(row: &sqlx::any::AnyRow) -> FetchmailSummary { active: row_bool(row, "active"), } } + +/// PostfixAdmin stores `src_password` base64-encoded (field type `b64p`); +/// `ADDITIONS/fetchmail.pl` decodes it before calling fetchmail. +fn encode_src_password(password: &str) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(password.as_bytes()) +} + +#[cfg(test)] +mod b64_tests { + #[test] + fn src_password_is_base64_like_postfixadmin() { + assert_eq!(super::encode_src_password("secret"), "c2VjcmV0"); + } +} diff --git a/src/routes/logs.rs b/src/routes/logs.rs index 6ee5307..9877194 100644 --- a/src/routes/logs.rs +++ b/src/routes/logs.rs @@ -1,7 +1,9 @@ +//! `viewlog.php` – audit log with domain filter and paging. + use crate::{ - db::{Table, row_string}, + db::{Table, row_i64, row_string}, error::AppResult, - models::LogEntry, + models::{ListQuery, LogEntry, Page}, routes, state::AppState, validation, @@ -11,72 +13,89 @@ use axum::{ extract::{Query, State}, }; use axum_extra::extract::cookie::CookieJar; -use serde::Deserialize; use std::sync::Arc; -#[derive(Deserialize)] -pub struct Filter { - pub domain: Option, - pub limit: Option, -} + pub async fn list( State(state): State>, jar: CookieJar, - Query(f): Query, -) -> AppResult>> { - let s = routes::session(&state, &jar)?; - s.require_admin()?; - if !state.config.features.logging { - return Ok(Json(Vec::new())); + Query(query): Query, +) -> AppResult>> { + let session = routes::session(&state, &jar).await?; + session.require_admin()?; + let (limit, offset, page, page_size) = query.paging(50); + let empty = || { + Json(Page { + items: Vec::new(), + total: 0, + page, + page_size, + }) + }; + if !state.config.features.logging || !state.db.has_table(Table::Log).await { + return Ok(empty()); } - let t = state.db.table(Table::Log); + let table = state.db.table(Table::Log); + let mut where_sql = String::from(" WHERE 1=1"); + let mut binds: Vec = Vec::new(); + match query.domain.as_deref().filter(|d| !d.is_empty()) { + Some(raw) => { + let domain = validation::domain(raw)?; + session.require_domain(&domain)?; + where_sql.push_str(" AND domain=?"); + binds.push(domain); + } + None if !session.role.is_global() => { + // "All domains" for a domain admin = all of his domains. + let domains = routes::scoped_domains(&session); + if domains.is_empty() { + return Ok(empty()); + } + where_sql.push_str(" AND domain IN ("); + where_sql.push_str(&vec!["?"; domains.len()].join(",")); + where_sql.push(')'); + binds.extend(domains); + } + None => {} + } + if let Some(search) = query.search() { + let pattern = crate::db::like_pattern(&search); + where_sql.push_str( + " AND (LOWER(username) LIKE ? ESCAPE '!' OR LOWER(action) LIKE ? ESCAPE '!' OR LOWER(data) LIKE ? ESCAPE '!')", + ); + binds.extend([pattern.clone(), pattern.clone(), pattern]); + } + let count_sql = state + .db + .q(&format!("SELECT COUNT(*) AS n FROM {table}{where_sql}")); + let mut count = sqlx::query(&count_sql); + for b in &binds { + count = count.bind(b.as_str()); + } + let total = row_i64(&count.fetch_one(state.db.pool()).await?, "n"); let ts = state.db.cast_text("timestamp"); - let mut qsql = format!("SELECT {ts} AS timestamp,username,domain,action,data FROM {t}"); - let mut bind = None; - if let Some(raw) = f.domain { - let d = validation::domain(&raw)?; - s.require_domain(&d)?; - qsql.push_str(" WHERE domain=?"); - bind = Some(d); - } else if !s.role.is_global() { - let ds: Vec<_> = s - .allowed_domains - .iter() - .filter(|d| d.as_str() != "ALL") - .cloned() - .collect(); - if ds.is_empty() { - return Ok(Json(Vec::new())); - } - qsql.push_str(" WHERE domain IN ("); - qsql.push_str(&vec!["?"; ds.len()].join(",")); - qsql.push(')'); - let sql = state.db.q(&(qsql + " ORDER BY timestamp DESC")); - let mut q = sqlx::query(&sql); - for d in ds { - q = q.bind(d); - } - let rows = q.fetch_all(state.db.pool()).await?; - return Ok(Json( - rows.iter() - .take(f.limit.unwrap_or(200).min(1000)) - .map(map) - .collect(), - )); + let mut sql = format!( + "SELECT {ts} AS timestamp,username,domain,action,data FROM {table}{where_sql} ORDER BY timestamp DESC" + ); + if limit.is_some() { + sql.push_str(" LIMIT ? OFFSET ?"); } - qsql.push_str(" ORDER BY timestamp DESC"); - let sql = state.db.q(&qsql); + let sql = state.db.q(&sql); let mut q = sqlx::query(&sql); - if let Some(b) = bind { - q = q.bind(b); + for b in &binds { + q = q.bind(b.as_str()); + } + if let Some(limit) = limit { + q = q.bind(limit).bind(offset); } let rows = q.fetch_all(state.db.pool()).await?; - Ok(Json( - rows.iter() - .take(f.limit.unwrap_or(200).min(1000)) - .map(map) - .collect(), - )) + Ok(Json(Page { + items: rows.iter().map(map).collect(), + total, + page, + page_size, + })) } + fn map(r: &sqlx::any::AnyRow) -> LogEntry { LogEntry { timestamp: row_string(r, "timestamp"), diff --git a/src/routes/mail.rs b/src/routes/mail.rs index c9102b0..0247650 100644 --- a/src/routes/mail.rs +++ b/src/routes/mail.rs @@ -19,7 +19,7 @@ pub async fn send( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; if !state.config.features.sendmail { return Err(AppError::forbidden("Send mail is disabled")); @@ -58,7 +58,7 @@ pub async fn broadcast( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; if !state.config.features.sendmail { return Err(AppError::forbidden("Broadcast mail is disabled")); diff --git a/src/routes/mailboxes.rs b/src/routes/mailboxes.rs index 4d35775..ed059c3 100644 --- a/src/routes/mailboxes.rs +++ b/src/routes/mailboxes.rs @@ -1,3 +1,5 @@ +//! Mailboxes – port of PostfixAdmin's `MailboxHandler`. + use std::{process::Stdio, sync::Arc}; use axum::{ @@ -6,55 +8,150 @@ use axum::{ http::HeaderMap, }; use axum_extra::extract::cookie::CookieJar; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tokio::process::Command; use crate::{ - db::{Table, row_bool, row_i64, row_opt_bool, row_opt_string, row_string}, + auth::Session, + db::{ + Table, like_pattern, row_bool, row_i64, row_opt_bool, row_opt_i64, row_opt_string, + row_string, + }, error::{AppError, AppResult}, - hooks, mailer, - models::{AccountKind, MailboxInput, MailboxSummary}, + hooks, + limits::{self, DomainProps}, + mailer, + models::{AccountKind, ListQuery, MailboxInput, MailboxSummary, Page}, passwords, routes, + routes::aliases, state::AppState, validation, }; -#[derive(Deserialize)] -pub struct Filter { - pub domain: Option, -} - pub async fn list( State(state): State>, jar: CookieJar, - Query(filter): Query, -) -> AppResult>> { - let session = routes::session(&state, &jar)?; - let mailbox = state.db.table(Table::Mailbox); - let vacation = state.db.table(Table::Vacation); - let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?; - let has_expiry = state - .db - .has_column(Table::Mailbox, "password_expiry") - .await?; - let has_phone = state.db.has_column(Table::Mailbox, "phone").await?; - let has_email = state.db.has_column(Table::Mailbox, "email_other").await?; - let has_created = state.db.has_column(Table::Mailbox, "created").await?; - let has_modified = state.db.has_column(Table::Mailbox, "modified").await?; - let active = state.db.flag("m.active", "active"); + Query(query): Query, +) -> AppResult>> { + let session = routes::session(&state, &jar).await?; + let (limit, offset, page, page_size) = query.paging(state.config.defaults.page_size); + let mut filter = MailboxFilter::default(); + if session.kind == AccountKind::User { + filter.username = Some(session.username.clone()); + } else if let Some(raw) = query.domain.as_deref().filter(|d| !d.is_empty()) { + let d = validation::domain(raw)?; + session.require_domain(&d)?; + filter.domains = Some(vec![d]); + } else if !session.role.is_global() { + let ds = routes::scoped_domains(&session); + if ds.is_empty() { + return Ok(Json(Page { + items: Vec::new(), + total: 0, + page, + page_size, + })); + } + filter.domains = Some(ds); + } + filter.search = query.search(); + let (items, total) = load_mailboxes(&state, &filter, limit, offset).await?; + Ok(Json(Page { + items, + total, + page, + page_size, + })) +} + +pub async fn get( + State(state): State>, + Path(path_user): Path, + jar: CookieJar, +) -> AppResult> { + let session = routes::session(&state, &jar).await?; + let username = validation::email(&path_user)?.to_ascii_lowercase(); + authorize_read(&session, &username)?; + one(&state, &username).await.map(Json) +} + +pub(crate) async fn one(state: &AppState, username: &str) -> AppResult { + let filter = MailboxFilter { + username: Some(username.to_string()), + ..Default::default() + }; + load_mailboxes(state, &filter, Some(1), 0) + .await? + .0 + .into_iter() + .next() + .ok_or_else(|| AppError::not_found("Mailbox does not exist")) +} + +fn authorize_read(session: &Session, username: &str) -> AppResult<()> { + if session.kind == AccountKind::User { + if session.username.eq_ignore_ascii_case(username) { + return Ok(()); + } + return Err(AppError::forbidden("Mailbox is outside this account")); + } + let (_, domain) = username + .rsplit_once('@') + .ok_or_else(|| AppError::bad_request("Invalid mailbox"))?; + session.require_domain(domain) +} + +#[derive(Default)] +pub(crate) struct MailboxFilter { + pub username: Option, + pub domains: Option>, + pub search: Option, +} + +pub(crate) async fn load_mailboxes( + state: &AppState, + filter: &MailboxFilter, + limit: Option, + offset: i64, +) -> AppResult<(Vec, i64)> { + let db = &state.db; + let mailbox = db.table(Table::Mailbox); + let alias = db.table(Table::Alias); + let has_smtp = db.has_column(Table::Mailbox, "smtp_active").await?; + let has_expiry = db.has_column(Table::Mailbox, "password_expiry").await?; + let has_domain_expiry = db.has_column(Table::Domain, "password_expiry").await?; + let has_phone = db.has_column(Table::Mailbox, "phone").await?; + let has_email = db.has_column(Table::Mailbox, "email_other").await?; + let has_created = db.has_column(Table::Mailbox, "created").await?; + let has_modified = db.has_column(Table::Mailbox, "modified").await?; + let has_totp = db.has_column(Table::Mailbox, "totp_secret").await?; + let has_vacation = db.has_table(Table::Vacation).await; + let active = db.flag("m.active", "active"); let smtp = if has_smtp { - state.db.flag("m.smtp_active", "smtp_active") + db.flag("m.smtp_active", "smtp_active") } else { "NULL AS smtp_active".into() }; let expiry = if has_expiry { - format!( - "{} AS password_expiry", - state.db.cast_text("m.password_expiry") - ) + format!("{} AS password_expiry", db.cast_text("m.password_expiry")) } else { "NULL AS password_expiry".into() }; + let now = match db.dialect() { + crate::db::Dialect::Sqlite => "datetime('now')", + _ => "CURRENT_TIMESTAMP", + }; + let expired = if has_expiry && has_domain_expiry && state.config.features.password_expiration { + let d = db.table(Table::Domain); + db.flag( + &format!( + "CASE WHEN m.password_expiry <= {now} AND (SELECT dx.password_expiry FROM {d} dx WHERE dx.domain=m.domain) > 0 THEN 1 ELSE 0 END" + ), + "password_expired", + ) + } else { + "0 AS password_expired".into() + }; let phone = if has_phone { "m.phone" } else { "'' AS phone" }; let email = if has_email { "m.email_other" @@ -62,103 +159,222 @@ pub async fn list( "'' AS email_other" }; let created = if has_created { - format!("{} AS created", state.db.cast_text("m.created")) + format!("{} AS created", db.cast_text("m.created")) } else { "NULL AS created".into() }; let modified = if has_modified { - format!("{} AS modified", state.db.cast_text("m.modified")) + format!("{} AS modified", db.cast_text("m.modified")) } else { "NULL AS modified".into() }; - let vacation_expr = if state.db.has_table(Table::Vacation).await { - state.db.flag( - "CASE WHEN v.email IS NULL THEN 0 ELSE v.active END", - "vacation_active", + let totp = if has_totp { + db.flag( + "CASE WHEN m.totp_secret IS NULL OR m.totp_secret = '' THEN 0 ELSE 1 END", + "totp_enabled", ) } else { - "0 AS vacation_active".into() + "0 AS totp_enabled".into() }; - let vacation_join = if state.db.has_table(Table::Vacation).await { - format!("LEFT JOIN {vacation} v ON v.email=m.username") + let (vacation_expr, vacation_join) = if has_vacation { + let vacation = db.table(Table::Vacation); + ( + db.flag("CASE WHEN v.active THEN 1 ELSE 0 END", "vacation_active"), + format!("LEFT JOIN {vacation} v ON v.email=m.username"), + ) } else { - String::new() + ("0 AS vacation_active".into(), String::new()) }; - let mut used_expr = "NULL AS used_quota_bytes".to_string(); - let mut used_join = String::new(); - if state.config.features.used_quotas { - let qt = if state.config.features.new_quota_table { - Table::Quota2 - } else { - Table::Quota - }; - if state.db.has_table(qt).await { - let q = state.db.table(qt); - if matches!(qt, Table::Quota2) { - used_expr = "q.bytes AS used_quota_bytes".into(); - used_join = format!("LEFT JOIN {q} q ON q.username=m.username"); - } else { - used_expr = "q.current AS used_quota_bytes".into(); - used_join = - format!("LEFT JOIN {q} q ON q.username=m.username AND q.path='quota/storage'"); - } - } + let (used_expr, used_join) = match crate::routes::domains::used_quota_source(state).await { + Some((Table::Quota2, q)) => ( + "q.bytes AS used_quota_bytes".to_string(), + format!("LEFT JOIN {q} q ON q.username=m.username"), + ), + Some((_, q)) => ( + "q.current AS used_quota_bytes".to_string(), + format!("LEFT JOIN {q} q ON q.username=m.username AND q.path='quota/storage'"), + ), + None => ("NULL AS used_quota_bytes".to_string(), String::new()), + }; + + let mut where_sql = String::from(" WHERE 1=1"); + let mut binds: Vec = Vec::new(); + if let Some(username) = &filter.username { + where_sql.push_str(" AND m.username=?"); + binds.push(username.clone()); } + if let Some(domains) = &filter.domains { + if domains.is_empty() { + return Ok((Vec::new(), 0)); + } + where_sql.push_str(" AND m.domain IN ("); + where_sql.push_str(&vec!["?"; domains.len()].join(",")); + where_sql.push(')'); + binds.extend(domains.iter().cloned()); + } + if let Some(search) = &filter.search { + let pattern = like_pattern(search); + where_sql.push_str( + " AND (LOWER(m.username) LIKE ? ESCAPE '!' OR LOWER(m.name) LIKE ? ESCAPE '!')", + ); + binds.push(pattern.clone()); + binds.push(pattern); + } + + let count_sql = db.q(&format!("SELECT COUNT(*) AS n FROM {mailbox} m{where_sql}")); + let mut count_query = sqlx::query(&count_sql); + for b in &binds { + count_query = count_query.bind(b.as_str()); + } + let total = row_i64(&count_query.fetch_one(db.pool()).await?, "n"); + let mut sql = format!( - "SELECT m.username,m.name,m.maildir,m.quota,m.local_part,m.domain,{active},{smtp},{expiry},{phone},{email},{created},{modified},{used_expr},{vacation_expr} FROM {mailbox} m {vacation_join} {used_join} WHERE 1=1" + "SELECT m.username,m.name,m.maildir,m.quota,m.local_part,m.domain,{active},{smtp},{expiry},{expired},{phone},{email},{created},{modified},{totp},{used_expr},{vacation_expr}, a.goto AS alias_goto \ + FROM {mailbox} m {vacation_join} {used_join} LEFT JOIN {alias} a ON a.address=m.username{where_sql} ORDER BY m.username" ); - let mut binds = Vec::new(); - if session.kind == AccountKind::User { - sql.push_str(" AND m.username=?"); - binds.push(session.username.clone()); - } else if let Some(raw) = filter.domain { - let d = validation::domain(&raw)?; - session.require_domain(&d)?; - sql.push_str(" AND m.domain=?"); - binds.push(d); - } else if !session.role.is_global() { - let ds: Vec<_> = session - .allowed_domains - .iter() - .filter(|d| d.as_str() != "ALL") - .cloned() - .collect(); - if ds.is_empty() { - return Ok(Json(Vec::new())); - } - sql.push_str(" AND m.domain IN ("); - sql.push_str(&vec!["?"; ds.len()].join(",")); - sql.push(')'); - binds.extend(ds); + if limit.is_some() { + sql.push_str(" LIMIT ? OFFSET ?"); } - sql.push_str(" ORDER BY m.username"); - let sql = state.db.q(&sql); - let mut q = sqlx::query(&sql); - for b in binds { - q = q.bind(b); + let sql = db.q(&sql); + let mut query = sqlx::query(&sql); + for b in &binds { + query = query.bind(b.as_str()); } - let rows = q.fetch_all(state.db.pool()).await?; - Ok(Json( - rows.iter() - .map(|r| MailboxSummary { - username: row_string(r, "username"), + if let Some(limit) = limit { + query = query.bind(limit).bind(offset); + } + let rows = query.fetch_all(db.pool()).await?; + let multiplier = state.config.defaults.quota_multiplier.max(1); + let items = rows + .iter() + .map(|r| { + let username = row_string(r, "username"); + let quota = row_i64(r, "quota"); + let goto = aliases::split_goto(&row_string(r, "alias_goto")); + let vacation = aliases::vacation_destination(state, &username); + let deliver_to_mailbox = goto.iter().any(|g| g.eq_ignore_ascii_case(&username)); + let forward_to = goto + .into_iter() + .filter(|g| { + !g.eq_ignore_ascii_case(&username) && !g.eq_ignore_ascii_case(&vacation) + }) + .collect(); + MailboxSummary { name: row_string(r, "name"), maildir: row_string(r, "maildir"), - quota: row_i64(r, "quota"), + quota, + quota_mb: if quota < 0 { -1 } else { quota / multiplier }, local_part: row_string(r, "local_part"), domain: row_string(r, "domain"), active: row_bool(r, "active"), smtp_active: row_opt_bool(r, "smtp_active"), password_expiry: row_opt_string(r, "password_expiry"), + password_expired: row_bool(r, "password_expired"), phone: row_string(r, "phone"), email_other: row_string(r, "email_other"), - used_quota_bytes: crate::db::row_opt_i64(r, "used_quota_bytes"), + used_quota_bytes: row_opt_i64(r, "used_quota_bytes"), vacation_active: row_bool(r, "vacation_active"), created: row_opt_string(r, "created"), modified: row_opt_string(r, "modified"), - }) - .collect(), - )) + totp_enabled: row_bool(r, "totp_enabled"), + forward_to, + deliver_to_mailbox, + username, + } + }) + .collect(); + Ok((items, total)) +} + +#[derive(Deserialize)] +pub struct QuotaHintQuery { + pub domain: String, + pub username: Option, +} + +#[derive(Serialize)] +pub struct QuotaHint { + /// Maximum quota (MB) for this mailbox; 0 = unlimited, <0 = disabled. + pub allowed_mb: i64, + pub maxquota_mb: i64, + pub domain_quota_mb: i64, + pub domain_allocated_mb: i64, + pub default_mb: i64, + pub mailboxes: i64, + pub mailbox_count: i64, + pub quota_enabled: bool, +} + +/// Quota limits for the mailbox form (PostfixAdmin shows them as the quota +/// field description: "max: N MB / unlimited / disabled"). +pub async fn quota_hint( + State(state): State>, + jar: CookieJar, + Query(query): Query, +) -> AppResult> { + let session = routes::session(&state, &jar).await?; + let domain = validation::domain(&query.domain)?; + session.require_domain(&domain)?; + let props = DomainProps::require(&state, &domain).await?; + let current_mb = match query.username.as_deref() { + Some(u) if !u.is_empty() => { + let u = validation::email(u)?.to_ascii_lowercase(); + one(&state, &u).await.map(|m| m.quota_mb).unwrap_or(0) + } + _ => 0, + }; + let f = &state.config.features; + let allowed = limits::allowed_quota(f.quota, f.domain_quota, &props, current_mb); + Ok(Json(QuotaHint { + allowed_mb: allowed, + maxquota_mb: props.maxquota, + domain_quota_mb: props.quota, + domain_allocated_mb: props.total_quota, + default_mb: default_quota(&props, allowed), + mailboxes: props.mailboxes, + mailbox_count: props.mailbox_count, + quota_enabled: f.quota, + })) +} + +fn default_quota(props: &DomainProps, allowed: i64) -> i64 { + if props.maxquota < 0 { + -1 + } else if allowed > 0 { + allowed + } else { + 0 + } +} + +fn validate_name(name: &str) -> AppResult { + // PostfixAdmin: up to 255 characters, no control characters. + if name.chars().count() > 255 || name.chars().any(char::is_control) { + return Err(AppError::bad_request( + "The name may contain up to 255 characters and no control characters", + )); + } + Ok(name.trim().to_string()) +} + +fn validate_other_email(value: &str) -> AppResult { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + validation::email(value) +} + +fn validate_phone(value: &str) -> AppResult { + let value = value.trim(); + if value.len() > 40 + || !value + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, '+' | ' ' | '-' | '(' | ')' | '/')) + { + return Err(AppError::bad_request("Invalid phone number")); + } + Ok(value.to_string()) } pub async fn create( @@ -167,63 +383,90 @@ pub async fn create( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; - let domain = validation::resolvable_domain(&state.config, &input.domain).await?; - session.require_domain(&domain)?; - let local = if let Some(u) = input.username.as_deref() { - let e = validation::resolvable_email(&state.config, u).await?; - let (l, d) = e.rsplit_once('@').unwrap(); - if d != domain { - return Err(AppError::bad_request( - "Mailbox domain does not match domain", - )); + let (local, domain) = match input.username.as_deref().filter(|u| !u.trim().is_empty()) { + Some(u) => { + let e = validation::resolvable_email(&state.config, u).await?; + let (l, d) = e.rsplit_once('@').expect("validated email"); + (l.to_ascii_lowercase(), d.to_string()) + } + None => { + let domain = validation::resolvable_domain(&state.config, &input.domain).await?; + let raw = input.local_part.as_deref().ok_or_else(|| { + AppError::bad_request("The mailbox name (local part) is required") + })?; + if raw.contains('@') { + return Err(AppError::bad_request( + "The local part must not contain an @ sign", + )); + } + (validation::local_part(raw)?.to_ascii_lowercase(), domain) } - l.to_string() - } else { - validation::local_part( - input - .local_part - .as_deref() - .ok_or_else(|| AppError::bad_request("local_part or username is required"))?, - )? }; - let username = format!("{local}@{domain}"); - let password = input - .password - .as_deref() - .ok_or_else(|| AppError::bad_request("Password is required"))?; - enforce_mailbox_limit(&state, &domain).await?; - let mailbox = state.db.table(Table::Mailbox); - let alias = state.db.table(Table::Alias); - let check = state - .db - .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); - if sqlx::query(&check) - .bind(&username) - .fetch_optional(state.db.pool()) - .await? - .is_some() - { - return Err(AppError::conflict("Mailbox already exists")); - } - let check = state - .db - .q(&format!("SELECT 1 FROM {alias} WHERE address=? LIMIT 1")); - if sqlx::query(&check) - .bind(&username) - .fetch_optional(state.db.pool()) - .await? - .is_some() - { - return Err(AppError::conflict( - "An alias with this address already exists", + if !input.domain.trim().is_empty() && validation::domain(&input.domain)? != domain { + return Err(AppError::bad_request( + "Mailbox domain does not match domain", )); } - let quota_mb = mailbox_quota(&state, &domain, input.quota, None).await?; - let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier); + session.require_domain(&domain)?; + let username = format!("{local}@{domain}"); + let name = validate_name(input.name.as_deref().unwrap_or(""))?; + let email_other = validate_other_email(input.email_other.as_deref().unwrap_or(""))?; + let phone = validate_phone(input.phone.as_deref().unwrap_or(""))?; + + let props = DomainProps::require(&state, &domain).await?; + props.ensure_mailbox_allowed()?; + let mailbox = state.db.table(Table::Mailbox); + let alias = state.db.table(Table::Alias); + for (table, column, message) in [ + ( + &mailbox, + "username", + "A mailbox with this address already exists", + ), + ( + &alias, + "address", + "An alias with this address already exists", + ), + ] { + let check = state + .db + .q(&format!("SELECT 1 FROM {table} WHERE {column}=? LIMIT 1")); + if sqlx::query(&check) + .bind(&username) + .fetch_optional(state.db.pool()) + .await? + .is_some() + { + return Err(AppError::conflict(message)); + } + } + + let f = &state.config.features; + let allowed = limits::allowed_quota(f.quota, f.domain_quota, &props, 0); + let quota_mb = input + .quota + .unwrap_or_else(|| default_quota(&props, allowed)); + let others = limits::others_total_quota(&state, &domain, None).await?; + limits::check_quota(f.quota, f.domain_quota, &props, quota_mb, others)?; + let quota_bytes = quota_to_bytes(&state, quota_mb); + + // PostfixAdmin: an empty password is auto-generated when generate_password is on. + let supplied = input.password.as_deref().unwrap_or(""); + let (password, generated) = if supplied.is_empty() { + if f.generate_password { + (passwords::generate_password(&state.config), true) + } else { + return Err(AppError::bad_request("Password is required")); + } + } else { + (supplied.to_string(), false) + }; + let hash = passwords::hash(&state.config, &password, &username).await?; let maildir = maildir(&state.config, &domain, &local, &username)?; - let hash = passwords::hash(&state.config, password, &username).await?; + let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?; let has_expiry = state .db @@ -231,14 +474,9 @@ pub async fn create( .await?; let has_phone = state.db.has_column(Table::Mailbox, "phone").await?; let has_email = state.db.has_column(Table::Mailbox, "email_other").await?; - let expiry = if has_expiry { - password_expiry_days(&state, &domain).await? - } else { - 0 - }; - if has_email && !input.email_other.trim().is_empty() { - let _ = validation::email(&input.email_other)?; - } + let has_alias_desc = state.db.has_column(Table::Alias, "description").await?; + let active = input.active.unwrap_or(true); + let mut cols = vec![ "username", "password", @@ -277,41 +515,35 @@ pub async fn create( } if has_expiry { cols.push("password_expiry"); - vals.push(if expiry > 0 { - "CURRENT_TIMESTAMP" - } else { - "'9999-12-31 23:59:59'" - }); + vals.push("'9999-12-31 23:59:59'"); } let sql = state.db.q(&format!( "INSERT INTO {mailbox} ({}) VALUES ({})", cols.join(","), vals.join(",") )); + let mut tx = state.db.pool().begin().await?; let mut q = sqlx::query(&sql) .bind(&username) - .bind(hash) - .bind(&input.name) + .bind(&hash) + .bind(&name) .bind(&maildir) - .bind(quota) + .bind(quota_bytes) .bind(&local) .bind(&domain) - .bind(input.active); + .bind(active); if has_smtp { q = q.bind(input.smtp_active.unwrap_or(true)); } if has_phone { - q = q.bind(&input.phone); + q = q.bind(&phone); } if has_email { - q = q.bind(&input.email_other); + q = q.bind(&email_other); } - q.execute(state.db.pool()).await?; - if has_expiry { - passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?; - } - let has_desc = state.db.has_column(Table::Alias, "description").await?; - let sql = if has_desc { + q.execute(&mut *tx).await?; + // Every mailbox gets an alias pointing to itself (PostfixAdmin "alias control"). + let sql = if has_alias_desc { state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES (?,?,?,'',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)")) } else { state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)")) @@ -320,11 +552,17 @@ pub async fn create( .bind(&username) .bind(&username) .bind(&domain) - .bind(input.active) - .execute(state.db.pool()) + .bind(active) + .execute(&mut *tx) .await?; - let quota_text = quota.to_string(); - hooks::run( + tx.commit().await?; + if has_expiry { + passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?; + } + + let mut warnings = Vec::new(); + let quota_text = quota_bytes.max(0).to_string(); + if let Err(error) = hooks::run( &state.config.hooks.mailbox_postcreation, &[ ("username", &username), @@ -334,27 +572,47 @@ pub async fn create( ], None, ) - .await?; + .await + { + tracing::warn!(%error, %username, "mailbox post-creation hook failed"); + warnings.push("The mailbox post-creation script failed".to_string()); + } + let stdin = format!("\0{password}\0"); + if let Err(error) = hooks::run( + &state.config.hooks.mailbox_postpassword, + &[("username", &username), ("domain", &domain)], + Some(stdin.as_bytes()), + ) + .await + { + tracing::warn!(%error, %username, "mailbox post-password hook failed"); + warnings.push("The mailbox password script failed".to_string()); + } let subdirs_created = match create_mailbox_subdirs(&state.config, &username).await { Ok(value) => value, Err(error) => { tracing::warn!(%error, %username, "mailbox created but automatic subfolder creation failed"); + warnings.push("Mailbox folders could not be created".to_string()); false } }; if input.send_welcome { - let subject = "Welcome"; - if let Err(e) = mailer::send_text( - &state.config, - &session.username, - &username, - subject, - &state.config.defaults.welcome_text, - ) - .await - { - tracing::warn!(%e,"welcome mail failed after mailbox creation"); - } + // Never block mailbox creation on SMTP; deliver the welcome mail in the background. + let config = state.config.clone(); + let recipient = username.clone(); + tokio::spawn(async move { + if let Err(e) = mailer::send_text( + &config, + &recipient, + &recipient, + "Welcome", + &config.defaults.welcome_text, + ) + .await + { + tracing::warn!(%e, username = %recipient, "welcome mail failed after mailbox creation"); + } + }); } state .db @@ -366,9 +624,25 @@ pub async fn create( &username, ) .await; - Ok(Json( - serde_json::json!({"ok":true,"username":username,"maildir":maildir,"quota_bytes":quota,"subdirs_created":subdirs_created}), - )) + let reveal = generated || f.show_password; + Ok(Json(serde_json::json!({ + "ok": true, + "username": username, + "maildir": maildir, + "quota_bytes": quota_bytes, + "subdirs_created": subdirs_created, + "warnings": warnings, + "password": if reveal { Some(password) } else { None }, + "password_generated": generated, + }))) +} + +fn quota_to_bytes(state: &AppState, quota_mb: i64) -> i64 { + if quota_mb < 0 { + -1 + } else { + quota_mb.saturating_mul(state.config.defaults.quota_multiplier.max(1)) + } } pub async fn update( @@ -378,48 +652,65 @@ pub async fn update( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; - let username = validation::email(&path_user)?; - let (_, domain) = username.rsplit_once('@').unwrap(); + let username = validation::email(&path_user)?.to_ascii_lowercase(); + let (local, domain) = username.rsplit_once('@').expect("validated email"); session.require_domain(domain)?; - if let Some(u) = input.username.as_deref() - && validation::email(u)? != username + if let Some(u) = input.username.as_deref().filter(|u| !u.is_empty()) + && !validation::email(u)?.eq_ignore_ascii_case(&username) { return Err(AppError::bad_request("Renaming mailboxes is not supported")); } - let mailbox = state.db.table(Table::Mailbox); - let exists = state - .db - .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); - if sqlx::query(&exists) - .bind(&username) - .fetch_optional(state.db.pool()) - .await? - .is_none() - { - return Err(AppError::not_found("Mailbox not found")); + let current = one(&state, &username).await?; + let props = DomainProps::require(&state, domain).await?; + let f = &state.config.features; + + let name = match input.name.as_deref() { + Some(n) => validate_name(n)?, + None => current.name.clone(), + }; + let quota_mb = input.quota.unwrap_or(current.quota_mb); + if quota_mb != current.quota_mb { + let others = limits::others_total_quota(&state, domain, Some(&username)).await?; + limits::check_quota(f.quota, f.domain_quota, &props, quota_mb, others)?; } - let quota_mb = mailbox_quota(&state, domain, input.quota, Some(&username)).await?; - let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier); + let quota_bytes = if quota_mb == current.quota_mb { + current.quota + } else { + quota_to_bytes(&state, quota_mb) + }; + let active = input.active.unwrap_or(current.active); + let smtp_active = input.smtp_active.or(current.smtp_active).unwrap_or(true); + let phone = match input.phone.as_deref() { + Some(p) => validate_phone(p)?, + None => current.phone.clone(), + }; + let email_other = match input.email_other.as_deref() { + Some(e) => validate_other_email(e)?, + None => current.email_other.clone(), + }; + let new_password = input.password.as_deref().filter(|p| !p.is_empty()); + if new_password.is_some() && !state.config.hooks.mailbox_postpassword.is_empty() { + return Err(AppError::forbidden( + "Passwords are managed by the mailbox_postpassword hook and cannot be changed here", + )); + } + let new_hash = match new_password { + Some(p) => Some(passwords::hash(&state.config, p, &username).await?), + None => None, + }; + + let mailbox = state.db.table(Table::Mailbox); let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?; let has_phone = state.db.has_column(Table::Mailbox, "phone").await?; let has_email = state.db.has_column(Table::Mailbox, "email_other").await?; - if has_email && !input.email_other.trim().is_empty() { - let _ = validation::email(&input.email_other)?; - } let mut sets = vec![ "name=?", "quota=?", "active=?", "modified=CURRENT_TIMESTAMP", ]; - let new_hash = if let Some(p) = input.password.as_deref() { - Some(passwords::hash(&state.config, p, &username).await?) - } else { - None - }; - let password_changed = new_hash.is_some(); if has_smtp { sets.push("smtp_active=?"); } @@ -436,50 +727,48 @@ pub async fn update( "UPDATE {mailbox} SET {} WHERE username=?", sets.join(",") )); - let mut q = sqlx::query(&sql) - .bind(&input.name) - .bind(quota) - .bind(input.active); + let mut tx = state.db.pool().begin().await?; + let mut q = sqlx::query(&sql).bind(&name).bind(quota_bytes).bind(active); if has_smtp { - q = q.bind(input.smtp_active.unwrap_or(true)); + q = q.bind(smtp_active); } if has_phone { - q = q.bind(&input.phone); + q = q.bind(&phone); } if has_email { - q = q.bind(&input.email_other); + q = q.bind(&email_other); } - if let Some(hash) = new_hash { + if let Some(hash) = &new_hash { q = q.bind(hash); } - q.bind(&username).execute(state.db.pool()).await?; - if password_changed { - passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?; - } + q.bind(&username).execute(&mut *tx).await?; + // PostfixAdmin keeps the mailbox alias' active flag in sync with the mailbox. let alias = state.db.table(Table::Alias); let sql = state.db.q(&format!( - "UPDATE {alias} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=? AND goto=?" + "UPDATE {alias} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=?" )); sqlx::query(&sql) - .bind(input.active) + .bind(active) .bind(&username) - .bind(&username) - .execute(state.db.pool()) + .execute(&mut *tx) .await?; - let local = username.split('@').next().unwrap_or(""); - let md = maildir(&state.config, domain, local, &username)?; - let quota_text = quota.to_string(); - hooks::run( - &state.config.hooks.mailbox_postedit, - &[ - ("username", &username), - ("domain", domain), - ("maildir", &md), - ("quota", "a_text), - ], - None, + tx.commit().await?; + if new_hash.is_some() { + passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?; + state.auth.revoke_user(AccountKind::User, &username, None); + } + if !active { + state.auth.revoke_user(AccountKind::User, &username, None); + } + run_postedit( + &state, + &username, + domain, + local, + ¤t.maildir, + quota_bytes, ) - .await?; + .await; state .db .log( @@ -493,78 +782,148 @@ pub async fn update( Ok(Json(serde_json::json!({"ok":true}))) } +async fn run_postedit( + state: &AppState, + username: &str, + domain: &str, + local: &str, + maildir_value: &str, + quota_bytes: i64, +) { + let md = if maildir_value.is_empty() { + maildir(&state.config, domain, local, username).unwrap_or_default() + } else { + maildir_value.to_string() + }; + let quota_text = quota_bytes.max(0).to_string(); + if let Err(error) = hooks::run( + &state.config.hooks.mailbox_postedit, + &[ + ("username", username), + ("domain", domain), + ("maildir", &md), + ("quota", "a_text), + ], + None, + ) + .await + { + tracing::warn!(%error, %username, "mailbox post-edit hook failed"); + } +} + +#[derive(Deserialize)] +pub struct ActiveInput { + pub active: bool, +} + +/// `editactive.php?table=mailbox` – toggles mailbox and its alias together. +pub async fn set_active( + State(state): State>, + Path(path_user): Path, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + session.require_admin()?; + let username = validation::email(&path_user)?.to_ascii_lowercase(); + let (local, domain) = username.rsplit_once('@').expect("validated email"); + session.require_domain(domain)?; + let current = one(&state, &username).await?; + let mailbox = state.db.table(Table::Mailbox); + let alias = state.db.table(Table::Alias); + let mut tx = state.db.pool().begin().await?; + for sql in [ + format!("UPDATE {mailbox} SET active=?,modified=CURRENT_TIMESTAMP WHERE username=?"), + format!("UPDATE {alias} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=?"), + ] { + let sql = state.db.q(&sql); + sqlx::query(&sql) + .bind(input.active) + .bind(&username) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + if !input.active { + state.auth.revoke_user(AccountKind::User, &username, None); + } + run_postedit( + &state, + &username, + domain, + local, + ¤t.maildir, + current.quota, + ) + .await; + state + .db + .log( + &state.config, + &session.username, + domain, + "edit_mailbox_state", + &format!( + "{username}: {}", + if input.active { "active" } else { "inactive" } + ), + ) + .await; + Ok(Json(serde_json::json!({"ok":true}))) +} + pub async fn remove( State(state): State>, Path(path_user): Path, jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; - let username = validation::email(&path_user)?; - let (_, domain) = username.rsplit_once('@').unwrap(); + let username = validation::email(&path_user)?.to_ascii_lowercase(); + let (_, domain) = username.rsplit_once('@').expect("validated email"); session.require_domain(domain)?; - let mailbox = state.db.table(Table::Mailbox); - let sql = state - .db - .q(&format!("SELECT maildir FROM {mailbox} WHERE username=?")); - let row = sqlx::query(&sql) - .bind(&username) - .fetch_optional(state.db.pool()) - .await? - .ok_or_else(|| AppError::not_found("Mailbox not found"))?; - let maildir = row_string(&row, "maildir"); + let current = one(&state, &username).await?; + let mut targets = Vec::new(); for (table, col) in [ (Table::Fetchmail, "mailbox"), (Table::Vacation, "email"), + (Table::VacationNotification, "on_vacation"), (Table::Quota, "username"), (Table::Quota2, "username"), (Table::MailboxAppPassword, "username"), (Table::TotpException, "username"), ] { if state.db.has_table(table).await { - let t = state.db.table(table); - let sql = state.db.q(&format!("DELETE FROM {t} WHERE {col}=?")); - let _ = sqlx::query(&sql) - .bind(&username) - .execute(state.db.pool()) - .await; + targets.push((state.db.table(table), col)); } } - if state.db.has_table(Table::VacationNotification).await { - let t = state.db.table(Table::VacationNotification); - let sql = state.db.q(&format!( - "DELETE FROM {t} WHERE on_vacation=? OR notified=?" - )); - let _ = sqlx::query(&sql) - .bind(&username) - .bind(&username) - .execute(state.db.pool()) - .await; - } let alias = state.db.table(Table::Alias); - let sql = state.db.q(&format!("DELETE FROM {alias} WHERE address=?")); - sqlx::query(&sql) - .bind(&username) - .execute(state.db.pool()) - .await?; - let sql = state - .db - .q(&format!("DELETE FROM {mailbox} WHERE username=?")); - sqlx::query(&sql) - .bind(&username) - .execute(state.db.pool()) - .await?; - hooks::run( + let mailbox = state.db.table(Table::Mailbox); + targets.push((alias, "address")); + targets.push((mailbox, "username")); + let mut tx = state.db.pool().begin().await?; + for (table, col) in targets { + let sql = state.db.q(&format!("DELETE FROM {table} WHERE {col}=?")); + sqlx::query(&sql).bind(&username).execute(&mut *tx).await?; + } + tx.commit().await?; + state.auth.revoke_user(AccountKind::User, &username, None); + if let Err(error) = hooks::run( &state.config.hooks.mailbox_postdeletion, &[ ("username", &username), ("domain", domain), - ("maildir", &maildir), + ("maildir", ¤t.maildir), ], None, ) - .await?; + .await + { + tracing::warn!(%error, %username, "mailbox post-deletion hook failed"); + } state .db .log( @@ -578,6 +937,129 @@ pub async fn remove( Ok(Json(serde_json::json!({"ok":true}))) } +#[derive(Deserialize)] +pub struct ForwardingInput { + /// Forwarding targets (without the mailbox itself). + #[serde(default)] + pub forward_to: Vec, + /// Keep a copy in the mailbox ("Forward and store"). + #[serde(default = "default_true")] + pub deliver_to_mailbox: bool, +} +fn default_true() -> bool { + true +} + +/// Edit the alias belonging to a mailbox (`edit.php?table=alias&edit=` +/// for admins, `users/edit-alias.php` for the mailbox owner). +pub async fn set_forwarding( + State(state): State>, + Path(path_user): Path, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + let username = validation::email(&path_user)?.to_ascii_lowercase(); + let (_, domain) = username.rsplit_once('@').expect("validated email"); + aliases::require_mailbox_alias_control(&state, &session, &username)?; + let _ = one(&state, &username).await?; + let mut goto = + aliases::validate_destinations(&state, &username, &input.forward_to, false).await?; + if input.deliver_to_mailbox { + goto.insert(0, username.clone()); + } + if goto.is_empty() { + return Err(AppError::bad_request( + "Enter at least one forwarding address or keep delivery to the mailbox", + )); + } + aliases::append_vacation_if_active(&state, &username, &mut goto).await?; + let alias = state.db.table(Table::Alias); + let sql = state.db.q(&format!( + "UPDATE {alias} SET goto=?,modified=CURRENT_TIMESTAMP WHERE address=?" + )); + let affected = sqlx::query(&sql) + .bind(goto.join(",")) + .bind(&username) + .execute(state.db.pool()) + .await? + .rows_affected(); + if affected == 0 { + // Repair a missing mailbox alias. + let sql = state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)")); + sqlx::query(&sql) + .bind(&username) + .bind(goto.join(",")) + .bind(domain) + .bind(true) + .execute(state.db.pool()) + .await?; + } + state + .db + .log( + &state.config, + &session.username, + domain, + "edit_alias", + &username, + ) + .await; + Ok(Json(serde_json::json!({"ok":true}))) +} + +#[derive(Deserialize)] +pub struct NameInput { + pub name: String, +} + +/// Mailbox users may edit their own display name when `edit_mailbox_name` +/// allows it globally or for their domain (PostfixAdmin 4.x master). +pub async fn set_own_name( + State(state): State>, + jar: CookieJar, + headers: HeaderMap, + Json(input): Json, +) -> AppResult> { + let session = routes::mutation_session(&state, &jar, &headers).await?; + if session.kind != AccountKind::User { + return Err(AppError::forbidden( + "Only mailbox users can use this endpoint", + )); + } + let (_, domain) = session + .username + .rsplit_once('@') + .ok_or_else(|| AppError::bad_request("Invalid account"))?; + if !state.config.features.edit_mailbox_name.allows(domain) { + return Err(AppError::forbidden( + "Changing the display name is not enabled", + )); + } + let name = validate_name(&input.name)?; + let mailbox = state.db.table(Table::Mailbox); + let sql = state.db.q(&format!( + "UPDATE {mailbox} SET name=?,modified=CURRENT_TIMESTAMP WHERE username=?" + )); + sqlx::query(&sql) + .bind(&name) + .bind(&session.username) + .execute(state.db.pool()) + .await?; + state + .db + .log( + &state.config, + &session.username, + domain, + "edit_mailbox", + &session.username, + ) + .await; + Ok(Json(serde_json::json!({"ok":true,"name":name}))) +} + fn maildir( config: &crate::config::AppConfig, domain: &str, @@ -598,6 +1080,7 @@ fn maildir( format!("{domain}/{local}/") } } else { + // domain_path = NO forces domain_in_mailbox = YES (PostfixAdmin). format!("{username}/") }; value = value.trim().to_string(); @@ -645,107 +1128,3 @@ async fn create_mailbox_subdirs( } Ok(true) } -async fn enforce_mailbox_limit(state: &AppState, domain: &str) -> AppResult<()> { - let d = state.db.table(Table::Domain); - let m = state.db.table(Table::Mailbox); - let sql = state - .db - .q(&format!("SELECT mailboxes FROM {d} WHERE domain=?")); - let row = sqlx::query(&sql) - .bind(domain) - .fetch_optional(state.db.pool()) - .await? - .ok_or_else(|| AppError::not_found("Domain not found"))?; - let limit = row_i64(&row, "mailboxes"); - if limit < 0 { - return Err(AppError::forbidden( - "Mailboxes are disabled for this domain", - )); - } - if limit == 0 { - return Ok(()); - } - let sql = state - .db - .q(&format!("SELECT COUNT(*) AS n FROM {m} WHERE domain=?")); - let row = sqlx::query(&sql) - .bind(domain) - .fetch_one(state.db.pool()) - .await?; - if row_i64(&row, "n") >= limit { - return Err(AppError::conflict("Domain mailbox limit reached")); - } - Ok(()) -} -async fn mailbox_quota( - state: &AppState, - domain: &str, - requested: Option, - exclude: Option<&str>, -) -> AppResult { - if !state.config.features.quota { - return Ok(0); - } - let d = state.db.table(Table::Domain); - let sql = state - .db - .q(&format!("SELECT maxquota,quota FROM {d} WHERE domain=?")); - let row = sqlx::query(&sql) - .bind(domain) - .fetch_optional(state.db.pool()) - .await? - .ok_or_else(|| AppError::not_found("Domain not found"))?; - let max = row_i64(&row, "maxquota"); - let q = requested.unwrap_or(max.max(0)); - if q < 0 { - return Err(AppError::bad_request("Quota cannot be negative")); - } - if max > 0 && q > max { - return Err(AppError::bad_request( - "Mailbox quota exceeds domain maxquota", - )); - } - if state.config.features.domain_quota { - let domain_limit = row_i64(&row, "quota"); - if domain_limit > 0 { - let m = state.db.table(Table::Mailbox); - let (sql, bind_exclude) = if exclude.is_some() { - (state.db.q(&format!("SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=? AND username<>?")),true) - } else { - ( - state.db.q(&format!( - "SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=?" - )), - false, - ) - }; - let mut query = sqlx::query(&sql).bind(domain); - if bind_exclude { - query = query.bind(exclude.unwrap()); - } - let r = query.fetch_one(state.db.pool()).await?; - let current = row_i64(&r, "total") / state.config.defaults.quota_multiplier.max(1); - if current.saturating_add(q) > domain_limit { - return Err(AppError::bad_request("Domain quota would be exceeded")); - } - } - } - Ok(q) -} -async fn password_expiry_days(state: &AppState, domain: &str) -> AppResult { - if !state.config.features.password_expiration { - return Ok(0); - } - let d = state.db.table(Table::Domain); - let sql = state - .db - .q(&format!("SELECT password_expiry FROM {d} WHERE domain=?")); - let row = sqlx::query(&sql) - .bind(domain) - .fetch_optional(state.db.pool()) - .await?; - Ok(row - .as_ref() - .map(|r| row_i64(r, "password_expiry")) - .unwrap_or(0)) -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index abd8480..ec4228b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -11,6 +11,7 @@ use axum_extra::extract::cookie::CookieJar; use crate::{ auth::{Session, verify_csrf}, error::{AppError, AppResult}, + models::{AccountKind, Role}, state::AppState, }; @@ -51,17 +52,30 @@ pub fn router(state: &AppState) -> Router> { "/admins/{username}", put(admins::update).delete(admins::remove), ) + .route("/admins/{username}/active", post(admins::set_active)) .route("/domains", get(domains::list).post(domains::create)) .route( "/domains/{domain}", - put(domains::update).delete(domains::remove), + get(domains::get) + .put(domains::update) + .delete(domains::remove), ) + .route("/domains/{domain}/active", post(domains::set_active)) .route("/domains/{domain}/dns-refresh", post(domains::dns_refresh)) .route("/mailboxes", get(mailboxes::list).post(mailboxes::create)) + .route("/mailboxes/quota-hint", get(mailboxes::quota_hint)) .route( "/mailboxes/{username}", - put(mailboxes::update).delete(mailboxes::remove), + get(mailboxes::get) + .put(mailboxes::update) + .delete(mailboxes::remove), ) + .route("/mailboxes/{username}/active", post(mailboxes::set_active)) + .route( + "/mailboxes/{username}/forwarding", + put(mailboxes::set_forwarding), + ) + .route("/account/name", put(mailboxes::set_own_name)) .route("/aliases", get(aliases::list).post(aliases::create)) .route( "/aliases/delete-inactive/{domain}", @@ -71,6 +85,7 @@ pub fn router(state: &AppState) -> Router> { "/aliases/{address}", put(aliases::update).delete(aliases::remove), ) + .route("/aliases/{address}/active", post(aliases::set_active)) .route( "/alias-domains", get(aliases::list_domains).post(aliases::create_domain), @@ -79,6 +94,10 @@ pub fn router(state: &AppState) -> Router> { "/alias-domains/{domain}", put(aliases::update_domain).delete(aliases::remove_domain), ) + .route( + "/alias-domains/{domain}/active", + post(aliases::set_domain_active), + ) .route("/fetchmail", get(fetchmail::list).post(fetchmail::create)) .route( "/fetchmail/{id}", @@ -172,15 +191,63 @@ pub fn router(state: &AppState) -> Router> { router.layer(DefaultBodyLimit::max(limit)) } -pub fn session(state: &AppState, jar: &CookieJar) -> AppResult { +pub async fn session(state: &AppState, jar: &CookieJar) -> AppResult { let token = jar .get("pa_session") - .map(|c| c.value()) + .map(|c| c.value().to_string()) .ok_or_else(|| AppError::unauthorized("Login required"))?; - state + let session = state .auth - .get_session(token) - .ok_or_else(|| AppError::unauthorized("Session expired")) + .get_session(&token) + .ok_or_else(|| AppError::unauthorized("Session expired"))?; + let interval = std::time::Duration::from_secs(state.config.security.session_revalidate_seconds); + if session.validated_at.elapsed() < interval { + return Ok(session); + } + revalidate(state, &token, session).await +} + +/// Re-check a session against the database: the account must still exist and +/// be active, and admin role / domain assignments are refreshed so that +/// revoked permissions take effect without waiting for logout. +async fn revalidate(state: &AppState, token: &str, mut session: Session) -> AppResult { + let record = state + .db + .login_record(session.kind, &session.username) + .await?; + let Some(record) = record.filter(|r| r.active) else { + state.auth.revoke(token); + return Err(AppError::unauthorized( + "Account is disabled or no longer exists", + )); + }; + if session.kind == AccountKind::Admin { + let domains = state.db.allowed_domains(&session.username).await?; + let role = if record.superadmin || domains.iter().any(|d| d == "ALL") { + Role::GlobalAdmin + } else { + Role::Admin + }; + let domains: Arc<[String]> = domains.into(); + state.auth.refresh_session(token, role, domains.clone()); + session.role = role; + session.allowed_domains = domains; + } else { + state + .auth + .refresh_session(token, session.role, session.allowed_domains.clone()); + } + Ok(session) +} + +/// Domains of a non-global administrator (without the `ALL` marker). +pub fn scoped_domains(session: &Session) -> Vec { + session + .allowed_domains + .iter() + .filter(|d| d.as_str() != "ALL") + .cloned() + .collect() } pub fn require_writable(state: &AppState) -> AppResult<()> { @@ -192,12 +259,12 @@ pub fn require_writable(state: &AppState) -> AppResult<()> { Ok(()) } -pub fn mutation_session( +pub async fn mutation_session( state: &AppState, jar: &CookieJar, headers: &HeaderMap, ) -> AppResult { - let session = session(state, jar)?; + let session = session(state, jar).await?; verify_csrf( &session, headers.get("x-csrf-token").and_then(|v| v.to_str().ok()), diff --git a/src/routes/preferences.rs b/src/routes/preferences.rs index 1261142..82ceee3 100644 --- a/src/routes/preferences.rs +++ b/src/routes/preferences.rs @@ -30,7 +30,7 @@ pub async fn list( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; session.require_admin()?; if !state.db.has_table(Table::AdminPreferences).await { return Ok(Json(Vec::new())); @@ -60,7 +60,7 @@ pub async fn set( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; validate_key(&key)?; if input.value.len() > 2048 { @@ -84,7 +84,7 @@ pub async fn remove( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; validate_key(&key)?; if state.db.has_table(Table::AdminPreferences).await { diff --git a/src/routes/rspamd_dkim.rs b/src/routes/rspamd_dkim.rs index 9587dce..476fa84 100644 --- a/src/routes/rspamd_dkim.rs +++ b/src/routes/rspamd_dkim.rs @@ -183,7 +183,7 @@ pub async fn list( State(state): State>, jar: CookieJar, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; session.require_admin()?; require_enabled(&state.config.rspamd_dkim)?; let cfg = &state.config.rspamd_dkim; @@ -265,7 +265,7 @@ pub async fn configure( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; require_enabled(&state.config.rspamd_dkim)?; ensure_managed_config(&state.config.rspamd_dkim).await?; @@ -282,7 +282,7 @@ pub async fn generate( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -367,7 +367,11 @@ pub async fn generate( if had_dns { let _ = fs::rename(&backup_dns, &dns_path).await; } - return Err(rspamd_io("activate generated private key", &final_path, error)); + return Err(rspamd_io( + "activate generated private key", + &final_path, + error, + )); } if let Err(error) = atomic_write( &dns_path, @@ -443,7 +447,7 @@ pub async fn adopt( headers: HeaderMap, Json(mut input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -493,7 +497,7 @@ pub async fn unmap( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -528,7 +532,7 @@ pub async fn purge( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_admin()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -569,7 +573,7 @@ pub async fn import_legacy_domain_blocks( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -723,7 +727,7 @@ pub async fn test_config( State(state): State>, jar: CookieJar, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; session.require_global()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -736,7 +740,7 @@ pub async fn reload( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; session.require_global()?; let cfg = &state.config.rspamd_dkim; require_enabled(cfg)?; @@ -1138,9 +1142,9 @@ async fn generate_key( let fake = format!( "-----BEGIN PRIVATE KEY-----\nPOSTFIXADMIN-RS-DEV-MOCK-{domain}-{selector}\n-----END PRIVATE KEY-----\n" ); - fs::write(private_path, fake) - .await - .map_err(|error| rspamd_io("write development mock private key", private_path, error))?; + fs::write(private_path, fake).await.map_err(|error| { + rspamd_io("write development mock private key", private_path, error) + })?; return Ok(format!( "{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p=DEV-MOCK-NOT-FOR-PRODUCTION\"" )); @@ -1186,7 +1190,9 @@ async fn generate_key( let metadata = match fs::metadata(private_path).await { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Err(AppError::config("rspamadm did not create the private key file")); + return Err(AppError::config( + "rspamadm did not create the private key file", + )); } Err(error) => return Err(rspamd_io("stat generated private key", private_path, error)), }; @@ -1247,7 +1253,9 @@ async fn reload_rspamd(cfg: &RspamdDkimConfig) -> AppResult { ) .await .map_err(|_| AppError::config("Rspamd reload command timed out"))? - .map_err(|error| AppError::config(format!("Failed to execute Rspamd reload command: {error}")))?; + .map_err(|error| { + AppError::config(format!("Failed to execute Rspamd reload command: {error}")) + })?; if !output.status.success() { return Err(AppError::config(format!( "Rspamd reload failed: {}", @@ -1283,7 +1291,13 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu let key_dir = match fs::canonicalize(&cfg.key_dir).await { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(rspamd_io("canonicalize key directory", Path::new(&cfg.key_dir), error)), + Err(error) => { + return Err(rspamd_io( + "canonicalize key directory", + Path::new(&cfg.key_dir), + error, + )); + } }; let key = match fs::canonicalize(path).await { Ok(value) => value, diff --git a/src/routes/security.rs b/src/routes/security.rs index b39d6d0..bc324b6 100644 --- a/src/routes/security.rs +++ b/src/routes/security.rs @@ -7,6 +7,7 @@ use crate::{ AccountKind, AppPasswordInput, AppPasswordSummary, PasswordChangeInput, TotpExceptionInput, TotpExceptionSummary, }, + net::ClientIp, passwords, routes, state::AppState, }; @@ -34,11 +35,21 @@ pub struct TotpDisable { pub async fn change_password( State(state): State>, + ClientIp(client_ip): ClientIp, jar: CookieJar, headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; + let attempt_key = format!("pwchange:{}", s.username); + if !state.auth.login_allowed(client_ip, &attempt_key) { + return Err(AppError::too_many("Too many password change attempts")); + } + if input.new_password == input.current_password { + return Err(AppError::bad_request( + "The new password must differ from the current password", + )); + } let (table, col) = account_table(&s); let t = state.db.table(table); let q = state @@ -68,6 +79,13 @@ pub async fn change_password( .bind(&s.username) .execute(state.db.pool()) .await?; + state.auth.clear_attempts(client_ip, &attempt_key); + // Sign out every other session of this account. + state.auth.revoke_user( + s.kind, + &s.username, + jar.get("pa_session").map(|c| c.value()), + ); if s.kind == AccountKind::User { passwords::refresh_mailbox_expiry(&state.config, &state.db, &s.username).await?; } @@ -104,7 +122,7 @@ pub async fn totp_setup( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; if !state.config.features.totp { return Err(AppError::forbidden("TOTP is disabled")); } @@ -129,7 +147,7 @@ pub async fn totp_confirm( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; if !state.config.features.totp { return Err(AppError::forbidden("TOTP is disabled")); } @@ -166,7 +184,7 @@ pub async fn totp_disable( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; verify_current(&state, &s, &input.current_password).await?; let (table, col) = account_table(&s); let t = state.db.table(table); @@ -186,7 +204,7 @@ pub async fn list_exceptions( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let s = routes::session(&state, &jar)?; + let s = routes::session(&state, &jar).await?; if !state.config.features.totp || !state.db.has_table(Table::TotpException).await { return Ok(Json(Vec::new())); } @@ -222,7 +240,7 @@ pub async fn add_exception( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; if !state.config.features.totp { return Err(AppError::forbidden("TOTP is disabled")); } @@ -261,7 +279,7 @@ pub async fn remove_exception( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; let t = state.db.table(Table::TotpException); let get = state .db @@ -296,7 +314,7 @@ pub async fn list_app_passwords( State(state): State>, jar: CookieJar, ) -> AppResult>> { - let s = routes::session(&state, &jar)?; + let s = routes::session(&state, &jar).await?; if !state.config.features.app_passwords || !state.db.has_table(Table::MailboxAppPassword).await { return Ok(Json(Vec::new())); @@ -334,7 +352,7 @@ pub async fn add_app_password( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; if s.kind != AccountKind::User { return Err(AppError::forbidden( "Application passwords are created by mailbox users", @@ -376,7 +394,7 @@ pub async fn remove_app_password( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let s = routes::mutation_session(&state, &jar, &headers)?; + let s = routes::mutation_session(&state, &jar, &headers).await?; let t = state.db.table(Table::MailboxAppPassword); let q = if s.role.is_global() { state.db.q(&format!("DELETE FROM {t} WHERE id=?")) diff --git a/src/routes/sieve.rs b/src/routes/sieve.rs index 6a5ac1e..171e6a3 100644 --- a/src/routes/sieve.rs +++ b/src/routes/sieve.rs @@ -110,12 +110,12 @@ async fn connect(state: &AppState, session: &crate::auth::Session) -> AppResult< .map_err(map_error) } -fn mutation_session( +async fn mutation_session( state: &AppState, jar: &CookieJar, headers: &HeaderMap, ) -> AppResult { - let session = routes::session(state, jar)?; + let session = routes::session(state, jar).await?; verify_csrf( &session, headers @@ -132,7 +132,7 @@ pub async fn status( State(state): State>, jar: CookieJar, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; let mut client = connect(&state, &session).await?; let capabilities = client.capabilities().clone(); client.logout().await; @@ -151,7 +151,7 @@ pub async fn list_scripts( State(state): State>, jar: CookieJar, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; let mut client = connect(&state, &session).await?; let scripts = client.list_scripts().await.map_err(map_error)?; client.logout().await; @@ -163,7 +163,7 @@ pub async fn get_script( Path(name): Path, jar: CookieJar, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; let mut client = connect(&state, &session).await?; let content = client.get_script(&name).await.map_err(map_error)?; client.logout().await; @@ -175,7 +175,7 @@ pub async fn check_script( jar: CookieJar, Json(body): Json, ) -> AppResult> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; let mut client = connect(&state, &session).await?; let result = client.check_script(&body.content).await; client.logout().await; @@ -202,7 +202,7 @@ pub async fn put_script( headers: HeaderMap, Json(body): Json, ) -> AppResult> { - let session = mutation_session(&state, &jar, &headers)?; + let session = mutation_session(&state, &jar, &headers).await?; let mut client = connect(&state, &session).await?; let warning = client .put_script(&name, &body.content) @@ -218,7 +218,7 @@ pub async fn activate_script( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = mutation_session(&state, &jar, &headers)?; + let session = mutation_session(&state, &jar, &headers).await?; let mut client = connect(&state, &session).await?; client .activate_script(Some(&name)) @@ -236,7 +236,7 @@ pub async fn deactivate_script( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = mutation_session(&state, &jar, &headers)?; + let session = mutation_session(&state, &jar, &headers).await?; let mut client = connect(&state, &session).await?; client.activate_script(None).await.map_err(map_error)?; client.logout().await; @@ -252,7 +252,7 @@ pub async fn delete_script( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = mutation_session(&state, &jar, &headers)?; + let session = mutation_session(&state, &jar, &headers).await?; let mut client = connect(&state, &session).await?; client.delete_script(&name).await.map_err(map_error)?; client.logout().await; @@ -269,7 +269,7 @@ pub async fn rename_script( headers: HeaderMap, Json(body): Json, ) -> AppResult> { - let session = mutation_session(&state, &jar, &headers)?; + let session = mutation_session(&state, &jar, &headers).await?; let mut client = connect(&state, &session).await?; client .rename_script(&name, &body.new_name) diff --git a/src/routes/system.rs b/src/routes/system.rs index fb2d392..8cf11da 100644 --- a/src/routes/system.rs +++ b/src/routes/system.rs @@ -24,19 +24,98 @@ pub async fn overview( State(state): State>, jar: CookieJar, ) -> AppResult> { - let s = routes::session(&state, &jar)?; - let domains = count(&state, Table::Domain, "domain<>'ALL'").await?; - let mailboxes = count_scoped(&state, Table::Mailbox, "domain", &s).await?; - let aliases = count_scoped(&state, Table::Alias, "domain", &s).await?; - Ok(Json( - serde_json::json!({"domains":if s.role.is_global(){domains}else{s.allowed_domains.iter().filter(|d|d.as_str()!="ALL").count() as i64},"mailboxes":mailboxes,"aliases":aliases,"role":s.role,"username":s.username,"features":&state.config.features}), - )) + let s = routes::session(&state, &jar).await?; + if !s.role.is_admin() { + return Ok(Json(serde_json::json!({ + "role": s.role, + "username": s.username, + }))); + } + let scope: Option> = if s.role.is_global() { + None + } else { + Some(routes::scoped_domains(&s)) + }; + let (in_clause, binds) = match &scope { + Some(domains) if domains.is_empty() => { + return Ok(Json(serde_json::json!({ + "domains": 0, "mailboxes": 0, "aliases": 0, "alias_domains": 0, + "role": s.role, "username": s.username, + }))); + } + Some(domains) => ( + format!(" AND {{col}} IN ({})", vec!["?"; domains.len()].join(",")), + domains.clone(), + ), + None => (String::new(), Vec::new()), + }; + let db = &state.db; + let domain = db.table(Table::Domain); + let mailbox = db.table(Table::Mailbox); + let alias = db.table(Table::Alias); + let mut counts = serde_json::Map::new(); + let queries = vec![ + ( + "domains", + format!( + "SELECT COUNT(*) AS n FROM {domain} d WHERE d.domain <> 'ALL'{}", + in_clause.replace("{col}", "d.domain") + ), + ), + ( + "mailboxes", + format!( + "SELECT COUNT(*) AS n FROM {mailbox} m WHERE 1=1{}", + in_clause.replace("{col}", "m.domain") + ), + ), + ( + "aliases", + format!( + "SELECT COUNT(*) AS n FROM {alias} a LEFT JOIN {mailbox} m ON m.username=a.address WHERE m.username IS NULL{}", + in_clause.replace("{col}", "a.domain") + ), + ), + ]; + for (key, sql) in queries { + let sql = db.q(&sql); + let mut q = sqlx::query(&sql); + for b in &binds { + q = q.bind(b.as_str()); + } + counts.insert( + key.into(), + row_i64(&q.fetch_one(db.pool()).await?, "n").into(), + ); + } + let alias_domains = + if state.config.features.alias_domain && db.has_table(Table::AliasDomain).await { + let ad = db.table(Table::AliasDomain); + let sql = db.q(&format!( + "SELECT COUNT(*) AS n FROM {ad} x WHERE 1=1{}", + in_clause.replace("{col}", "x.target_domain") + )); + let mut q = sqlx::query(&sql); + for b in &binds { + q = q.bind(b.as_str()); + } + row_i64(&q.fetch_one(db.pool()).await?, "n") + } else { + 0 + }; + counts.insert("alias_domains".into(), alias_domains.into()); + counts.insert( + "role".into(), + serde_json::to_value(s.role).unwrap_or_default(), + ); + counts.insert("username".into(), s.username.clone().into()); + Ok(Json(serde_json::Value::Object(counts))) } pub async fn schema( State(state): State>, jar: CookieJar, ) -> AppResult> { - let s = routes::session(&state, &jar)?; + let s = routes::session(&state, &jar).await?; s.require_global()?; let warnings = state.db.check_schema(&state.config).await?; Ok(Json( @@ -47,62 +126,22 @@ pub async fn features( State(state): State>, jar: CookieJar, ) -> AppResult> { - let _ = routes::session(&state, &jar)?; + let s = routes::session(&state, &jar).await?; + let f = &state.config.features; + let domain = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or(""); + let permissions = serde_json::json!({ + "edit_own_name": s.kind == crate::models::AccountKind::User && f.edit_mailbox_name.allows(domain), + "edit_own_forwarding": s.kind == crate::models::AccountKind::User && f.edit_alias, + "edit_own_vacation": s.kind == crate::models::AccountKind::User && f.vacation && f.vacation_control, + "edit_mailbox_forwarding": s.role.is_admin() && if s.role.is_global() { f.alias_control } else { f.alias_control_admin }, + "edit_vacation": s.role.is_admin() && f.vacation && (s.role.is_global() || f.vacation_control_admin), + "manage_domains": s.role.is_global(), + "manage_admins": s.role.is_global(), + "edit_special_aliases": s.role.is_global() || f.special_alias_control, + "sendmail": s.role.is_admin() && f.sendmail && (s.role.is_global() || f.sendmail_all_admins), + "backup": s.role.is_global() && f.backup, + }); Ok(Json( - serde_json::json!({"features":&state.config.features,"defaults":{"page_size":state.config.defaults.page_size,"quota_multiplier":state.config.defaults.quota_multiplier,"quota_level_med_pct":state.config.defaults.quota_level_med_pct,"quota_level_high_pct":state.config.defaults.quota_level_high_pct,"motd_user":state.config.defaults.motd_user,"motd_admin":state.config.defaults.motd_admin,"motd_superadmin":state.config.defaults.motd_superadmin},"password":{"configured":state.config.password.scheme,"supported":passwords::supported_schemes()},"development_mock":state.config.database.dev_mock,"database_read_only":state.config.database.read_only,"dkim":{"default_backend":state.config.dkim.default_backend,"user_read_only":state.config.dkim.user_read_only},"rspamd_dkim":{"enabled":state.config.rspamd_dkim.enabled,"default_selector":state.config.rspamd_dkim.default_selector,"development_mock":state.config.rspamd_dkim.dev_mock},"sieve":{"enabled":state.config.sieve.enabled,"port":state.config.sieve.port,"tls_mode":state.config.sieve.tls_mode,"read_only":state.config.sieve.read_only},"version":env!("CARGO_PKG_VERSION")}), + serde_json::json!({"permissions":permissions,"features":&state.config.features,"defaults":{"page_size":state.config.defaults.page_size,"quota_multiplier":state.config.defaults.quota_multiplier,"quota_level_med_pct":state.config.defaults.quota_level_med_pct,"quota_level_high_pct":state.config.defaults.quota_level_high_pct,"motd_user":state.config.defaults.motd_user,"motd_admin":state.config.defaults.motd_admin,"motd_superadmin":state.config.defaults.motd_superadmin,"vacation_choice_of_reply":state.config.defaults.vacation_choice_of_reply,"transport_options":state.config.defaults.transport_options,"transport_default":state.config.defaults.transport_default,"default_aliases":state.config.defaults.default_aliases.keys().collect::>(),"password_min_length":state.config.password.min_length,"password_min_letters":state.config.password.min_letters,"password_min_digits":state.config.password.min_digits,"mailbox_postpassword_hook":!state.config.hooks.mailbox_postpassword.is_empty()},"password":{"configured":state.config.password.scheme,"supported":passwords::supported_schemes()},"development_mock":state.config.database.dev_mock,"database_read_only":state.config.database.read_only,"dkim":{"default_backend":state.config.dkim.default_backend,"user_read_only":state.config.dkim.user_read_only},"rspamd_dkim":{"enabled":state.config.rspamd_dkim.enabled,"default_selector":state.config.rspamd_dkim.default_selector,"development_mock":state.config.rspamd_dkim.dev_mock},"sieve":{"enabled":state.config.sieve.enabled,"port":state.config.sieve.port,"tls_mode":state.config.sieve.tls_mode,"read_only":state.config.sieve.read_only},"version":env!("CARGO_PKG_VERSION")}), )) } -async fn count(state: &AppState, table: Table, where_sql: &str) -> AppResult { - let t = state.db.table(table); - let r = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {t} WHERE {where_sql}")) - .fetch_one(state.db.pool()) - .await?; - Ok(row_i64(&r, "n")) -} -async fn count_scoped( - state: &AppState, - table: Table, - col: &str, - s: &crate::auth::Session, -) -> AppResult { - let t = state.db.table(table); - if s.role.is_global() { - let r = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {t}")) - .fetch_one(state.db.pool()) - .await?; - return Ok(row_i64(&r, "n")); - } - if !s.role.is_admin() { - let q = state.db.q(&format!( - "SELECT COUNT(*) AS n FROM {t} WHERE {}=?", - if matches!(table, Table::Mailbox) { - "username" - } else { - "address" - } - )); - let r = sqlx::query(&q) - .bind(&s.username) - .fetch_one(state.db.pool()) - .await?; - return Ok(row_i64(&r, "n")); - } - let ds: Vec<_> = s - .allowed_domains - .iter() - .filter(|d| d.as_str() != "ALL") - .collect(); - if ds.is_empty() { - return Ok(0); - } - let q = state.db.q(&format!( - "SELECT COUNT(*) AS n FROM {t} WHERE {col} IN ({})", - vec!["?"; ds.len()].join(",") - )); - let mut x = sqlx::query(&q); - for d in ds { - x = x.bind(d.as_str()); - } - let r = x.fetch_one(state.db.pool()).await?; - Ok(row_i64(&r, "n")) -} diff --git a/src/routes/vacation.rs b/src/routes/vacation.rs index fef339d..d41ff9a 100644 --- a/src/routes/vacation.rs +++ b/src/routes/vacation.rs @@ -12,7 +12,7 @@ use axum::{ http::HeaderMap, }; use axum_extra::extract::cookie::CookieJar; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use std::sync::Arc; pub async fn get( @@ -20,7 +20,7 @@ pub async fn get( Path(path): Path, jar: CookieJar, ) -> AppResult>> { - let session = routes::session(&state, &jar)?; + let session = routes::session(&state, &jar).await?; let u = validation::email(&path)?; authorize(&state, &session, &u, false)?; if !state.config.features.vacation || !state.db.has_table(Table::Vacation).await { @@ -47,7 +47,7 @@ pub async fn set( headers: HeaderMap, Json(input): Json, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; let u = validation::email(&path)?; authorize(&state, &session, &u, true)?; if validation::email(&input.email)? != u { @@ -68,7 +68,11 @@ pub async fn set( "Vacation reply interval cannot be negative", )); } - validate_dates(&input.active_from, &input.active_until)?; + let (active_from, active_until) = normalize_dates(&input.active_from, &input.active_until)?; + let choices = &state.config.defaults.vacation_choice_of_reply; + if !choices.is_empty() && !choices.contains(&input.interval_time) { + return Err(AppError::bad_request("Unsupported vacation reply interval")); + } let (_, domain) = u.rsplit_once('@').expect("validated email"); let mailbox = state.db.table(Table::Mailbox); @@ -96,21 +100,22 @@ pub async fn set( .is_some(); let mut tx = state.db.pool().begin().await?; + let ts = state.db.ts_param(); let q = if exists { state.db.q(&format!( - "UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?" + "UPDATE {t} SET subject=?,body=?,activefrom={ts},activeuntil={ts},interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?" )) } else { state.db.q(&format!( - "INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,?,?,'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)" + "INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,{ts},{ts},'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)" )) }; if exists { sqlx::query(&q) .bind(&input.subject) .bind(&input.body) - .bind(&input.active_from) - .bind(&input.active_until) + .bind(&active_from) + .bind(&active_until) .bind(input.interval_time) .bind(input.active) .bind(&u) @@ -121,8 +126,8 @@ pub async fn set( .bind(&u) .bind(&input.subject) .bind(&input.body) - .bind(&input.active_from) - .bind(&input.active_until) + .bind(&active_from) + .bind(&active_until) .bind(domain) .bind(input.interval_time) .bind(input.active) @@ -130,6 +135,12 @@ pub async fn set( .await?; } + if input.active && state.db.has_table(Table::VacationNotification).await { + // PostfixAdmin clears already sent notifications when going away again. + let n = state.db.table(Table::VacationNotification); + let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?")); + sqlx::query(&q).bind(&u).execute(&mut *tx).await?; + } sync_vacation_alias( &state, &mut tx, @@ -165,7 +176,7 @@ pub async fn remove( jar: CookieJar, headers: HeaderMap, ) -> AppResult> { - let session = routes::mutation_session(&state, &jar, &headers)?; + let session = routes::mutation_session(&state, &jar, &headers).await?; let u = validation::email(&path)?; authorize(&state, &session, &u, true)?; let (_, domain) = u.rsplit_once('@').expect("validated email"); @@ -176,13 +187,18 @@ pub async fn remove( let mut tx = state.db.pool().begin().await?; let t = state.db.table(Table::Vacation); - let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?")); - sqlx::query(&q).bind(&u).execute(&mut *tx).await?; + // PostfixAdmin keeps the message and only deactivates it ("coming back"). + let q = state.db.q(&format!( + "UPDATE {t} SET active=?,modified=CURRENT_TIMESTAMP WHERE email=?" + )); + sqlx::query(&q) + .bind(false) + .bind(&u) + .execute(&mut *tx) + .await?; if has_notifications { let n = state.db.table(Table::VacationNotification); - let q = state - .db - .q(&format!("DELETE FROM {n} WHERE on_vacation=?")); + let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?")); sqlx::query(&q).bind(&u).execute(&mut *tx).await?; } sync_vacation_alias( @@ -284,17 +300,40 @@ fn vacation_address(email: &str, vacation_domain: &str) -> String { format!("{}@{}", email.replace('@', "#"), vacation_domain) } -fn validate_dates(active_from: &str, active_until: &str) -> AppResult<()> { - let from = NaiveDate::parse_from_str(active_from, "%Y-%m-%d") - .map_err(|_| AppError::bad_request("Invalid vacation start date"))?; - let until = NaiveDate::parse_from_str(active_until, "%Y-%m-%d") - .map_err(|_| AppError::bad_request("Invalid vacation end date"))?; +/// Accepts `YYYY-MM-DD`, `YYYY-MM-DD HH:MM[:SS]` or `YYYY-MM-DDTHH:MM[:SS]` and +/// returns the normalised `YYYY-MM-DD HH:MM:SS` values PostfixAdmin stores. +fn normalize_dates(active_from: &str, active_until: &str) -> AppResult<(String, String)> { + fn parse(value: &str, end_of_day: bool, label: &str) -> AppResult { + let value = value.trim().replace('T', " "); + if let Ok(date) = NaiveDate::parse_from_str(&value, "%Y-%m-%d") { + let time = if end_of_day { + NaiveTime::from_hms_opt(23, 59, 59) + } else { + NaiveTime::from_hms_opt(0, 0, 0) + } + .expect("valid time"); + return Ok(date.and_time(time)); + } + for format in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] { + if let Ok(value) = NaiveDateTime::parse_from_str(&value, format) { + return Ok(value); + } + } + Err(AppError::bad_request(format!( + "Invalid vacation {label} date" + ))) + } + let from = parse(active_from, false, "start")?; + let until = parse(active_until, true, "end")?; if until < from { return Err(AppError::bad_request( "Vacation end date cannot be before start date", )); } - Ok(()) + Ok(( + from.format("%Y-%m-%d %H:%M:%S").to_string(), + until.format("%Y-%m-%d %H:%M:%S").to_string(), + )) } fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -> AppResult<()> { @@ -345,7 +384,19 @@ mod tests { #[test] fn rejects_reversed_vacation_dates() { - assert!(validate_dates("2026-11-10", "2026-11-09").is_err()); - assert!(validate_dates("2026-11-09", "2026-11-10").is_ok()); + assert!(normalize_dates("2026-11-10", "2026-11-09").is_err()); + assert_eq!( + normalize_dates("2026-11-09", "2026-11-10").unwrap(), + ( + "2026-11-09 00:00:00".to_string(), + "2026-11-10 23:59:59".to_string() + ) + ); + assert_eq!( + normalize_dates("2026-11-09T08:30", "2026-11-09 17:00:00") + .unwrap() + .0, + "2026-11-09 08:30:00" + ); } } diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..199ced2 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,676 @@ +//! PostfixAdmin-compatible schema installer / upgrader (replacement for +//! `setup.php` + `upgrade.php`). +//! +//! The target is the schema of PostfixAdmin 4.x (database version 1857). +//! The upgrader is additive and idempotent: it creates missing tables, adds +//! missing columns and indexes, and never drops or rewrites existing data. +//! After a successful run the PostfixAdmin `config.version` row is set to +//! [`PFA_SCHEMA_VERSION`] so that a PHP PostfixAdmin pointed at the same +//! database does not try to replay old migrations. + +use crate::{ + db::{Db, Dialect, Table, row_string}, + error::{AppError, AppResult}, +}; + +/// Last PostfixAdmin `upgrade_NNNN` covered by this schema. +pub const PFA_SCHEMA_VERSION: i64 = 1857; + +#[derive(Clone, Copy)] +enum Ty { + /// varchar(n) NOT NULL DEFAULT '' + Str(u16), + /// varchar(n) NULL DEFAULT NULL + StrNull(u16), + /// varchar(n) NOT NULL DEFAULT '' + StrDefault(u16, &'static str), + /// text NOT NULL (no default – MySQL TEXT cannot have one) + Text, + TextNull, + Int(i64), + BigInt(i64), + Bool(bool), + BoolNull, + /// datetime NOT NULL DEFAULT '2000-01-01 00:00:00' + Date, + /// datetime NOT NULL DEFAULT '2038-01-18 00:00:00' + DateFuture, + /// timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + DateCurrent, + /// timestamp NULL DEFAULT NULL + DateNull, + /// auto-increment integer primary key + AutoId, +} + +struct Col { + name: &'static str, + ty: Ty, +} + +struct TableDef { + key: Table, + columns: Vec, + primary: &'static [&'static str], + unique: &'static [(&'static str, &'static [&'static str])], + indexes: &'static [(&'static str, &'static [&'static str])], + /// (column, parent table, parent column) – ON DELETE CASCADE + foreign: Option<(&'static str, Table, &'static str)>, +} + +fn c(name: &'static str, ty: Ty) -> Col { + Col { name, ty } +} + +fn definitions() -> Vec { + use Ty::*; + vec![ + TableDef { + key: Table::Config, + columns: vec![c("id", AutoId), c("name", Str(20)), c("value", Str(20))], + primary: &[], + unique: &[("name", &["name"])], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::Admin, + columns: vec![ + c("username", Str(255)), + c("password", Str(255)), + c("superadmin", Bool(false)), + c("created", Date), + c("modified", Date), + c("active", Bool(true)), + c("phone", Str(30)), + c("email_other", Str(255)), + c("token", Str(255)), + c("token_validity", Date), + c("totp_secret", StrNull(255)), + ], + primary: &["username"], + unique: &[], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::Domain, + columns: vec![ + c("domain", Str(255)), + c("description", Str(255)), + c("aliases", Int(0)), + c("mailboxes", Int(0)), + c("maxquota", BigInt(0)), + c("quota", BigInt(0)), + c("transport", Str(255)), + c("backupmx", Bool(false)), + c("created", Date), + c("modified", Date), + c("active", Bool(true)), + c("password_expiry", Int(0)), + c("dns_active", BoolNull), + c("dns_checked", DateNull), + ], + primary: &["domain"], + unique: &[], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::DomainAdmins, + columns: vec![ + c("id", AutoId), + c("username", Str(255)), + c("domain", Str(255)), + c("created", Date), + c("active", Bool(true)), + ], + primary: &[], + unique: &[], + indexes: &[( + "idx_domadm_user_domain_active", + &["username", "domain", "active"], + )], + foreign: None, + }, + TableDef { + key: Table::Mailbox, + columns: vec![ + c("username", Str(255)), + c("password", Str(255)), + c("name", Str(255)), + c("maildir", Str(255)), + c("quota", BigInt(0)), + c("local_part", Str(255)), + c("domain", Str(255)), + c("created", Date), + c("modified", Date), + c("active", Bool(true)), + c("phone", Str(30)), + c("email_other", Str(255)), + c("token", Str(255)), + c("token_validity", Date), + c("password_expiry", Date), + c("totp_secret", StrNull(255)), + c("smtp_active", Bool(true)), + ], + primary: &["username"], + unique: &[], + indexes: &[("idx_mailbox_domain", &["domain"])], + foreign: None, + }, + TableDef { + key: Table::Alias, + columns: vec![ + c("address", Str(255)), + c("goto", Text), + c("domain", Str(255)), + c("description", Str(255)), + c("created", Date), + c("modified", Date), + c("active", Bool(true)), + ], + primary: &["address"], + unique: &[], + indexes: &[("idx_alias_domain", &["domain"])], + foreign: None, + }, + TableDef { + key: Table::AliasDomain, + columns: vec![ + c("alias_domain", Str(255)), + c("target_domain", Str(255)), + c("description", Str(255)), + c("created", Date), + c("modified", Date), + c("active", Bool(true)), + ], + primary: &["alias_domain"], + unique: &[], + indexes: &[ + ("idx_alias_domain_active", &["active"]), + ("idx_alias_domain_target", &["target_domain"]), + ], + foreign: None, + }, + TableDef { + key: Table::Fetchmail, + columns: vec![ + c("id", AutoId), + c("domain", Str(255)), + c("mailbox", Str(255)), + c("src_server", Str(255)), + c("src_port", Int(0)), + c("src_auth", StrNull(255)), + c("src_user", Str(255)), + c("src_password", Str(255)), + c("src_folder", Str(255)), + c("poll_time", Int(10)), + c("fetchall", Bool(false)), + c("keep", Bool(false)), + c("protocol", StrNull(255)), + c("usessl", Bool(false)), + c("sslcertck", Bool(false)), + c("sslcertpath", Str(255)), + c("sslfingerprint", Str(255)), + c("extra_options", TextNull), + c("returned_text", TextNull), + c("mda", Str(255)), + c("date", Date), + c("created", Date), + c("modified", DateCurrent), + c("active", Bool(false)), + ], + primary: &[], + unique: &[], + indexes: &[("idx_fetchmail_domain", &["domain"])], + foreign: None, + }, + TableDef { + key: Table::Log, + columns: vec![ + c("id", AutoId), + c("timestamp", Date), + c("username", Str(255)), + c("domain", Str(255)), + c("action", Str(255)), + c("data", Text), + ], + primary: &[], + unique: &[], + indexes: &[ + ("idx_log_timestamp", &["timestamp"]), + ("idx_log_domain_timestamp", &["domain", "timestamp"]), + ], + foreign: None, + }, + TableDef { + key: Table::Vacation, + columns: vec![ + c("email", Str(255)), + c("subject", Str(255)), + c("body", Text), + c("activefrom", Date), + c("activeuntil", DateFuture), + c("cache", Text), + c("domain", Str(255)), + c("interval_time", Int(0)), + c("created", Date), + c("modified", DateCurrent), + c("active", Bool(true)), + ], + primary: &["email"], + unique: &[], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::VacationNotification, + columns: vec![ + c("on_vacation", Str(255)), + c("notified", Str(255)), + c("notified_at", DateCurrent), + ], + primary: &["on_vacation", "notified"], + unique: &[], + indexes: &[], + foreign: Some(("on_vacation", Table::Vacation, "email")), + }, + TableDef { + key: Table::Quota, + columns: vec![ + c("username", Str(255)), + c("path", Str(100)), + c("current", BigInt(0)), + ], + primary: &["username", "path"], + unique: &[], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::Quota2, + columns: vec![ + c("username", Str(255)), + c("bytes", BigInt(0)), + c("messages", Int(0)), + ], + primary: &["username"], + unique: &[], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::Dkim, + columns: vec![ + c("id", AutoId), + c("domain_name", Str(255)), + c("description", Str(255)), + c("selector", StrDefault(63, "default")), + c("private_key", TextNull), + c("public_key", TextNull), + c("created", Date), + c("modified", Date), + ], + primary: &[], + unique: &[], + indexes: &[], + foreign: Some(("domain_name", Table::Domain, "domain")), + }, + TableDef { + key: Table::DkimSigning, + columns: vec![ + c("id", AutoId), + c("author", Str(255)), + c("dkim_id", Int(0)), + c("created", Date), + c("modified", Date), + ], + primary: &[], + unique: &[], + indexes: &[], + foreign: Some(("dkim_id", Table::Dkim, "id")), + }, + TableDef { + key: Table::TotpException, + columns: vec![ + c("id", AutoId), + c("ip", Str(46)), + c("username", StrNull(255)), + c("description", StrNull(255)), + c("created", DateCurrent), + c("modified", Date), + ], + primary: &[], + unique: &[("ip_user", &["ip", "username"])], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::MailboxAppPassword, + columns: vec![ + c("id", AutoId), + c("username", StrNull(255)), + c("description", StrNull(255)), + c("password_hash", StrNull(255)), + ], + primary: &[], + unique: &[], + indexes: &[], + foreign: None, + }, + TableDef { + key: Table::AdminPreferences, + columns: vec![ + c("username", Str(255)), + c("pref_key", Str(64)), + c("pref_value", Str(2048)), + ], + primary: &["username", "pref_key"], + unique: &[], + indexes: &[], + foreign: None, + }, + ] +} + +fn column_ddl(dialect: Dialect, col: &Col, quote: &dyn Fn(&str) -> String) -> String { + let name = quote(col.name); + let ty = match (dialect, col.ty) { + (Dialect::MySql, Ty::Str(n)) => { + format!("varchar({n}) CHARACTER SET utf8mb4 NOT NULL DEFAULT ''") + } + (_, Ty::Str(n)) => format!("varchar({n}) NOT NULL DEFAULT ''"), + (Dialect::MySql, Ty::StrNull(n)) => { + format!("varchar({n}) CHARACTER SET utf8mb4 DEFAULT NULL") + } + (_, Ty::StrNull(n)) => format!("varchar({n}) DEFAULT NULL"), + (Dialect::MySql, Ty::StrDefault(n, v)) => { + format!("varchar({n}) CHARACTER SET utf8mb4 NOT NULL DEFAULT '{v}'") + } + (_, Ty::StrDefault(n, v)) => format!("varchar({n}) NOT NULL DEFAULT '{v}'"), + (Dialect::MySql, Ty::Text) => "text CHARACTER SET utf8mb4 NOT NULL".into(), + (Dialect::Sqlite, Ty::Text) => "text NOT NULL DEFAULT ''".into(), + (Dialect::Postgres, Ty::Text) => "text NOT NULL DEFAULT ''".into(), + (Dialect::MySql, Ty::TextNull) => "text CHARACTER SET utf8mb4".into(), + (_, Ty::TextNull) => "text".into(), + (Dialect::Sqlite, Ty::Int(d)) => format!("int(11) NOT NULL DEFAULT {d}"), + (_, Ty::Int(d)) => format!("integer NOT NULL DEFAULT {d}"), + (_, Ty::BigInt(d)) => format!("bigint NOT NULL DEFAULT {d}"), + (Dialect::Postgres, Ty::Bool(d)) => format!("boolean NOT NULL DEFAULT {d}"), + (_, Ty::Bool(d)) => format!("tinyint(1) NOT NULL DEFAULT {}", i32::from(d)), + (Dialect::Postgres, Ty::BoolNull) => "boolean DEFAULT NULL".into(), + (_, Ty::BoolNull) => "tinyint(1) DEFAULT NULL".into(), + (Dialect::Postgres, Ty::Date) => { + "timestamp with time zone NOT NULL DEFAULT '2000-01-01'".into() + } + (_, Ty::Date) => "datetime NOT NULL DEFAULT '2000-01-01 00:00:00'".into(), + (Dialect::Postgres, Ty::DateFuture) => { + "timestamp with time zone NOT NULL DEFAULT '2038-01-18'".into() + } + (_, Ty::DateFuture) => "datetime NOT NULL DEFAULT '2038-01-18 00:00:00'".into(), + (Dialect::Postgres, Ty::DateCurrent) => { + "timestamp with time zone NOT NULL DEFAULT now()".into() + } + (Dialect::MySql, Ty::DateCurrent) => "timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP".into(), + (Dialect::Sqlite, Ty::DateCurrent) => "datetime NOT NULL DEFAULT CURRENT_TIMESTAMP".into(), + (Dialect::Postgres, Ty::DateNull) => "timestamp with time zone NULL DEFAULT NULL".into(), + (Dialect::MySql, Ty::DateNull) => "timestamp NULL DEFAULT NULL".into(), + (Dialect::Sqlite, Ty::DateNull) => "datetime NULL DEFAULT NULL".into(), + (Dialect::MySql, Ty::AutoId) => "int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY".into(), + (Dialect::Postgres, Ty::AutoId) => "SERIAL PRIMARY KEY".into(), + (Dialect::Sqlite, Ty::AutoId) => "integer PRIMARY KEY AUTOINCREMENT NOT NULL".into(), + }; + format!("{name} {ty}") +} + +#[derive(Debug, Default)] +pub struct SchemaReport { + pub created_tables: Vec, + pub added_columns: Vec, + pub created_indexes: Vec, + pub warnings: Vec, + pub version: Option, +} + +impl SchemaReport { + pub fn changed(&self) -> bool { + !self.created_tables.is_empty() + || !self.added_columns.is_empty() + || !self.created_indexes.is_empty() + } +} + +/// Raw (unquoted, prefixed) table name. +fn raw_name(db: &Db, key: Table) -> String { + let quoted = db.table(key); + quoted.trim_matches(|c| c == '`' || c == '"').to_string() +} + +/// Create missing tables/columns/indexes. With `dry_run` nothing is changed +/// and the report lists what would be done. +pub async fn upgrade(db: &Db, dry_run: bool) -> AppResult { + let dialect = db.dialect(); + let quote = |ident: &str| db.quote_ident(ident); + let mut report = SchemaReport::default(); + if dialect == Dialect::Sqlite && !dry_run { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(db.pool()) + .await?; + } + for def in definitions() { + let table = db.table(def.key); + let raw = raw_name(db, def.key); + if !db.has_table(def.key).await { + let mut parts: Vec = def + .columns + .iter() + .map(|col| column_ddl(dialect, col, "e)) + .collect(); + if !def.primary.is_empty() { + parts.push(format!( + "PRIMARY KEY ({})", + def.primary + .iter() + .map(|c| quote(c)) + .collect::>() + .join(",") + )); + } + for (name, cols) in def.unique { + let cols = cols.iter().map(|c| quote(c)).collect::>().join(","); + match dialect { + Dialect::MySql => parts.push(format!("UNIQUE KEY {} ({cols})", quote(name))), + _ => parts.push(format!("UNIQUE ({cols})")), + } + } + let suffix = match dialect { + Dialect::MySql => " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + _ => "", + }; + let foreign = def.foreign.map(|(col, parent, parent_col)| { + format!( + "FOREIGN KEY ({}) REFERENCES {}({}) ON DELETE CASCADE", + quote(col), + db.table(parent), + quote(parent_col) + ) + }); + let build = |with_fk: bool| { + let mut all = parts.clone(); + if with_fk && let Some(fk) = &foreign { + all.push(fk.clone()); + } + format!("CREATE TABLE {table} (\n {}\n){suffix}", all.join(",\n ")) + }; + if !dry_run && let Err(error) = sqlx::query(&build(true)).execute(db.pool()).await { + if foreign.is_none() { + return Err(AppError::config(format!( + "creating table {raw} failed: {error}" + ))); + } + // Existing PostfixAdmin databases may use other charsets for + // the parent table (e.g. latin1) – create without the FK. + sqlx::query(&build(false)) + .execute(db.pool()) + .await + .map_err(|e| AppError::config(format!("creating table {raw} failed: {e}")))?; + report + .warnings + .push(format!("table {raw} created without foreign key ({error})")); + } + report.created_tables.push(raw.clone()); + } else { + for col in &def.columns { + if db.has_column(def.key, col.name).await? { + continue; + } + if matches!(col.ty, Ty::AutoId) && dialect == Dialect::Sqlite { + // SQLite cannot add a primary key column; PostfixAdmin skips it too. + continue; + } + let sql = format!( + "ALTER TABLE {table} ADD COLUMN {}", + column_ddl(dialect, col, "e) + ); + if !dry_run { + sqlx::query(&sql).execute(db.pool()).await.map_err(|e| { + AppError::config(format!("adding column {raw}.{} failed: {e}", col.name)) + })?; + db.forget_column_cache(); + } + report.added_columns.push(format!("{raw}.{}", col.name)); + } + } + for (name, cols) in def.indexes { + let index = format!("{}{}", db.prefix(), name); + if index_exists(db, &raw, &index).await? { + continue; + } + let cols = cols.iter().map(|c| quote(c)).collect::>().join(","); + let sql = format!("CREATE INDEX {} ON {table} ({cols})", quote(&index)); + if !dry_run && let Err(error) = sqlx::query(&sql).execute(db.pool()).await { + report + .warnings + .push(format!("index {index} on {raw} not created: {error}")); + continue; + } + report.created_indexes.push(format!("{raw}.{index}")); + } + } + if !dry_run { + db.forget_column_cache(); + report.version = Some(set_version(db).await?); + } else { + report.version = current_version(db).await.ok().flatten(); + } + Ok(report) +} + +async fn index_exists(db: &Db, table: &str, index: &str) -> AppResult { + let row = match db.dialect() { + Dialect::MySql => { + let sql = "SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1"; + sqlx::query(sql) + .bind(table) + .bind(index) + .fetch_optional(db.pool()) + .await? + } + Dialect::Postgres => { + sqlx::query("SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = $1 LIMIT 1") + .bind(index) + .fetch_optional(db.pool()) + .await? + } + Dialect::Sqlite => { + sqlx::query("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1") + .bind(index) + .fetch_optional(db.pool()) + .await? + } + }; + Ok(row.is_some()) +} + +pub async fn current_version(db: &Db) -> AppResult> { + if !db.has_table(Table::Config).await { + return Ok(None); + } + let table = db.table(Table::Config); + let sql = db.q(&format!("SELECT value FROM {table} WHERE name = ?")); + let row = sqlx::query(&sql) + .bind("version") + .fetch_optional(db.pool()) + .await?; + Ok(row.and_then(|r| row_string(&r, "value").trim().parse::().ok())) +} + +async fn set_version(db: &Db) -> AppResult { + let table = db.table(Table::Config); + let current = current_version(db).await?; + match current { + Some(v) if v >= PFA_SCHEMA_VERSION => Ok(v), + Some(_) => { + let sql = db.q(&format!("UPDATE {table} SET value = ? WHERE name = ?")); + sqlx::query(&sql) + .bind(PFA_SCHEMA_VERSION.to_string()) + .bind("version") + .execute(db.pool()) + .await?; + Ok(PFA_SCHEMA_VERSION) + } + None => { + let sql = db.q(&format!("INSERT INTO {table} (name, value) VALUES (?, ?)")); + sqlx::query(&sql) + .bind("version") + .bind(PFA_SCHEMA_VERSION.to_string()) + .execute(db.pool()) + .await?; + Ok(PFA_SCHEMA_VERSION) + } + } +} + +/// Orphan checks from PostfixAdmin's `DatabaseIntegrityChecker` (read-only). +pub async fn integrity_check(db: &Db) -> AppResult> { + let relations = [ + (Table::Fetchmail, "mailbox", Table::Mailbox, "username"), + (Table::Quota, "username", Table::Mailbox, "username"), + (Table::Quota2, "username", Table::Mailbox, "username"), + (Table::Vacation, "email", Table::Mailbox, "username"), + (Table::DomainAdmins, "domain", Table::Domain, "domain"), + (Table::DomainAdmins, "username", Table::Admin, "username"), + (Table::Mailbox, "domain", Table::Domain, "domain"), + (Table::Alias, "domain", Table::Domain, "domain"), + (Table::AliasDomain, "alias_domain", Table::Domain, "domain"), + (Table::AliasDomain, "target_domain", Table::Domain, "domain"), + ]; + let mut out = Vec::new(); + for (child, child_col, parent, parent_col) in relations { + if !db.has_table(child).await || !db.has_table(parent).await { + continue; + } + let child_table = db.table(child); + let parent_table = db.table(parent); + let mut sql = format!( + "SELECT COUNT(*) AS n FROM {child_table} c LEFT JOIN {parent_table} p ON c.{child_col} = p.{parent_col} WHERE p.{parent_col} IS NULL" + ); + if matches!(child, Table::DomainAdmins) && child_col == "domain" { + sql.push_str(" AND c.domain <> 'ALL'"); + } + let row = sqlx::query(&sql).fetch_one(db.pool()).await?; + let n = crate::db::row_i64(&row, "n"); + if n > 0 { + out.push(( + format!( + "{}.{child_col} -> {}.{parent_col}", + raw_name(db, child), + raw_name(db, parent) + ), + n, + )); + } + } + Ok(out) +} diff --git a/src/web/css/30-responsive.css b/src/web/css/30-responsive.css index 522d864..634b018 100644 --- a/src/web/css/30-responsive.css +++ b/src/web/css/30-responsive.css @@ -1,2 +1,26 @@ -@media(max-width:850px){.body{grid-template-columns:58px minmax(0,1fr)}.sidebar{padding:8px}.nav button{justify-content:center;padding:0}.nav button span,.nav-section,.brand span,.search{display:none}.topbar{grid-template-columns:40px 1fr}.who strong{max-width:120px}.cards{grid-template-columns:repeat(2,1fr)}.feature-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.content{padding:12px}.cards{grid-template-columns:1fr 1fr}.page-head{display:block}.page-head .actions{margin-top:10px}.modal-body{grid-template-columns:1fr}.modal-body .wide{grid-column:auto}.who{display:none}.feature-grid{grid-template-columns:1fr}} -@media(max-width:860px){.two-col{grid-template-columns:1fr}} +@media(max-width:1100px){.two-col{grid-template-columns:1fr}} +@media(max-width:900px){ + .nav-toggle{display:inline-flex} + .topbar{grid-template-columns:auto 1fr auto} + .body{grid-template-columns:minmax(0,1fr)} + .sidebar{position:fixed;z-index:30;top:44px;bottom:0;left:0;width:240px;transform:translateX(-100%);transition:transform .15s ease;box-shadow:0 10px 30px var(--shadow)} + body.nav-open .sidebar{transform:none} + .search input{min-width:0} + .who strong{max-width:140px} + .cards{grid-template-columns:repeat(2,minmax(0,1fr))} + .feature-grid{grid-template-columns:1fr 1fr} + .sieve-layout{grid-template-columns:1fr} +} +@media(max-width:600px){ + .content{padding:12px} + .brand span,.who,.language-select{display:none} + .page-head{display:block} + .page-head .actions{margin-top:10px} + .page-head .compact-select,.inline-search input,.compact-input,.inline-form input,.inline-form select{width:100%;min-width:0} + .inline-search,.inline-form{width:100%} + .cards,.form-grid{grid-template-columns:1fr} + .modal-body{grid-template-columns:1fr} + .modal-body .wide{grid-column:auto} + .feature-grid{grid-template-columns:1fr} + .actions-cell .action-btn span{display:none} +} diff --git a/src/web/css/40-views.css b/src/web/css/40-views.css new file mode 100644 index 0000000..80d8505 --- /dev/null +++ b/src/web/css/40-views.css @@ -0,0 +1,83 @@ +/* 0.1.0 views: navigation links, toggles, pager, forms */ +.nav a{display:flex;align-items:center;gap:9px;height:34px;padding:0 9px;border:1px solid transparent;border-radius:3px;color:var(--muted);text-decoration:none} +.nav a:hover{background:var(--hover);color:var(--text)} +.nav a.active{background:var(--selected);color:var(--text);border-color:var(--line)} +.nav a svg{width:16px;height:16px;flex:none} +a{color:var(--accent2)} +a:hover{color:var(--text)} +.strong{font-weight:650} +.nowrap{white-space:nowrap} +.small{font-size:11px} +.break{word-break:break-all} +.select-all{user-select:all;padding:4px 6px;border:1px dashed var(--line2);border-radius:3px;display:inline-block} +.nav-toggle{display:none;min-height:28px;padding:2px 6px} +.badge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,var(--line))} +.badge.info{color:var(--accent2);border-color:color-mix(in srgb,var(--accent) 45%,var(--line))} +.badge.ok{border-color:color-mix(in srgb,var(--success) 40%,var(--line))} +button.badge.toggle{cursor:pointer;background:transparent;font-weight:650;min-height:20px} +button.badge.toggle:hover{border-color:var(--accent);color:var(--text)} +.panel-head{gap:10px;flex-wrap:wrap} +.panel-head .actions{align-items:center} +.panel-head .compact-select{width:auto;min-height:26px;height:26px;padding:2px 6px} +.empty-cell{padding:22px;text-align:center;color:var(--muted)} +.goto-cell{max-width:340px;word-break:break-all} +.goto-cell div+div{margin-top:2px} +tr.clickable{cursor:pointer} +.pager{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-top:1px solid var(--line);flex-wrap:wrap} +.pager .actions{gap:4px} +.pager .btn{min-width:30px} +.compact-input{width:200px;min-height:30px;height:30px} +.inline-search input{width:240px;min-height:30px;height:30px} +.inline-form{display:flex;gap:7px;align-items:center;flex-wrap:wrap} +.inline-form input,.inline-form select{width:auto;min-width:200px} +.page-head .actions{align-items:center} +.page-head .compact-select{width:auto;min-width:180px} +.compact-cards{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))} +.compact-cards .card{padding:11px 13px} +.compact-cards .card .k{margin-bottom:6px} +.link-card{text-decoration:none;color:inherit;transition:border-color .12s} +.link-card:hover{border-color:var(--accent)} +.user-cards{grid-template-columns:repeat(auto-fit,minmax(190px,1fr))} +.user-cards .progress-cell{margin-top:8px} +.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px 22px} +.facts>div{display:grid;gap:2px} +.facts span{color:var(--muted);font-size:11px} +.facts strong{font-weight:600;word-break:break-word} +.two-col{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px;margin-top:12px} +.form-grid .wide,.form-grid .panel-actions{grid-column:1/-1} +.field-label{display:block;margin-bottom:5px;color:var(--muted);font-size:11px;font-weight:650} +.field-help{display:block;margin-top:4px;color:var(--subtle);font-size:11px} +.field-help:empty{display:none} +.modal-form{display:flex;flex-direction:column;max-height:90vh} +.modal-form .modal-body{overflow:auto} +.modal-intro{padding:12px 16px 0} +.modal-intro p{margin:0} +.modal-error{margin:0 16px 10px;padding:8px 10px;border:1px solid color-mix(in srgb,var(--danger) 50%,var(--line));border-radius:3px;color:var(--danger);background:color-mix(in srgb,var(--danger) 8%,transparent)} +.checkbox-field{align-self:end} +.checkbox-row{display:flex;align-items:center;gap:8px;cursor:pointer} +.checkbox-row input{width:16px;height:16px;min-height:16px;margin:0;flex:none} +.checklist{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:6px 12px;max-height:220px;overflow:auto;padding:8px;border:1px solid var(--line);border-radius:3px;background:var(--panel2)} +.static-value{padding:6px 0;word-break:break-all} +.input-action{display:flex;gap:5px} +.input-action input{flex:1;min-width:0} +.input-action .btn{flex:none;min-height:var(--control)} +.login-form .notice{margin:0} +.login-advanced summary{cursor:pointer;color:var(--muted);font-size:11px;margin-bottom:6px} +.login-error{color:var(--danger);min-height:1em} +.login-links{display:flex;justify-content:space-between;gap:6px} +.login-links .btn{font-weight:500;color:var(--muted);padding:2px 4px;min-height:24px} +.motd{margin-bottom:14px} +.notice{padding:9px 12px;border:1px solid var(--line2);border-radius:3px;background:var(--panel2)} +.notice.warning{color:var(--warn)} +.status-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 10px} +.panel-actions{display:flex;gap:8px;flex-wrap:wrap} +.content:focus{outline:none} +.actions-cell{white-space:nowrap;text-align:right} +.actions-cell .action-btn{padding:4px 7px;min-height:28px} +.actions-cell .action-btn span{display:none} +.progress-cell{min-width:96px;max-width:150px} +.content a{text-decoration:none} +.content a:hover{text-decoration:underline} +td,th{padding-left:10px;padding-right:10px} +.card .progress-cell{max-width:none} diff --git a/src/web/css/manifest.txt b/src/web/css/manifest.txt index e90759e..22ff8ec 100644 --- a/src/web/css/manifest.txt +++ b/src/web/css/manifest.txt @@ -1,4 +1,5 @@ 00-tokens.css 10-base.css 20-app.css +40-views.css 30-responsive.css diff --git a/src/web/i18n/en.json b/src/web/i18n/en.json index 1c2f12e..4cb61bf 100644 --- a/src/web/i18n/en.json +++ b/src/web/i18n/en.json @@ -1,436 +1,5 @@ { - "language": "en", - "name": "English", - "messages": { - "Filtruj bieżący widok…": "Filter current view…", - "Język": "Language", - "Zmień motyw": "Change theme", - "Wyloguj": "Sign out", - "Przełącz na ciemny motyw": "Switch to dark theme", - "Przełącz na jasny motyw": "Switch to light theme", - "Przegląd": "Overview", - "Domeny": "Domains", - "Skrzynki": "Mailboxes", - "Aliasy": "Aliases", - "Administratorzy": "Administrators", - "Logi": "Logs", - "Wyślij / broadcast": "Send / broadcast", - "Bezpieczeństwo": "Security", - "Zarządzanie": "Management", - "Usługi": "Services", - "Konto i system": "Account & system", - "Ładowanie…": "Loading…", - "Anuluj": "Cancel", - "Zapisz": "Save", - "Zapisano": "Saved", - "Skopiuj": "Copy", - "Zamknij": "Close", - "Skopiowano": "Copied", - "Nie udało się skopiować do schowka": "Could not copy to clipboard", - "Potwierdź": "Confirm", - "Dalej": "Continue", - "Odzyskiwanie hasła": "Password recovery", - "Adres konta": "Account address", - "Typ konta": "Account type", - "Użytkownik skrzynki": "Mailbox user", - "Jeśli konto spełnia warunki, kod został wysłany.": "If the account is eligible, a recovery code has been sent.", - "Ustaw nowe hasło": "Set a new password", - "Kod odzyskiwania": "Recovery code", - "Nowe hasło": "New password", - "Hasło zostało zmienione.": "Password has been changed.", - "Panel administracyjny poczty": "Mail administration panel", - "Hasło": "Password", - "Zaloguj": "Sign in", - "Odzyskaj hasło": "Recover password", - "Mam kod": "I have a code", - "Weryfikacja TOTP": "TOTP verification", - "Kod 6-cyfrowy": "6-digit code", - "Zweryfikuj": "Verify", - "Tak": "Yes", - "Nie": "No", - "niedostarczalny": "undeliverable", - "zewnetrzny": "external", - "wylaczony": "disabled", - "haslo wygaslo": "password expired", - "wyłączony": "disabled", - "Stan wirtualnej poczty i aktywnych funkcji": "Virtual mail status and enabled features", - "Rola": "Role", - "Aktywne moduły": "Enabled modules", - "Limity, transport, DNS i stan domen": "Limits, transport, DNS and domain status", - "Domena": "Domain", - "Opis": "Description", - "Max quota": "Max quota", - "Aktywna": "Active", - "Edytuj": "Edit", - "Usuń": "Delete", - "Nowa domena": "New domain", - "Usuń domenę": "Delete domain", - "Usunięto": "Deleted", - "DNS odświeżony": "DNS refreshed", - "Edytuj domenę": "Edit domain", - "Limit aliasów": "Alias limit", - "Limit skrzynek": "Mailbox limit", - "Max quota skrzynki [MB]": "Mailbox max quota [MB]", - "Quota domeny [MB]": "Domain quota [MB]", - "Ważność hasła [dni]": "Password validity [days]", - "Domena jest backup MX": "Domain is backup MX", - "Aktywna domena": "Active domain", - "Aliasy domyślne": "Default aliases", - "Utwórz abuse/hostmaster/postmaster/webmaster": "Create abuse/hostmaster/postmaster/webmaster", - "Konta, quota, SMTP, dane odzyskiwania i vacation": "Accounts, quota, SMTP, recovery data and vacation", - "Adres": "Address", - "Nazwa": "Name", - "użyte": "used", - "Nowa skrzynka": "New mailbox", - "Usuń skrzynkę": "Delete mailbox", - "Skrzynka usunięta": "Mailbox deleted", - "Edytuj skrzynkę": "Edit mailbox", - "Adres e-mail": "Email address", - "Nazwa wyświetlana": "Display name", - "Nowe hasło (puste = bez zmian)": "New password (empty = unchanged)", - "Quota [MB]": "Quota [MB]", - "Telefon": "Phone", - "Drugi e-mail / recovery": "Secondary email / recovery", - "Logowanie i dostarczanie aktywne": "Login and delivery enabled", - "SMTP aktywny": "SMTP active", - "Zezwalaj na SMTP AUTH": "Allow SMTP AUTH", - "Wiadomość powitalna": "Welcome message", - "Wyślij po utworzeniu": "Send after creation", - "Usuń nieaktywne": "Delete inactive", - "Nowy alias": "New alias", - "Alias domeny": "Alias domain", - "Cel": "Destination", - "Aktywny": "Active", - "Aliasy domen": "Alias domains", - "Domena docelowa": "Target domain", - "Przekierowania, self-aliasy skrzynek i alias-domains": "Forwards, mailbox self-aliases and alias domains", - "Usuń nieaktywne aliasy": "Delete inactive aliases", - "Alias usunięty": "Alias deleted", - "Usuń alias": "Delete alias", - "Edytuj AD": "Edit alias domain", - "Usuń AD": "Delete alias domain", - "Usuń alias domeny": "Delete alias domain", - "Adres aliasu": "Alias address", - "Cele (jeden na linię)": "Destinations (one per line)", - "Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie": "If the address is a mailbox, keep local delivery", - "Zapamiętaj cele": "Remember destinations", - "Zapisz jako domyślny cel nowych aliasów dla tego administratora": "Save as the default destination for new aliases for this administrator", - "Alias aktywny": "Alias active", - "Edytuj alias": "Edit alias", - "Domena aliasowa": "Alias domain", - "Alias domeny aktywny": "Alias domain active", - "Edytuj alias domeny": "Edit alias domain", - "Nowy alias domeny": "New alias domain", - "Global-admin i administratorzy domen": "Global admin and domain administrators", - "Nowy administrator": "New administrator", - "Recovery": "Recovery", - "Usuń administratora": "Delete administrator", - "Edytuj administratora": "Edit administrator", - "Login e-mail": "Login email", - "Domeny (po przecinku)": "Domains (comma-separated)", - "Pełny dostęp do wszystkich domen": "Full access to all domains", - "Konto aktywne": "Account active", - "Pobieranie poczty ze zdalnych skrzynek": "Fetch mail from remote mailboxes", - "Nowy wpis": "New entry", - "Serwer": "Server", - "Użytkownik": "User", - "Protokół": "Protocol", - "Co ile": "Interval", - "Usuń fetchmail": "Delete fetchmail", - "Usunąć wpis fetchmail?": "Delete this fetchmail entry?", - "Edytuj fetchmail": "Edit fetchmail", - "Nowy fetchmail": "New fetchmail entry", - "Docelowa skrzynka": "Target mailbox", - "Serwer źródłowy": "Source server", - "Użytkownik źródłowy": "Source user", - "Nowe hasło źródłowe (puste = bez zmian)": "New source password (empty = unchanged)", - "Hasło źródłowe": "Source password", - "Folder źródłowy": "Source folder", - "Interwał [min]": "Interval [min]", - "Użyj SSL": "Use SSL", - "Weryfikacja certyfikatu": "Certificate verification", - "Weryfikuj certyfikat serwera": "Verify server certificate", - "Pozostaw wiadomości na serwerze": "Keep messages on server", - "Pobierz także stare": "Fetch old messages too", - "Wpis aktywny": "Entry active", - "Skrzynka dla autorespondera": "Mailbox for autoresponder", - "Podaj pełny adres skrzynki, np. user@example.org.": "Enter the full mailbox address, e.g. user@example.org.", - "Autoresponder": "Autoresponder", - "Wybierz skrzynkę ponownie z menu.": "Select the mailbox again from the menu.", - "Wyłącz/usuń": "Disable/delete", - "Usuń vacation": "Delete vacation", - "Vacation usunięty": "Vacation deleted", - "Interwał odpowiedzi [s]": "Reply interval [s]", - "Autoresponder aktywny": "Autoresponder enabled", - "PostfixAdmin DB oraz podpisywanie hosta przez Rspamd": "PostfixAdmin DB and host signing via Rspamd", - "PostfixAdmin DKIM (tabele DB)": "PostfixAdmin DKIM (DB tables)", - "PostfixAdmin DKIM signing": "PostfixAdmin DKIM signing", - "Domena klucza": "Key domain", - "Autor": "Author", - "Klucz": "Key", - "Plik": "File", - "Ścieżka private key": "Private key path", - "NIESKONFIGUROWANE": "NOT CONFIGURED", - "MAPY AKTYWNE": "MAPS ACTIVE", - "Skonfiguruj mapy": "Configure maps", - "Import Rspamd DKIM": "Import Rspamd DKIM", - "Test config": "Test config", - "Reload Rspamd": "Reload Rspamd", - "Nowy klucz DKIM Rspamd": "New Rspamd DKIM key", - "Podepnij istniejący klucz Rspamd": "Adopt existing Rspamd key", - "Usuń plik": "Delete key file", - "Odłącz": "Unmap", - "BRAK": "MISSING", - "Usuń klucz DKIM": "Delete DKIM key", - "Edytuj klucz DKIM": "Edit DKIM key", - "Nowy klucz DKIM": "New DKIM key", - "Edytuj map.": "Edit mapping", - "Usuń map.": "Delete mapping", - "Usuń mapowanie DKIM": "Delete DKIM mapping", - "Edytuj mapowanie DKIM": "Edit DKIM mapping", - "Nowe mapowanie DKIM": "New DKIM mapping", - "Mapy skonfigurowane; wykonaj jednorazowy reload Rspamd.": "Maps configured; perform a one-time Rspamd reload.", - "Mapy Rspamd skonfigurowane": "Rspamd maps configured", - "Przenieść poprawne wpisy domain { path/selector } do zarządzanych map? Powstanie kopia konfiguracji, zaimportowane stare bloki zostaną usunięte, a przy błędzie configtest nastąpi rollback.": "Move valid domain { path/selector } entries to managed maps? A configuration backup will be created, imported legacy blocks will be removed, and configtest failure will trigger rollback.", - "Import DKIM": "Import DKIM", - "Rspamd przeładowany": "Rspamd reloaded", - "Odłącz DKIM": "Unmap DKIM", - "Domena odłączona od map Rspamd": "Domain removed from Rspamd maps", - "Usuń klucz Rspamd": "Delete Rspamd key", - "Klucz Rspamd usunięty": "Rspamd key deleted", - "Zastąp istniejące mapowanie": "Replace existing mapping", - "Używaj dopiero po przygotowaniu nowego rekordu DNS.": "Use only after preparing the new DNS record.", - "Reload po zapisie": "Reload after save", - "Uruchom skonfigurowane polecenie reload Rspamd.": "Run the configured Rspamd reload command.", - "Klucz wygenerowany. Opublikuj rekord DNS.": "Key generated. Publish the DNS record.", - "Nowy private key (puste = zachowaj)": "New private key (empty = keep current)", - "Private key (puste = wygeneruj)": "Private key (empty = generate)", - "Nowy public key (puste = zachowaj)": "New public key (empty = keep current)", - "Public key (puste = wygeneruj)": "Public key (empty = generate)", - "Najpierw utwórz klucz DKIM.": "Create a DKIM key first.", - "Autor (domena lub e-mail)": "Author (domain or email)", - "Audyt zmian zgodny z tabelą log PostfixAdmin": "Audit trail compatible with the PostfixAdmin log table", - "Czas": "Time", - "Akcja": "Action", - "Dane": "Data", - "Wiadomości": "Messages", - "Sendmail i broadcast przez skonfigurowany SMTP": "Sendmail and broadcast through configured SMTP", - "Wyślij wiadomość": "Send message", - "Do": "Until", - "Temat": "Subject", - "Treść": "Message", - "Wyślij": "Send", - "Nazwa nadawcy": "Sender name", - "Tylko skrzynki, bez celów aliasów": "Mailboxes only, excluding alias destinations", - "Wysłano": "Sent", - "Konto": "Account", - "Wyjątki TOTP": "TOTP exceptions", - "Hasło, TOTP, wyjątki i application passwords": "Password, TOTP, exceptions and application passwords", - "Zmień hasło": "Change password", - "Skonfiguruj TOTP": "Configure TOTP", - "Wyłącz TOTP": "Disable TOTP", - "Dodaj wyjątek TOTP": "Add TOTP exception", - "Nowe application password": "New application password", - "Zmiana hasła": "Change password", - "Obecne hasło": "Current password", - "Potwierdź TOTP": "Confirm TOTP", - "Sekret": "Secret", - "URI dla aplikacji": "Application URI", - "Wyjątek TOTP": "TOTP exception", - "Adres IP": "IP address", - "Konto (opcjonalnie)": "Account (optional)", - "Nowe hasło aplikacyjne": "New application password", - "Obecne hasło konta": "Current account password", - "Usuń wyjątek": "Delete exception", - "Usuń wyjątek TOTP": "Delete TOTP exception", - "Usuń app": "Delete app password", - "Usuń application password": "Delete application password", - "Zgodność schematu i konfiguracja runtime": "Schema compatibility and runtime configuration", - "Tryb DB": "DB mode", - "Hasła": "Passwords", - "Wersja": "Version", - "UWAGI": "WARNINGS", - "Ostrzeżenia schematu": "Schema warnings", - "Brak ostrzeżeń.": "No warnings.", - "Kompatybilne schematy haseł": "Compatible password schemes", - "Application passwords": "Application passwords", - "Application password": "Application password", - "Global admin": "Global admin", - "Store & forward": "Store & forward", - "Fetch all": "Fetch all", - "Keep": "Keep", - "RSA bits": "RSA bits", - "Selector": "Selector", - "Public key": "Public key", - "Private key": "Private key", - "Backup MX": "Backup MX", - "Backend": "Backend", - "Schema": "Schema", - "Połączenie z bazą tylko do odczytu": "Read-only database connection", - "Skrzynki, aliasy i ustawienia tej domeny": "Mailboxes, aliases and settings for this domain", - "Zarządzanie kluczami DKIM": "DKIM key management", - "Klucze i mapowania przechowywane w bazie PostfixAdmin": "Keys and mappings stored in the PostfixAdmin database", - "Klucze podpisujące hosta zarządzane przez Rspamd": "Host signing keys managed by Rspamd", - "Konfigurowalny backend oparty o tabele DKIM PostfixAdmin.": "Configurable backend based on PostfixAdmin DKIM tables.", - "Klucz DKIM": "DKIM key", - "Mapowanie": "Mapping", - "Status": "Status", - "Domena nie istnieje": "Domain not found", - "Generuj": "Generate", - "Hasło nowej skrzynki": "New mailbox password", - "Skrzynka zapisana": "Mailbox saved", - "Skrzynka utworzona": "Mailbox created", - "Hasło administratora": "Administrator password", - "Administrator zapisany": "Administrator saved", - "Administrator utworzony": "Administrator created", - "Wygasanie hasła": "Password expiry", - "Filtry": "Filters", - "Twoja skrzynka pocztowa": "Your mailbox", - "Nie znaleziono danych skrzynki dla zalogowanego konta.": "No mailbox data was found for the signed-in account.", - "Dane skrzynki": "Mailbox details", - "Nieaktywna": "Inactive", - "E-mail odzyskiwania": "Recovery email", - "Utworzona": "Created", - "Zmodyfikowana": "Modified", - "Dostarczanie i aliasy": "Delivery and aliases", - "Cele": "Targets", - "Brak celów aliasu.": "No alias targets.", - "Automatyczna odpowiedź dla Twojej skrzynki": "Automatic reply for your mailbox", - "Od": "From", - "Zapisz Vacation": "Save autoresponder", - "Vacation zapisany": "Autoresponder saved", - "ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules", - "Nowy skrypt": "New script", - "Skrypty": "Scripts", - "Brak skryptów Sieve.": "No Sieve scripts.", - "Sprawdź": "Validate", - "Aktywuj": "Activate", - "Zmień nazwę": "Rename", - "Dezaktywuj wszystkie": "Deactivate all", - "Utwórz skrypt, aby rozpocząć zarządzanie filtrami.": "Create a script to start managing filters.", - "Rozszerzenia serwera": "Server extensions", - "Brak danych.": "No data.", - "Nowy skrypt Sieve": "New Sieve script", - "Nazwa skryptu": "Script name", - "Utwórz": "Create", - "Skrypt utworzony": "Script created", - "Skrypt jest poprawny": "Script is valid", - "Skrypt zawiera błąd": "The script contains an error", - "Skrypt zapisany": "Script saved", - "Skrypt aktywowany": "Script activated", - "Skrypty dezaktywowane": "Scripts deactivated", - "Zmień nazwę skryptu": "Rename script", - "Nowa nazwa": "New name", - "Zmień": "Rename", - "Nazwa zmieniona": "Name changed", - "Usuń skrypt Sieve": "Delete Sieve script", - "Skrypt usunięty": "Script deleted", - "Hasło, TOTP i application passwords": "Password, TOTP and application passwords", - "Powtórz nowe hasło": "Repeat new password", - "Dodatkowe zabezpieczenia": "Additional security", - "TOTP jest wyłączone w konfiguracji.": "TOTP is disabled in the configuration.", - "Nowe hasła nie są identyczne": "The new passwords do not match", - "Hasło zostało zmienione": "Password has been changed", - "Quota": "Quota", - "Nieaktywny": "Inactive", - "Vacation": "Vacation", - "Dodaj reguły poniżej.": "Add rules below.", - "Reguła dodana do edytora — sprawdź i zapisz skrypt": "Rule added to the editor — validate and save the script", - "Źródło": "Source", - "Pokaż": "Show", - "Rekord DNS": "DNS record", - "Rekordy DNS DKIM dla Twojej domeny — tylko do odczytu": "DKIM DNS records for your domain — read-only", - "Brak skonfigurowanego rekordu DKIM dla tej domeny.": "No DKIM record is configured for this domain.", - "Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node", - "Aliasy użytkowników": "User aliases", - "Aliasy techniczne": "Technical aliases", - "Wpisz tylko część przed znakiem @": "Enter only the part before @", - "Podaj wyłącznie adresy docelowe przekierowania.": "Enter forwarding destination addresses only.", - "Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.": "Enter an address or search for a mailbox by address or name.", - "Edytuj autoresponder": "Edit autoresponder", - "Brak pasujących skrzynek": "No matching mailboxes", - "Wybierz istniejącą skrzynkę z listy.": "Select an existing mailbox from the list.", - "Wybierz inną skrzynkę": "Choose another mailbox", - "Wybierz skrzynkę": "Choose mailbox", - "Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.": "Choose the mailbox for which you want to configure an autoresponder.", - "Skrzynka": "Mailbox" - }, - "patterns": [ - { - "match": "^Usunąć (.+?) wraz z danymi domeny\\?$", - "replace": "Delete $1 and all domain data?" - }, - { - "match": "^Usunąć skrzynkę (.+?)\\?$", - "replace": "Delete mailbox $1?" - }, - { - "match": "^Usunąć wszystkie nieaktywne forwardy w domenie (.+?)\\? Tej operacji nie można cofnąć\\.$", - "replace": "Delete all inactive forwards in domain $1? This cannot be undone." - }, - { - "match": "^Usunięto: (\\d+), pominięto: (\\d+)$", - "replace": "Deleted: $1, skipped: $2" - }, - { - "match": "^Usunąć alias (.+?)\\?$", - "replace": "Delete alias $1?" - }, - { - "match": "^Usunąć alias domeny (.+?)\\?$", - "replace": "Delete alias domain $1?" - }, - { - "match": "^Usunąć administratora (.+?)\\?$", - "replace": "Delete administrator $1?" - }, - { - "match": "^Wyłączyć i usunąć autoresponder dla (.+?)\\?$", - "replace": "Disable and delete autoresponder for $1?" - }, - { - "match": "^Usunąć klucz (.+?)\\?$", - "replace": "Delete key $1?" - }, - { - "match": "^Usunąć mapowanie dla (.+?)\\?$", - "replace": "Delete mapping for $1?" - }, - { - "match": "^Usunąć (.+?) z map Rspamd\\? Plik klucza zostanie na dysku\\.$", - "replace": "Remove $1 from Rspamd maps? The key file will remain on disk." - }, - { - "match": "^Usunąć mapowanie i prywatny klucz (.+?) z dysku\\?$", - "replace": "Delete the mapping and private key $1 from disk?" - }, - { - "match": "^Broadcast: wysłano (\\d+), błędy (\\d+)$", - "replace": "Broadcast: sent $1, failed $2" - }, - { - "match": "^Usunąć wyjątek (.+?)\\?$", - "replace": "Delete exception $1?" - }, - { - "match": "^Usunąć application password: (.+?)\\?$", - "replace": "Delete application password: $1?" - }, - { - "match": "^(.+?) / użyte (.+?)$", - "replace": "$1 / used $2" - }, - { - "match": "^Zaimportowano: (\\d+)$", - "replace": "Imported: $1" - }, - { - "match": "^Pominięto: (\\d+)$", - "replace": "Skipped: $1" - }, - { - "match": "^Wpisz tylko nazwę przed @(.+)$", - "replace": "Enter only the name before @$1" - } - ] + "language": "en", + "messages": {}, + "patterns": [] } diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 2fccb80..8036fc5 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -1,61 +1,643 @@ { - "language": "pl", - "name": "Polski", - "messages": { - "Application passwords": "Hasła aplikacyjne", - "Application password": "Hasło aplikacyjne", - "Store & forward": "Zachowaj i przekieruj", - "Global admin": "Globalny administrator", - "Fetch all": "Pobierz wszystko", - "Keep": "Pozostaw", - "Private key": "Klucz prywatny", - "Public key": "Klucz publiczny", - "Selector": "Selektor", - "RSA bits": "Bity RSA", - "Backend": "Backend", - "Schema": "Schemat", - "Backup MX": "Backup MX", - "Recovery": "Odzyskiwanie", - "Mailbox": "Skrzynka", - "Broadcast": "Broadcast", - "Vacation": "Vacation", - "Fetchmail": "Fetchmail", - "DKIM": "DKIM", - "System": "System", - "POP/IMAP": "POP/IMAP", - "SMTP": "SMTP", - "SSL/TLS": "SSL/TLS", - "MDA": "MDA", - "Extra options": "Opcje dodatkowe", - "Global-admin i administratorzy domen": "Global-admin i administratorzy domen", - "READ ONLY": "TYLKO ODCZYT", - "Invalid credentials": "Nieprawidłowe dane logowania", - "Too many login attempts": "Zbyt wiele prób logowania", - "Session expired": "Sesja wygasła", - "Login required": "Wymagane logowanie", - "MFA challenge expired": "Wyzwanie MFA wygasło", - "Invalid TOTP code": "Nieprawidłowy kod TOTP", - "TOTP is not configured": "TOTP nie jest skonfigurowane", - "Account no longer exists": "Konto już nie istnieje", - "Password recovery is disabled": "Odzyskiwanie hasła jest wyłączone", - "Too many recovery requests": "Zbyt wiele żądań odzyskiwania hasła", - "Too many reset attempts": "Zbyt wiele prób resetowania hasła", - "Invalid or expired recovery code": "Nieprawidłowy lub wygasły kod odzyskiwania", - "Database schema does not support password recovery": "Schemat bazy nie obsługuje odzyskiwania hasła", - "Database is read-only in this development session": "Baza jest tylko do odczytu w tej sesji deweloperskiej", - "If the account is eligible, a recovery code has been sent.": "Jeśli konto spełnia warunki, kod odzyskiwania został wysłany.", - "DKIM is disabled.": "DKIM jest wyłączony.", - "ACTIVE": "AKTYWNA", - "DISABLED": "WYŁĄCZONA", - "PostfixAdmin DKIM": "PostfixAdmin DKIM", - "Rspamd DKIM": "Rspamd DKIM", - "Generuj": "Generuj", - "Hasło nowej skrzynki": "Hasło nowej skrzynki", - "Skrzynka zapisana": "Skrzynka zapisana", - "Skrzynka utworzona": "Skrzynka utworzona", - "Hasło administratora": "Hasło administratora", - "Administrator zapisany": "Administrator zapisany", - "Administrator utworzony": "Administrator utworzony" + "language": "pl", + "messages": { + "-1 = disabled, 0 = unlimited": "-1 = wyłączone, 0 = bez limitu", + "0 = passwords never expire": "0 = hasła nie wygasają", + "0 = unlimited": "0 = bez limitu", + "6-digit code": "Kod 6-cyfrowy", + "6-digit code from your authenticator app": "6-cyfrowy kod z aplikacji uwierzytelniającej", + "@{domain} — use * for a catch-all alias": "@{domain} — wpisz *, aby utworzyć alias catch-all", + "A catch-all may forward to a whole domain: @otherdomain.tld": "Alias catch-all może przekazywać na całą domenę: @innadomena.pl", + "A one-time code will be sent to the alternative e-mail address stored for the account.": "Jednorazowy kod zostanie wysłany na alternatywny adres e-mail zapisany dla konta.", + "A stale alias-domain entry with this name exists; remove it first": "Istnieje osierocony wpis domeny aliasowej o tej nazwie; najpierw go usuń", + "Account": "Konto", + "Account (empty = all your accounts)": "Konto (puste = wszystkie Twoje konta)", + "Account is disabled or no longer exists": "Konto jest wyłączone lub już nie istnieje", + "Account no longer exists": "Konto już nie istnieje", + "Account not found": "Nie znaleziono konta", + "Account type": "Typ konta", + "Action": "Akcja", + "Activate": "Aktywuj", + "Active": "Aktywne", + "Add": "Dodaj", + "Add alias": "Dodaj alias", + "Add alias domain": "Dodaj domenę aliasową", + "Add alias to {domain}": "Nowy alias w {domain}", + "Add default aliases": "Dodaj domyślne aliasy", + "Add mailbox": "Dodaj skrzynkę", + "Add mailbox to {domain}": "Nowa skrzynka w {domain}", + "Add rule": "Dodaj regułę", + "Add the key to your authenticator app (e.g. by opening the link on your phone), then enter the code it shows.": "Dodaj klucz do aplikacji uwierzytelniającej (np. otwierając link na telefonie), a następnie wpisz wyświetlony kod.", + "Administrator": "Administrator", + "Administrator already exists": "Administrator już istnieje", + "Administrator not found": "Nie znaleziono administratora", + "Administrator role required": "Wymagana rola administratora", + "Administrator saved": "Administrator zapisany", + "Administrators": "Administratorzy", + "Alias": "Alias", + "Alias created": "Alias utworzony", + "Alias does not exist": "Alias nie istnieje", + "Alias domain": "Domena aliasowa", + "Alias domain does not exist": "Domena aliasowa nie istnieje", + "Alias domain saved": "Domena aliasowa zapisana", + "Alias domains": "Domeny aliasowe", + "Alias domains are disabled": "Domeny aliasowe są wyłączone", + "Alias is outside this account": "Alias nie należy do tego konta", + "Alias updated": "Alias zaktualizowany", + "Aliases": "Aliasy", + "Aliases are disabled for this domain": "Aliasy są wyłączone dla tej domeny", + "All": "Wszystkie", + "All domains": "Wszystkie domeny", + "Allow sending mail (SMTP)": "Zezwalaj na wysyłanie poczty (SMTP)", + "Alternative e-mail (password recovery)": "Alternatywny e-mail (odzyskiwanie hasła)", + "An alias domain cannot point to itself": "Domena aliasowa nie może wskazywać na samą siebie", + "Application password": "Hasło aplikacji", + "Application password created": "Hasło aplikacji utworzone", + "Application password not found": "Nie znaleziono hasła aplikacji", + "Application passwords": "Hasła aplikacji", + "Application passwords are created by mailbox users": "Hasła aplikacji tworzą użytkownicy skrzynek", + "Application passwords are disabled": "Hasła aplikacji są wyłączone", + "At least one destination is required": "Wymagany jest co najmniej jeden adres docelowy", + "At least one local destination does not exist": "Co najmniej jeden lokalny adres docelowy nie istnieje", + "At least {n} characters, {l} letters and {d} digits": "Co najmniej {n} znaków, w tym {l} litery i {d} cyfry", + "Authentication": "Uwierzytelnianie", + "Author (domain or address)": "Nadawca (domena lub adres)", + "Auto-reply": "Autoresponder", + "Auto-reply activated": "Autoresponder włączony", + "Auto-reply is active": "Autoresponder jest aktywny", + "Auto-reply is switched off": "Autoresponder jest wyłączony", + "Auto-reply is switched on": "Autoresponder jest włączony", + "Auto-reply switched off": "Autoresponder wyłączony", + "Automatic": "Automatycznie", + "Automatic reply while you are away": "Automatyczna odpowiedź podczas Twojej nieobecności", + "Back": "Wstecz", + "Backup MX": "Zapasowy MX", + "Backup is disabled": "Kopia zapasowa jest wyłączona", + "Broadcast is restricted to global administrators": "Wiadomości do wszystkich mogą wysyłać tylko administratorzy globalni", + "Broadcast mail is disabled": "Wiadomości do wszystkich są wyłączone", + "Broadcast message": "Wiadomość do wszystkich", + "Cancel": "Anuluj", + "Change name": "Zmień nazwę", + "Change password": "Zmień hasło", + "Changes made through the administration interface": "Zmiany wprowadzone w panelu administracyjnym", + "Changing the display name is not enabled": "Zmiana nazwy nie jest włączona", + "Check": "Sprawdź", + "Check DNS now": "Sprawdź DNS teraz", + "Choose the mailbox whose auto-reply (vacation) you want to edit": "Wybierz skrzynkę, której autoresponder chcesz edytować", + "Click to activate": "Kliknij, aby aktywować", + "Click to deactivate": "Kliknij, aby dezaktywować", + "Close": "Zamknij", + "Coming back": "Wracam", + "Configure maps": "Skonfiguruj mapy", + "Continue": "Dalej", + "Copied": "Skopiowano", + "Copy": "Kopiuj", + "Copy to folder": "Kopiuj do folderu", + "Could not copy to the clipboard": "Nie udało się skopiować do schowka", + "Create": "Utwórz", + "Create a DKIM key first.": "Najpierw utwórz klucz DKIM.", + "Create a script to start filtering your mail.": "Utwórz skrypt, aby zacząć filtrować pocztę.", + "Current password": "Obecne hasło", + "Current password is incorrect": "Obecne hasło jest nieprawidłowe", + "Currently stored: {v}": "Obecnie zapisane: {v}", + "DKIM": "DKIM", + "DKIM DNS records of your domain (read-only)": "Rekordy DNS DKIM Twojej domeny (tylko do odczytu)", + "DKIM key not found": "Nie znaleziono klucza DKIM", + "DKIM key saved": "Klucz DKIM zapisany", + "DKIM management is disabled": "Zarządzanie DKIM jest wyłączone", + "DNS check failed": "Sprawdzenie DNS nie powiodło się", + "DNS check passed": "Sprawdzenie DNS zakończone powodzeniem", + "DNS record": "Rekord DNS", + "DNS status checks are disabled": "Sprawdzanie DNS jest wyłączone", + "Data": "Dane", + "Database": "Baza danych", + "Database connection is read-only": "Połączenie z bazą jest tylko do odczytu", + "Database is read-only in this development session": "Baza danych jest w tej sesji tylko do odczytu", + "Database schema and runtime configuration": "Schemat bazy danych i konfiguracja", + "Deactivate all": "Dezaktywuj wszystkie", + "Default alias": "Alias domyślny", + "Default/special aliases can only be edited by a global administrator": "Aliasy domyślne/specjalne może edytować tylko administrator globalny", + "Delete": "Usuń", + "Delete DKIM key": "Usuń klucz DKIM", + "Delete administrator": "Usuń administratora", + "Delete alias": "Usuń alias", + "Delete alias domain": "Usuń domenę aliasową", + "Delete all inactive aliases of {domain}? Mailbox aliases and protected aliases are skipped.": "Usunąć wszystkie nieaktywne aliasy domeny {domain}? Aliasy skrzynek i aliasy chronione zostaną pominięte.", + "Delete application password": "Usuń hasło aplikacji", + "Delete domain": "Usuń domenę", + "Delete exception": "Usuń wyjątek", + "Delete fetchmail entry": "Usuń wpis fetchmail", + "Delete inactive": "Usuń nieaktywne", + "Delete inactive aliases": "Usuń nieaktywne aliasy", + "Delete key": "Usuń klucz", + "Delete mailbox": "Usuń skrzynkę", + "Delete script": "Usuń skrypt", + "Delete signing entry": "Usuń wpis podpisywania", + "Delete the administrator {a}?": "Usunąć administratora {a}?", + "Delete the alias domain {a} → {b}?": "Usunąć domenę aliasową {a} → {b}?", + "Delete the alias {a}?": "Usunąć alias {a}?", + "Delete the application password “{d}”?": "Usunąć hasło aplikacji „{d}”?", + "Delete the exception for {ip}?": "Usunąć wyjątek dla {ip}?", + "Delete the fetchmail entry {u}@{s}?": "Usunąć wpis fetchmail {u}@{s}?", + "Delete the key {d} / {s}?": "Usunąć klucz {d} / {s}?", + "Delete the mailbox {m}? Its alias, auto-reply, fetchmail entries and quota data are removed as well. Mail files on the server are only removed by a configured deletion hook.": "Usunąć skrzynkę {m}? Zostaną usunięte także jej alias, autoresponder, wpisy fetchmail i dane quota. Pliki poczty na serwerze usuwa tylko skonfigurowany hook usuwania.", + "Delete the script {s}?": "Usunąć skrypt {s}?", + "Delete the signing entry for {a}?": "Usunąć wpis podpisywania dla {a}?", + "Delete {domain} together with all mailboxes, aliases, alias domains and log entries? This cannot be undone.": "Usunąć {domain} razem ze wszystkimi skrzynkami, aliasami, domenami aliasowymi i wpisami dziennika? Tej operacji nie można cofnąć.", + "Deleted: {d}, skipped: {s}": "Usunięto: {d}, pominięto: {s}", + "Delivered to the mailbox (POP/IMAP)": "Dostarczane do skrzynki (POP/IMAP)", + "Delivery": "Dostarczanie", + "Description": "Opis", + "Description (e.g. phone)": "Opis (np. telefon)", + "Description is required": "Opis jest wymagany", + "Description is too long": "Opis jest za długi", + "Disable": "Wyłącz", + "Disable two-factor authentication": "Wyłącz uwierzytelnianie dwuskładnikowe", + "Disabled": "Wyłączone", + "Discard": "Odrzuć", + "Domain": "Domena", + "Domain administrator": "Administrator domen", + "Domain already exists": "Domena już istnieje", + "Domain deleted": "Domena usunięta", + "Domain does not exist": "Domena nie istnieje", + "Domain is outside this administrator's scope": "Domena jest poza zakresem tego administratora", + "Domain quota": "Quota domeny", + "Domain quota (MB)": "Quota domeny (MB)", + "Domains": "Domeny", + "Download backup": "Pobierz kopię zapasową", + "E-mail address": "Adres e-mail", + "Edit": "Edytuj", + "Edit DKIM key": "Edytuj klucz DKIM", + "Edit administrator": "Edytuj administratora", + "Edit alias": "Edytuj alias", + "Edit alias domain": "Edytuj domenę aliasową", + "Edit auto-reply": "Edytuj autoresponder", + "Edit domain": "Edytuj domenę", + "Edit fetchmail entry": "Edytuj wpis fetchmail", + "Edit mailbox": "Edytuj skrzynkę", + "Edit signing entry": "Edytuj wpis podpisywania", + "Editing mailbox aliases is disabled for your role (alias_control / alias_control_admin)": "Edycja aliasów skrzynek jest wyłączona dla Twojej roli (alias_control / alias_control_admin)", + "Editing the forwarding is not allowed": "Edycja przekierowania jest niedozwolona", + "Empty = protocol default": "Puste = domyślny port protokołu", + "Enabled": "Włączone", + "Enabled features": "Włączone funkcje", + "Enter a search term in the search field above.": "Wpisz szukaną frazę w polu wyszukiwania powyżej.", + "Enter a valid header name": "Podaj poprawną nazwę nagłówka", + "Enter a value for the condition": "Podaj wartość warunku", + "Enter at least one forwarding address or keep delivery to the mailbox": "Podaj co najmniej jeden adres przekierowania albo pozostaw dostarczanie do skrzynki", + "Enter the forwarding address": "Podaj adres przekierowania", + "Enter the target folder": "Podaj folder docelowy", + "Error": "Błąd", + "External": "Zewnętrzny", + "Extra options": "Dodatkowe opcje", + "Fetch all messages (also already seen)": "Pobieraj wszystkie wiadomości (także przeczytane)", + "Fetchmail": "Fetchmail", + "Fetchmail entry saved": "Wpis fetchmail zapisany", + "Fetchmail is disabled": "Fetchmail jest wyłączony", + "Fetchmail record not found": "Nie znaleziono wpisu fetchmail", + "File": "Plik", + "Filter": "Filtruj", + "Filters": "Filtry", + "Filter…": "Filtruj…", + "Folder": "Folder", + "Folder or address": "Folder lub adres", + "Forgot password": "Nie pamiętam hasła", + "Forgot password?": "Nie pamiętasz hasła?", + "Forward incoming mail to other addresses": "Przekazuj przychodzącą pocztę na inne adresy", + "Forward only": "Tylko przekazywanie", + "Forward to (one address per line)": "Przekazuj na (jeden adres w wierszu)", + "Forwarding": "Przekierowanie", + "Forwarding for {m}": "Przekierowanie dla {m}", + "Forwarding saved": "Przekierowanie zapisane", + "Forwards to a domain outside your administration": "Przekazuje do domeny spoza Twojej administracji", + "From": "Od", + "Generate": "Generuj", + "Generate DKIM key": "Wygeneruj klucz DKIM", + "Generate key": "Wygeneruj klucz", + "Global administrator": "Administrator globalny", + "Global administrator (all domains)": "Administrator globalny (wszystkie domeny)", + "Global administrator role required": "Wymagana rola administratora globalnego", + "Global and domain administrators": "Administratorzy globalni i administratorzy domen", + "Going away": "Wyjeżdżam", + "Header name (for “Other header”)": "Nazwa nagłówka (dla „Inny nagłówek”)", + "I am currently out of the office and will reply after my return.": "Jestem obecnie poza biurem i odpowiem po powrocie.", + "I have a code": "Mam kod", + "IP address": "Adres IP", + "If": "Jeżeli", + "If the account is eligible, a recovery code has been sent to its alternative e-mail address.": "Jeśli konto spełnia warunki, kod odzyskiwania został wysłany na jego alternatywny adres e-mail.", + "Import": "Importuj", + "Import Rspamd configuration": "Import konfiguracji Rspamd", + "Import existing configuration": "Importuj istniejącą konfigurację", + "Import result": "Wynik importu", + "Imported: {n}": "Zaimportowano: {n}", + "Inactive": "Nieaktywne", + "Interval": "Interwał", + "Invalid CSRF token": "Nieprawidłowy token CSRF – odśwież stronę", + "Invalid IP address": "Nieprawidłowy adres IP", + "Invalid TOTP code": "Nieprawidłowy kod TOTP", + "Invalid address": "Nieprawidłowy adres", + "Invalid alias destination": "Nieprawidłowy adres docelowy aliasu", + "Invalid catch-all alias address": "Nieprawidłowy adres aliasu catch-all", + "Invalid credentials": "Nieprawidłowy login lub hasło", + "Invalid domain": "Nieprawidłowa domena", + "Invalid email address": "Nieprawidłowy adres e-mail", + "Invalid email local part": "Nieprawidłowa część lokalna adresu e-mail", + "Invalid mailbox/alias local part": "Nieprawidłowa nazwa skrzynki/aliasu", + "Invalid or expired recovery code": "Nieprawidłowy lub wygasły kod odzyskiwania", + "Invalid phone number": "Nieprawidłowy numer telefonu", + "Keep a copy in my mailbox (forward and store)": "Zachowaj kopię w mojej skrzynce (przekaż i zachowaj)", + "Keep a copy in the mailbox (forward and store)": "Zachowaj kopię w skrzynce (przekaż i zachowaj)", + "Keep in inbox": "Zostaw w skrzynce odbiorczej", + "Keep messages on the server": "Pozostaw wiadomości na serwerze", + "Key": "Klucz", + "Key mapped": "Klucz podłączony", + "Key size": "Długość klucza", + "Keys": "Klucze", + "Keys and signing table stored in the PostfixAdmin database (OpenDKIM)": "Klucze i tabela podpisywania w bazie PostfixAdmin (OpenDKIM)", + "Language": "Język", + "Last result": "Ostatni wynik", + "Leave empty to generate a password": "Pozostaw puste, aby wygenerować hasło", + "Legacy domain blocks found: {d}. Import them so that the maps can manage these domains.": "Znaleziono stare bloki domain: {d}. Zaimportuj je, aby mapy mogły obsługiwać te domeny.", + "Link for the authenticator app": "Link dla aplikacji uwierzytelniającej", + "Loading…": "Ładowanie…", + "Log": "Dziennik", + "Login required": "Wymagane logowanie", + "MFA challenge expired": "Weryfikacja dwuskładnikowa wygasła – zaloguj się ponownie", + "Mail server administration": "Administracja serwerem poczty", + "Mail server is backup MX": "Serwer jest zapasowym MX", + "Mail to any address of this domain is delivered to the same address in the target domain.": "Poczta na dowolny adres tej domeny trafia na ten sam adres w domenie docelowej.", + "Mailbox": "Skrzynka", + "Mailbox created": "Skrzynka utworzona", + "Mailbox deleted": "Skrzynka usunięta", + "Mailbox does not exist": "Skrzynka nie istnieje", + "Mailbox domain does not match domain": "Domena skrzynki nie zgadza się z domeną", + "Mailbox folders could not be created": "Nie udało się utworzyć folderów skrzynki", + "Mailbox is disabled": "Skrzynka jest wyłączona", + "Mailbox is outside this account": "Skrzynka nie należy do tego konta", + "Mailbox name": "Nazwa skrzynki", + "Mailbox not found": "Nie znaleziono skrzynki", + "Mailbox updated": "Skrzynka zaktualizowana", + "Mailbox user": "Użytkownik skrzynki", + "Mailbox {m} created": "Utworzono skrzynkę {m}", + "Mailboxes": "Skrzynki", + "Mailboxes are disabled for this domain": "Skrzynki są wyłączone dla tej domeny", + "Mailboxes, aliases and alias domains": "Skrzynki, aliasy i domeny aliasowe", + "ManageSieve credentials are not available; sign in again": "Dane logowania ManageSieve są niedostępne – zaloguj się ponownie", + "ManageSieve is disabled": "ManageSieve jest wyłączony", + "ManageSieve is read-only": "ManageSieve jest tylko do odczytu", + "Management": "Zarządzanie", + "Maps active": "Mapy aktywne", + "Mark as read": "Oznacz jako przeczytane", + "Match": "Dopasowanie", + "Max. mailbox quota": "Maks. quota skrzynki", + "Max. mailbox quota (MB)": "Maks. quota skrzynki (MB)", + "Maximum: {n} MB": "Maksymalnie: {n} MB", + "Menu": "Menu", + "Message": "Treść", + "Message sent": "Wiadomość wysłana", + "Missing CSRF token": "Brak tokenu CSRF – odśwież stronę", + "Modified": "Zmieniono", + "Move to folder": "Przenieś do folderu", + "Move valid domain { path/selector } blocks into the managed maps? A backup is created and the change is rolled back if the configuration test fails.": "Przenieść poprawne bloki domain { path/selector } do zarządzanych map? Zostanie utworzona kopia zapasowa, a zmiana zostanie wycofana, jeśli test konfiguracji się nie powiedzie.", + "My mailbox": "Moja skrzynka", + "Name": "Nazwa", + "Name changed": "Nazwa zmieniona", + "Negate the condition": "Zaneguj warunek", + "Never": "Nigdy", + "New DKIM key": "Nowy klucz DKIM", + "New administrator": "Nowy administrator", + "New application password": "Nowe hasło aplikacji", + "New domain": "Nowa domena", + "New entry": "Nowy wpis", + "New fetchmail entry": "Nowy wpis fetchmail", + "New key": "Nowy klucz", + "New name": "Nowa nazwa", + "New password": "Nowe hasło", + "New password (leave empty to keep)": "Nowe hasło (puste = bez zmian)", + "New private key (leave empty to keep)": "Nowy klucz prywatny (puste = bez zmian)", + "New public key (leave empty to keep)": "Nowy klucz publiczny (puste = bez zmian)", + "New script": "Nowy skrypt", + "New signing entry": "Nowy wpis podpisywania", + "No": "Nie", + "No DKIM keys.": "Brak kluczy DKIM.", + "No DKIM record is configured for your domain.": "Dla Twojej domeny nie skonfigurowano rekordu DKIM.", + "No Rspamd DKIM keys.": "Brak kluczy DKIM Rspamd.", + "No administrators.": "Brak administratorów.", + "No aliases found.": "Nie znaleziono aliasów.", + "No aliases match the search.": "Żaden alias nie pasuje do wyszukiwania.", + "No application passwords.": "Brak haseł aplikacji.", + "No domains are assigned to you.": "Nie masz przypisanych domen.", + "No domains found.": "Nie znaleziono domen.", + "No domains yet.": "Brak domen.", + "No exceptions.": "Brak wyjątków.", + "No fetchmail entries.": "Brak wpisów fetchmail.", + "No log entries.": "Brak wpisów w dzienniku.", + "No mailboxes found.": "Nie znaleziono skrzynek.", + "No mailboxes match the search.": "Żadna skrzynka nie pasuje do wyszukiwania.", + "No scripts yet.": "Brak skryptów.", + "No signing entries.": "Brak wpisów podpisywania.", + "None": "Brak", + "Not configured": "Nieskonfigurowane", + "Nothing to select.": "Brak pozycji do wyboru.", + "Nothing to show.": "Brak danych.", + "Only after the new DNS record has been published.": "Dopiero po opublikowaniu nowego rekordu DNS.", + "Only mailbox users can use this endpoint": "Funkcja dostępna tylko dla użytkowników skrzynek", + "Only mailboxes (not alias destinations)": "Tylko skrzynki (bez celów aliasów)", + "Other header": "Inny nagłówek", + "Out of office": "Poza biurem", + "Overview": "Przegląd", + "POP/IMAP": "POP/IMAP", + "Password": "Hasło", + "Password (leave empty to keep)": "Hasło (puste = bez zmian)", + "Password changed": "Hasło zmienione", + "Password expired": "Hasło wygasło", + "Password expires": "Hasło wygasa", + "Password expiry": "Wygasanie hasła", + "Password expiry (days)": "Ważność hasła (dni)", + "Password is required": "Hasło jest wymagane", + "Password recovery is disabled": "Odzyskiwanie hasła jest wyłączone", + "Password scheme": "Schemat haseł", + "Password – copy it now, it will not be shown again.": "Hasło – skopiuj je teraz, nie zostanie pokazane ponownie.", + "Password, two-factor authentication and application passwords": "Hasło, uwierzytelnianie dwuskładnikowe i hasła aplikacji", + "Passwords are managed by the mailbox_postpassword hook and cannot be changed here": "Hasła są zarządzane przez hook mailbox_postpassword i nie można ich tu zmienić", + "Path of the private key": "Ścieżka klucza prywatnego", + "Pipe/file alias destinations are disabled by the Rust security profile": "Cele aliasów typu potok/plik są wyłączone ze względów bezpieczeństwa", + "Poll every (minutes)": "Sprawdzaj co (minuty)", + "Port": "Port", + "Private key": "Klucz prywatny", + "Private key (leave empty to generate)": "Klucz prywatny (puste = wygeneruj)", + "Protect the sign-in with a one-time code from an authenticator app.": "Zabezpiecz logowanie jednorazowym kodem z aplikacji uwierzytelniającej.", + "Protocol": "Protokół", + "Public key": "Klucz publiczny", + "Public key (leave empty to generate)": "Klucz publiczny (puste = wygeneruj)", + "Publish this DNS record.": "Opublikuj ten rekord DNS.", + "Quota": "Quota", + "Quota (MB)": "Quota (MB)", + "Quota -1 (disabled) is only allowed when the domain maximum quota is disabled": "Quota -1 (wyłączona) jest dozwolona tylko, gdy maks. quota domeny jest wyłączona", + "Quota is disabled for this domain (use -1)": "Quota jest wyłączona dla tej domeny (użyj -1)", + "Quota must be -1 or greater": "Quota musi wynosić co najmniej -1", + "Recipient (To/Cc)": "Odbiorca (To/Cc)", + "Recovery code": "Kod odzyskiwania", + "Recovery e-mail": "E-mail do odzyskiwania", + "Redirect to": "Przekieruj na", + "Reload Rspamd": "Przeładuj Rspamd", + "Reload Rspamd afterwards": "Przeładuj Rspamd po zapisie", + "Remember these destinations for new aliases": "Zapamiętaj te adresy dla nowych aliasów", + "Remove the mapping and delete the private key {d} / {s} from disk?": "Usunąć mapowanie i skasować z dysku klucz prywatny {d} / {s}?", + "Remove {d} from the Rspamd maps? The key file stays on disk.": "Usunąć {d} z map Rspamd? Plik klucza pozostanie na dysku.", + "Rename": "Zmień nazwę", + "Rename script": "Zmień nazwę skryptu", + "Renaming administrators is not supported": "Zmiana nazwy administratora nie jest obsługiwana", + "Renaming alias domains is not supported": "Zmiana nazwy domeny aliasowej nie jest obsługiwana", + "Renaming aliases is not supported": "Zmiana nazwy aliasu nie jest obsługiwana", + "Renaming domains is not supported; create a new domain instead": "Zmiana nazwy domeny nie jest obsługiwana; utwórz nową domenę", + "Renaming mailboxes is not supported": "Zmiana nazwy skrzynki nie jest obsługiwana", + "Repeat password": "Powtórz hasło", + "Replace an existing mapping": "Zastąp istniejące mapowanie", + "Reply at most every {n} seconds": "Odpowiadaj nie częściej niż co {n} s", + "Reply once (per sender)": "Odpowiedz raz (każdemu nadawcy)", + "Reply once per day": "Odpowiadaj raz dziennie", + "Reply once per week": "Odpowiadaj raz w tygodniu", + "Reply to every message": "Odpowiadaj na każdą wiadomość", + "Reply type": "Sposób odpowiedzi", + "Retrieve mail from remote POP3/IMAP accounts": "Pobieranie poczty ze zdalnych kont POP3/IMAP", + "Role": "Rola", + "Rspamd maps configured": "Mapy Rspamd skonfigurowane", + "Rspamd reloaded": "Rspamd przeładowany", + "Rule added – check and save the script": "Reguła dodana – sprawdź i zapisz skrypt", + "Rule name": "Nazwa reguły", + "Run “postfixadmin-rs --init-schema” on the server to create missing tables and columns.": "Uruchom na serwerze „postfixadmin-rs --init-schema”, aby utworzyć brakujące tabele i kolumny.", + "Save": "Zapisz", + "Saved": "Zapisano", + "Schema": "Schemat", + "Schema check": "Kontrola schematu", + "Script name": "Nazwa skryptu", + "Script saved": "Skrypt zapisany", + "Scripts": "Skrypty", + "Search": "Szukaj", + "Search in this domain…": "Szukaj w tej domenie…", + "Search mailboxes and aliases…": "Szukaj skrzynek i aliasów…", + "Search results for “{q}”": "Wyniki wyszukiwania „{q}”", + "Searches addresses, names and forwarding targets in your domains": "Przeszukuje adresy, nazwy i cele przekierowań w Twoich domenach", + "Secret key": "Klucz tajny", + "Security": "Bezpieczeństwo", + "Select at least one domain": "Wybierz co najmniej jedną domenę", + "Selector": "Selektor", + "Send": "Wyślij", + "Send a copy to": "Wyślij kopię na", + "Send a message through the configured SMTP server": "Wysyłanie wiadomości przez skonfigurowany serwer SMTP", + "Send code": "Wyślij kod", + "Send e-mail": "Wyślij e-mail", + "Send mail": "Wyślij pocztę", + "Send mail is disabled": "Wysyłanie poczty jest wyłączone", + "Send mail is restricted to global administrators": "Wysyłanie poczty jest dostępne tylko dla administratorów globalnych", + "Send this message to all recipients in {n} domain(s)?": "Wysłać tę wiadomość do wszystkich odbiorców w domenach: {n}?", + "Send to all": "Wyślij do wszystkich", + "Send welcome e-mail": "Wyślij wiadomość powitalną", + "Sender": "Nadawca", + "Sender domain": "Domena nadawcy", + "Sender name": "Nazwa nadawcy", + "Sending (SMTP)": "Wysyłanie (SMTP)", + "Sending disabled": "Wysyłanie wyłączone", + "Sent: {s}, failed: {f}": "Wysłano: {s}, błędy: {f}", + "Server": "Serwer", + "Server-side mail filters (Sieve)": "Filtry poczty po stronie serwera (Sieve)", + "Services": "Usługi", + "Session expired": "Sesja wygasła", + "Set a new password": "Ustaw nowe hasło", + "Set up": "Skonfiguruj", + "Set up two-factor authentication": "Konfiguracja uwierzytelniania dwuskładnikowego", + "Show": "Pokaż", + "Show all ({n})": "Pokaż wszystkie ({n})", + "Show password": "Pokaż hasło", + "Sign in": "Zaloguj", + "Sign out": "Wyloguj", + "Signed in as {user}": "Zalogowano jako {user}", + "Signing entry saved": "Wpis podpisywania zapisany", + "Signing keys used by Rspamd on this host": "Klucze podpisujące używane przez Rspamd na tym serwerze", + "Signing table": "Tabela podpisywania", + "Skipped: {n}": "Pominięto: {n}", + "Source": "Źródło", + "Source password is required": "Hasło źródłowe jest wymagane", + "Source server and user are required": "Serwer i użytkownik źródłowy są wymagani", + "Status": "Status", + "Stop processing further rules": "Zatrzymaj przetwarzanie kolejnych reguł", + "Stored in mailbox": "Zapisywana w skrzynce", + "Subject": "Temat", + "Switch to dark theme": "Przełącz na ciemny motyw", + "Switch to light theme": "Przełącz na jasny motyw", + "System": "System", + "TOTP exception": "Wyjątek TOTP", + "TOTP exceptions (trusted IP addresses)": "Wyjątki TOTP (zaufane adresy IP)", + "TOTP is disabled": "TOTP jest wyłączone", + "TOTP is not configured": "TOTP nie jest skonfigurowane", + "Target domain": "Domena docelowa", + "Target mailbox": "Skrzynka docelowa", + "Target mailbox does not exist": "Skrzynka docelowa nie istnieje", + "Test configuration": "Testuj konfigurację", + "The alias name (local part) is required": "Nazwa aliasu jest wymagana", + "The alias of a mailbox can only be deleted together with the mailbox": "Alias skrzynki można usunąć tylko razem ze skrzynką", + "The alias of a mailbox is activated together with the mailbox": "Alias skrzynki jest aktywowany razem ze skrzynką", + "The database schema is complete.": "Schemat bazy danych jest kompletny.", + "The local part must not contain an @ sign": "Nazwa nie może zawierać znaku @", + "The mailbox name (local part) is required": "Nazwa skrzynki jest wymagana", + "The mailbox password has expired": "Hasło skrzynki wygasło", + "The mailbox password script failed": "Skrypt hasła skrzynki zakończył się błędem", + "The mailbox post-creation script failed": "Skrypt po utworzeniu skrzynki zakończył się błędem", + "The maximum mailbox quota cannot be larger than the domain quota": "Maks. quota skrzynki nie może być większa niż quota domeny", + "The name may contain up to 255 characters and no control characters": "Nazwa może mieć do 255 znaków i nie może zawierać znaków sterujących", + "The new password must differ from the current password": "Nowe hasło musi być inne niż obecne", + "The password has been changed. You can sign in now.": "Hasło zostało zmienione. Możesz się zalogować.", + "The passwords do not match.": "Hasła nie są identyczne.", + "The script contains an error": "Skrypt zawiera błąd", + "The script is valid": "Skrypt jest poprawny", + "The target domain is itself an alias domain; chained alias domains are not supported": "Domena docelowa sama jest domeną aliasową; łańcuchy domen aliasowych nie są obsługiwane", + "Then": "Wtedy", + "This address already exists": "Ten adres już istnieje", + "This domain has no alias domains.": "Ta domena nie ma domen aliasowych.", + "This domain has no aliases.": "Ta domena nie ma aliasów.", + "This domain has no mailboxes.": "Ta domena nie ma skrzynek.", + "This domain is already an alias domain": "Ta domena jest już domeną aliasową", + "This domain is already the target of another alias domain": "Ta domena jest już celem innej domeny aliasowej", + "This domain is configured as the vacation (autoreply) domain": "Ta domena jest skonfigurowana jako domena autorespondera", + "Time": "Czas", + "To": "Do", + "To (one address per line)": "Do (jeden adres w wierszu)", + "Too many login attempts": "Zbyt wiele prób logowania – spróbuj później", + "Too many password change attempts": "Zbyt wiele prób zmiany hasła – spróbuj później", + "Too many recovery requests": "Zbyt wiele próśb o odzyskanie hasła – spróbuj później", + "Too many reset attempts": "Zbyt wiele prób – spróbuj później", + "Transport": "Transport", + "Two-factor authentication": "Uwierzytelnianie dwuskładnikowe", + "Two-factor authentication (TOTP)": "Uwierzytelnianie dwuskładnikowe (TOTP)", + "Two-factor authentication disabled": "Uwierzytelnianie dwuskładnikowe wyłączone", + "Two-factor authentication enabled": "Uwierzytelnianie dwuskładnikowe włączone", + "Two-factor authentication enabled ": "Uwierzytelnianie dwuskładnikowe włączone", + "Undeliverable": "Niedostarczalny", + "Unlimited quota is not allowed because the domain has a total quota": "Quota bez limitu jest niedozwolona, ponieważ domena ma quotę całkowitą", + "Unmap": "Odłącz", + "Unmap key": "Odłącz klucz", + "Unsupported vacation reply interval": "Nieobsługiwany interwał odpowiedzi", + "Until": "Do", + "Use SSL/TLS": "Użyj SSL/TLS", + "Use existing key": "Użyj istniejącego klucza", + "Use this password in the application instead of your mailbox password.": "Użyj tego hasła w aplikacji zamiast hasła do skrzynki.", + "User": "Użytkownik", + "Vacation": "Autoresponder", + "Vacation control is disabled for domain administrators": "Zarządzanie autoresponderem jest wyłączone dla administratorów domen", + "Vacation control is disabled for this user": "Zarządzanie autoresponderem jest wyłączone dla tego użytkownika", + "Vacation end date cannot be before start date": "Data końcowa nie może być wcześniejsza niż początkowa", + "Vacation is disabled": "Autoresponder jest wyłączony", + "Vacation message is too long": "Treść autorespondera jest za długa", + "Value": "Wartość", + "Verify": "Zweryfikuj", + "Verify the server certificate": "Weryfikuj certyfikat serwera", + "Version": "Wersja", + "Virtual list": "Lista wirtualna", + "Warnings": "Ostrzeżenia", + "Yes": "Tak", + "You cannot deactivate yourself": "Nie możesz dezaktywować własnego konta", + "You cannot deactivate yourself or remove your own global administrator role": "Nie możesz dezaktywować własnego konta ani odebrać sobie roli administratora globalnego", + "You cannot delete the currently logged-in administrator": "Nie możesz usunąć zalogowanego administratora", + "Your domains": "Twoje domeny", + "Your session has ended. Please sign in again.": "Sesja wygasła. Zaloguj się ponownie.", + "active": "aktywny", + "all": "wszystkie", + "catch-all": "catch-all", + "contains": "zawiera", + "disabled": "wyłączone", + "expired": "wygasło", + "fetchmail extra options/MDA are disabled": "Dodatkowe opcje fetchmail/MDA są wyłączone", + "forward only": "tylko przekazywanie", + "is exactly": "jest równe", + "matches (wildcards)": "pasuje do wzorca (*, ?)", + "max. {q}": "maks. {q}", + "missing": "brak", + "poll_time must be positive": "Interwał musi być dodatni", + "system": "systemowy", + "unlimited": "bez limitu", + "{from}–{to} of {total}": "{from}–{to} z {total}", + "{n} address(es)": "Adresy: {n}", + "{n} days": "{n} dni", + "{n} domain(s)": "Domeny: {n}", + "{n} min": "{n} min" + }, + "patterns": [ + { + "match": "^The mailbox limit of this domain has been reached \\((\\d+)\\)$", + "replace": "Osiągnięto limit skrzynek tej domeny ($1)" }, - "patterns": [] + { + "match": "^The alias limit of this domain has been reached \\((\\d+)\\)$", + "replace": "Osiągnięto limit aliasów tej domeny ($1)" + }, + { + "match": "^Unlimited quota is not allowed – the maximum mailbox quota of this domain is (\\d+) MB$", + "replace": "Quota bez limitu jest niedozwolona – maks. quota skrzynki w tej domenie to $1 MB" + }, + { + "match": "^The quota exceeds the maximum mailbox quota of this domain \\((\\d+) MB\\)$", + "replace": "Quota przekracza maks. quotę skrzynki tej domeny ($1 MB)" + }, + { + "match": "^The domain quota would be exceeded \\((\\d+) MB of (\\d+) MB already allocated\\)$", + "replace": "Quota domeny zostałaby przekroczona (przydzielono już $1 MB z $2 MB)" + }, + { + "match": "^(.+): an alias must not point to itself$", + "replace": "$1: alias nie może wskazywać na samego siebie" + }, + { + "match": "^Invalid destination address: (.+)$", + "replace": "Nieprawidłowy adres docelowy: $1" + }, + { + "match": "^You may only make aliases to domains hosted on this server\\. (.+) is a remote domain name\\.$", + "replace": "Aliasy mogą wskazywać tylko na domeny obsługiwane przez ten serwer. $1 jest domeną zewnętrzną." + }, + { + "match": "^Domain does not exist: (.+)$", + "replace": "Domena nie istnieje: $1" + }, + { + "match": "^Unknown domain: (.+)$", + "replace": "Nieznana domena: $1" + }, + { + "match": "^Unsupported transport: (.+)$", + "replace": "Nieobsługiwany transport: $1" + }, + { + "match": "^Password must contain at least (\\d+) characters$", + "replace": "Hasło musi mieć co najmniej $1 znaków" + }, + { + "match": "^Password must contain at least (\\d+) letters$", + "replace": "Hasło musi zawierać co najmniej $1 litery" + }, + { + "match": "^Password must contain at least (\\d+) digits$", + "replace": "Hasło musi zawierać co najmniej $1 cyfry" + }, + { + "match": "^(.+) must be -1 \\(disabled\\), 0 \\(unlimited\\) or a positive number$", + "replace": "$1: dozwolone -1 (wyłączone), 0 (bez limitu) lub liczba dodatnia" + }, + { + "match": "^Password expiration must be between 0 \\(never\\) and (\\d+) days$", + "replace": "Ważność hasła musi wynosić od 0 (nigdy) do $1 dni" + }, + { + "match": "^The domain is the target of the alias domain (.+); delete that alias domain first$", + "replace": "Domena jest celem domeny aliasowej $1; najpierw ją usuń" + }, + { + "match": "^Domain has no A, AAAA, MX or NS record: (.+)$", + "replace": "Domena nie ma rekordu A, AAAA, MX ani NS: $1" + }, + { + "match": "^Invalid vacation (start|end) date$", + "replace": "Nieprawidłowa data autorespondera" + } + ] } diff --git a/src/web/js/00-core.js b/src/web/js/00-core.js index 299d399..b73612f 100644 --- a/src/web/js/00-core.js +++ b/src/web/js/00-core.js @@ -7,369 +7,450 @@ const modal = $('#modal'); const toast = $('#toast'); - const S = { me: null, features: {}, csrf: '', page: 'overview', cache: {}, language: 'en', catalog: { messages: {}, patterns: [] }, loading: 0 }; + const S = { + me: null, features: {}, runtime: {}, permissions: {}, csrf: '', route: { path: [], query: {} }, + cache: {}, language: 'en', catalog: { messages: {}, patterns: [] }, loading: 0, + }; const LANGUAGES = { en: 'English', pl: 'Polski' }; const ASSET_ID = document.querySelector('meta[name="pa-rs-asset"]')?.content || ''; + + // ---------------------------------------------------------------- helpers const icon = name => ``; const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[c]); + const enc = encodeURIComponent; - function translateText(value) { - const text = String(value ?? ''); - const exact = S.catalog?.messages?.[text]; - if (exact != null) return exact; - for (const pattern of S.catalog?.patterns || []) { - try { - const regex = new RegExp(pattern.match, pattern.flags || ''); - if (regex.test(text)) return text.replace(regex, pattern.replace); - } catch {} - } - return text; - } - - function translateDom(root) { - if (!root) return; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - const nodes = []; - while (walker.nextNode()) nodes.push(walker.currentNode); - for (const node of nodes) { - const raw = node.nodeValue || ''; - const match = raw.match(/^(\s*)(.*?)(\s*)$/s); - if (!match || !match[2]) continue; - const translated = translateText(match[2]); - if (translated !== match[2]) node.nodeValue = `${match[1]}${translated}${match[3]}`; - } - root.querySelectorAll?.('[title],[aria-label],[placeholder]').forEach(element => { - for (const attr of ['title', 'aria-label', 'placeholder']) { - if (!element.hasAttribute(attr)) continue; - const value = element.getAttribute(attr); - const translated = translateText(value); - if (translated !== value) element.setAttribute(attr, translated); + /** Translate an English UI string; `{name}` placeholders are filled from vars. */ + function t(text, vars) { + const source = String(text ?? ''); + let out = S.catalog.messages?.[source]; + if (out == null) { + out = source; + for (const pattern of S.catalog.patterns || []) { + try { + const regex = new RegExp(pattern.match, pattern.flags || ''); + if (regex.test(source)) { out = source.replace(regex, pattern.replace); break; } + } catch { /* ignore broken pattern */ } } - }); - } - - async function loadLanguage(language) { - const normalized = String(language || 'en').toLowerCase().split('-')[0]; - const selected = LANGUAGES[normalized] ? normalized : 'en'; - try { - const response = await fetch(`assets/i18n/${selected}.json`, { cache: 'no-cache' }); - if (!response.ok) throw new Error(`language ${selected}: ${response.status}`); - const catalog = await response.json(); - S.catalog = { messages: catalog.messages || {}, patterns: catalog.patterns || [] }; - S.language = selected; - } catch (error) { - console.warn('i18n catalog load failed', error); - S.catalog = { messages: {}, patterns: [] }; - S.language = 'en'; } - document.documentElement.lang = S.language; - localStorage.setItem('pa_language', S.language); - } - - async function initLanguage() { - const configured = document.documentElement.lang || 'en'; - const selected = localStorage.getItem('pa_language') || configured || 'en'; - await loadLanguage(selected); - } - - async function setLanguage(language) { - if (!LANGUAGES[language] || language === S.language) return; - await loadLanguage(language); - if (S.me) { - shell(); - await go(S.page); - } else { - loginView(); - } - } - - const languageOptions = () => Object.entries(LANGUAGES) - .map(([value, label]) => ``) - .join(''); - - function showToast(message, error = false) { - toast.textContent = translateText(message); - toast.classList.toggle('error', error); - toast.hidden = false; - clearTimeout(showToast.timer); - showToast.timer = setTimeout(() => { toast.hidden = true; }, 3500); - } - - function updateLoading() { - const bar = $('#routeProgress'); - if (!bar) return; - const busy = S.loading > 0; - bar.hidden = !busy; - document.body.classList.toggle('api-busy', busy); - } - - function beginLoading() { - S.loading += 1; - updateLoading(); - } - - function endLoading() { - S.loading = Math.max(0, S.loading - 1); - updateLoading(); - } - - async function api(path, options = {}) { - const headers = new Headers(options.headers || {}); - if (options.body && !(options.body instanceof FormData)) headers.set('content-type', 'application/json'); - if (options.method && options.method !== 'GET' && S.csrf) headers.set('x-csrf-token', S.csrf); - beginLoading(); - try { - const response = await fetch(`api${path}`, { credentials: 'same-origin', ...options, headers }); - if (response.status === 204) return null; - let data = null; - try { data = await response.json(); } catch {} - if (!response.ok) { - const error = new Error(data?.error?.message || `${response.status} ${response.statusText}`); - error.code = data?.error?.code; - throw error; - } - return data; - } finally { - endLoading(); - } - } - - - function randomPassword(length = 20) { - const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%_-'; - const bytes = new Uint32Array(length); - crypto.getRandomValues(bytes); - return Array.from(bytes, value => alphabet[value % alphabet.length]).join(''); + if (vars) out = out.replace(/\{(\w+)\}/g, (m, key) => (key in vars ? String(vars[key]) : m)); + return out; } + const te = (text, vars) => esc(t(text, vars)); function h(tag, attrs = {}, ...children) { const element = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { if (key === 'class') element.className = value; else if (key.startsWith('on')) element.addEventListener(key.slice(2), value); - else if (value != null) element.setAttribute(key, value); + else if (value != null && value !== false) element.setAttribute(key, value === true ? '' : value); } for (const child of children.flat()) { + if (child == null) continue; element.append(child instanceof Node ? child : document.createTextNode(String(child))); } return element; } + // ---------------------------------------------------------------- i18n + async function loadLanguage(language) { + const normalized = String(language || 'en').toLowerCase().split('-')[0]; + const selected = LANGUAGES[normalized] ? normalized : 'en'; + S.catalog = { messages: {}, patterns: [] }; + if (selected !== 'en') { + try { + const response = await fetch(`assets/i18n/${selected}.json?v=${ASSET_ID}`, { cache: 'no-cache' }); + if (!response.ok) throw new Error(`language ${selected}: ${response.status}`); + const catalog = await response.json(); + S.catalog = { messages: catalog.messages || {}, patterns: catalog.patterns || [] }; + } catch (error) { + console.warn('i18n catalog load failed', error); + } + } + S.language = selected; + document.documentElement.lang = selected; + try { localStorage.setItem('pa_language', selected); } catch { /* storage unavailable */ } + } + + async function initLanguage() { + let stored = ''; + try { stored = localStorage.getItem('pa_language') || ''; } catch { /* ignore */ } + const browser = (navigator.language || '').toLowerCase().startsWith('pl') ? 'pl' : ''; + await loadLanguage(stored || document.documentElement.lang || browser || 'en'); + } + + async function setLanguage(language) { + if (!LANGUAGES[language] || language === S.language) return; + await loadLanguage(language); + if (S.me) { shell(); render(); } else loginView(); + } + + const languageOptions = () => Object.entries(LANGUAGES) + .map(([value, label]) => ``) + .join(''); + + // ---------------------------------------------------------------- feedback + function showToast(message, error = false) { + toast.textContent = t(message); + toast.classList.toggle('error', error); + toast.hidden = false; + clearTimeout(showToast.timer); + showToast.timer = setTimeout(() => { toast.hidden = true; }, error ? 6000 : 3500); + } + + function setLoading(delta) { + S.loading = Math.max(0, S.loading + delta); + const bar = $('#routeProgress'); + if (bar) bar.hidden = S.loading === 0; + document.body.classList.toggle('api-busy', S.loading > 0); + } + + // ---------------------------------------------------------------- API + async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + let body = options.body; + if (body != null && typeof body !== 'string' && !(body instanceof FormData)) body = JSON.stringify(body); + if (body != null && !(body instanceof FormData)) headers.set('content-type', 'application/json'); + const method = options.method || 'GET'; + if (method !== 'GET' && S.csrf) headers.set('x-csrf-token', S.csrf); + setLoading(1); + try { + const response = await fetch(`api${path}`, { credentials: 'same-origin', method, headers, body }); + if (response.status === 204) return null; + let data = null; + try { data = await response.json(); } catch { /* empty body */ } + if (!response.ok) { + const error = new Error(data?.error?.message || `${response.status} ${response.statusText}`); + error.code = data?.error?.code; + error.status = response.status; + if (response.status === 401 && S.me && !path.startsWith('/auth/')) { + S.me = null; + S.csrf = ''; + loginView(t('Your session has ended. Please sign in again.')); + } + throw error; + } + return data; + } finally { + setLoading(-1); + } + } + api.get = path => api(path); + api.post = (path, body) => api(path, { method: 'POST', body: body ?? {} }); + api.put = (path, body) => api(path, { method: 'PUT', body: body ?? {} }); + api.del = (path, body) => api(path, { method: 'DELETE', body }); + + // ---------------------------------------------------------------- routing + function parseHash() { + const raw = (location.hash || '').replace(/^#\/?/, ''); + const [pathPart, queryPart = ''] = raw.split('?'); + const path = pathPart.split('/').filter(Boolean).map(decodeURIComponent); + const query = Object.fromEntries(new URLSearchParams(queryPart)); + return { path, query }; + } + + function href(path, query) { + const segments = (Array.isArray(path) ? path : [path]).filter(item => item != null && item !== '').map(item => enc(item)); + const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '')).toString() : ''; + return `#/${segments.join('/')}${qs ? `?${qs}` : ''}`; + } + + function navigate(path, query, replace = false) { + const target = href(path, query); + if (target === location.hash) { render(); return; } + if (replace) { history.replaceState(null, '', target); render(); } else location.hash = target; + } + + const ROUTES = []; + function setRoutes(list) { ROUTES.splice(0, ROUTES.length, ...list); } + + async function render() { + if (!S.me) return; + S.route = parseHash(); + const name = S.route.path[0] || 'overview'; + const def = ROUTES.find(item => item.name === name && item.visible()); + const content = $('#content'); + if (!content) return; + $$('#nav [data-route]').forEach(link => link.classList.toggle('active', link.dataset.route === (def?.nav || name))); + document.body.classList.remove('nav-open'); + if (!def) { + navigate('overview', null, true); + return; + } + content.innerHTML = `
${te('Loading…')}
`; + const token = (render.token = (render.token || 0) + 1); + try { + await def.render(S.route.path.slice(1), S.route.query, content); + if (token === render.token) content.focus({ preventScroll: true }); + } catch (error) { + if (token !== render.token || error.status === 401) return; + content.innerHTML = `
${esc(t(error.message))}
`; + } + } + + // ---------------------------------------------------------------- layout + function roleLabel() { + if (S.me.role === 'global_admin') return t('Global administrator'); + if (S.me.role === 'admin') return t('Domain administrator'); + return t('Mailbox user'); + } + function shell() { - const role = S.me.role.replace('_', ' '); + const searchBox = S.me.kind === 'admin' + ? `` + : '
'; app.innerHTML = `
-
${icon('mail')}PostfixAdmin-RS
- +
${icon('mail')}PostfixAdmin-RS
+ ${searchBox}
- ${S.runtime?.database_read_only ? 'READ ONLY' : ''} - - -
${esc(S.me.username)}${esc(role)}
- + ${S.runtime?.database_read_only ? `READ ONLY` : ''} + + +
${esc(S.me.username)}${esc(roleLabel())}
+
-
+
`; $('#logoutBtn').onclick = logout; - $('#languageSelect').onchange = event => { setLanguage(event.target.value); }; - const updateThemeButton = () => { - const current = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'; + $('#navToggle').onclick = () => document.body.classList.toggle('nav-open'); + $('#languageSelect').onchange = event => setLanguage(event.target.value); + const updateTheme = () => { + const light = document.documentElement.dataset.theme === 'light'; + const label = t(light ? 'Switch to dark theme' : 'Switch to light theme'); const button = $('#themeBtn'); - const nextLabel = current === 'light' ? 'Przełącz na ciemny motyw' : 'Przełącz na jasny motyw'; - button.innerHTML = `${icon(current === 'light' ? 'moon' : 'sun')}`; - button.title = nextLabel; - button.setAttribute('aria-label', nextLabel); + button.innerHTML = icon(light ? 'moon' : 'sun'); + button.title = label; + button.setAttribute('aria-label', label); }; - updateThemeButton(); + updateTheme(); $('#themeBtn').onclick = () => { const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'; document.documentElement.dataset.theme = next; - localStorage.setItem('pa_theme', next); - updateThemeButton(); + try { localStorage.setItem('pa_theme', next); } catch { /* ignore */ } + updateTheme(); }; - $('#globalSearch').oninput = event => { - const query = event.target.value.toLowerCase(); - $$('#content tbody tr').forEach(row => { row.hidden = !row.textContent.toLowerCase().includes(query); }); + const searchForm = $('#globalSearchForm'); + if (searchForm) searchForm.onsubmit = event => { + event.preventDefault(); + const q = $('#globalSearch').value.trim(); + if (q) navigate('search', { q }); }; renderNav(); - translateDom(app); } function renderNav() { const nav = $('#nav'); - const items = [ - ['overview', 'inbox', 'Przegląd', true], - ['domains', 'folder', 'Domeny', S.me.kind === 'admin'], - ['mailboxes', 'mail', 'Skrzynki', S.me.kind === 'admin'], - ['aliases', 'forward', 'Aliasy', true], - ['admins', 'user-round', 'Administratorzy', S.me.role === 'global_admin'], - ['fetchmail', 'download', 'Fetchmail', S.features.fetchmail && S.me.kind === 'admin'], - ['vacation', 'calendar-days', 'Vacation', S.features.vacation && (S.me.kind === 'admin' || S.features.vacation_control)], - ['filters', 'filter', 'Filtry', S.me.kind === 'user' && S.runtime?.sieve?.enabled], - ['dkim', 'shield-alert', 'DKIM', ((S.features.dkim || S.runtime?.rspamd_dkim?.enabled) && S.me.kind === 'admin') || (S.me.kind === 'user' && S.runtime?.dkim?.user_read_only && (S.features.dkim || S.runtime?.rspamd_dkim?.enabled))], - ['logs', 'file-text', 'Logi', S.features.logging && S.me.kind === 'admin'], - ['mail', 'send', 'Wyślij / broadcast', S.features.sendmail && S.me.kind === 'admin'], - ['security', 'settings', 'Bezpieczeństwo', true], - ['system', 'package', 'System', S.me.role === 'global_admin'], - ]; let group = ''; - for (const [page, iconName, label, visible] of items) { - if (!visible) continue; - const nextGroup = ['overview', 'domains', 'mailboxes', 'aliases', 'admins'].includes(page) - ? 'Zarządzanie' - : ['fetchmail', 'vacation', 'filters', 'dkim', 'logs', 'mail'].includes(page) - ? 'Usługi' - : 'Konto i system'; - if (nextGroup !== group) { - nav.insertAdjacentHTML('beforeend', ``); - group = nextGroup; + for (const def of ROUTES) { + if (!def.label || !def.visible()) continue; + if (def.group !== group) { + nav.insertAdjacentHTML('beforeend', ``); + group = def.group; } - const button = h('button', { 'data-page': page, onclick: () => go(page) }); - button.innerHTML = `${icon(iconName)}${label}`; - if (page === S.page) button.classList.add('active'); - nav.append(button); + nav.insertAdjacentHTML('beforeend', `${icon(def.icon)}${te(def.label)}`); } } - async function go(page) { - S.page = page; - $$('#nav button').forEach(button => button.classList.toggle('active', button.dataset.page === page)); - const content = $('#content'); - content.innerHTML = '
Ładowanie…
'; - try { - await window.PAGES[page](); - translateDom(content); - } catch (error) { - content.innerHTML = `
${esc(translateText(error.message))}
`; - showToast(error.message, true); + function pageHead(title, description = '', buttons = '') { + return `

${esc(title)}

${description ? `

${esc(description)}

` : ''}
${buttons}
`; + } + + function table(headers, rows, options = {}) { + const body = rows.length ? rows.join('') : `${esc(options.empty || t('Nothing to show.'))}`; + return `
${options.head ? `
${options.head}
` : ''}
${headers.map(header => ``).join('')}${body}
${esc(header)}
${options.foot || ''}
`; + } + + /** Active/inactive badge that toggles on click (editactive.php). */ + function toggle(active, attrs = '', enabled = true) { + const label = active ? t('Active') : t('Inactive'); + if (!enabled) return `${esc(label)}`; + return ``; + } + + function pager(total, page, pageSize, attr = 'data-page') { + if (!pageSize || total <= pageSize) return ''; + const pages = Math.ceil(total / pageSize); + const item = (p, label = String(p), disabled = false, current = false) => + ``; + const numbers = []; + const window = new Set([1, pages, page - 1, page, page + 1, page - 2, page + 2].filter(p => p >= 1 && p <= pages)); + let last = 0; + for (const p of [...window].sort((a, b) => a - b)) { + if (p - last > 1) numbers.push('…'); + numbers.push(item(p, String(p), false, p === page)); + last = p; } + return `
${te('{from}–{to} of {total}', { from: (page - 1) * pageSize + 1, to: Math.min(total, page * pageSize), total })}
${item(page - 1, '‹', page <= 1)}${numbers.join('')}${item(page + 1, '›', page >= pages)}
`; } - function pageHead(title, description, buttons = '') { - return `

${esc(translateText(title))}

${esc(translateText(description))}

${buttons}
`; - } - - function table(headers, rows) { - return `
${headers.map(header => ``).join('')}${rows.join('')}
${esc(translateText(header))}
`; - } - + // ---------------------------------------------------------------- modals function closeModal() { if (modal.open) modal.close(); modal.innerHTML = ''; } - function formModal(title, fields, values, onSubmit) { - modal.innerHTML = ''; - const form = h('form'); - form.innerHTML = ``; - const body = $('.modal-body', form); + function randomPassword(length = 16) { + const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const digits = '23456789'; + const all = `${letters}${digits}`; + const bytes = new Uint32Array(length); + crypto.getRandomValues(bytes); + const chars = Array.from(bytes, value => all[value % all.length]); + chars[0] = digits[bytes[0] % digits.length]; + chars[1] = digits[bytes[1] % digits.length]; + for (let i = chars.length - 1; i > 0; i--) { const j = bytes[i] % (i + 1); [chars[i], chars[j]] = [chars[j], chars[i]]; } + return chars.join(''); + } + /** + * Generic form dialog. + * field: { name, label, type: text|email|password|number|select|textarea|checkbox|checklist|static, + * options, required, readonly, placeholder, help, wide, generate, confirm, min, list } + * onSubmit(data) may return { message, reveal: { title, text }, keepOpen }. + */ + function formModal(title, fields, values, onSubmit, options = {}) { + modal.innerHTML = ''; + const form = h('form', { class: 'modal-form', novalidate: false }); + form.innerHTML = `${options.intro ? `` : ''}`; + const body = $('.modal-body', form); + const errorBox = $('.modal-error', form); for (const field of fields) { - const label = h('label', { class: `field ${field.wide ? 'wide' : ''}` }); - label.innerHTML = `${esc(field.label)}`; + if (field.hidden) continue; + const wrapper = h('div', { class: `field ${field.wide ? 'wide' : ''} ${field.type === 'checkbox' ? 'checkbox-field' : ''}` }); + const id = `f_${field.name}`; + const value = values[field.name]; + if (field.type === 'static') { + wrapper.innerHTML = `${esc(field.label)}
${field.html ?? esc(value ?? '')}
`; + body.append(wrapper); + continue; + } + if (field.type === 'checkbox') { + wrapper.innerHTML = `${field.help ? `${esc(field.help)}` : ''}`; + body.append(wrapper); + continue; + } + if (field.type === 'checklist') { + const selected = new Set(value || []); + wrapper.classList.add('wide'); + wrapper.innerHTML = `${esc(field.label)}
${(field.options || []).map(([optValue, optLabel]) => ``).join('') || `${te('Nothing to select.')}`}
${field.help ? `${esc(field.help)}` : ''}`; + body.append(wrapper); + continue; + } + wrapper.append(h('label', { class: 'field-label', for: id }, field.label)); let element; if (field.type === 'select') { - element = h('select', { name: field.name }); - for (const [value, optionLabel] of field.options) { - const option = h('option', { value }, optionLabel); - if (String(values[field.name] ?? '') === String(value)) option.selected = true; + element = h('select', { id, name: field.name }); + for (const [optValue, optLabel] of field.options || []) { + const option = h('option', { value: optValue }, optLabel); + if (String(value ?? '') === String(optValue)) option.selected = true; element.append(option); } } else if (field.type === 'textarea') { - element = h('textarea', { name: field.name }); - element.value = values[field.name] ?? ''; - } else if (field.type === 'checkbox') { - label.className = `field checkbox-row ${field.wide ? 'wide' : ''}`; - element = h('input', { type: 'checkbox', name: field.name }); - element.checked = Boolean(values[field.name]); - label.append(element, h('span', {}, field.help || '')); - body.append(label); - continue; + element = h('textarea', { id, name: field.name, rows: field.rows || 4, placeholder: field.placeholder || null }); + element.value = value ?? ''; } else { element = h('input', { - type: field.type || 'text', - name: field.name, - autocomplete: field.autocomplete || 'off', + id, name: field.name, type: field.type || 'text', autocomplete: field.autocomplete || 'off', + placeholder: field.placeholder || null, min: field.min ?? null, max: field.max ?? null, step: field.step ?? null, + list: field.list || null, inputmode: field.inputmode || null, }); - element.value = values[field.name] ?? ''; - if (field.min != null) element.min = field.min; - if (field.placeholder) element.placeholder = field.placeholder; + element.value = value ?? ''; } if (field.required) element.required = true; if (field.readonly) element.readOnly = true; - if (field.generate) { - const wrapper = h('div', { class: 'input-action' }); - const generate = h('button', { type: 'button', class: 'btn mini' }); - generate.innerHTML = `${icon('refresh-cw')}${translateText('Generuj')}`; - generate.onclick = () => { - element.value = randomPassword(field.generateLength || 20); - element.type = 'text'; - element.focus(); - element.select(); - }; - wrapper.append(element, generate); - label.append(wrapper); + if (field.maxlength) element.maxLength = field.maxlength; + if (field.type === 'password' || field.generate) { + const group = h('div', { class: 'input-action' }); + group.append(element); + const reveal = h('button', { type: 'button', class: 'btn mini', title: t('Show password'), 'aria-label': t('Show password') }); + reveal.innerHTML = icon('search'); + reveal.onclick = () => { element.type = element.type === 'password' ? 'text' : 'password'; }; + group.append(reveal); + if (field.generate) { + const generate = h('button', { type: 'button', class: 'btn mini' }); + generate.innerHTML = `${icon('refresh-cw')}${te('Generate')}`; + generate.onclick = () => { + element.value = randomPassword(); + element.type = 'text'; + const confirm = form.elements[`${field.name}_confirm`]; + if (confirm) { confirm.value = element.value; confirm.type = 'text'; } + }; + group.append(generate); + } + wrapper.append(group); } else { - label.append(element); + wrapper.append(element); + } + const help = h('small', { class: 'field-help', id: `${id}_help` }); + if (field.help) help.textContent = field.help; + wrapper.append(help); + body.append(wrapper); + if (field.confirm) { + const confirmWrap = h('div', { class: `field ${field.wide ? 'wide' : ''}` }); + confirmWrap.append(h('label', { class: 'field-label', for: `${id}_confirm` }, t('Repeat password'))); + confirmWrap.append(h('input', { id: `${id}_confirm`, name: `${field.name}_confirm`, type: 'password', autocomplete: 'new-password' })); + body.append(confirmWrap); } - if (field.help) label.append(h('small', { class: 'field-help' }, field.help)); - body.append(label); } - $$('[data-close]', form).forEach(button => { button.onclick = closeModal; }); form.onsubmit = async event => { event.preventDefault(); const data = {}; for (const field of fields) { - const element = form.elements[field.name]; - data[field.name] = field.type === 'checkbox' ? element.checked : element.value; + if (field.hidden || field.type === 'static') continue; + if (field.type === 'checkbox') data[field.name] = Boolean(form.elements[field.name]?.checked); + else if (field.type === 'checklist') data[field.name] = $$(`input[name="${field.name}"]:checked`, form).map(input => input.value); + else data[field.name] = form.elements[field.name]?.value ?? ''; + if (field.confirm && data[field.name] !== (form.elements[`${field.name}_confirm`]?.value ?? '')) { + errorBox.textContent = t('The passwords do not match.'); + errorBox.hidden = false; + return; + } } const submit = $('button[type="submit"]', form); submit.disabled = true; + errorBox.hidden = true; try { - const outcome = await onSubmit(data); + const outcome = await onSubmit(data, form); + if (outcome?.keepOpen) { submit.disabled = false; return; } closeModal(); - if (outcome?.refresh !== false && S.me) await go(S.page); - if (outcome?.reveal) { - textModal(outcome.reveal.title || 'Hasło', outcome.reveal.text || '', { copy: true }); - } else { - showToast(outcome?.message || 'Zapisano'); - } + if (outcome?.reveal) textModal(outcome.reveal.title, outcome.reveal.text, { copy: true, note: outcome.reveal.note }); + else if (outcome?.message !== null) showToast(outcome?.message || 'Saved'); + if (outcome?.refresh !== false) render(); } catch (error) { submit.disabled = false; - showToast(error.message, true); + errorBox.textContent = t(error.message); + errorBox.hidden = false; } }; modal.append(form); - translateDom(modal); modal.showModal(); + const first = $('input:not([readonly]):not([type=checkbox]),textarea,select', form); + first?.focus(); + if (options.onReady) options.onReady(form); + return form; } - - - - function textModal(title, text, options = {}) { modal.innerHTML = ''; const wrapper = h('div'); - wrapper.innerHTML = ` - - `; + wrapper.innerHTML = ` + + `; $$('[data-close]', wrapper).forEach(button => { button.onclick = closeModal; }); const copy = $('[data-copy]', wrapper); if (copy) copy.onclick = async () => { - try { await navigator.clipboard.writeText(String(text || '')); showToast('Skopiowano'); } - catch { showToast('Nie udało się skopiować do schowka', true); } + try { await navigator.clipboard.writeText(String(text || '')); showToast('Copied'); } + catch { showToast('Could not copy to the clipboard', true); } }; modal.append(wrapper); - translateDom(modal); modal.showModal(); } @@ -377,24 +458,18 @@ return new Promise(resolve => { modal.innerHTML = ''; const form = h('form'); - const confirmLabel = options.confirmLabel || 'Potwierdź'; const dangerClass = options.danger === false ? 'primary' : 'danger'; - form.innerHTML = ` + form.innerHTML = ` - `; + `; let settled = false; - const finish = value => { - if (settled) return; - settled = true; - closeModal(); - resolve(value); - }; + const finish = value => { if (settled) return; settled = true; closeModal(); resolve(value); }; $$('[data-cancel]', form).forEach(button => { button.onclick = () => finish(false); }); form.onsubmit = event => { event.preventDefault(); finish(true); }; modal.addEventListener('cancel', event => { event.preventDefault(); finish(false); }, { once: true }); modal.append(form); - translateDom(modal); modal.showModal(); + $('button[type="submit"]', form).focus(); }); } @@ -402,110 +477,116 @@ return new Promise(resolve => { modal.innerHTML = ''; const form = h('form'); - form.innerHTML = ` - - `; + form.innerHTML = ` + + `; const input = $('input[name="value"]', form); input.value = value || ''; let settled = false; - const finish = result => { - if (settled) return; - settled = true; - closeModal(); - resolve(result); - }; + const finish = result => { if (settled) return; settled = true; closeModal(); resolve(result); }; $$('[data-cancel]', form).forEach(button => { button.onclick = () => finish(null); }); form.onsubmit = event => { event.preventDefault(); finish(input.value); }; modal.addEventListener('cancel', event => { event.preventDefault(); finish(null); }, { once: true }); modal.append(form); - translateDom(modal); modal.showModal(); input.focus(); input.select(); }); } + // ---------------------------------------------------------------- auth async function logout() { - try { await api('/auth/logout', { method: 'POST' }); } - finally { - S.me = null; - S.csrf = ''; - loginView(); - } + try { await api.post('/auth/logout'); } catch { /* ignore */ } + S.me = null; + S.csrf = ''; + S.cache = {}; + history.replaceState(null, '', location.pathname); + loginView(); } function recoveryRequestModal() { - formModal('Odzyskiwanie hasła', [ - { name: 'username', label: 'Adres konta', type: 'email', required: true }, - { name: 'kind', label: 'Typ konta', type: 'select', options: [['user', 'Użytkownik skrzynki'], ['admin', 'Administrator']] }, + formModal(t('Forgot password'), [ + { name: 'username', label: t('E-mail address'), type: 'email', required: true, autocomplete: 'username' }, + { name: 'kind', label: t('Account type'), type: 'select', options: [['user', t('Mailbox user')], ['admin', t('Administrator')]] }, ], { kind: 'user' }, async data => { - const result = await api('/auth/recovery/request', { method: 'POST', body: JSON.stringify(data) }); - return { refresh: false, message: result.message || 'Jeśli konto spełnia warunki, kod został wysłany.' }; - }); + await api.post('/auth/recovery/request', data); + return { refresh: false, message: 'If the account is eligible, a recovery code has been sent to its alternative e-mail address.' }; + }, { submitLabel: t('Send code'), intro: `

${te('A one-time code will be sent to the alternative e-mail address stored for the account.')}

` }); } function recoveryResetModal() { - formModal('Ustaw nowe hasło', [ - { name: 'username', label: 'Adres konta', type: 'email', required: true }, - { name: 'kind', label: 'Typ konta', type: 'select', options: [['user', 'Użytkownik skrzynki'], ['admin', 'Administrator']] }, - { name: 'token', label: 'Kod odzyskiwania', required: true, autocomplete: 'one-time-code' }, - { name: 'new_password', label: 'Nowe hasło', type: 'password', required: true, autocomplete: 'new-password' }, + formModal(t('Set a new password'), [ + { name: 'username', label: t('E-mail address'), type: 'email', required: true, autocomplete: 'username' }, + { name: 'kind', label: t('Account type'), type: 'select', options: [['user', t('Mailbox user')], ['admin', t('Administrator')]] }, + { name: 'token', label: t('Recovery code'), required: true, autocomplete: 'one-time-code' }, + { name: 'new_password', label: t('New password'), type: 'password', required: true, autocomplete: 'new-password', confirm: true }, ], { kind: 'user' }, async data => { - await api('/auth/recovery/reset', { method: 'POST', body: JSON.stringify(data) }); - return { refresh: false, message: 'Hasło zostało zmienione.' }; + await api.post('/auth/recovery/reset', { username: data.username, kind: data.kind, token: data.token, new_password: data.new_password }); + return { refresh: false, message: 'The password has been changed. You can sign in now.' }; }); } - function loginView() { + function loginView(notice = '') { + const runtimeRecovery = true; app.innerHTML = ``; - - translateDom(app); - $('#loginLanguage').onchange = event => { setLanguage(event.target.value); }; - $('#recoverBtn').onclick = recoveryRequestModal; - $('#resetBtn').onclick = recoveryResetModal; + $('#loginLanguage').onchange = event => setLanguage(event.target.value); + $('#recoverBtn')?.addEventListener('click', recoveryRequestModal); + $('#resetBtn')?.addEventListener('click', recoveryResetModal); $('#loginForm').onsubmit = async event => { event.preventDefault(); const form = new FormData(event.target); + const submit = $('button[type="submit"]', event.target); + submit.disabled = true; + $('#loginErr').textContent = ''; try { - const result = await api('/auth/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(form)) }); + const result = await api.post('/auth/login', { username: form.get('username'), password: form.get('password'), kind: form.get('kind') }); if (result.mfa_required) { - const code = await promptModal('Weryfikacja TOTP', 'Kod 6-cyfrowy', '', { autocomplete: 'one-time-code', submitLabel: 'Zweryfikuj' }); - if (!code) return; - const mfa = await api('/auth/mfa', { method: 'POST', body: JSON.stringify({ challenge: result.challenge, code }) }); + const code = await promptModal(t('Two-factor authentication'), t('6-digit code from your authenticator app'), '', { autocomplete: 'one-time-code', inputmode: 'numeric', submitLabel: t('Verify') }); + if (!code) { submit.disabled = false; return; } + const mfa = await api.post('/auth/mfa', { challenge: result.challenge, code }); S.csrf = mfa.csrf; } else { S.csrf = result.csrf; } await bootAuthed(); } catch (error) { - $('#loginErr').textContent = translateText(error.message); + submit.disabled = false; + $('#loginErr').textContent = t(error.message); } }; } async function bootAuthed() { - S.me = await api('/auth/me'); + S.me = await api.get('/auth/me'); S.csrf = S.me.csrf; - const runtime = await api('/system/features'); - S.features = runtime.features; + const runtime = await api.get('/system/features'); + S.features = runtime.features || {}; + S.permissions = runtime.permissions || {}; S.runtime = runtime; + S.route = parseHash(); shell(); - go(S.page); + await render(); } + window.addEventListener('hashchange', () => { if (S.me) render(); }); + window.UI = { - S, api, esc, icon, showToast, go, pageHead, table, formModal, confirmModal, promptModal, textModal, closeModal, loginView, bootAuthed, - t: translateText, translateDom, initLanguage, setLanguage, + S, $, $$, h, api, esc, enc, icon, t, te, showToast, pageHead, table, toggle, pager, formModal, confirmModal, + promptModal, textModal, closeModal, loginView, bootAuthed, initLanguage, setLanguage, setRoutes, navigate, href, render, + randomPassword, }; })(); diff --git a/src/web/js/10-pages.js b/src/web/js/10-pages.js index 82aa6fb..aae5de1 100644 --- a/src/web/js/10-pages.js +++ b/src/web/js/10-pages.js @@ -1,796 +1,968 @@ (() => { 'use strict'; - const { S, api, esc, icon, pageHead, table, formModal, confirmModal, promptModal, textModal, showToast, t } = window.UI; - const content = () => document.querySelector('#content'); - const enc = encodeURIComponent; - const number = value => Number(value || 0); - const yes = value => t(value ? 'Tak' : 'Nie'); - const actionIcons = { - 'Edytuj': 'square-pen', 'Usuń': 'trash', 'Hasło': 'shield-alert', 'DNS': 'refresh-cw', - 'Edytuj AD': 'square-pen', 'Usuń AD': 'trash', 'Edytuj map.': 'square-pen', 'Usuń map.': 'trash', - 'Odłącz': 'link', 'Usuń plik': 'trash', 'Usuń app': 'trash', 'Usuń wyjątek': 'trash', + const { + S, $, $$, api, esc, enc, icon, t, te, showToast, pageHead, table, toggle, pager, formModal, confirmModal, + promptModal, textModal, navigate, href, render, + } = window.UI; + + // ------------------------------------------------------------------ utils + const isAdmin = () => S.me?.kind === 'admin'; + const isGlobal = () => S.me?.role === 'global_admin'; + const isUser = () => S.me?.kind === 'user'; + const F = name => Boolean(S.features?.[name]); + const P = name => Boolean(S.permissions?.[name]); + const D = name => S.runtime?.defaults?.[name]; + const multiplier = () => Number(D('quota_multiplier') || 1024000); + const mbFromBytes = bytes => Math.round(Number(bytes || 0) / multiplier()); + const lines = value => String(value || '').split(/[\n,;]+/).map(item => item.trim()).filter(Boolean); + const yesNo = value => (value ? t('Yes') : t('No')); + const fmtDate = value => (value ? String(value).replace('T', ' ').slice(0, 16) : '—'); + + function limitLabel(value) { + const n = Number(value); + if (n === 0) return '∞'; + if (n < 0) return t('disabled'); + return String(n); + } + + function quotaLabelMb(value) { + const n = Number(value); + if (n === 0) return t('unlimited'); + if (n < 0) return t('disabled'); + return `${n} MB`; + } + + function usage(value, limit, options = {}) { + const n = Number(limit); + const unlimited = !n || n <= 0; + const pct = unlimited ? 0 : Math.max(0, Math.min(100, Math.round((Number(value) / n) * 100))); + const medium = Number(D('quota_level_med_pct') ?? 55); + const high = Number(D('quota_level_high_pct') ?? 90); + const level = unlimited ? 'normal' : (pct >= high ? 'high' : (pct >= medium ? 'medium' : 'normal')); + const label = options.label || `${value} / ${n < 0 ? t('disabled') : (unlimited ? '∞' : n)}`; + return `
${options.icon ? icon(options.icon) : ''}${esc(label)}${!unlimited ? `${pct}%` : ''}
`; + } + + function mailboxUsage(row) { + const limit = Number(row.quota_mb); + const used = row.used_quota_bytes == null ? null : mbFromBytes(row.used_quota_bytes); + if (used == null) return `${icon('archive')}${esc(quotaLabelMb(limit))}`; + return usage(used, limit, { icon: 'archive', label: `${used} / ${limit > 0 ? `${limit} MB` : quotaLabelMb(limit)}` }); + } + + const STATUS_LABELS = { + undeliverable: 'Undeliverable', external: 'External', vacation: 'Vacation', disabled: 'Disabled', + expired: 'Password expired', popimap: 'POP/IMAP', }; - const action = (label, danger = false) => ``; - const domainOfAddress = value => String(value || '').includes('@') ? String(value).split('@').pop() : ''; - const percent = (value, limit) => limit > 0 ? Math.max(0, Math.min(100, Math.round((value / limit) * 100))) : 0; - const quotaMultiplier = () => Number(S.runtime?.defaults?.quota_multiplier || 1024000); - const mbFromBytes = value => Math.round(Number(value || 0) / quotaMultiplier()); - const progress = (value, limit, options = {}) => { - const unlimited = !limit || limit <= 0; - const p = unlimited ? 0 : percent(value, limit); - const medium = Number(S.runtime?.defaults?.quota_level_med_pct ?? 55); - const high = Number(S.runtime?.defaults?.quota_level_high_pct ?? 90); - const level = !unlimited && p >= high ? 'high' : (!unlimited && p >= medium ? 'medium' : 'normal'); - const label = options.label || `${value}/${unlimited ? '∞' : limit}`; - const progressValue = unlimited ? 100 : p; - return `
${options.icon ? icon(options.icon) : ''}${esc(label)}${!unlimited && options.percent !== false ? `${p}%` : ''}
`; + const STATUS_HINTS = { + undeliverable: 'At least one local destination does not exist', + external: 'Forwards to a domain outside your administration', + vacation: 'Auto-reply is active', disabled: 'Mailbox is disabled', expired: 'The mailbox password has expired', + popimap: 'Delivered to the mailbox (POP/IMAP)', }; - const domainPicker = (id, domains, selected) => `
`; - const readonlyDomain = domain => `
`; - const mailboxQuota = (row, domainMaxMb = 0) => { - const limitBytes = Number(row.quota || 0); - const usedBytes = row.used_quota_bytes == null ? null : Number(row.used_quota_bytes || 0); - const limitMb = mbFromBytes(limitBytes); - if (usedBytes != null && limitBytes > 0) { - return progress(usedBytes, limitBytes, { icon: 'archive', label: `${mbFromBytes(usedBytes)} / ${limitMb} MB` }); - } - if (limitBytes > 0 && Number(domainMaxMb || 0) > 0) { - const maxBytes = Number(domainMaxMb) * quotaMultiplier(); - return progress(limitBytes, maxBytes, { icon: 'archive', label: `${limitMb} / ${domainMaxMb} MB`, percent: true }); - } - if (limitBytes <= 0) return progress(0, 0, { icon: 'archive', label: '∞' }); - return `${icon('archive')}${limitMb} MB`; - }; - // The alias list is already split by purpose. Keep Status intentionally - // simple and unambiguous instead of exposing delivery diagnostics here. - const aliasStatus = () => 'Alias'; + const statusBadges = list => (list || []).map(item => { + const key = item.startsWith('custom:') ? 'custom' : item; + const label = key === 'custom' ? item.slice(7) : t(STATUS_LABELS[key] || item); + const cls = { undeliverable: 'warn', external: '', vacation: 'info', disabled: 'off', expired: 'warn', popimap: 'ok' }[key] ?? ''; + return `${esc(label)}`; + }).join(' '); - function bind(rows, handlers, scope = document) { - rows.forEach((row, index) => { - for (const [label, handler] of Object.entries(handlers)) { - scope.querySelectorAll(`[data-row="${index}"] [data-act="${label}"]`).forEach(button => { - button.onclick = () => handler(row); - }); - } - }); - } + const btn = (act, label, iconName, extra = '') => ``; - function splitList(value) { - return String(value || '').split(/[\n,]+/).map(item => item.trim()).filter(Boolean); - } - - async function overview() { - const data = await api('/overview'); - const motd = S.me.role === 'global_admin' ? S.runtime?.defaults?.motd_superadmin : (S.me.kind === 'admin' ? S.runtime?.defaults?.motd_admin : S.runtime?.defaults?.motd_user); - if (S.me.kind === 'user') { - const [mailboxes, aliases] = await Promise.all([api('/mailboxes'), api('/aliases')]); - const mailbox = mailboxes[0]; - const alias = aliases.find(row => row.address === S.me.username) || aliases[0]; - if (!mailbox) { - content().innerHTML = pageHead('Przegląd', 'Twoja skrzynka pocztowa') + '
Nie znaleziono danych skrzynki dla zalogowanego konta.
'; - return; - } - const quota = mailboxQuota(mailbox, 0); - const targets = alias?.goto || []; - content().innerHTML = pageHead('Przegląd', 'Twoja skrzynka pocztowa') + - (motd ? `
${esc(motd)}
` : '') + - `
-
${icon('mail')}
Adres
${esc(mailbox.username)}
-
${icon('folder')}
Domena
${esc(mailbox.domain)}
-
${icon('archive')}
Quota
${quota}
-
${icon('calendar-days')}
Vacation
${mailbox.vacation_active ? t('Aktywny') : t('Nieaktywny')}
-
- `; - return; - } - content().innerHTML = pageHead('Przegląd', 'Stan wirtualnej poczty i aktywnych funkcji') + - (motd ? `
${esc(motd)}
` : '') + - `
-
Domeny
${data.domains}
-
Skrzynki
${data.mailboxes}
-
Aliasy
${data.aliases}
-
Rola
${esc(data.role)}
-
-
Aktywne moduły
${Object.entries(data.features).map(([key, value]) => `
${esc(key)}${typeof value === 'boolean' ? (value ? 'ON' : 'OFF') : `${esc(value)}`}
`).join('')}
`; - } - - async function domains() { - const rows = await api('/domains'); - const showMaxQuota = !S.features.domain_list_hide_maxquota; - const showPasswordExpiry = Boolean(S.features.password_expiration && !S.features.domain_list_hide_password_expiry && rows.some(row => row.password_expiry != null)); - const headers = ['Domena', 'Opis', 'Skrzynki', 'Aliasy']; - if (showMaxQuota) headers.push('Max quota'); - if (showPasswordExpiry) headers.push('Wygasanie hasła'); - headers.push('Transport', 'DNS', 'Aktywna', ''); - const add = S.me.role === 'global_admin' ? `` : ''; - content().innerHTML = pageHead('Domeny', 'Limity, transport, DNS i stan domen', add) + table( - headers, - rows.map((row, index) => ` - ${esc(row.description)} - ${progress(row.mailbox_count, row.mailboxes, { icon: 'mail' })} - ${progress(row.alias_count, row.aliases, { icon: 'forward' })} - ${showMaxQuota ? `${icon('archive')}${row.maxquota} MB` : ''} - ${showPasswordExpiry ? `${row.password_expiry ? `${row.password_expiry} d` : '—'}` : ''} - ${esc(row.transport)} - ${row.dns_active == null ? '—' : (row.dns_active ? 'OK' : '?')} - ${yes(row.active)}${S.me.role === 'global_admin' ? action('Edytuj') + action('Usuń', true) : ''} ${S.features.domain_dns_status_check ? action('DNS') : ''} - `) - ); - if (S.me.role === 'global_admin') document.querySelector('#addDomain').onclick = () => domainForm(); - document.querySelectorAll('[data-open-domain]').forEach(button => { - button.onclick = () => { - S.cache.domainWorkspace = button.dataset.openDomain; - S.cache.mailboxDomain = button.dataset.openDomain; - S.cache.aliasDomain = button.dataset.openDomain; - window.UI.go('domain'); - }; - }); - bind(rows, { - Edytuj: row => domainForm(row), - Usuń: async row => { - if (await confirmModal('Usuń domenę', `Usunąć ${row.domain} wraz z danymi domeny?`)) { - await api(`/domains/${enc(row.domain)}`, { method: 'DELETE' }); - showToast('Usunięto'); - domains(); - } - }, - DNS: async row => { - await api(`/domains/${enc(row.domain)}/dns-refresh`, { method: 'POST' }); - showToast('DNS odświeżony'); - domains(); - }, - }); - } - - function aliasDestinations(row) { - if (Array.isArray(row?.goto)) return row.goto.filter(Boolean); - if (typeof row?.goto === 'string') return row.goto.split(',').map(value => value.trim()).filter(Boolean); - return []; - } - - function aliasDisplayAddress(value) { - const address = String(value || ''); - return address.startsWith('@') ? `*${address}` : address; - } - - function aliasTargetsCell(row) { - const targets = aliasDestinations(row); - return targets.length ? targets.map(esc).join('
') : '—'; - } - - async function domainWorkspace() { - const domain = S.cache.domainWorkspace; - if (!domain) { await window.UI.go('domains'); return; } - const [domainsRows, mailboxRows, aliasRows, aliasDomainRows] = await Promise.all([ - api('/domains'), api(`/mailboxes?domain=${enc(domain)}`), api(`/aliases?domain=${enc(domain)}`), - S.me.kind === 'admin' && S.features.alias_domain ? api('/alias-domains') : Promise.resolve([]), - ]); - const row = domainsRows.find(item => item.domain === domain); - if (!row) throw new Error('Domain not found'); - const mailboxes = mailboxRows; - const aliases = aliasRows.filter(item => !item.is_mailbox); - const indexedAliases = aliases.map((item, index) => ({ item, index })); - const regularAliases = indexedAliases.filter(({ item }) => !item.technical); - const technicalAliases = indexedAliases.filter(({ item }) => item.technical); - const aliasDomains = aliasDomainRows.filter(item => item.alias_domain === domain || item.target_domain === domain); - const workspaceAliasTable = entries => table( - ['Adres', 'Cel', 'Opis', 'Status', 'Aktywny', ''], - entries.map(({ item, index }) => { - const canEditTechnical = !item.technical || S.me.role === 'global_admin' || S.features.special_alias_control; - const editAction = canEditTechnical ? action('Edytuj') : ''; - const deleteAction = S.me.kind === 'admin' && canEditTechnical ? action('Usuń', true) : ''; - return `${esc(aliasDisplayAddress(item.address))}${aliasTargetsCell(item)}${esc(item.description)}${aliasStatus(item.status)}${yes(item.active)}${editAction}${deleteAction}`; - }) - ); - const back = ``; - const actions = `${back}${S.me.kind === 'admin' ? `` : ''}${S.me.role === 'global_admin' ? `` : ''}`; - const quotaKnown = mailboxes.filter(item => item.used_quota_bytes != null); - const usedBytes = quotaKnown.reduce((sum, item) => sum + Number(item.used_quota_bytes || 0), 0); - const quotaBytes = mailboxes.reduce((sum, item) => sum + Number(item.quota || 0), 0); - content().innerHTML = pageHead(domain, 'Skrzynki, aliasy i ustawienia tej domeny', actions) + - `
-
${icon('mail')}
Skrzynki
${mailboxes.length} / ${row.mailboxes === 0 ? '∞' : row.mailboxes}
${progress(mailboxes.length, row.mailboxes, { label: `${mailboxes.length}/${row.mailboxes === 0 ? '∞' : row.mailboxes}` })}
-
${icon('forward')}
Aliasy
${aliases.length} / ${row.aliases === 0 ? '∞' : row.aliases}
${progress(aliases.length, row.aliases, { label: `${aliases.length}/${row.aliases === 0 ? '∞' : row.aliases}` })}
-
${icon('archive')}
Quota
${quotaKnown.length ? `${mbFromBytes(usedBytes)} / ${mbFromBytes(quotaBytes)} MB` : `${row.maxquota} MB max`}
${quotaKnown.length && quotaBytes > 0 ? progress(usedBytes, quotaBytes, { label: `${mbFromBytes(usedBytes)} / ${mbFromBytes(quotaBytes)} MB` }) : ''}
-
${icon('shield-alert')}
Status
${row.active ? 'ACTIVE' : 'DISABLED'}
${esc(row.transport || 'virtual')}
-
-
-
Skrzynki
${table(['Adres', 'Nazwa', 'Quota', 'SMTP', 'Vacation', ''], mailboxes.map((item, index) => `${esc(item.username)}${esc(item.name)}${mailboxQuota(item, row.maxquota)}${item.smtp_active == null ? '—' : yes(item.smtp_active)}${yes(item.vacation_active)}${action('Edytuj')}${action('Usuń', true)}`))}
-
Aliasy użytkowników
${workspaceAliasTable(regularAliases)}
- ${technicalAliases.length ? `
Aliasy techniczne
${workspaceAliasTable(technicalAliases)}
` : ''} -
- ${aliasDomains.length ? `
Aliasy domen
${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny'], aliasDomains.map(item => `${esc(item.alias_domain)}${esc(item.target_domain)}${esc(item.description)}${yes(item.active)}`))}` : ''}`; - document.querySelector('#domainBack').onclick = () => window.UI.go('domains'); - document.querySelector('#domainAddMailbox')?.addEventListener('click', () => mailboxForm({}, domain)); - document.querySelector('#domainAddAlias')?.addEventListener('click', () => aliasForm({}, domain)); - document.querySelector('#domainEdit')?.addEventListener('click', () => domainForm(row)); - mailboxes.forEach((item, index) => { - const scope = `[data-ws-mailbox="${index}"]`; - document.querySelector(`${scope} [data-act="Edytuj"]`)?.addEventListener('click', () => mailboxForm(item, domain)); - document.querySelector(`${scope} [data-act="Usuń"]`)?.addEventListener('click', async () => { - if (!await confirmModal('Usuń skrzynkę', `Usunąć skrzynkę ${item.username}?`)) return; - await api(`/mailboxes/${enc(item.username)}`, { method: 'DELETE' }); - domainWorkspace(); - }); - }); - aliases.forEach((item, index) => { - const scope = `[data-ws-alias="${index}"]`; - document.querySelector(`${scope} [data-act="Edytuj"]`)?.addEventListener('click', () => aliasForm(item, domain)); - document.querySelector(`${scope} [data-act="Usuń"]`)?.addEventListener('click', async () => { - if (!await confirmModal('Usuń alias', `Usunąć alias ${aliasDisplayAddress(item.address)}?`)) return; - await api(`/aliases/${enc(item.address)}`, { method: 'DELETE' }); - domainWorkspace(); - }); - }); - } - - function domainForm(row = {}) { - formModal(row.domain ? 'Edytuj domenę' : 'Nowa domena', [ - { name: 'domain', label: 'Domena', required: true }, - { name: 'description', label: 'Opis' }, - { name: 'aliases', label: 'Limit aliasów', type: 'number' }, - { name: 'mailboxes', label: 'Limit skrzynek', type: 'number' }, - { name: 'maxquota', label: 'Max quota skrzynki [MB]', type: 'number' }, - { name: 'quota', label: 'Quota domeny [MB]', type: 'number' }, - ...(S.features.transport ? [{ name: 'transport', label: 'Transport' }] : []), - ...(S.features.password_expiration ? [{ name: 'password_expiry', label: 'Ważność hasła [dni]', type: 'number' }] : []), - { name: 'backupmx', label: 'Backup MX', type: 'checkbox', help: 'Domena jest backup MX' }, - { name: 'active', label: 'Aktywna', type: 'checkbox', help: 'Aktywna domena' }, - { name: 'default_aliases', label: 'Aliasy domyślne', type: 'checkbox', help: 'Utwórz abuse/hostmaster/postmaster/webmaster' }, - ], { - domain: row.domain || '', description: row.description || '', aliases: row.aliases ?? 10, - mailboxes: row.mailboxes ?? 10, maxquota: row.maxquota ?? 10, quota: row.quota ?? 2048, - transport: row.transport || 'virtual', backupmx: row.backupmx || false, active: row.domain ? row.active : true, - default_aliases: !row.domain, password_expiry: row.password_expiry ?? 0, - }, async data => { - const body = { - ...data, - aliases: number(data.aliases), mailboxes: number(data.mailboxes), maxquota: number(data.maxquota), - quota: number(data.quota), password_expiry: number(data.password_expiry), - }; - await api(row.domain ? `/domains/${enc(row.domain)}` : '/domains', { - method: row.domain ? 'PUT' : 'POST', body: JSON.stringify(body), - }); - }); - } - - async function mailboxes() { - let domainRows = []; - let domains = []; - let selected = ''; - let rows = []; - if (S.me.kind === 'admin') { - domainRows = await api('/domains'); - domains = domainRows.map(row => row.domain).sort(); - selected = domains.includes(S.cache.mailboxDomain) ? S.cache.mailboxDomain : (domains.includes(S.cache.domainWorkspace) ? S.cache.domainWorkspace : domains[0]); - S.cache.mailboxDomain = selected || ''; - rows = selected ? await api(`/mailboxes?domain=${enc(selected)}`) : []; - } else { - rows = await api('/mailboxes'); - domains = [...new Set(rows.map(row => row.domain || domainOfAddress(row.username)).filter(Boolean))].sort(); - selected = domains[0] || ''; - } - const selectedDomain = domainRows.find(row => row.domain === selected); - const add = S.me.kind === 'admin' ? `` : ''; - const tools = domains.length > 1 ? domainPicker('mailboxDomainSelect', domains, selected) : ''; - const usedKnown = rows.some(row => row.used_quota_bytes != null); - const used = rows.filter(row => row.used_quota_bytes != null).reduce((sum, row) => sum + Number(row.used_quota_bytes || 0), 0); - const limit = rows.reduce((sum, row) => sum + Number(row.quota || 0), 0); - content().innerHTML = pageHead('Skrzynki', 'Konta, quota, SMTP, dane odzyskiwania i vacation', add) + - `
${tools}
${icon('mail')} ${rows.length}${limit > 0 && usedKnown ? `${icon('archive')} ${mbFromBytes(used)} / ${mbFromBytes(limit)} MB` : ''}
${esc(selected || t('Skrzynki'))}
` + table( - ['Adres', 'Nazwa', 'Maildir', 'Quota', 'SMTP', 'Recovery', 'Vacation', 'Aktywna', ''], - rows.map((row, index) => ` - ${esc(row.username)}${esc(row.name)}${esc(row.maildir)} - ${mailboxQuota(row, selectedDomain?.maxquota || 0)} - ${row.smtp_active == null ? '—' : yes(row.smtp_active)}${row.email_other ? esc(row.email_other) : '—'} - ${yes(row.vacation_active)}${yes(row.active)} - ${S.me.kind === 'admin' ? action('Edytuj') + action('Usuń', true) : action('Hasło')} - `) - ); - document.querySelector('#mailboxDomainSelect')?.addEventListener('change', async event => { - event.target.disabled = true; - S.cache.mailboxDomain = event.target.value; - await mailboxes(); - translateCurrentContent(); - }); - if (S.me.kind === 'admin') document.querySelector('#addMailbox').onclick = () => mailboxForm({}, selected); - bind(rows, { - Edytuj: row => mailboxForm(row), - Usuń: async row => { - if (await confirmModal('Usuń skrzynkę', `Usunąć skrzynkę ${row.username}?`)) { - await api(`/mailboxes/${enc(row.username)}`, { method: 'DELETE' }); - showToast('Skrzynka usunięta'); - mailboxes(); - } - }, - Hasło: () => window.UI.go('security'), - }); - } - - function translateCurrentContent() { - // The normal router translates after page render. Domain-switch refreshes happen in-place. - const root = content(); - if (root && window.UI.translateDom) window.UI.translateDom(root); - } - - function mailboxForm(row = {}, domainOverride = '') { - const domain = row.domain || domainOverride || S.cache.mailboxDomain || (S.me.domains || []).find(item => item !== 'ALL') || ''; - const localPart = row.local_part || (row.username ? row.username.split('@')[0] : ''); - const fields = [ - { name: 'local_part', label: 'Adres e-mail', required: true, readonly: !!row.username, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' }, - { name: 'domain', label: 'Domena', required: true, readonly: true }, - ...(S.features.edit_mailbox_name ? [{ name: 'name', label: 'Nazwa wyświetlana' }] : []), - { name: 'password', label: row.username ? 'Nowe hasło (puste = bez zmian)' : 'Hasło', type: 'password', autocomplete: 'new-password', required: !row.username, generate: !!S.features.generate_password }, - { name: 'quota', label: 'Quota [MB]', type: 'number', min: 0 }, - { name: 'phone', label: 'Telefon' }, - { name: 'email_other', label: 'Drugi e-mail / recovery', type: 'email' }, - { name: 'active', label: 'Aktywna', type: 'checkbox', help: 'Logowanie i dostarczanie aktywne' }, - { name: 'smtp_active', label: 'SMTP aktywny', type: 'checkbox', help: 'Zezwalaj na SMTP AUTH' }, - { name: 'send_welcome', label: 'Wiadomość powitalna', type: 'checkbox', help: 'Wyślij po utworzeniu' }, - ]; - formModal(row.username ? 'Edytuj skrzynkę' : 'Nowa skrzynka', fields, { - local_part: localPart, domain, name: row.name || '', password: '', - quota: row.quota ? mbFromBytes(row.quota) : 10, - phone: row.phone || '', email_other: row.email_other || '', active: row.username ? row.active : true, - smtp_active: row.smtp_active ?? true, send_welcome: false, - }, async data => { - const body = { - username: row.username || null, - local_part: row.username ? null : data.local_part, - domain: data.domain, - name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null, - quota: number(data.quota), phone: data.phone, email_other: data.email_other, - active: data.active, smtp_active: data.smtp_active, send_welcome: data.send_welcome, - }; - await api(row.username ? `/mailboxes/${enc(row.username)}` : '/mailboxes', { - method: row.username ? 'PUT' : 'POST', body: JSON.stringify(body), - }); - if (!row.username && S.features.show_password && data.password) { - return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.local_part}@${data.domain} -${data.password}` } }; - } - return { message: row.username ? 'Skrzynka zapisana' : 'Skrzynka utworzona' }; - }); - } - - async function aliases() { - let domainRows = []; - let domains = []; - let selected = ''; - let aliasRows = []; - let aliasDomainRows = []; - if (S.me.kind === 'admin') { - domainRows = await api('/domains'); - domains = domainRows.map(row => row.domain).sort(); - selected = domains.includes(S.cache.aliasDomain) ? S.cache.aliasDomain : (domains.includes(S.cache.domainWorkspace) ? S.cache.domainWorkspace : domains[0]); - S.cache.aliasDomain = selected || ''; - [aliasRows, aliasDomainRows] = await Promise.all([ - selected ? api(`/aliases?domain=${enc(selected)}`) : Promise.resolve([]), - S.features.alias_domain ? api('/alias-domains') : Promise.resolve([]), - ]); - } else { - aliasRows = await api('/aliases'); - domains = (S.me.domains || []).filter(domain => domain !== 'ALL'); - if (!domains.length) domains = [...new Set(aliasRows.map(row => row.domain || domainOfAddress(row.address)).filter(Boolean))].sort(); - selected = domains[0] || domainOfAddress(S.me.username) || ''; - } - const filteredAliases = aliasRows; - const filteredAliasDomains = selected ? aliasDomainRows.filter(row => row.alias_domain === selected || row.target_domain === selected) : aliasDomainRows; - const hasInactive = filteredAliases.some(row => !row.active && !row.is_mailbox); - const inactiveAction = S.me.kind === 'admin' && hasInactive ? `` : ''; - const add = S.me.kind === 'admin' ? `${S.features.alias_domain ? `` : ''}${inactiveAction}` : ''; - const tools = S.me.kind === 'user' ? readonlyDomain(selected) : (domains.length > 1 ? domainPicker('aliasDomainSelect', domains, selected) : readonlyDomain(selected)); - const indexedAliases = filteredAliases.map((row, index) => ({ row, index })); - const aliasTableFor = entries => table( - ['Adres', 'Cel', 'Opis', 'Status', 'Mailbox', 'Aktywny', ''], - entries.map(({ row, index }) => { - const canEditTechnical = !row.technical || S.me.role === 'global_admin' || S.features.special_alias_control; - const editAction = canEditTechnical ? action('Edytuj') : ''; - const deleteAction = !row.is_mailbox && S.me.kind === 'admin' && canEditTechnical ? action('Usuń', true) : ''; - return `${esc(aliasDisplayAddress(row.address))}${aliasTargetsCell(row)}${esc(row.description)}${aliasStatus(row.status)}${yes(row.is_mailbox)}${yes(row.active)}${editAction}${deleteAction}`; - }) - ); - let aliasSections; - if (S.me.kind === 'admin') { - const technical = indexedAliases.filter(item => item.row.technical); - const regular = indexedAliases.filter(item => !item.row.technical); - aliasSections = `
Aliasy użytkowników
${aliasTableFor(regular)}
` + - (technical.length ? `
Aliasy techniczne
${aliasTableFor(technical)}
` : ''); - } else { - aliasSections = `
${esc(selected || t('Aliasy'))}
${aliasTableFor(indexedAliases)}`; - } - const domainTable = filteredAliasDomains.length || (S.me.kind === 'admin' && S.features.alias_domain) - ? `
Aliasy domen
${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny', ''], filteredAliasDomains.map((row, index) => `${esc(row.alias_domain)}${esc(row.target_domain)}${esc(row.description)}${yes(row.active)}${action('Edytuj AD')}${action('Usuń AD', true)}`))}` - : ''; - content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `
${tools}
${icon('forward')} ${filteredAliases.length}${icon('folder-plus')} ${filteredAliasDomains.length}
` + aliasSections + domainTable; - document.querySelector('#aliasDomainSelect')?.addEventListener('change', async event => { - event.target.disabled = true; - S.cache.aliasDomain = event.target.value; - await aliases(); - translateCurrentContent(); - }); - if (S.me.kind === 'admin') document.querySelector('#addAlias').onclick = () => aliasForm({}, selected); - const addAliasDomain = document.querySelector('#addAliasDomain'); - if (addAliasDomain) addAliasDomain.onclick = () => aliasDomainForm({}, selected); - const deleteInactive = document.querySelector('#deleteInactive'); - if (deleteInactive) deleteInactive.onclick = async () => { - if (!await confirmModal('Usuń nieaktywne aliasy', `Usunąć wszystkie nieaktywne forwardy w domenie ${selected}? Tej operacji nie można cofnąć.`)) return; - const result = await api(`/aliases/delete-inactive/${enc(selected)}`, { method: 'POST' }); - showToast(`Usunięto: ${result.deleted}, pominięto: ${result.skipped}`); - aliases(); - }; - bind(filteredAliases, { - Edytuj: row => aliasForm(row), - Usuń: async row => { - if (await confirmModal('Usuń alias', `Usunąć alias ${aliasDisplayAddress(row.address)}?`)) { - await api(`/aliases/${enc(row.address)}`, { method: 'DELETE' }); - showToast('Alias usunięty'); - aliases(); - } - }, - }); - filteredAliasDomains.forEach((row, index) => { - document.querySelectorAll(`[data-ad-row="${index}"] [data-act="Edytuj AD"]`).forEach(button => { button.onclick = () => aliasDomainForm(row); }); - document.querySelectorAll(`[data-ad-row="${index}"] [data-act="Usuń AD"]`).forEach(button => { - button.onclick = async () => { - if (await confirmModal('Usuń alias domeny', `Usunąć alias domeny ${row.alias_domain}?`)) { - await api(`/alias-domains/${enc(row.alias_domain)}`, { method: 'DELETE' }); - aliases(); - } + /** Bind [data-act] buttons inside rows marked with data-key to handlers. */ + function bindRows(root, rows, keyAttr, handlers) { + $$(`[${keyAttr}]`, root).forEach(rowEl => { + const row = rows[Number(rowEl.getAttribute(keyAttr))]; + if (!row) return; + $$('[data-act]', rowEl).forEach(button => { + const handler = handlers[button.dataset.act]; + if (handler) button.onclick = async event => { + event.preventDefault(); + event.stopPropagation(); + try { await handler(row, button); } catch (error) { showToast(error.message, true); } }; }); }); } - async function aliasForm(row = {}, domainOverride = '') { - const domain = row.domain || domainOverride || S.cache.aliasDomain || (S.me.domains || []).find(item => item !== 'ALL') || ''; - const userEdit = S.me.kind === 'user' && Boolean(row.address); - const localPart = row.address ? (row.address.startsWith('@') ? '*' : row.address.split('@')[0]) : ''; - const selfTarget = row.address || ''; - const rowTargets = aliasDestinations(row); - const visibleTargets = rowTargets.filter(value => !selfTarget || value.toLowerCase() !== selfTarget.toLowerCase()); - const keepsLocalDelivery = Boolean(row.is_mailbox && rowTargets.some(value => value.toLowerCase() === selfTarget.toLowerCase())); - let defaultGoto = ''; - if (!row.address && S.me.kind === 'admin') { - try { - const preferences = await api('/preferences'); - defaultGoto = preferences.find(pref => pref.key === 'alias_goto_default')?.value || ''; - } catch {} + async function domainsCached(force = false) { + if (!isAdmin()) return []; + if (force || !S.cache.domains) S.cache.domains = await api.get('/domains'); + return S.cache.domains; + } + const invalidateDomains = () => { delete S.cache.domains; }; + + async function mailboxOptions() { + const page = await api.get('/mailboxes?page_size=0'); + return page.items.map(item => item.username); + } + + function datalist(id, values) { + return `${values.map(value => ``).join('')}`; + } + + const motd = () => { + const text = isGlobal() ? D('motd_superadmin') : (isAdmin() ? D('motd_admin') : D('motd_user')); + return text ? `
${esc(text)}
` : ''; + }; + + // ------------------------------------------------------------------ overview + async function overview(_args, _q, content) { + if (isUser()) return userOverview(content); + const [stats, domains] = await Promise.all([api.get('/overview'), domainsCached(true)]); + const top = domains.slice(0, 12); + content.innerHTML = pageHead(t('Overview'), t('Signed in as {user}', { user: S.me.username })) + motd() + + `
+
${te('Domains')}
${stats.domains}
+
${te('Mailboxes')}
${stats.mailboxes}
+
${te('Aliases')}
${stats.aliases}
+ ${F('alias_domain') ? `
${te('Alias domains')}
${stats.alias_domains}
` : ''} +
` + + table([t('Domain'), t('Mailboxes'), t('Aliases'), t('Quota'), t('Status')], top.map(row => + `${esc(row.domain)} + ${usage(row.mailbox_count, row.mailboxes, { icon: 'mail' })} + ${usage(row.alias_count, row.aliases, { icon: 'forward' })} + ${F('domain_quota') && F('quota') ? usage(row.quota_used_mb ?? row.quota_allocated_mb, row.quota, { icon: 'archive', label: `${row.quota_used_mb ?? row.quota_allocated_mb} / ${quotaLabelMb(row.quota)}` }) : '—'} + ${row.active ? `${te('Active')}` : `${te('Inactive')}`}${row.backupmx ? ` ${te('Backup MX')}` : ''}`), + { head: `${te('Your domains')}${domains.length > top.length ? `${te('Show all ({n})', { n: domains.length })}` : ''}`, empty: t('No domains yet.') }); + $$('tr[data-href]', content).forEach(row => { row.onclick = event => { if (!event.target.closest('a,button')) location.hash = row.dataset.href; }; }); + } + + async function userOverview(content) { + const me = await api.get(`/mailboxes/${enc(S.me.username)}`); + const canName = P('edit_own_name'); + content.innerHTML = pageHead(t('My mailbox'), me.username) + motd() + + `
+
${te('Account')}${canName ? `` : ''}
+
+
${te('Name')}${esc(me.name || '—')}
+
${te('E-mail address')}${esc(me.username)}
+ ${me.smtp_active == null ? '' : `
${te('Sending (SMTP)')}${yesNo(me.smtp_active)}
`} + ${F('password_expiration') ? `
${te('Password expires')}${me.password_expiry && !me.password_expiry.startsWith('9999') ? esc(fmtDate(me.password_expiry)) : te('Never')}
` : ''} +
${te('Recovery e-mail')}${esc(me.email_other || '—')}
+
${te('Delivery')}${me.deliver_to_mailbox ? te('Stored in mailbox') : te('Forward only')}${me.forward_to.length ? ` + ${esc(me.forward_to.join(', '))}` : ''}
+
`; + $('#editName', content)?.addEventListener('click', () => formModal(t('Change name'), [ + { name: 'name', label: t('Name'), maxlength: 255 }, + ], { name: me.name }, async data => { await api.put('/account/name', { name: data.name }); return { message: 'Name changed' }; })); + } + + // ------------------------------------------------------------------ domains + async function domainsPage(_args, query, content) { + const rows = await domainsCached(true); + const filter = String(query.filter || '').toLowerCase(); + const shown = rows.filter(row => !filter || row.domain.includes(filter) || String(row.description || '').toLowerCase().includes(filter)); + const quota = F('quota'); + const showMax = quota && !F('domain_list_hide_maxquota'); + const showDomainQuota = quota && F('domain_quota'); + const showExpiry = F('password_expiration') && !F('domain_list_hide_password_expiry'); + const showDns = Number(S.features.domain_dns_status_check || 0) > 0; + const headers = [t('Domain'), t('Description'), t('Aliases'), t('Mailboxes')]; + if (showDomainQuota) headers.push(t('Domain quota')); + if (showMax) headers.push(t('Max. mailbox quota')); + if (F('transport')) headers.push(t('Transport')); + headers.push(t('Backup MX')); + if (showExpiry) headers.push(t('Password expiry')); + if (showDns) headers.push('DNS'); + headers.push(t('Active'), ''); + content.innerHTML = pageHead(t('Domains'), t('{n} domain(s)', { n: rows.length }), + `${P('manage_domains') ? `` : ''}`) + + table(headers, shown.map((row, index) => { + const cells = [ + `${esc(row.domain)}`, + `${esc(row.description)}`, + `${usage(row.alias_count, row.aliases, { icon: 'forward' })}`, + `${usage(row.mailbox_count, row.mailboxes, { icon: 'mail' })}`, + ]; + if (showDomainQuota) { + const used = row.quota_used_mb ?? row.quota_allocated_mb; + cells.push(`${usage(used, row.quota, { icon: 'archive', label: `${used} / ${quotaLabelMb(row.quota)}` })}`); + } + if (showMax) cells.push(`${esc(quotaLabelMb(row.maxquota))}`); + if (F('transport')) cells.push(`${esc(row.transport || '')}`); + cells.push(`${yesNo(row.backupmx)}`); + if (showExpiry) cells.push(`${row.password_expiry ? te('{n} days', { n: row.password_expiry }) : te('Never')}`); + if (showDns) cells.push(`${row.dns_active == null ? '?' : (row.dns_active ? 'OK' : `${te('Error')}`)} `); + cells.push(`${toggle(row.active, 'data-act="active"', P('manage_domains'))}`); + cells.push(`${P('manage_domains') ? btn('edit', t('Edit'), 'square-pen') + btn('delete', t('Delete'), 'trash', 'danger') : ''}`); + return `${cells.join('')}`; + }), { empty: t('No domains found.') }); + const filterInput = $('#domainFilter', content); + filterInput.oninput = () => { + clearTimeout(filterInput.timer); + filterInput.timer = setTimeout(() => navigate('domains', { filter: filterInput.value.trim() }, true), 250); + }; + $('#addDomain', content)?.addEventListener('click', () => domainForm()); + bindRows(content, shown, 'data-row', { + edit: row => domainForm(row), + active: async row => { await api.post(`/domains/${enc(row.domain)}/active`, { active: !row.active }); invalidateDomains(); render(); }, + dns: async row => { + const result = await api.post(`/domains/${enc(row.domain)}/dns-refresh`); + showToast(result.active ? t('DNS check passed') : t('DNS check failed'), !result.active); + invalidateDomains(); render(); + }, + delete: async row => { + if (!await confirmModal(t('Delete domain'), t('Delete {domain} together with all mailboxes, aliases, alias domains and log entries? This cannot be undone.', { domain: row.domain }))) return; + await api.del(`/domains/${enc(row.domain)}`); + invalidateDomains(); + showToast('Domain deleted'); + render(); + }, + }); + } + + function domainForm(row = null) { + const quota = F('quota'); + const limitHelp = t('-1 = disabled, 0 = unlimited'); + const fields = [ + { name: 'domain', label: t('Domain'), required: true, readonly: Boolean(row), placeholder: 'example.com' }, + { name: 'description', label: t('Description'), maxlength: 255 }, + { name: 'aliases', label: t('Aliases'), type: 'number', min: -1, help: limitHelp }, + { name: 'mailboxes', label: t('Mailboxes'), type: 'number', min: -1, help: limitHelp }, + { name: 'maxquota', label: t('Max. mailbox quota (MB)'), type: 'number', min: -1, help: limitHelp, hidden: !quota }, + { name: 'quota', label: t('Domain quota (MB)'), type: 'number', min: -1, help: limitHelp, hidden: !(quota && F('domain_quota')) }, + { name: 'transport', label: t('Transport'), type: 'select', options: (D('transport_options') || []).map(item => [item, item]), hidden: !F('transport') }, + { name: 'password_expiry', label: t('Password expiry (days)'), type: 'number', min: 0, help: t('0 = passwords never expire'), hidden: !F('password_expiration') }, + { name: 'backupmx', label: t('Mail server is backup MX'), type: 'checkbox' }, + { name: 'active', label: t('Active'), type: 'checkbox' }, + { name: 'default_aliases', label: t('Add default aliases'), type: 'checkbox', help: (D('default_aliases') || []).join(', '), hidden: Boolean(row) }, + ]; + const r = row || {}; + formModal(row ? t('Edit domain') : t('New domain'), fields, { + domain: r.domain || '', description: r.description || '', + aliases: r.aliases ?? 10, mailboxes: r.mailboxes ?? 10, maxquota: r.maxquota ?? 10, quota: r.quota ?? 2048, + transport: r.transport || D('transport_default') || 'virtual', password_expiry: r.password_expiry ?? 0, + backupmx: Boolean(r.backupmx), active: row ? row.active : true, default_aliases: true, + }, async data => { + const body = { + domain: data.domain.trim().toLowerCase(), description: data.description, + aliases: Number(data.aliases), mailboxes: Number(data.mailboxes), backupmx: data.backupmx, active: data.active, + }; + if (quota) body.maxquota = Number(data.maxquota); + if (quota && F('domain_quota')) body.quota = Number(data.quota); + if (F('transport')) body.transport = data.transport; + if (F('password_expiration')) body.password_expiry = Number(data.password_expiry || 0); + if (!row) body.default_aliases = data.default_aliases; + if (row) await api.put(`/domains/${enc(row.domain)}`, body); else await api.post('/domains', body); + invalidateDomains(); + return { message: row ? 'Domain updated' : 'Domain created' }; + }); + if (!row && !S.cache.domainDefaultsLoaded) S.cache.domainDefaultsLoaded = true; + } + + // ------------------------------------------------------------------ virtual list (domain workspace) + async function virtualPage(args, query, content) { + const domains = await domainsCached(); + if (!domains.length) { + content.innerHTML = pageHead(t('Virtual list'), '') + `
${te(isGlobal() ? 'No domains yet.' : 'No domains are assigned to you.')}
`; + return; + } + const domainName = args[0] || S.cache.lastDomain || domains[0].domain; + const domain = domains.find(row => row.domain === domainName); + if (!domain) { navigate(['virtual', domains[0].domain], null, true); return; } + S.cache.lastDomain = domain.domain; + const q = String(query.q || ''); + const mp = Number(query.mp || 1); + const ap = Number(query.ap || 1); + const status = String(query.status || ''); + const pageSize = Number(D('page_size') || 25); + const qs = new URLSearchParams({ domain: domain.domain, page_size: String(pageSize) }); + if (q) qs.set('q', q); + const [mailboxes, aliases, aliasDomains] = await Promise.all([ + api.get(`/mailboxes?${qs}&page=${mp}`), + api.get(`/aliases?${qs}&page=${ap}${status ? `&status=${enc(status)}` : ''}`), + F('alias_domain') ? api.get(`/alias-domains?domain=${enc(domain.domain)}`) : Promise.resolve([]), + ]); + const go = changes => navigate(['virtual', domain.domain], { q, mp, ap, status, ...changes }, true); + const quota = F('quota'); + const canFwd = P('edit_mailbox_forwarding'); + const canVacation = P('edit_vacation'); + + const domainPicker = ``; + const head = pageHead(domain.domain, domain.description || t('Mailboxes, aliases and alias domains'), + `${domainPicker}`); + const facts = `
+
${te('Mailboxes')}
${usage(domain.mailbox_count, domain.mailboxes, { icon: 'mail' })}
+
${te('Aliases')}
${usage(domain.alias_count, domain.aliases, { icon: 'forward' })}
+ ${quota && F('domain_quota') ? `
${te('Domain quota')}
${usage(domain.quota_used_mb ?? domain.quota_allocated_mb, domain.quota, { icon: 'archive', label: `${domain.quota_used_mb ?? domain.quota_allocated_mb} / ${quotaLabelMb(domain.quota)}` })}
` : ''} +
${te('Status')}
${domain.active ? te('Active') : te('Inactive')}${domain.backupmx ? ` · ${te('Backup MX')}` : ''}${quota ? ` · ${te('max. {q}', { q: quotaLabelMb(domain.maxquota) })}` : ''}
+
`; + + // alias domains + let aliasDomainHtml = ''; + if (F('alias_domain')) { + const isAliasDomain = aliasDomains.some(row => row.alias_domain === domain.domain); + aliasDomainHtml = table([t('Alias domain'), t('Target domain'), t('Description'), t('Modified'), t('Active'), ''], + aliasDomains.map((row, index) => `${row.alias_domain === domain.domain ? esc(row.alias_domain) : `${esc(row.alias_domain)}`}${row.target_domain === domain.domain ? esc(row.target_domain) : `${esc(row.target_domain)}`}${esc(row.description)}${esc(fmtDate(row.modified))}${toggle(row.active, 'data-act="active"')}${btn('edit', t('Edit'), 'square-pen')}${btn('delete', t('Delete'), 'trash', 'danger')}`), + { head: `${te('Alias domains')}${!isAliasDomain ? `` : ''}`, empty: t('This domain has no alias domains.') }); } - const fields = userEdit ? [ - { name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, help: 'Podaj wyłącznie adresy docelowe przekierowania.' }, - ...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []), - { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' }, - ] : [ - { name: 'local_part', label: 'Adres aliasu', required: true, readonly: !!row.address, help: domain ? `Wpisz nazwę przed @${domain} albo * dla catch-all` : 'Wpisz część przed @ albo * dla catch-all' }, - { name: 'domain', label: 'Domena', required: true, readonly: true }, - { name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true }, - { name: 'description', label: 'Opis', wide: true }, - ...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []), - ...(!row.address ? [{ name: 'save_goto_default', label: 'Zapamiętaj cele', type: 'checkbox', help: 'Zapisz jako domyślny cel nowych aliasów dla tego administratora' }] : []), - { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' }, + // aliases + const statusFilter = ``; + const aliasHead = `${te('Aliases')} (${aliases.total})
${statusFilter}
`; + const aliasTable = table([t('Alias'), t('To'), t('Description'), ...(F('show_status') ? [t('Status')] : []), t('Modified'), t('Active'), ''], + aliases.items.map((row, index) => `${row.catch_all ? `${te('catch-all')} ` : ''}${esc(row.catch_all ? `*@${row.domain}` : row.address)}${row.technical ? ` ${te('system')}` : ''}${row.goto.map(item => `
${esc(item)}
`).join('')}${esc(row.description)}${F('show_status') ? `${statusBadges(row.status)}` : ''}${esc(fmtDate(row.modified))}${toggle(row.active, 'data-act="active"', row.can_edit)}${row.can_edit ? btn('edit', t('Edit'), 'square-pen') : ''}${row.can_delete ? btn('delete', t('Delete'), 'trash', 'danger') : ''}`), + { head: aliasHead, empty: q ? t('No aliases match the search.') : t('This domain has no aliases.'), foot: pager(aliases.total, aliases.page, aliases.page_size, 'data-ap') }); + + // mailboxes + const mailboxHead = `${te('Mailboxes')} (${mailboxes.total})`; + const mbHeaders = [t('E-mail address'), t('Name'), ...(quota ? [t('Quota')] : []), t('Forwarding'), ...(F('vacation') ? [t('Auto-reply')] : []), ...(F('password_expiration') ? [t('Password expires')] : []), t('Modified'), t('Active'), '']; + const mailboxTable = table(mbHeaders, mailboxes.items.map((row, index) => { + const fwd = row.forward_to.length + ? `${row.deliver_to_mailbox ? '' : `${te('forward only')} `}${row.forward_to.map(item => `
${esc(item)}
`).join('')}` + : '—'; + return `${esc(row.username)}${row.smtp_active === false ? ` SMTP` : ''}${row.totp_enabled ? ` 2FA` : ''} + ${esc(row.name)} + ${quota ? `${mailboxUsage(row)}` : ''} + ${fwd} + ${F('vacation') ? `${row.vacation_active ? `${te('Active')}` : '—'}` : ''} + ${F('password_expiration') ? `${row.password_expired ? `${te('expired')}` : esc(row.password_expiry && !row.password_expiry.startsWith('9999') ? fmtDate(row.password_expiry) : t('Never'))}` : ''} + ${esc(fmtDate(row.modified))} + ${toggle(row.active, 'data-act="active"')} + ${btn('edit', t('Edit'), 'square-pen')}${canFwd ? btn('forward', t('Forwarding'), 'forward') : ''}${canVacation ? btn('vacation', t('Auto-reply'), 'calendar-days') : ''}${btn('delete', t('Delete'), 'trash', 'danger')}`; + }), { head: mailboxHead, empty: q ? t('No mailboxes match the search.') : t('This domain has no mailboxes.'), foot: pager(mailboxes.total, mailboxes.page, mailboxes.page_size, 'data-mp') }); + + content.innerHTML = head + facts + '
' + aliasDomainHtml + (aliasDomainHtml ? '
' : '') + aliasTable + '
' + mailboxTable; + + $('#domainSelect', content).onchange = event => navigate(['virtual', event.target.value]); + $('#vsearch', content).onsubmit = event => { event.preventDefault(); go({ q: new FormData(event.target).get('q'), mp: 1, ap: 1 }); }; + $('#aliasStatus', content).onchange = event => go({ status: event.target.value, ap: 1 }); + $$('[data-ap]', content).forEach(button => { button.onclick = () => go({ ap: Number(button.dataset.ap) }); }); + $$('[data-mp]', content).forEach(button => { button.onclick = () => go({ mp: Number(button.dataset.mp) }); }); + $('#addMailbox', content).onclick = () => mailboxForm(domain.domain); + $('#addAlias', content).onclick = () => aliasForm(domain.domain); + $('#addAliasDomain', content)?.addEventListener('click', () => aliasDomainForm(domain.domain, null, domains)); + $('#deleteInactive', content).onclick = async () => { + if (!await confirmModal(t('Delete inactive aliases'), t('Delete all inactive aliases of {domain}? Mailbox aliases and protected aliases are skipped.', { domain: domain.domain }))) return; + const result = await api.post(`/aliases/delete-inactive/${enc(domain.domain)}`); + showToast(t('Deleted: {d}, skipped: {s}', { d: result.deleted, s: result.skipped })); + invalidateDomains(); + render(); + }; + bindRows(content, aliasDomains, 'data-ad', { + edit: row => aliasDomainForm(domain.domain, row, domains), + active: async row => { await api.post(`/alias-domains/${enc(row.alias_domain)}/active`, { active: !row.active }); render(); }, + delete: async row => { + if (!await confirmModal(t('Delete alias domain'), t('Delete the alias domain {a} → {b}?', { a: row.alias_domain, b: row.target_domain }))) return; + await api.del(`/alias-domains/${enc(row.alias_domain)}`); + render(); + }, + }); + bindRows(content, aliases.items, 'data-alias', { + edit: row => aliasForm(domain.domain, row), + active: async row => { await api.post(`/aliases/${enc(row.address)}/active`, { active: !row.active }); render(); }, + delete: async row => { + if (!await confirmModal(t('Delete alias'), t('Delete the alias {a}?', { a: row.address }))) return; + await api.del(`/aliases/${enc(row.address)}`); + invalidateDomains(); + render(); + }, + }); + bindRows(content, mailboxes.items, 'data-mb', { + edit: row => mailboxForm(domain.domain, row), + forward: row => forwardingForm(row), + vacation: row => navigate(['vacation', row.username]), + active: async row => { await api.post(`/mailboxes/${enc(row.username)}/active`, { active: !row.active }); render(); }, + delete: async row => { + if (!await confirmModal(t('Delete mailbox'), t('Delete the mailbox {m}? Its alias, auto-reply, fetchmail entries and quota data are removed as well. Mail files on the server are only removed by a configured deletion hook.', { m: row.username }))) return; + await api.del(`/mailboxes/${enc(row.username)}`); + invalidateDomains(); + showToast('Mailbox deleted'); + render(); + }, + }); + } + + function aliasDomainForm(currentDomain, row, domains) { + const options = domains.map(item => [item.domain, item.domain]); + formModal(row ? t('Edit alias domain') : t('Add alias domain'), [ + { name: 'alias_domain', label: t('Alias domain'), type: row ? 'text' : 'select', options, readonly: Boolean(row), help: t('Mail to any address of this domain is delivered to the same address in the target domain.') }, + { name: 'target_domain', label: t('Target domain'), type: 'select', options }, + { name: 'description', label: t('Description'), maxlength: 255 }, + { name: 'active', label: t('Active'), type: 'checkbox' }, + ], { + alias_domain: row?.alias_domain || currentDomain, + target_domain: row?.target_domain || (domains.find(item => item.domain !== currentDomain)?.domain || ''), + description: row?.description || '', active: row ? row.active : true, + }, async data => { + const body = { alias_domain: data.alias_domain, target_domain: data.target_domain, description: data.description, active: data.active }; + if (row) await api.put(`/alias-domains/${enc(row.alias_domain)}`, body); else await api.post('/alias-domains', body); + return { message: 'Alias domain saved' }; + }); + } + + async function mailboxForm(domain, row = null) { + const hint = await api.get(`/mailboxes/quota-hint?domain=${enc(domain)}${row ? `&username=${enc(row.username)}` : ''}`); + const quotaHelp = !hint.quota_enabled ? '' : (hint.allowed_mb > 0 + ? t('Maximum: {n} MB', { n: hint.allowed_mb }) + : (hint.allowed_mb < 0 ? t('Quota is disabled for this domain (use -1)') : t('0 = unlimited'))); + const hookManagesPassword = Boolean(D('mailbox_postpassword_hook')); + const fields = [ + row ? { name: 'username', label: t('E-mail address'), readonly: true } + : { name: 'local_part', label: t('Mailbox name'), required: true, help: `@${domain}`, placeholder: 'john.doe' }, + { name: 'name', label: t('Name'), maxlength: 255 }, + { name: 'password', label: row ? t('New password (leave empty to keep)') : t('Password'), type: 'password', autocomplete: 'new-password', + required: !row && !F('generate_password'), generate: true, confirm: true, hidden: Boolean(row) && hookManagesPassword, + help: !row && F('generate_password') ? t('Leave empty to generate a password') : t('At least {n} characters, {l} letters and {d} digits', { n: D('password_min_length') ?? 5, l: D('password_min_letters') ?? 3, d: D('password_min_digits') ?? 2 }) }, + { name: 'quota', label: t('Quota (MB)'), type: 'number', min: -1, help: quotaHelp, hidden: !hint.quota_enabled }, + { name: 'email_other', label: t('Alternative e-mail (password recovery)'), type: 'email', hidden: !F('forgotten_user_password_reset') }, + { name: 'active', label: t('Active'), type: 'checkbox' }, + { name: 'smtp_active', label: t('Allow sending mail (SMTP)'), type: 'checkbox', hidden: !F('smtp_active_flag') }, + { name: 'send_welcome', label: t('Send welcome e-mail'), type: 'checkbox', hidden: Boolean(row) }, ]; - - formModal(row.address ? 'Edytuj alias' : 'Nowy alias', fields, { - local_part: localPart, - domain, - goto: row.address ? visibleTargets.join('\n') : defaultGoto, - description: row.description || '', - store_and_forward: keepsLocalDelivery, - save_goto_default: false, - active: row.address ? row.active : true, + formModal(row ? t('Edit mailbox') : t('Add mailbox to {domain}', { domain }), fields, { + username: row?.username || '', local_part: '', name: row?.name || '', password: '', + quota: row ? row.quota_mb : hint.default_mb, email_other: row?.email_other || '', + active: row ? row.active : true, smtp_active: row ? row.smtp_active !== false : true, send_welcome: true, }, async data => { - const body = { - address: row.address || null, - local_part: row.address ? null : data.local_part, - domain, - goto: splitList(data.goto), - description: userEdit ? (row.description || '') : data.description, - store_and_forward: row.is_mailbox ? Boolean(data.store_and_forward) : false, - save_goto_default: userEdit ? false : !!data.save_goto_default, - active: data.active, - }; - await api(row.address ? `/aliases/${enc(row.address)}` : '/aliases', { - method: row.address ? 'PUT' : 'POST', body: JSON.stringify(body), - }); - }); - } - - function aliasDomainForm(row = {}, domainOverride = '') { - formModal(row.alias_domain ? 'Edytuj alias domeny' : 'Nowy alias domeny', [ - { name: 'alias_domain', label: 'Domena aliasowa', required: true }, - { name: 'target_domain', label: 'Domena docelowa', required: true }, - { name: 'description', label: 'Opis', wide: true }, - { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias domeny aktywny' }, - ], { - alias_domain: row.alias_domain || '', target_domain: row.target_domain || domainOverride || '', description: row.description || '', active: row.alias_domain ? row.active : true, - }, async data => { - await api(row.alias_domain ? `/alias-domains/${enc(row.alias_domain)}` : '/alias-domains', { - method: row.alias_domain ? 'PUT' : 'POST', body: JSON.stringify(data), - }); - }); - } - - async function admins() { - const rows = await api('/admins'); - content().innerHTML = pageHead('Administratorzy', 'Global-admin i administratorzy domen', ``) + table( - ['Login', 'Rola', 'Domeny', 'TOTP', 'Recovery', 'Aktywny', ''], - rows.map((row, index) => `${esc(row.username)}${row.superadmin ? 'global-admin' : 'admin'}${row.domains.map(esc).join(', ')}${yes(row.totp_enabled)}${row.email_other ? esc(row.email_other) : '—'}${yes(row.active)}${action('Edytuj')}${row.username !== S.me.username ? action('Usuń', true) : ''}`) - ); - document.querySelector('#addAdmin').onclick = () => adminForm(); - bind(rows, { - Edytuj: row => adminForm(row), - Usuń: async row => { - if (await confirmModal('Usuń administratora', `Usunąć administratora ${row.username}?`)) { - await api(`/admins/${enc(row.username)}`, { method: 'DELETE' }); - admins(); - } - }, - }); - } - - function adminForm(row = {}) { - formModal(row.username ? 'Edytuj administratora' : 'Nowy administrator', [ - { name: 'username', label: 'Login e-mail', required: true, readonly: !!row.username }, - { name: 'password', label: row.username ? 'Nowe hasło (puste = bez zmian)' : 'Hasło', type: 'password', required: !row.username, autocomplete: 'new-password', generate: !!S.features.generate_password }, - { name: 'domains', label: 'Domeny (po przecinku)', wide: true }, - { name: 'superadmin', label: 'Global admin', type: 'checkbox', help: 'Pełny dostęp do wszystkich domen' }, - { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Konto aktywne' }, - { name: 'phone', label: 'Telefon' }, - { name: 'email_other', label: 'Drugi e-mail / recovery', type: 'email' }, - ], { - username: row.username || '', password: '', domains: (row.domains || []).filter(item => item !== 'ALL').join(', '), - superadmin: row.superadmin || false, active: row.username ? row.active : true, phone: row.phone || '', email_other: row.email_other || '', - }, async data => { - const body = { - username: data.username, password: data.password || null, domains: splitList(data.domains), - superadmin: data.superadmin, active: data.active, phone: data.phone, email_other: data.email_other, - }; - await api(row.username ? `/admins/${enc(row.username)}` : '/admins', { - method: row.username ? 'PUT' : 'POST', body: JSON.stringify(body), - }); - if (!row.username && S.features.show_password && data.password) { - return { reveal: { title: 'Hasło administratora', text: `${data.username}\n${data.password}` } }; + const body = { name: data.name, active: data.active }; + if (hint.quota_enabled) body.quota = Number(data.quota); + if (F('smtp_active_flag')) body.smtp_active = data.smtp_active; + if (F('forgotten_user_password_reset')) body.email_other = data.email_other; + if (data.password) body.password = data.password; + if (row) { + await api.put(`/mailboxes/${enc(row.username)}`, body); + invalidateDomains(); + return { message: 'Mailbox updated' }; } - return { message: row.username ? 'Administrator zapisany' : 'Administrator utworzony' }; + body.domain = domain; + body.local_part = data.local_part.trim().toLowerCase(); + body.send_welcome = data.send_welcome; + const result = await api.post('/mailboxes', body); + invalidateDomains(); + const warnings = (result.warnings || []).map(item => t(item)).join(' '); + if (warnings) showToast(warnings, true); + if (result.password) return { reveal: { title: t('Mailbox {m} created', { m: result.username }), text: result.password, note: t('Password – copy it now, it will not be shown again.') } }; + return { message: 'Mailbox created' }; }); } - async function fetchmail() { - const rows = await api('/fetchmail'); - content().innerHTML = pageHead('Fetchmail', 'Pobieranie poczty ze zdalnych skrzynek', ``) + table( - ['Mailbox', 'Serwer', 'Użytkownik', 'Protokół', 'SSL', 'Co ile', 'Status', 'Aktywny', ''], - rows.map((row, index) => `${esc(row.mailbox)}${esc(row.src_server)}${row.src_port ? `:${row.src_port}` : ''}${esc(row.src_user)}${esc(row.protocol || '')}${yes(row.usessl)}${row.poll_time} min${esc(row.returned_text || '')}${yes(row.active)}${action('Edytuj')}${action('Usuń', true)}`) - ); - document.querySelector('#addFetch').onclick = () => fetchForm(); - bind(rows, { - Edytuj: row => fetchForm(row), - Usuń: async row => { - if (await confirmModal('Usuń fetchmail', 'Usunąć wpis fetchmail?')) { - await api(`/fetchmail/${row.id}`, { method: 'DELETE' }); - fetchmail(); - } + function forwardingForm(row, own = false) { + formModal(own ? t('Forwarding') : t('Forwarding for {m}', { m: row.username }), [ + { name: 'forward_to', label: t('Forward to (one address per line)'), type: 'textarea', rows: 5, wide: true, placeholder: 'someone@example.org' }, + { name: 'deliver_to_mailbox', label: t('Keep a copy in the mailbox (forward and store)'), type: 'checkbox' }, + ], { forward_to: row.forward_to.join('\n'), deliver_to_mailbox: row.deliver_to_mailbox }, async data => { + await api.put(`/mailboxes/${enc(row.username)}/forwarding`, { forward_to: lines(data.forward_to), deliver_to_mailbox: data.deliver_to_mailbox }); + return { message: 'Forwarding saved' }; + }); + } + + async function aliasForm(domain, row = null) { + let stored = ''; + if (!row) { + try { stored = (await api.get('/preferences')).find(item => item.key === 'alias_goto_default')?.value || ''; } catch { stored = ''; } + } + formModal(row ? t('Edit alias') : t('Add alias to {domain}', { domain }), [ + row ? { name: 'address', label: t('Alias'), readonly: true } + : { name: 'local_part', label: t('Alias'), required: true, help: t('@{domain} — use * for a catch-all alias', { domain }), placeholder: 'info' }, + { name: 'goto', label: t('To (one address per line)'), type: 'textarea', rows: 5, wide: true, required: true, help: t('A catch-all may forward to a whole domain: @otherdomain.tld') }, + { name: 'description', label: t('Description'), maxlength: 255 }, + { name: 'active', label: t('Active'), type: 'checkbox' }, + { name: 'save_goto_default', label: t('Remember these destinations for new aliases'), type: 'checkbox', hidden: Boolean(row), help: stored ? t('Currently stored: {v}', { v: stored }) : '' }, + ], { + address: row ? (row.catch_all ? `*@${row.domain}` : row.address) : '', local_part: '', + goto: row ? row.goto.join('\n') : stored.split(',').join('\n'), description: row?.description || '', + active: row ? row.active : true, save_goto_default: false, + }, async data => { + const body = { goto: lines(data.goto), description: data.description, active: data.active }; + if (row) { + await api.put(`/aliases/${enc(row.address)}`, body); + return { message: 'Alias updated' }; + } + await api.post('/aliases', { ...body, domain, local_part: data.local_part.trim().toLowerCase(), save_goto_default: data.save_goto_default }); + invalidateDomains(); + return { message: 'Alias created' }; + }); + } + + // ------------------------------------------------------------------ search + async function searchPage(_args, query, content) { + const q = String(query.q || '').trim(); + const searchInput = $('#globalSearch'); + if (searchInput && searchInput.value !== q) searchInput.value = q; + if (!q) { content.innerHTML = pageHead(t('Search'), '') + `
${te('Enter a search term in the search field above.')}
`; return; } + const [domains, mailboxes, aliases] = await Promise.all([ + domainsCached(), + api.get(`/mailboxes?q=${enc(q)}&page_size=50`), + api.get(`/aliases?q=${enc(q)}&page_size=50`), + ]); + const matchingDomains = domains.filter(row => row.domain.includes(q.toLowerCase())); + content.innerHTML = pageHead(t('Search results for “{q}”', { q }), t('Searches addresses, names and forwarding targets in your domains')) + + (matchingDomains.length ? `${table([t('Domain'), t('Mailboxes'), t('Aliases')], matchingDomains.map(row => `${esc(row.domain)}${row.mailbox_count}${row.alias_count}`), { head: te('Domains') })}
` : '') + + table([t('E-mail address'), t('Name'), t('Forwarding'), t('Active')], mailboxes.items.map(row => `${esc(row.username)}${esc(row.name)}${row.forward_to.map(item => `
${esc(item)}
`).join('') || '—'}${yesNo(row.active)}`), { head: `${te('Mailboxes')} (${mailboxes.total})`, empty: t('No mailboxes found.') }) + + '
' + + table([t('Alias'), t('To'), t('Active')], aliases.items.map(row => `${esc(row.catch_all ? `*@${row.domain}` : row.address)}${row.goto.map(item => `
${esc(item)}
`).join('')}${yesNo(row.active)}`), { head: `${te('Aliases')} (${aliases.total})`, empty: t('No aliases found.') }); + } + + // ------------------------------------------------------------------ admins + async function adminsPage(_args, _q, content) { + const [rows, domains] = await Promise.all([api.get('/admins'), domainsCached()]); + content.innerHTML = pageHead(t('Administrators'), t('Global and domain administrators'), ``) + + table([t('Administrator'), t('Role'), t('Domains'), ...(F('totp') ? ['2FA'] : []), t('Active'), ''], rows.map((row, index) => `${esc(row.username)} + ${row.superadmin ? `${te('Global administrator')}` : te('Domain administrator')} + ${row.superadmin ? `${te('all')}${row.domains.length ? ` + ${row.domains.length}` : ''}` : (row.domains.map(d => `${esc(d)}`).join(' ') || '—')} + ${F('totp') ? `${row.totp_enabled ? 'TOTP' : '—'}` : ''} + ${toggle(row.active, 'data-act="active"', row.username !== S.me.username)} + ${btn('edit', t('Edit'), 'square-pen')}${row.username !== S.me.username ? btn('delete', t('Delete'), 'trash', 'danger') : ''}`), { empty: t('No administrators.') }); + $('#addAdmin', content).onclick = () => adminForm(null, domains); + bindRows(content, rows, 'data-row', { + edit: row => adminForm(row, domains), + active: async row => { await api.post(`/admins/${enc(row.username)}/active`, { active: !row.active }); render(); }, + delete: async row => { + if (!await confirmModal(t('Delete administrator'), t('Delete the administrator {a}?', { a: row.username }))) return; + await api.del(`/admins/${enc(row.username)}`); + render(); }, }); } - function fetchForm(row = {}) { - const advanced = S.features.fetchmail_extra_options ? [ - { name: 'extra_options', label: 'Extra options', wide: true }, - { name: 'mda', label: 'MDA', wide: true }, - ] : []; - formModal(row.id ? 'Edytuj fetchmail' : 'Nowy fetchmail', [ - { name: 'mailbox', label: 'Docelowa skrzynka', required: true }, - { name: 'src_server', label: 'Serwer źródłowy', required: true }, - { name: 'src_port', label: 'Port', type: 'number' }, - { name: 'src_user', label: 'Użytkownik źródłowy', required: true }, - { name: 'src_password', label: row.id ? 'Nowe hasło źródłowe (puste = bez zmian)' : 'Hasło źródłowe', type: 'password', required: !row.id }, - { name: 'src_folder', label: 'Folder źródłowy' }, - { name: 'protocol', label: 'Protokół', type: 'select', options: [['POP3', 'POP3'], ['IMAP', 'IMAP']] }, - { name: 'poll_time', label: 'Interwał [min]', type: 'number' }, - { name: 'usessl', label: 'SSL/TLS', type: 'checkbox', help: 'Użyj SSL' }, - { name: 'sslcertck', label: 'Weryfikacja certyfikatu', type: 'checkbox', help: 'Weryfikuj certyfikat serwera' }, - { name: 'keep', label: 'Keep', type: 'checkbox', help: 'Pozostaw wiadomości na serwerze' }, - { name: 'fetchall', label: 'Fetch all', type: 'checkbox', help: 'Pobierz także stare' }, - ...advanced, - { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Wpis aktywny' }, + function adminForm(row, domains) { + formModal(row ? t('Edit administrator') : t('New administrator'), [ + { name: 'username', label: t('E-mail address'), type: 'email', required: true, readonly: Boolean(row) }, + { name: 'password', label: row ? t('New password (leave empty to keep)') : t('Password'), type: 'password', required: !row, autocomplete: 'new-password', generate: true, confirm: true }, + { name: 'superadmin', label: t('Global administrator (all domains)'), type: 'checkbox' }, + { name: 'domains', label: t('Domains'), type: 'checklist', options: domains.map(item => [item.domain, item.domain]) }, + { name: 'email_other', label: t('Alternative e-mail (password recovery)'), type: 'email', hidden: !F('forgotten_admin_password_reset') }, + { name: 'active', label: t('Active'), type: 'checkbox' }, ], { - mailbox: row.mailbox || '', src_server: row.src_server || '', src_port: row.src_port || '', src_user: row.src_user || '', - src_password: '', src_folder: row.src_folder || '', protocol: row.protocol || 'POP3', poll_time: row.poll_time || 10, - usessl: row.usessl || false, sslcertck: row.sslcertck || false, keep: row.keep || false, fetchall: row.fetchall || false, - extra_options: row.extra_options || '', mda: row.mda || '', active: row.id ? row.active : true, + username: row?.username || '', password: '', superadmin: Boolean(row?.superadmin), domains: row?.domains || [], + email_other: row?.email_other || '', active: row ? row.active : true, }, async data => { const body = { - mailbox: data.mailbox, src_server: data.src_server, src_port: data.src_port ? number(data.src_port) : null, - src_auth: 'password', src_user: data.src_user, src_password: data.src_password, src_folder: data.src_folder, - poll_time: number(data.poll_time), fetchall: data.fetchall, keep: data.keep, protocol: data.protocol, - usessl: data.usessl, sslcertck: data.sslcertck, sslcertpath: '', sslfingerprint: '', + username: data.username.trim().toLowerCase(), password: data.password || null, superadmin: data.superadmin, + domains: data.domains, active: data.active, email_other: data.email_other || '', phone: row?.phone || '', + }; + if (row) await api.put(`/admins/${enc(row.username)}`, body); else await api.post('/admins', body); + return { message: 'Administrator saved' }; + }); + } + + // ------------------------------------------------------------------ fetchmail + async function fetchmailPage(_args, _q, content) { + const rows = await api.get('/fetchmail'); + content.innerHTML = pageHead(t('Fetchmail'), t('Retrieve mail from remote POP3/IMAP accounts'), ``) + + table([t('Mailbox'), t('Server'), t('User'), t('Protocol'), 'SSL', t('Interval'), t('Last result'), t('Active'), ''], + rows.map((row, index) => `${esc(row.mailbox)}${esc(row.src_server)}${row.src_port ? `:${row.src_port}` : ''}${esc(row.src_user)}${esc(row.protocol || '')}${yesNo(row.usessl)}${te('{n} min', { n: row.poll_time })}${esc(row.returned_text || '')}${row.active ? `${te('Active')}` : `${te('Inactive')}`}${btn('edit', t('Edit'), 'square-pen')}${btn('delete', t('Delete'), 'trash', 'danger')}`), + { empty: t('No fetchmail entries.') }); + $('#addFetch', content).onclick = () => fetchForm(); + bindRows(content, rows, 'data-row', { + edit: row => fetchForm(row), + delete: async row => { + if (!await confirmModal(t('Delete fetchmail entry'), t('Delete the fetchmail entry {u}@{s}?', { u: row.src_user, s: row.src_server }))) return; + await api.del(`/fetchmail/${row.id}`); + render(); + }, + }); + } + + async function fetchForm(row = {}) { + const mailboxes = await mailboxOptions(); + const advanced = F('fetchmail_extra_options'); + formModal(row.id ? t('Edit fetchmail entry') : t('New fetchmail entry'), [ + { name: 'mailbox', label: t('Target mailbox'), required: true, list: 'fetchMailboxes' }, + { name: 'src_server', label: t('Server'), required: true }, + { name: 'src_port', label: t('Port'), type: 'number', min: 0, help: t('Empty = protocol default') }, + { name: 'protocol', label: t('Protocol'), type: 'select', options: [['POP3', 'POP3'], ['IMAP', 'IMAP'], ['POP2', 'POP2'], ['ETRN', 'ETRN'], ['AUTO', 'AUTO']] }, + { name: 'src_auth', label: t('Authentication'), type: 'select', options: ['password', 'kerberos_v5', 'kerberos', 'kerberos_v4', 'gssapi', 'cram-md5', 'otp', 'ntlm', 'msn', 'ssh', 'any'].map(item => [item, item]) }, + { name: 'src_user', label: t('User'), required: true }, + { name: 'src_password', label: row.id ? t('Password (leave empty to keep)') : t('Password'), type: 'password', required: !row.id }, + { name: 'src_folder', label: t('Folder') }, + { name: 'poll_time', label: t('Poll every (minutes)'), type: 'number', min: 1 }, + { name: 'usessl', label: t('Use SSL/TLS'), type: 'checkbox' }, + { name: 'sslcertck', label: t('Verify the server certificate'), type: 'checkbox' }, + { name: 'keep', label: t('Keep messages on the server'), type: 'checkbox' }, + { name: 'fetchall', label: t('Fetch all messages (also already seen)'), type: 'checkbox' }, + { name: 'extra_options', label: t('Extra options'), wide: true, hidden: !advanced }, + { name: 'mda', label: 'MDA', wide: true, hidden: !advanced }, + { name: 'active', label: t('Active'), type: 'checkbox' }, + ], { + mailbox: row.mailbox || '', src_server: row.src_server || '', src_port: row.src_port || '', protocol: row.protocol || 'POP3', + src_auth: row.src_auth || 'password', src_user: row.src_user || '', src_password: '', src_folder: row.src_folder || '', + poll_time: row.poll_time || 10, usessl: row.id ? row.usessl : true, sslcertck: row.id ? row.sslcertck : true, + keep: row.id ? row.keep : true, fetchall: Boolean(row.fetchall), extra_options: row.extra_options || '', mda: row.mda || '', + active: row.id ? row.active : true, + }, async data => { + const body = { + mailbox: data.mailbox.trim().toLowerCase(), src_server: data.src_server.trim(), src_port: data.src_port ? Number(data.src_port) : null, + src_auth: data.src_auth, src_user: data.src_user, src_password: data.src_password, src_folder: data.src_folder, + poll_time: Number(data.poll_time || 10), fetchall: data.fetchall, keep: data.keep, protocol: data.protocol, + usessl: data.usessl, sslcertck: data.sslcertck, sslcertpath: row.sslcertpath || '', sslfingerprint: row.sslfingerprint || '', extra_options: data.extra_options || '', mda: data.mda || '', active: data.active, }; - await api(row.id ? `/fetchmail/${row.id}` : '/fetchmail', { method: row.id ? 'PUT' : 'POST', body: JSON.stringify(body) }); - }); + if (row.id) await api.put(`/fetchmail/${row.id}`, body); else await api.post('/fetchmail', body); + return { message: 'Fetchmail entry saved' }; + }, { intro: datalist('fetchMailboxes', mailboxes) }); } - function defaultVacation(mailbox) { - const localDate = date => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 10); - const now = new Date(); - return { - email: mailbox, - subject: 'Out of office', - body: '', - interval_time: 0, - active_from: localDate(now), - active_until: localDate(new Date(now.getTime() + 7 * 86400000)), - active: true, + // ------------------------------------------------------------------ vacation + const INTERVAL_LABELS = { 0: 'Reply once (per sender)', 1: 'Reply to every message', 86400: 'Reply once per day', 604800: 'Reply once per week' }; + const intervalLabel = seconds => (INTERVAL_LABELS[seconds] ? t(INTERVAL_LABELS[seconds]) : t('Reply at most every {n} seconds', { n: seconds })); + + async function vacationPage(args, _q, content) { + if (isUser()) return vacationEditor(S.me.username, content, false); + if (args[0]) return vacationEditor(args[0], content, true); + const mailboxes = await mailboxOptions(); + content.innerHTML = pageHead(t('Auto-reply'), t('Choose the mailbox whose auto-reply (vacation) you want to edit')) + + `
${datalist('vacMailboxes', mailboxes)}
`; + $('#vacPick', content).onsubmit = event => { + event.preventDefault(); + navigate(['vacation', String(new FormData(event.target).get('mailbox')).trim().toLowerCase()]); }; } - const vacationDate = value => String(value || '').slice(0, 10); - - function vacationMailboxPicker(mailboxes) { - return new Promise(resolve => { - const modal = document.querySelector('#modal'); - modal.innerHTML = `
- - - -
`; - const form = modal.querySelector('#vacationMailboxPickerForm'); - const input = modal.querySelector('#vacationMailboxSearch'); - const results = modal.querySelector('#vacationMailboxResults'); - const error = modal.querySelector('#vacationMailboxError'); - let filtered = []; - let settled = false; - const onCancel = event => { event.preventDefault(); finish(null); }; - const finish = value => { - if (settled) return; - settled = true; - modal.removeEventListener('cancel', onCancel); - if (modal.open) modal.close(); - modal.innerHTML = ''; - resolve(value); - }; - const render = () => { - const query = input.value.trim().toLowerCase(); - filtered = mailboxes.filter(row => !query || row.username.toLowerCase().includes(query) || String(row.name || '').toLowerCase().includes(query)).slice(0, 40); - results.innerHTML = filtered.length ? filtered.map(row => ``).join('') : `
${esc(t('Brak pasujących skrzynek'))}
`; - results.querySelectorAll('[data-mailbox]').forEach(button => { - button.onclick = () => finish(button.dataset.mailbox); - }); - }; - input.oninput = () => { error.textContent = ''; render(); }; - form.onsubmit = event => { - event.preventDefault(); - const value = input.value.trim().toLowerCase(); - const exact = mailboxes.find(row => row.username.toLowerCase() === value); - if (exact) return finish(exact.username); - if (filtered.length === 1) return finish(filtered[0].username); - error.textContent = t('Wybierz istniejącą skrzynkę z listy.'); - }; - form.querySelectorAll('[data-cancel]').forEach(button => { button.onclick = () => finish(null); }); - modal.addEventListener('cancel', onCancel, { once: true }); - render(); - modal.showModal(); - input.focus(); - }); - } - - async function renderVacationEditor(mailbox, adminMode) { - const existing = await api(`/vacation/${enc(mailbox)}`); - const row = existing || defaultVacation(mailbox); - row.active_from = vacationDate(row.active_from); - row.active_until = vacationDate(row.active_until); - const switchMailbox = adminMode ? `` : ''; - content().innerHTML = pageHead('Vacation', adminMode ? mailbox : 'Automatyczna odpowiedź dla Twojej skrzynki', switchMailbox) + - `
-
-
- ${adminMode ? `` : ''} - - - - - - -
-
${existing ? `` : ''}
-
-
`; - document.querySelector('#chooseVacationMailbox')?.addEventListener('click', () => { - S.cache.vacationMailbox = ''; - vacation(); - }); - document.querySelector('#vacationUserForm').onsubmit = async event => { + async function vacationEditor(mailbox, content, adminMode) { + const existing = await api.get(`/vacation/${enc(mailbox)}`); + const today = new Date(); + const local = date => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + const row = existing || { + subject: t('Out of office'), body: t('I am currently out of the office and will reply after my return.'), + interval_time: (D('vacation_choice_of_reply') || [0])[0], active_from: local(today), active_until: local(new Date(today.getTime() + 7 * 86400000)), active: false, + }; + const toInput = value => String(value || '').replace(' ', 'T').slice(0, 16); + const choices = D('vacation_choice_of_reply') || [0, 604800]; + content.innerHTML = pageHead(t('Auto-reply'), adminMode ? mailbox : t('Automatic reply while you are away'), adminMode ? `${icon('arrow-left')}${te('Back')}` : '') + + `
${existing?.active ? `${te('Active')} ${te('Auto-reply is switched on')}` : `${te('Inactive')} ${te('Auto-reply is switched off')}`}
+
+ + + + + +
${existing?.active ? `` : ''}
+
`; + $('#vacationForm', content).onsubmit = async event => { event.preventDefault(); const form = new FormData(event.target); - const submit = event.submitter || event.target.querySelector('button[type="submit"]'); - if (submit) submit.disabled = true; try { - await api(`/vacation/${enc(mailbox)}`, { method: 'PUT', body: JSON.stringify({ - email: mailbox, - subject: form.get('subject'), body: form.get('body'), + await api.put(`/vacation/${enc(mailbox)}`, { + email: mailbox, subject: form.get('subject'), body: form.get('body'), active_from: form.get('active_from'), active_until: form.get('active_until'), - interval_time: number(form.get('interval_time')), active: form.get('active') === 'on', - }) }); - showToast('Vacation zapisany'); - await renderVacationEditor(mailbox, adminMode); - } catch (error) { - if (submit) submit.disabled = false; - showToast(error.message, true); - } + interval_time: Number(form.get('interval_time')), active: true, + }); + showToast('Auto-reply activated'); + render(); + } catch (error) { showToast(error.message, true); } }; - document.querySelector('#removeUserVacation')?.addEventListener('click', async () => { - if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return; - await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' }); - showToast('Vacation usunięty'); - await renderVacationEditor(mailbox, adminMode); + $('#vacationOff', content)?.addEventListener('click', async () => { + try { await api.del(`/vacation/${enc(mailbox)}`); showToast('Auto-reply switched off'); render(); } + catch (error) { showToast(error.message, true); } }); } - async function vacation() { - if (S.me.kind === 'user') { - await renderVacationEditor(S.me.username, false); - return; - } - - const mailboxes = await api('/mailboxes'); - let mailbox = S.cache.vacationMailbox || ''; - if (mailbox && !mailboxes.some(row => row.username.toLowerCase() === mailbox.toLowerCase())) mailbox = ''; - if (!mailbox) mailbox = await vacationMailboxPicker(mailboxes); - if (!mailbox) { - content().innerHTML = pageHead('Vacation', 'Autoresponder', ``) + '
Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.
'; - document.querySelector('#chooseVacationMailbox').onclick = () => vacation(); - return; - } - S.cache.vacationMailbox = mailbox; - await renderVacationEditor(mailbox, true); + // ------------------------------------------------------------------ user forwarding + async function forwardingPage(_args, _q, content) { + const me = await api.get(`/mailboxes/${enc(S.me.username)}`); + content.innerHTML = pageHead(t('Forwarding'), t('Forward incoming mail to other addresses')) + + `
+ + +
+
`; + $('#fwdForm', content).onsubmit = async event => { + event.preventDefault(); + const form = new FormData(event.target); + try { + await api.put(`/mailboxes/${enc(S.me.username)}/forwarding`, { forward_to: lines(form.get('forward_to')), deliver_to_mailbox: form.get('deliver') === 'on' }); + showToast('Forwarding saved'); + render(); + } catch (error) { showToast(error.message, true); } + }; } - - function sieveQuote(value) { - return String(value ?? '').replaceAll('\\', '\\\\').replaceAll('"', '\\"').replace(/[\r\n]+/g, ' '); + // ------------------------------------------------------------------ logs + async function logsPage(_args, query, content) { + const domains = await domainsCached(); + const domain = String(query.domain || ''); + const page = Number(query.page || 1); + const q = String(query.q || ''); + const data = await api.get(`/logs?page=${page}&page_size=50${domain ? `&domain=${enc(domain)}` : ''}${q ? `&q=${enc(q)}` : ''}`); + content.innerHTML = pageHead(t('Log'), t('Changes made through the administration interface'), + `
`) + + table([t('Time'), t('Administrator'), t('Domain'), t('Action'), t('Data')], data.items.map(row => `${esc(fmtDate(row.timestamp))}${esc(row.username)}${esc(row.domain)}${esc(row.action)}${esc(row.data)}`), + { empty: t('No log entries.'), foot: pager(data.total, data.page, data.page_size) }); + $('#logFilter', content).onsubmit = event => { + event.preventDefault(); + const form = new FormData(event.target); + navigate('logs', { domain: form.get('domain'), q: form.get('q') }); + }; + $$('[data-page]', content).forEach(button => { button.onclick = () => navigate('logs', { domain, q, page: button.dataset.page }); }); } + // ------------------------------------------------------------------ send mail / broadcast + async function mailPage(args, _q, content) { + const domains = await domainsCached(); + const broadcastAllowed = isGlobal(); + content.innerHTML = pageHead(t('Send mail'), t('Send a message through the configured SMTP server')) + + `
+

${te('Send e-mail')}

+ + + +
+
+ ${broadcastAllowed ? `

${te('Broadcast message')}

+ + +
${te('Domains')}
${domains.map(row => ``).join('')}
+ + +
+
` : ''} +
`; + $('#sendForm', content).onsubmit = async event => { + event.preventDefault(); + const data = Object.fromEntries(new FormData(event.target)); + try { await api.post('/mail/send', data); showToast('Message sent'); event.target.reset(); } + catch (error) { showToast(error.message, true); } + }; + $('#broadcastForm', content)?.addEventListener('submit', async event => { + event.preventDefault(); + const fd = new FormData(event.target); + const body = { name: fd.get('name'), subject: fd.get('subject'), message: fd.get('message'), domains: fd.getAll('domains'), mailboxes_only: fd.get('mailboxes_only') === 'on' }; + if (!body.domains.length) { showToast('Select at least one domain', true); return; } + if (!await confirmModal(t('Broadcast message'), t('Send this message to all recipients in {n} domain(s)?', { n: body.domains.length }), { danger: false, confirmLabel: t('Send') })) return; + try { + const result = await api.post('/mail/broadcast', body); + showToast(t('Sent: {s}, failed: {f}', { s: result.sent, f: result.failed }), result.failed > 0); + } catch (error) { showToast(error.message, true); } + }); + } + + // ------------------------------------------------------------------ security + async function securityPage(_args, _q, content) { + const [appPasswords, exceptions, me] = await Promise.all([ + F('app_passwords') && isUser() ? api.get('/security/app-passwords') : Promise.resolve([]), + F('totp') ? api.get('/security/totp-exceptions') : Promise.resolve([]), + api.get('/auth/me'), + ]); + const hookManaged = isUser() && Boolean(D('mailbox_postpassword_hook')) && false; + content.innerHTML = pageHead(t('Security'), t('Password, two-factor authentication and application passwords')) + + `
+

${te('Change password')}

${hookManaged ? '' : `
+ + + + ${te('At least {n} characters, {l} letters and {d} digits', { n: D('password_min_length') ?? 5, l: D('password_min_letters') ?? 3, d: D('password_min_digits') ?? 2 })} +
+
`}
+ ${F('totp') ? `

${te('Two-factor authentication (TOTP)')}

+

${me.totp_enabled ? `${te('Enabled')}` : `${te('Disabled')}`}

+

${te('Protect the sign-in with a one-time code from an authenticator app.')}

+
${me.totp_enabled ? `` : ``}
` : ''} +
` + + (F('totp') ? `
${table([t('IP address'), t('Account'), t('Description'), ''], exceptions.map((row, index) => `${esc(row.ip)}${esc(row.username || t('all'))}${esc(row.description)}${btn('delete', t('Delete'), 'trash', 'danger')}`), { head: `${te('TOTP exceptions (trusted IP addresses)')}`, empty: t('No exceptions.') })}` : '') + + (F('app_passwords') && isUser() ? `
${table([t('Description'), ''], appPasswords.map((row, index) => `${esc(row.description)}${btn('delete', t('Delete'), 'trash', 'danger')}`), { head: `${te('Application passwords')}`, empty: t('No application passwords.') })}` : ''); + + $('#passwordForm', content)?.addEventListener('submit', async event => { + event.preventDefault(); + const form = new FormData(event.target); + if (form.get('new_password') !== form.get('confirm_password')) { showToast('The passwords do not match.', true); return; } + try { + await api.post('/security/password', { current_password: form.get('current_password'), new_password: form.get('new_password') }); + event.target.reset(); + showToast('Password changed'); + } catch (error) { showToast(error.message, true); } + }); + $('#totpBtn', content)?.addEventListener('click', async () => { + const setup = await api.post('/security/totp/setup'); + formModal(t('Set up two-factor authentication'), [ + { name: 'secret', label: t('Secret key'), type: 'static', wide: true, html: `${esc(setup.secret)}` }, + { name: 'uri', label: t('Link for the authenticator app'), type: 'static', wide: true, html: `${esc(setup.otpauth_uri)}` }, + { name: 'code', label: t('6-digit code'), required: true, autocomplete: 'one-time-code', inputmode: 'numeric' }, + { name: 'current_password', label: t('Current password'), type: 'password', required: true, autocomplete: 'current-password' }, + ], {}, async data => { + await api.post('/security/totp/confirm', { secret: setup.secret, code: data.code, current_password: data.current_password }); + return { message: 'Two-factor authentication enabled' }; + }, { intro: `

${te('Add the key to your authenticator app (e.g. by opening the link on your phone), then enter the code it shows.')}

` }); + }); + $('#totpOffBtn', content)?.addEventListener('click', () => formModal(t('Disable two-factor authentication'), [ + { name: 'current_password', label: t('Current password'), type: 'password', required: true }, + ], {}, async data => { await api.del('/security/totp', data); return { message: 'Two-factor authentication disabled' }; }, { submitLabel: t('Disable') })); + $('#addException', content)?.addEventListener('click', () => formModal(t('TOTP exception'), [ + { name: 'ip', label: t('IP address'), required: true }, + { name: 'username', label: t('Account (empty = all your accounts)'), hidden: !isAdmin() }, + { name: 'description', label: t('Description'), wide: true }, + ], {}, async data => { + await api.post('/security/totp-exceptions', { ip: data.ip, username: data.username || null, description: data.description || '' }); + return { message: 'Saved' }; + })); + $('#addAppPassword', content)?.addEventListener('click', () => formModal(t('New application password'), [ + { name: 'description', label: t('Description (e.g. phone)'), required: true }, + { name: 'password', label: t('Application password'), type: 'password', required: true, generate: true }, + { name: 'current_password', label: t('Current password'), type: 'password', required: true }, + ], {}, async data => { + await api.post('/security/app-passwords', data); + return { reveal: { title: t('Application password created'), text: data.password, note: t('Use this password in the application instead of your mailbox password.') } }; + })); + bindRows(content, exceptions, 'data-ex', { + delete: async row => { if (!await confirmModal(t('Delete exception'), t('Delete the exception for {ip}?', { ip: row.ip }))) return; await api.del(`/security/totp-exceptions/${row.id}`); render(); }, + }); + bindRows(content, appPasswords, 'data-app', { + delete: async row => { if (!await confirmModal(t('Delete application password'), t('Delete the application password “{d}”?', { d: row.description }))) return; await api.del(`/security/app-passwords/${row.id}`); render(); }, + }); + } + + // ------------------------------------------------------------------ system + async function systemPage(_args, _q, content) { + const [schema, runtime] = await Promise.all([api.get('/system/schema'), api.get('/system/features')]); + content.innerHTML = pageHead(t('System'), t('Database schema and runtime configuration'), P('backup') ? `${icon('download')}${te('Download backup')}` : '') + + `
+
${te('Version')}
${esc(runtime.version)}
+
${te('Database')}
${esc(schema.database_backend)}${runtime.database_read_only ? ' (read-only)' : ''}
+
${te('Schema')}
${schema.ok ? 'OK' : te('Warnings')}
+
${te('Password scheme')}
${esc(runtime.password.configured)}
+
+
${te('Schema check')}
${schema.warnings.length ? `
${esc(schema.warnings.join('\n'))}

${te('Run “postfixadmin-rs --init-schema” on the server to create missing tables and columns.')}

` : `
${te('The database schema is complete.')}
`}
+
+
${te('Enabled features')}
${Object.entries(runtime.features).map(([key, value]) => `
${esc(key)}${typeof value === 'boolean' ? (value ? 'ON' : 'OFF') : `${esc(Array.isArray(value) ? value.join(', ') : value)}`}
`).join('')}
`; + } + + // ------------------------------------------------------------------ DKIM + async function dkimPage(_args, _q, content) { + if (isUser()) { + const data = await api.get('/dkim/records'); + const rows = data.records || []; + content.innerHTML = pageHead('DKIM', t('DKIM DNS records of your domain (read-only)')) + + (data.warnings || []).map(message => `
${esc(t(message))}
`).join('') + + table([t('Source'), t('Domain'), t('Selector'), t('DNS record'), ''], rows.map((row, index) => `${row.source === 'rspamd' ? 'Rspamd' : 'PostfixAdmin'}${esc(row.domain)}${esc(row.selector)}${esc(row.record)}${btn('show', t('Show'), 'file-text')}`), { empty: t('No DKIM record is configured for your domain.') }); + bindRows(content, rows, 'data-row', { show: row => textModal(row.name, row.record) }); + return; + } + const paEnabled = F('dkim'); + const rspamdEnabled = Boolean(S.runtime?.rspamd_dkim?.enabled); + const available = [paEnabled ? 'postfixadmin' : null, rspamdEnabled ? 'rspamd' : null].filter(Boolean); + const configured = S.runtime?.dkim?.default_backend || 'postfixadmin'; + const selected = available.includes(S.cache.dkimBackend) ? S.cache.dkimBackend : (available.includes(configured) ? configured : available[0]); + S.cache.dkimBackend = selected; + const tabs = available.length > 1 ? `
${available.map(backend => ``).join('')}
` : ''; + if (selected === 'postfixadmin') await dkimPostfixAdmin(content, tabs); + else await dkimRspamd(content, tabs); + $$('[data-backend]', content).forEach(button => { button.onclick = () => { S.cache.dkimBackend = button.dataset.backend; render(); }; }); + } + + function dkimRecord(row) { + const body = String(row.public_key || '').split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('-----')).join(''); + const value = /v=DKIM1/i.test(row.public_key || '') ? String(row.public_key).trim() : `v=DKIM1; k=rsa; p=${body}`; + return `${row.selector}._domainkey.${row.domain_name}. IN TXT "${value.replaceAll('"', '\\"')}"`; + } + + async function dkimPostfixAdmin(content, tabs) { + const [keys, signing, domains] = await Promise.all([api.get('/dkim/keys'), api.get('/dkim/signing'), domainsCached()]); + content.innerHTML = pageHead('DKIM', t('Keys and signing table stored in the PostfixAdmin database (OpenDKIM)'), + ``) + tabs + + table([t('Domain'), t('Selector'), t('Description'), t('Public key'), ''], keys.map((row, index) => `${esc(row.domain_name)}${esc(row.selector)}${esc(row.description)}${esc((row.public_key || '').slice(0, 60))}…${btn('dns', 'DNS', 'file-text')}${btn('edit', t('Edit'), 'square-pen')}${btn('delete', t('Delete'), 'trash', 'danger')}`), { head: te('Keys'), empty: t('No DKIM keys.') }) + + '
' + + table([t('Author (domain or address)'), t('Key'), ''], signing.map((row, index) => `${esc(row.author)}${esc(row.domain_name)} / ${esc(row.selector)}${btn('edit', t('Edit'), 'square-pen')}${btn('delete', t('Delete'), 'trash', 'danger')}`), { head: te('Signing table'), empty: t('No signing entries.') }); + const domainOptions = domains.map(item => [item.domain, item.domain]); + const keyForm = row => formModal(row ? t('Edit DKIM key') : t('New DKIM key'), [ + { name: 'domain_name', label: t('Domain'), type: 'select', options: domainOptions }, + { name: 'selector', label: t('Selector'), required: true }, + { name: 'description', label: t('Description'), wide: true }, + { name: 'private_key', label: row ? t('New private key (leave empty to keep)') : t('Private key (leave empty to generate)'), type: 'textarea', wide: true }, + { name: 'public_key', label: row ? t('New public key (leave empty to keep)') : t('Public key (leave empty to generate)'), type: 'textarea', wide: true }, + ], { domain_name: row?.domain_name || domainOptions[0]?.[0] || '', selector: row?.selector || 'default', description: row?.description || '' }, async data => { + const body = { ...data, id: row?.id || null, private_key: data.private_key || null, public_key: data.public_key || null }; + if (row) await api.put(`/dkim/keys/${row.id}`, body); else await api.post('/dkim/keys', body); + return { message: 'DKIM key saved' }; + }); + const signingForm = row => { + if (!keys.length) { showToast('Create a DKIM key first.', true); return; } + formModal(row ? t('Edit signing entry') : t('New signing entry'), [ + { name: 'author', label: t('Author (domain or address)'), required: true }, + { name: 'dkim_id', label: t('Key'), type: 'select', options: keys.map(key => [String(key.id), `${key.domain_name} / ${key.selector}`]) }, + ], { author: row?.author || '', dkim_id: String(row?.dkim_id || keys[0].id) }, async data => { + const body = { id: row?.id || null, author: data.author, dkim_id: Number(data.dkim_id) }; + if (row) await api.put(`/dkim/signing/${row.id}`, body); else await api.post('/dkim/signing', body); + return { message: 'Signing entry saved' }; + }); + }; + $('#addKey', content).onclick = () => keyForm(null); + $('#addSigning', content).onclick = () => signingForm(null); + bindRows(content, keys, 'data-key', { + dns: row => textModal(`${row.selector}._domainkey.${row.domain_name}`, dkimRecord(row)), + edit: row => keyForm(row), + delete: async row => { if (!await confirmModal(t('Delete DKIM key'), t('Delete the key {d} / {s}?', { d: row.domain_name, s: row.selector }))) return; await api.del(`/dkim/keys/${row.id}`); render(); }, + }); + bindRows(content, signing, 'data-sign', { + edit: row => signingForm(row), + delete: async row => { if (!await confirmModal(t('Delete signing entry'), t('Delete the signing entry for {a}?', { a: row.author }))) return; await api.del(`/dkim/signing/${row.id}`); render(); }, + }); + } + + async function dkimRspamd(content, tabs) { + const status = await api.get('/rspamd-dkim'); + const global = isGlobal(); + const hostActions = global ? `${status.reload_available ? `` : ''}` : ''; + content.innerHTML = pageHead('DKIM', t('Signing keys used by Rspamd on this host'), + ``) + tabs + + `
${status.configured ? `${te('Maps active')}` : `${te('Not configured')}`}${esc(status.signing_config)}
+

${te('Keys')}: ${esc(status.key_dir)}
selector_map: ${esc(status.selector_map)}
path_map: ${esc(status.path_map)}

+ ${status.legacy_domains?.length ? `
${te('Legacy domain blocks found: {d}. Import them so that the maps can manage these domains.', { d: status.legacy_domains.join(', ') })}
` : ''} +
${hostActions}
` + + table([t('Domain'), t('Selector'), t('Private key'), t('File'), 'DNS', ''], status.entries.map((row, index) => `${esc(row.domain)}${esc(row.selector)}${esc(row.private_path)}${row.private_exists ? 'OK' : `${te('missing')}`}${row.dns_record ? 'TXT' : '—'}${row.dns_record ? btn('dns', 'DNS', 'file-text') : ''}${btn('unmap', t('Unmap'), 'link')}${btn('purge', t('Delete key'), 'trash', 'danger')}`), { empty: t('No Rspamd DKIM keys.') }); + const defaultDomain = (S.me.domains || []).find(item => item !== 'ALL') || ''; + $('#rGenerate', content).onclick = () => formModal(t('Generate DKIM key'), [ + { name: 'domain', label: t('Domain'), required: true }, + { name: 'selector', label: t('Selector'), required: true }, + { name: 'bits', label: t('Key size'), type: 'select', options: [['2048', '2048'], ['3072', '3072'], ['4096', '4096']] }, + { name: 'replace', label: t('Replace an existing mapping'), type: 'checkbox', help: t('Only after the new DNS record has been published.') }, + { name: 'reload', label: t('Reload Rspamd afterwards'), type: 'checkbox' }, + ], { domain: defaultDomain, selector: status.default_selector || 'mail', bits: String(status.key_bits || 2048), reload: true }, async data => { + const result = await api.post('/rspamd-dkim/generate', { ...data, bits: Number(data.bits) }); + return { reveal: { title: `${result.selector}._domainkey.${result.domain}`, text: result.dns_record, note: t('Publish this DNS record.') } }; + }); + $('#rAdopt', content).onclick = () => formModal(t('Use existing key'), [ + { name: 'domain', label: t('Domain'), required: true }, + { name: 'selector', label: t('Selector'), required: true }, + { name: 'private_path', label: t('Path of the private key'), required: true, wide: true, placeholder: `${status.key_dir}/example.org/mail.private` }, + { name: 'reload', label: t('Reload Rspamd afterwards'), type: 'checkbox' }, + ], { domain: defaultDomain, selector: status.default_selector || 'mail', reload: true }, async data => { await api.post('/rspamd-dkim/adopt', data); return { message: 'Key mapped' }; }); + $('#rConfigure', content)?.addEventListener('click', async () => { + try { await api.post('/rspamd-dkim/configure'); showToast('Rspamd maps configured'); render(); } catch (error) { showToast(error.message, true); } + }); + $('#rImport', content)?.addEventListener('click', async () => { + if (!await confirmModal(t('Import Rspamd configuration'), t('Move valid domain { path/selector } blocks into the managed maps? A backup is created and the change is rolled back if the configuration test fails.'), { danger: false, confirmLabel: t('Import') })) return; + try { + const result = await api.post('/rspamd-dkim/import-legacy', { reload: true }); + textModal(t('Import result'), [t('Imported: {n}', { n: result.imported.length }), t('Skipped: {n}', { n: result.skipped.length }), result.backup ? `Backup: ${result.backup}` : '', ...result.skipped].filter(Boolean).join('\n'), { copy: false }); + render(); + } catch (error) { showToast(error.message, true); } + }); + $('#rTest', content)?.addEventListener('click', async () => { + try { const result = await api.get('/rspamd-dkim/test'); textModal('rspamadm configtest', result.output || 'OK', { copy: false }); } catch (error) { showToast(error.message, true); } + }); + $('#rReload', content)?.addEventListener('click', async () => { + try { const result = await api.post('/rspamd-dkim/reload'); showToast(result.reload || 'Rspamd reloaded'); } catch (error) { showToast(error.message, true); } + }); + bindRows(content, status.entries, 'data-row', { + dns: row => textModal(`${row.selector}._domainkey.${row.domain}`, row.dns_record), + unmap: async row => { if (!await confirmModal(t('Unmap key'), t('Remove {d} from the Rspamd maps? The key file stays on disk.', { d: row.domain }), { danger: false, confirmLabel: t('Unmap') })) return; await api.del(`/rspamd-dkim/${enc(row.domain)}`); render(); }, + purge: async row => { if (!await confirmModal(t('Delete key'), t('Remove the mapping and delete the private key {d} / {s} from disk?', { d: row.domain, s: row.selector }))) return; await api.post(`/rspamd-dkim/${enc(row.domain)}/purge`, { reload: true }); render(); }, + }); + } + + // ------------------------------------------------------------------ Sieve filters + const sieveQuote = value => String(value ?? '').replaceAll('\\', '\\\\').replaceAll('"', '\\"').replace(/[\r\n]+/g, ' '); + function mergeSieveRequirements(source, requirements) { const found = new Set(requirements || []); const without = String(source || '').replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => { const raw = single ? [`"${single}"`] : String(list || '').split(','); - for (const item of raw) { - const match = item.match(/"([^"]+)"/); - if (match) found.add(match[1]); - } + for (const item of raw) { const match = item.match(/"([^"]+)"/); if (match) found.add(match[1]); } return ''; }).trim(); const header = found.size ? `require [${[...found].sort().map(item => `"${sieveQuote(item)}"`).join(', ')}];` : ''; - return [header, without].filter(Boolean).join('\n\n').trim() + '\n'; + return `${[header, without].filter(Boolean).join('\n\n').trim()}\n`; } function buildSieveRule(data) { const value = String(data.value || '').trim(); - if (!value) throw new Error(t('Wartość warunku jest wymagana')); + if (!value) throw new Error(t('Enter a value for the condition')); const matchToken = data.match === 'is' ? ':is' : data.match === 'matches' ? ':matches' : ':contains'; let test; if (data.condition === 'from') test = `address ${matchToken} "from" "${sieveQuote(value)}"`; @@ -799,501 +971,123 @@ ${data.password}` } }; else if (data.condition === 'subject') test = `header ${matchToken} "subject" "${sieveQuote(value)}"`; else { const header = String(data.header || '').trim(); - if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error(t('Podaj poprawną nazwę nagłówka')); + if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error(t('Enter a valid header name')); test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`; } if (data.negate) test = `not ${test}`; - const target = String(data.target || '').trim(); const requirements = []; let command; - if (data.action === 'move') { - if (!target) throw new Error(t('Folder docelowy jest wymagany')); - requirements.push('fileinto'); command = `fileinto "${sieveQuote(target)}";`; - } else if (data.action === 'copy') { - if (!target) throw new Error(t('Folder docelowy jest wymagany')); - requirements.push('fileinto', 'copy'); command = `fileinto :copy "${sieveQuote(target)}";`; + if (data.action === 'move' || data.action === 'copy') { + if (!target) throw new Error(t('Enter the target folder')); + requirements.push('fileinto'); + if (data.action === 'copy') requirements.push('copy'); + command = `fileinto${data.action === 'copy' ? ' :copy' : ''} "${sieveQuote(target)}";`; } else if (data.action === 'redirect' || data.action === 'redirect-copy') { - if (!target.includes('@')) throw new Error(t('Adres przekierowania jest wymagany')); + if (!target.includes('@')) throw new Error(t('Enter the forwarding address')); if (data.action === 'redirect-copy') requirements.push('copy'); command = `redirect${data.action === 'redirect-copy' ? ' :copy' : ''} "${sieveQuote(target)}";`; } else if (data.action === 'discard') command = 'discard;'; else if (data.action === 'mark-read') { requirements.push('imap4flags'); command = 'addflag "\\\\Seen";'; } else command = 'keep;'; - const name = String(data.name || '').trim().replace(/[\r\n]+/g, ' ').slice(0, 80) || 'rule'; - const body = [`# PostfixAdmin-RS rule: ${name}`, `if ${test} {`, ` ${command}`, ...(data.stop ? [' stop;'] : []), '}'].join('\n'); - return { body, requirements }; + return { body: [`# rule: ${name}`, `if ${test} {`, ` ${command}`, ...(data.stop ? [' stop;'] : []), '}'].join('\n'), requirements }; } - function openSieveRuleBuilder() { - const editor = document.querySelector('#sieveSource'); - if (!editor) return; - formModal('Nowa reguła Sieve', [ - { name: 'name', label: 'Nazwa reguły', required: true, wide: true }, - { name: 'condition', label: 'Warunek', type: 'select', options: [['from', 'Nadawca'], ['from-domain', 'Domena nadawcy'], ['to', 'Odbiorca To/Cc'], ['subject', 'Temat'], ['header', 'Własny nagłówek']] }, - { name: 'match', label: 'Dopasowanie', type: 'select', options: [['contains', 'Zawiera'], ['is', 'Jest dokładnie'], ['matches', 'Wzorzec wildcard']] }, - { name: 'value', label: 'Wartość', required: true, wide: true }, - { name: 'header', label: 'Nazwa nagłówka (dla własnego)', placeholder: 'X-Spam-Status', wide: true }, - { name: 'negate', label: 'Negacja', type: 'checkbox', help: 'Warunek NIE pasuje' }, - { name: 'action', label: 'Akcja', type: 'select', options: [['move', 'Przenieś do folderu'], ['copy', 'Kopiuj do folderu'], ['redirect', 'Przekieruj'], ['redirect-copy', 'Przekieruj kopię'], ['discard', 'Odrzuć'], ['mark-read', 'Oznacz jako przeczytane'], ['keep', 'Zostaw w INBOX']] }, - { name: 'target', label: 'Cel akcji', placeholder: 'Junk lub user@example.org', wide: true }, - { name: 'stop', label: 'Zatrzymaj dalsze reguły', type: 'checkbox', help: 'Dodaj stop; po wykonaniu akcji' }, - ], { name: '', condition: 'from', match: 'contains', value: '', header: '', negate: false, action: 'move', target: 'Junk', stop: true }, async data => { - const rule = buildSieveRule(data); - const current = editor.value.trim(); - editor.value = mergeSieveRequirements(`${current}${current ? '\n\n' : ''}${rule.body}\n`, rule.requirements); - editor.focus(); - return { refresh: false, message: 'Reguła dodana do edytora — sprawdź i zapisz skrypt' }; - }); - } - - async function filters() { - if (S.me.kind !== 'user' || !S.runtime?.sieve?.enabled) { - content().innerHTML = pageHead('Filtry', 'ManageSieve') + '
ManageSieve is disabled.
'; - return; - } - const [status, result] = await Promise.all([api('/sieve/status'), api('/sieve/scripts')]); + async function filtersPage(_args, _q, content) { + const [status, result] = await Promise.all([api.get('/sieve/status'), api.get('/sieve/scripts')]); const scripts = result.scripts || []; let selected = S.cache.sieveScript; if (!scripts.some(script => script.name === selected)) selected = scripts.find(script => script.active)?.name || scripts[0]?.name || ''; S.cache.sieveScript = selected; - let current = { name: selected, content: '' }; - if (selected) current = await api(`/sieve/scripts/${enc(selected)}`); + const current = selected ? await api.get(`/sieve/scripts/${enc(selected)}`) : { content: '' }; const activeName = scripts.find(script => script.active)?.name || ''; const readOnly = Boolean(status.read_only); - const actions = readOnly ? '' : `${selected ? `` : ''}`; - const scriptList = scripts.length ? scripts.map(script => ``).join('') : '
Brak skryptów Sieve.
'; - const editor = selected ? `
${esc(selected)}${selected === activeName ? 'ACTIVE' : ''}
- - ${readOnly ? '' : ``} -
` : '
Utwórz skrypt, aby rozpocząć zarządzanie filtrami.
'; - content().innerHTML = pageHead('Filtry', 'ManageSieve — reguły po stronie serwera', actions) + - `
CONNECTED${esc(status.implementation || 'ManageSieve')}${status.tls_mode.toUpperCase()} :${status.port}${readOnly ? 'READ ONLY' : ''}
-
Skrypty
${scriptList}
${activeName && !readOnly ? `
` : ''}
-
${editor}
-
${icon('info')}Rozszerzenia serwera
${(status.extensions || []).map(item => `${esc(item)}`).join('') || 'Brak danych.'}
`; - - document.querySelectorAll('[data-sieve-script]').forEach(button => { - button.onclick = () => { S.cache.sieveScript = button.dataset.sieveScript; filters(); }; - }); - document.querySelector('#newSieveRule')?.addEventListener('click', openSieveRuleBuilder); - document.querySelector('#newSieve')?.addEventListener('click', async () => { - const name = await promptModal('Nowy skrypt Sieve', 'Nazwa skryptu', 'main', { submitLabel: 'Utwórz' }); + content.innerHTML = pageHead(t('Filters'), t('Server-side mail filters (Sieve)'), readOnly ? '' : `${selected ? `` : ''}`) + + `
${te('Scripts')}
${scripts.length ? scripts.map(script => ``).join('') : `
${te('No scripts yet.')}
`}
${activeName && !readOnly ? `
` : ''}
+
${selected ? `
${esc(selected)}
${readOnly ? '' : `${selected === activeName ? '' : ``}`}
` : `
${te('Create a script to start filtering your mail.')}
`}
+
${icon('info')}${esc(status.implementation || 'ManageSieve')}${readOnly ? 'READ ONLY' : ''}
${(status.extensions || []).map(item => `${esc(item)}`).join('')}
`; + const source = () => $('#sieveSource', content).value; + $$('[data-script]', content).forEach(button => { button.onclick = () => { S.cache.sieveScript = button.dataset.script; render(); }; }); + $('#newScript', content)?.addEventListener('click', async () => { + const name = await promptModal(t('New script'), t('Script name'), 'filters', { submitLabel: t('Create') }); if (!name) return; - const template = `# PostfixAdmin-RS ManageSieve\n\nrequire [\"fileinto\"];\n\n# ${t('Dodaj reguły poniżej.')}\n`; - await api(`/sieve/scripts/${enc(name)}`, { method: 'PUT', body: JSON.stringify({ content: template }) }); + await api.put(`/sieve/scripts/${enc(name)}`, { content: 'require ["fileinto"];\n\n' }); S.cache.sieveScript = name; - showToast('Skrypt utworzony'); - filters(); + render(); }); - document.querySelector('#checkSieve')?.addEventListener('click', async () => { - const validation = await api('/sieve/check', { method: 'POST', body: JSON.stringify({ content: document.querySelector('#sieveSource').value }) }); - if (validation.valid) showToast(validation.warning || 'Skrypt jest poprawny'); - else showToast(validation.error || 'Skrypt zawiera błąd', true); + $('#newRule', content)?.addEventListener('click', () => formModal(t('Add rule'), [ + { name: 'name', label: t('Rule name'), required: true, wide: true }, + { name: 'condition', label: t('If'), type: 'select', options: [['from', t('Sender')], ['from-domain', t('Sender domain')], ['to', t('Recipient (To/Cc)')], ['subject', t('Subject')], ['header', t('Other header')]] }, + { name: 'match', label: t('Match'), type: 'select', options: [['contains', t('contains')], ['is', t('is exactly')], ['matches', t('matches (wildcards)')]] }, + { name: 'value', label: t('Value'), required: true, wide: true }, + { name: 'header', label: t('Header name (for “Other header”)'), placeholder: 'X-Spam-Flag', wide: true }, + { name: 'negate', label: t('Negate the condition'), type: 'checkbox' }, + { name: 'action', label: t('Then'), type: 'select', options: [['move', t('Move to folder')], ['copy', t('Copy to folder')], ['redirect', t('Redirect to')], ['redirect-copy', t('Send a copy to')], ['discard', t('Discard')], ['mark-read', t('Mark as read')], ['keep', t('Keep in inbox')]] }, + { name: 'target', label: t('Folder or address'), placeholder: 'Junk', wide: true }, + { name: 'stop', label: t('Stop processing further rules'), type: 'checkbox' }, + ], { condition: 'from', match: 'contains', action: 'move', target: 'Junk', stop: true }, async data => { + const rule = buildSieveRule(data); + const editor = $('#sieveSource', content); + const existing = editor.value.trim(); + editor.value = mergeSieveRequirements(`${existing}${existing ? '\n\n' : ''}${rule.body}\n`, rule.requirements); + return { refresh: false, message: 'Rule added – check and save the script' }; + })); + $('#check', content)?.addEventListener('click', async () => { + const check = await api.post('/sieve/check', { content: source() }); + showToast(check.valid ? (check.warning || t('The script is valid')) : (check.error || t('The script contains an error')), !check.valid); }); - document.querySelector('#saveSieve')?.addEventListener('click', async () => { - const contentValue = document.querySelector('#sieveSource').value; - const validation = await api('/sieve/check', { method: 'POST', body: JSON.stringify({ content: contentValue }) }); - if (!validation.valid) { showToast(validation.error || 'Skrypt zawiera błąd', true); return; } - const result = await api(`/sieve/scripts/${enc(selected)}`, { method: 'PUT', body: JSON.stringify({ content: contentValue }) }); - showToast(result.warning || 'Skrypt zapisany'); + $('#save', content)?.addEventListener('click', async () => { + const check = await api.post('/sieve/check', { content: source() }); + if (!check.valid) { showToast(check.error || t('The script contains an error'), true); return; } + const saved = await api.put(`/sieve/scripts/${enc(selected)}`, { content: source() }); + showToast(saved.warning || t('Script saved')); }); - document.querySelector('#activateSieve')?.addEventListener('click', async () => { - if (selected === activeName) return; - await api(`/sieve/scripts/${enc(selected)}/activate`, { method: 'POST' }); - showToast('Skrypt aktywowany'); - filters(); - }); - document.querySelector('#deactivateSieve')?.addEventListener('click', async () => { - await api('/sieve/deactivate', { method: 'POST' }); - showToast('Skrypty dezaktywowane'); - filters(); - }); - document.querySelector('#renameSieve')?.addEventListener('click', async () => { - const next = await promptModal('Zmień nazwę skryptu', 'Nowa nazwa', selected, { submitLabel: 'Zmień' }); + $('#activate', content)?.addEventListener('click', async () => { await api.post(`/sieve/scripts/${enc(selected)}/activate`); render(); }); + $('#deactivate', content)?.addEventListener('click', async () => { await api.post('/sieve/deactivate'); render(); }); + $('#rename', content)?.addEventListener('click', async () => { + const next = await promptModal(t('Rename script'), t('New name'), selected, { submitLabel: t('Rename') }); if (!next || next === selected) return; - await api(`/sieve/scripts/${enc(selected)}/rename`, { method: 'POST', body: JSON.stringify({ new_name: next }) }); + await api.post(`/sieve/scripts/${enc(selected)}/rename`, { new_name: next }); S.cache.sieveScript = next; - showToast('Nazwa zmieniona'); - filters(); + render(); }); - document.querySelector('#deleteSieve')?.addEventListener('click', async () => { - if (!await confirmModal('Usuń skrypt Sieve', `Usunąć skrypt ${selected}?`)) return; - await api(`/sieve/scripts/${enc(selected)}`, { method: 'DELETE' }); + $('#delete', content)?.addEventListener('click', async () => { + if (!await confirmModal(t('Delete script'), t('Delete the script {s}?', { s: selected }))) return; + await api.del(`/sieve/scripts/${enc(selected)}`); S.cache.sieveScript = ''; - showToast('Skrypt usunięty'); - filters(); + render(); }); } - async function dkim() { - if (S.me.kind === 'user') { - const data = await api('/dkim/records'); - const warningHtml = (data.warnings || []).map(message => `
${esc(t(message))}
`).join(''); - const rows = data.records || []; - const sourceLabel = source => source === 'rspamd' ? 'Rspamd' : 'PostfixAdmin'; - const body = rows.length - ? table( - ['Źródło', 'Domena', 'Selector', 'Rekord DNS', ''], - rows.map((row, index) => `${esc(sourceLabel(row.source))}${esc(row.domain)}${esc(row.selector)}${esc(row.record)}${action('Pokaż')}`) - ) - : '
Brak skonfigurowanego rekordu DKIM dla tej domeny.
'; - content().innerHTML = pageHead('DKIM', 'Rekordy DNS DKIM dla Twojej domeny — tylko do odczytu') + warningHtml + body; - rows.forEach((row, index) => { - document.querySelectorAll(`[data-user-dkim-row="${index}"] [data-act="Pokaż"]`).forEach(button => { - button.onclick = () => textModal(row.name, row.record); - }); - }); - return; - } - - const paEnabled = Boolean(S.features.dkim); - const rspamdEnabled = Boolean(S.runtime?.rspamd_dkim?.enabled); - const available = [paEnabled ? 'postfixadmin' : null, rspamdEnabled ? 'rspamd' : null].filter(Boolean); - if (!available.length) { - content().innerHTML = pageHead('DKIM', 'Zarządzanie kluczami DKIM') + '
DKIM is disabled.
'; - return; - } - const configuredDefault = S.runtime?.dkim?.default_backend || 'postfixadmin'; - const selected = available.includes(S.cache.dkimBackend) ? S.cache.dkimBackend : (available.includes(configuredDefault) ? configuredDefault : available[0]); - S.cache.dkimBackend = selected; - - let keys = []; - let signing = []; - let rspamd = null; - if (selected === 'postfixadmin') { - [keys, signing] = await Promise.all([api('/dkim/keys'), api('/dkim/signing')]); - } else { - rspamd = await api('/rspamd-dkim'); - } - - const backendTabs = `
${available.map(backend => { - const label = backend === 'postfixadmin' ? 'PostfixAdmin DKIM' : 'Rspamd DKIM'; - const iconName = backend === 'postfixadmin' ? 'shield-alert' : 'package'; - return ``; - }).join('')}
`; - - const headActions = selected === 'postfixadmin' - ? `` - : ``; - - let html = pageHead('DKIM', selected === 'postfixadmin' ? 'Klucze i mapowania przechowywane w bazie PostfixAdmin' : 'Klucze podpisujące hosta zarządzane przez Rspamd', headActions) + backendTabs; - - if (selected === 'postfixadmin') { - html += `
${icon('shield-alert')}PostfixAdmin DKIM
${esc(t('Konfigurowalny backend oparty o tabele DKIM PostfixAdmin.'))}
`; - html += table( - ['ID', 'Domena', 'Selector', 'Opis', 'Public key', ''], - keys.map((row, index) => `${row.id}${esc(row.domain_name)}${esc(row.selector)}${esc(row.description)}${esc((row.public_key || '').slice(0, 54))}${row.public_key?.length > 54 ? '…' : ''}${action('DNS')}${action('Edytuj')}${action('Usuń', true)}`) - ); - html += '
PostfixAdmin DKIM signing
' + table( - ['Autor', 'Domena klucza', 'Selector', 'Opis', ''], - signing.map((row, index) => `${esc(row.author)}${esc(row.domain_name)}${esc(row.selector)}${esc(row.description)}${action('Edytuj map.')}${action('Usuń map.', true)}`) - ); - } else if (rspamd) { - const statusBadge = rspamd.configured ? 'MAPY AKTYWNE' : 'NIESKONFIGUROWANE'; - const hostActions = S.me.role === 'global_admin' - ? `${rspamd.reload_available ? `` : ''}` - : ''; - const legacyWarning = rspamd.legacy_domains?.length - ? `
Legacy domain{}: ${esc(rspamd.legacy_domains.join(', '))}. Najpierw użyj importu, aby mapy mogły bezpiecznie sterować tymi domenami.
` - : ''; - html += `
${statusBadge}${esc(rspamd.signing_config)}
-

Klucze: ${esc(rspamd.key_dir)}
selector map: ${esc(rspamd.selector_map)}
path map: ${esc(rspamd.path_map)}

- ${legacyWarning}
${hostActions}
`; - html += table( - ['Domena', 'Selector', 'Private key', 'Plik', 'DNS', ''], - rspamd.entries.map((row, index) => `${esc(row.domain)}${esc(row.selector)}${esc(row.private_path)}${row.private_exists ? 'OK' : 'BRAK'}${row.dns_record ? 'TXT' : '—'}${row.dns_record ? action('DNS') : ''}${action('Odłącz')}${action('Usuń plik', true)}`) - ); - } - - content().innerHTML = html; - document.querySelectorAll('[data-dkim-backend]').forEach(button => { - button.onclick = () => { S.cache.dkimBackend = button.dataset.dkimBackend; dkim(); }; - }); - - if (selected === 'postfixadmin') { - document.querySelector('#addDkim').onclick = () => dkimKeyForm(); - document.querySelector('#addSigning').onclick = () => dkimSigningForm(null, keys); - bind(keys, { - DNS: row => { - const body = String(row.public_key || '').split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('-----BEGIN') && !line.startsWith('-----END')).join(''); - const value = /v=DKIM1/i.test(row.public_key || '') ? String(row.public_key).trim() : `v=DKIM1; k=rsa; p=${body}`; - textModal(`${row.selector}._domainkey.${row.domain_name}`, `${row.selector}._domainkey.${row.domain_name} IN TXT "${value.replaceAll('"', '\\"')}"`); - }, - Edytuj: row => dkimKeyForm(row), - Usuń: async row => { - if (await confirmModal('Usuń klucz DKIM', `Usunąć klucz ${row.domain_name}:${row.selector}?`)) { - await api(`/dkim/keys/${row.id}`, { method: 'DELETE' }); - dkim(); - } - }, - }); - signing.forEach((row, index) => { - document.querySelectorAll(`[data-sign-row="${index}"] [data-act="Edytuj map."]`).forEach(button => { button.onclick = () => dkimSigningForm(row, keys); }); - document.querySelectorAll(`[data-sign-row="${index}"] [data-act="Usuń map."]`).forEach(button => { - button.onclick = async () => { - if (!await confirmModal('Usuń mapowanie DKIM', `Usunąć mapowanie dla ${row.author}?`)) return; - await api(`/dkim/signing/${row.id}`, { method: 'DELETE' }); - dkim(); - }; - }); - }); - return; - } - - document.querySelector('#addRspamdDkim').onclick = () => rspamdDkimGenerateForm(rspamd); - document.querySelector('#adoptRspamdDkim').onclick = () => rspamdDkimAdoptForm(rspamd); - const configure = document.querySelector('#rspamdConfigure'); - if (configure) configure.onclick = async () => { - try { const result = await api('/rspamd-dkim/configure', { method: 'POST' }); showToast(result.reload === 'reload command not configured' ? 'Mapy skonfigurowane; wykonaj jednorazowy reload Rspamd.' : 'Mapy Rspamd skonfigurowane'); dkim(); } - catch (error) { showToast(error.message, true); } - }; - const importLegacy = document.querySelector('#rspamdImport'); - if (importLegacy) importLegacy.onclick = async () => { - if (!await confirmModal('Import Rspamd DKIM', 'Przenieść poprawne wpisy domain { path/selector } do zarządzanych map? Powstanie kopia konfiguracji, zaimportowane stare bloki zostaną usunięte, a przy błędzie configtest nastąpi rollback.')) return; - try { - const result = await api('/rspamd-dkim/import-legacy', { method: 'POST', body: JSON.stringify({ reload: true }) }); - const details = [t(`Zaimportowano: ${result.imported.length}`), t(`Pominięto: ${result.skipped.length}`), result.backup ? `Backup: ${result.backup}` : '', ...result.skipped].filter(Boolean).join('\n'); - textModal('Import DKIM', details, { copy: false }); - dkim(); - } catch (error) { showToast(error.message, true); } - }; - const test = document.querySelector('#rspamdTest'); - if (test) test.onclick = async () => { - try { const result = await api('/rspamd-dkim/test'); textModal('rspamadm configtest', result.output || 'OK', { copy: false }); } - catch (error) { showToast(error.message, true); } - }; - const reload = document.querySelector('#rspamdReload'); - if (reload) reload.onclick = async () => { - try { const result = await api('/rspamd-dkim/reload', { method: 'POST' }); showToast(result.reload || 'Rspamd przeładowany'); } - catch (error) { showToast(error.message, true); } - }; - rspamd.entries.forEach((row, index) => { - const scope = `[data-rspamd-row="${index}"]`; - document.querySelectorAll(`${scope} [data-act="DNS"]`).forEach(button => { button.onclick = () => textModal(`${row.selector}._domainkey.${row.domain}`, row.dns_record); }); - document.querySelectorAll(`${scope} [data-act="Odłącz"]`).forEach(button => { - button.onclick = async () => { - if (!await confirmModal('Odłącz DKIM', `Usunąć ${row.domain} z map Rspamd? Plik klucza zostanie na dysku.`)) return; - await api(`/rspamd-dkim/${enc(row.domain)}`, { method: 'DELETE' }); - showToast('Domena odłączona od map Rspamd'); - dkim(); - }; - }); - document.querySelectorAll(`${scope} [data-act="Usuń plik"]`).forEach(button => { - button.onclick = async () => { - if (!await confirmModal('Usuń klucz Rspamd', `Usunąć mapowanie i prywatny klucz ${row.domain}:${row.selector} z dysku?`)) return; - await api(`/rspamd-dkim/${enc(row.domain)}/purge`, { method: 'POST', body: JSON.stringify({ reload: true }) }); - showToast('Klucz Rspamd usunięty'); - dkim(); - }; - }); - }); + // ------------------------------------------------------------------ routes + const sieveOn = () => Boolean(S.runtime?.sieve?.enabled); + const dkimOn = () => F('dkim') || Boolean(S.runtime?.rspamd_dkim?.enabled); + const defs = [ + ['overview', 'Overview', 'Management', 'inbox', () => isAdmin(), overview], + ['domains', 'Domains', 'Management', 'folder', () => isAdmin(), domainsPage], + ['virtual', 'Virtual list', 'Management', 'mail', () => isAdmin(), virtualPage, () => href(['virtual', S.cache.lastDomain || ''])], + ['admins', 'Administrators', 'Management', 'user-round', () => P('manage_admins'), adminsPage], + ['fetchmail', 'Fetchmail', 'Services', 'download', () => isAdmin() && F('fetchmail'), fetchmailPage], + ['vacation', 'Auto-reply', 'Services', 'calendar-days', () => (isAdmin() ? P('edit_vacation') : P('edit_own_vacation')), vacationPage], + ['dkim', 'DKIM', 'Services', 'shield-alert', () => (isAdmin() ? dkimOn() : dkimOn() && Boolean(S.runtime?.dkim?.user_read_only)), dkimPage], + ['sendmail', 'Send mail', 'Services', 'send', () => P('sendmail'), mailPage], + ['logs', 'Log', 'Services', 'file-text', () => isAdmin() && F('logging'), logsPage], + ['security', 'Security', 'Account', 'settings', () => true, securityPage], + ['system', 'System', 'Account', 'package', () => isGlobal(), systemPage], + ]; + const userDefs = [ + ['overview', 'My mailbox', 'Mailbox', 'inbox', () => isUser(), overview], + ['forwarding', 'Forwarding', 'Mailbox', 'forward', () => isUser() && P('edit_own_forwarding'), forwardingPage], + ['filters', 'Filters', 'Mailbox', 'filter', () => isUser() && sieveOn(), filtersPage], + ]; + const routeTable = []; + for (const [name, label, group, iconName, visible, renderFn, hrefFn] of [...userDefs, ...defs]) { + routeTable.push({ name, label, group, icon: iconName, visible, render: renderFn, href: hrefFn }); } + routeTable.push({ name: 'search', visible: () => isAdmin(), render: searchPage }); + window.UI.setRoutes(routeTable); - function rspamdDkimGenerateForm(status) { - const domain = (S.me.domains || []).find(item => item !== 'ALL') || ''; - formModal('Nowy klucz DKIM Rspamd', [ - { name: 'domain', label: 'Domena', required: true }, - { name: 'selector', label: 'Selector', required: true }, - { name: 'bits', label: 'RSA bits', type: 'select', options: [['2048', '2048'], ['3072', '3072'], ['4096', '4096']] }, - { name: 'replace', label: 'Zastąp istniejące mapowanie', type: 'checkbox', help: 'Używaj dopiero po przygotowaniu nowego rekordu DNS.' }, - { name: 'reload', label: 'Reload po zapisie', type: 'checkbox', help: 'Uruchom skonfigurowane polecenie reload Rspamd.' }, - ], { domain, selector: status.default_selector || 'mail', bits: String(status.key_bits || 2048), replace: false, reload: true }, async data => { - const result = await api('/rspamd-dkim/generate', { method: 'POST', body: JSON.stringify({ ...data, bits: number(data.bits) }) }); - showToast('Klucz wygenerowany. Opublikuj rekord DNS.'); - setTimeout(() => textModal(`${result.selector}._domainkey.${result.domain}`, result.dns_record), 0); - }); - } - - function rspamdDkimAdoptForm(status) { - const domain = (S.me.domains || []).find(item => item !== 'ALL') || ''; - formModal('Podepnij istniejący klucz Rspamd', [ - { name: 'domain', label: 'Domena', required: true }, - { name: 'selector', label: 'Selector', required: true }, - { name: 'private_path', label: 'Ścieżka private key', required: true, wide: true, placeholder: `${status.key_dir}/example.org/mail.private` }, - { name: 'reload', label: 'Reload po zapisie', type: 'checkbox', help: 'Uruchom skonfigurowane polecenie reload Rspamd.' }, - ], { domain, selector: status.default_selector || 'mail', private_path: '', reload: true }, async data => { - await api('/rspamd-dkim/adopt', { method: 'POST', body: JSON.stringify(data) }); - }); - } - - function dkimKeyForm(row = {}) { - formModal(row.id ? 'Edytuj klucz DKIM' : 'Nowy klucz DKIM', [ - { name: 'domain_name', label: 'Domena', required: true }, - { name: 'selector', label: 'Selector', required: true }, - { name: 'description', label: 'Opis', wide: true }, - { name: 'private_key', label: row.id ? 'Nowy private key (puste = zachowaj)' : 'Private key (puste = wygeneruj)', type: 'textarea', wide: true }, - { name: 'public_key', label: row.id ? 'Nowy public key (puste = zachowaj)' : 'Public key (puste = wygeneruj)', type: 'textarea', wide: true }, - ], { domain_name: row.domain_name || '', selector: row.selector || 'default', description: row.description || '', private_key: '', public_key: row.id ? '' : '' }, async data => { - const body = { ...data, id: row.id || null, private_key: data.private_key || null, public_key: data.public_key || null }; - await api(row.id ? `/dkim/keys/${row.id}` : '/dkim/keys', { method: row.id ? 'PUT' : 'POST', body: JSON.stringify(body) }); - }); - } - - function dkimSigningForm(row, keys) { - if (!keys.length) { - showToast('Najpierw utwórz klucz DKIM.', true); - return; - } - formModal(row ? 'Edytuj mapowanie DKIM' : 'Nowe mapowanie DKIM', [ - { name: 'author', label: 'Autor (domena lub e-mail)', required: true }, - { name: 'dkim_id', label: 'Klucz', type: 'select', options: keys.map(key => [String(key.id), `${key.domain_name} / ${key.selector}`]) }, - ], { author: row?.author || '', dkim_id: String(row?.dkim_id || keys[0].id) }, async data => { - const body = { id: row?.id || null, author: data.author, dkim_id: number(data.dkim_id) }; - await api(row ? `/dkim/signing/${row.id}` : '/dkim/signing', { method: row ? 'PUT' : 'POST', body: JSON.stringify(body) }); - }); - } - - async function logs() { - const rows = await api('/logs?limit=500'); - content().innerHTML = pageHead('Logi', 'Audyt zmian zgodny z tabelą log PostfixAdmin') + table( - ['Czas', 'Użytkownik', 'Domena', 'Akcja', 'Dane'], - rows.map(row => `${esc(row.timestamp)}${esc(row.username)}${esc(row.domain)}${esc(row.action)}${esc(row.data)}`) - ); - } - - async function mail() { - const canBroadcast = S.me.role === 'global_admin' || S.features.sendmail_all_admins; - const domains = canBroadcast ? await api('/domains') : []; - content().innerHTML = pageHead('Wiadomości', 'Sendmail i broadcast przez skonfigurowany SMTP') + - `
-

Wyślij wiadomość

- - - -
-
- ${canBroadcast ? `

Broadcast

- - - - - -
-
` : ''} -
`; - document.querySelector('#sendForm').onsubmit = async event => { - event.preventDefault(); - const data = Object.fromEntries(new FormData(event.target)); - try { - await api('/mail/send', { method: 'POST', body: JSON.stringify(data) }); - showToast('Wysłano'); - } catch (error) { showToast(error.message, true); } - }; - const broadcast = document.querySelector('#broadcastForm'); - if (broadcast) broadcast.onsubmit = async event => { - event.preventDefault(); - const fd = new FormData(event.target); - const body = { - name: fd.get('name'), subject: fd.get('subject'), message: fd.get('message'), - domains: splitList(fd.get('domains')), mailboxes_only: fd.get('mailboxes_only') === 'on', - }; - try { - const result = await api('/mail/broadcast', { method: 'POST', body: JSON.stringify(body) }); - showToast(`Broadcast: wysłano ${result.sent}, błędy ${result.failed}`, result.failed > 0); - } catch (error) { showToast(error.message, true); } - }; - } - - async function security() { - const appPasswords = S.features.app_passwords ? await api('/security/app-passwords') : []; - const exceptions = S.features.totp ? await api('/security/totp-exceptions') : []; - const appPasswordSection = S.features.app_passwords ? `
Application passwords
${table(['ID', 'Konto', 'Opis', ''], appPasswords.map((row, index) => `${row.id}${esc(row.username)}${esc(row.description)}${action('Usuń app', true)}`))}` : ''; - const exceptionSection = S.features.totp ? `
Wyjątki TOTP
${table(['IP', 'Konto', 'Opis', ''], exceptions.map((row, index) => `${esc(row.ip)}${esc(row.username || '*')}${esc(row.description)}${action('Usuń wyjątek', true)}`))}` : ''; - content().innerHTML = pageHead('Bezpieczeństwo', 'Hasło, TOTP i application passwords') + - `
-

Zmiana hasła

- - - -
-
-

Dodatkowe zabezpieczenia

- ${S.features.totp ? '' : 'TOTP jest wyłączone w konfiguracji.'} - ${S.features.app_passwords && S.me.kind === 'user' ? '' : ''} -
-
` + exceptionSection + appPasswordSection; - - document.querySelector('#passwordForm').onsubmit = async event => { - event.preventDefault(); - const form = new FormData(event.target); - const current = String(form.get('current_password') || ''); - const next = String(form.get('new_password') || ''); - const confirm = String(form.get('confirm_password') || ''); - if (next !== confirm) { showToast('Nowe hasła nie są identyczne', true); return; } - const submit = event.target.querySelector('button[type="submit"]'); - submit.disabled = true; - try { - await api('/security/password', { method: 'POST', body: JSON.stringify({ current_password: current, new_password: next }) }); - event.target.reset(); - showToast('Hasło zostało zmienione'); - } catch (error) { showToast(error.message, true); } - finally { submit.disabled = false; } - }; - - const totp = document.querySelector('#totpBtn'); - if (totp) totp.onclick = async () => { - try { - const setup = await api('/security/totp/setup', { method: 'POST' }); - formModal('Potwierdź TOTP', [ - { name: 'secret_show', label: 'Sekret', wide: true, readonly: true }, - { name: 'uri_show', label: 'URI dla aplikacji', wide: true, readonly: true }, - { name: 'code', label: 'Kod 6-cyfrowy', required: true, autocomplete: 'one-time-code' }, - { name: 'current_password', label: 'Obecne hasło', type: 'password', required: true }, - ], { secret_show: setup.secret, uri_show: setup.otpauth_uri }, async data => { - await api('/security/totp/confirm', { method: 'POST', body: JSON.stringify({ secret: setup.secret, code: data.code, current_password: data.current_password }) }); - }); - } catch (error) { showToast(error.message, true); } - }; - - const totpOff = document.querySelector('#totpOffBtn'); - if (totpOff) totpOff.onclick = () => formModal('Wyłącz TOTP', [ - { name: 'current_password', label: 'Obecne hasło', type: 'password', required: true }, - ], {}, async data => { await api('/security/totp', { method: 'DELETE', body: JSON.stringify(data) }); }); - - const addException = document.querySelector('#totpExceptionBtn'); - if (addException) addException.onclick = () => formModal('Wyjątek TOTP', [ - { name: 'ip', label: 'Adres IP', required: true }, - ...(S.me.role === 'global_admin' ? [{ name: 'username', label: 'Konto (opcjonalnie)' }] : []), - { name: 'description', label: 'Opis', wide: true }, - ], {}, async data => { - await api('/security/totp-exceptions', { method: 'POST', body: JSON.stringify({ ip: data.ip, username: data.username || null, description: data.description }) }); - }); - - const addApp = document.querySelector('#appPasswordBtn'); - if (addApp) addApp.onclick = () => formModal('Application password', [ - { name: 'description', label: 'Opis', required: true }, - { name: 'password', label: 'Nowe hasło aplikacyjne', type: 'password', required: true }, - { name: 'current_password', label: 'Obecne hasło konta', type: 'password', required: true }, - ], {}, async data => { await api('/security/app-passwords', { method: 'POST', body: JSON.stringify(data) }); }); - - exceptions.forEach((row, index) => { - document.querySelectorAll(`[data-ex-row="${index}"] [data-act="Usuń wyjątek"]`).forEach(button => { - button.onclick = async () => { if (!await confirmModal('Usuń wyjątek TOTP', `Usunąć wyjątek ${row.ip}?`)) return; await api(`/security/totp-exceptions/${row.id}`, { method: 'DELETE' }); security(); }; - }); - }); - appPasswords.forEach((row, index) => { - document.querySelectorAll(`[data-app-row="${index}"] [data-act="Usuń app"]`).forEach(button => { - button.onclick = async () => { if (!await confirmModal('Usuń application password', `Usunąć application password: ${row.description}?`)) return; await api(`/security/app-passwords/${row.id}`, { method: 'DELETE' }); security(); }; - }); - }); - } - - async function system() { - const [schema, runtime] = await Promise.all([api('/system/schema'), api('/system/features')]); - content().innerHTML = pageHead('System', 'Zgodność schematu i konfiguracja runtime', S.features.backup ? `${icon('download')}Backup DB` : '') + - `
-
Schema
${schema.ok ? 'OK' : 'UWAGI'}
-
Backend
${esc(schema.database_backend)}
-
Tryb DB
${runtime.development_mock ? 'DEV MOCK' : 'LIVE'}
-
Hasła
${esc(runtime.password.configured)}
-
Wersja
${esc(runtime.version)}
-
-
Ostrzeżenia schematu
${schema.warnings.length ? `
${esc(schema.warnings.join('\n'))}
` : '
Brak ostrzeżeń.
'}
-
-
Kompatybilne schematy haseł
${esc(runtime.password.supported.join('\n'))}
`; - } - - window.PAGES = { overview, domains, domain: domainWorkspace, mailboxes, aliases, admins, fetchmail, vacation, filters, dkim, logs, mail, security, system }; + // keep a reference for debugging in the console + window.PAGES = { overview, domainsPage, virtualPage, searchPage }; })(); diff --git a/src/web/js/90-boot.js b/src/web/js/90-boot.js index 693091e..512cdff 100644 --- a/src/web/js/90-boot.js +++ b/src/web/js/90-boot.js @@ -1 +1,14 @@ -(()=>{'use strict';window.PA_RS={version:document.querySelector('meta[name="pa-rs-version"]')?.content||''};const saved=localStorage.getItem('pa_theme');if(saved)document.documentElement.dataset.theme=saved;async function boot(){await window.UI.initLanguage();try{await window.UI.bootAuthed()}catch{window.UI.loginView()}}boot()})(); +(() => { + 'use strict'; + window.PA_RS = { version: document.querySelector('meta[name="pa-rs-version"]')?.content || '' }; + try { + const saved = localStorage.getItem('pa_theme'); + if (saved) document.documentElement.dataset.theme = saved; + else if (window.matchMedia?.('(prefers-color-scheme: light)').matches) document.documentElement.dataset.theme = 'light'; + } catch { /* storage unavailable */ } + async function boot() { + await window.UI.initLanguage(); + try { await window.UI.bootAuthed(); } catch { window.UI.loginView(); } + } + boot(); +})();