This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 08:55:53 +01:00
1 parent cdca612a59
commit eb25f8de4a
56 files changed
+7848 -4345

No files matched your search

+3
View File
@@ -6,3 +6,6 @@
.dev
__pycache__
*.zip
.vendor
.cargo
.xfer
+38
View File
@@ -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`.
+15 -71
View File
@@ -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`) |
Generated
+1 -1
View File
@@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postfixadmin-rs"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"argon2",
"async-native-tls",
+1 -1
View File
@@ -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"
+43 -71
View File
@@ -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 |
+120 -173
View File
@@ -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).
+22 -87
View File
@@ -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 <progress>, 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.
+5
View File
@@ -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
+24 -10
View File
@@ -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.
+26 -2
View File
@@ -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
+5 -1
View File
@@ -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
+1
View File
@@ -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.
Binary file not shown.
@@ -0,0 +1 @@
8da3efee31740887838f1a4304cfb23d31bdd0de603f3140f1357c0fcfab2d68 /packages/postfixadmin-rs_0.1.0_amd64.deb
+56 -6
View File
@@ -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;
+20 -2
View File
@@ -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
+160
View File
@@ -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()
+34
View File
@@ -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)
+593
View File
@@ -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"),
]
+17 -13
View File
@@ -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:
+39
View File
@@ -28,6 +28,7 @@ pub struct Session {
pub csrf: String,
mail_password: Option<Arc<str>>,
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<Session> {
let item = self.sessions.get(token)?;
if Instant::now() >= item.expires_at {
+65 -2
View File
@@ -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<String>),
}
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<String>,
#[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<i64>,
#[serde(default = "default_alias_map")]
pub default_aliases: BTreeMap<String, String>,
#[serde(default = "default_welcome")]
@@ -585,6 +607,9 @@ pub struct DefaultsConfig {
#[serde(default)]
pub motd_superadmin: String,
}
fn default_vacation_choices() -> Vec<i64> {
vec![0, 604_800]
}
fn default_transport_options() -> Vec<String> {
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<String>,
/// 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)
}
}
+86 -264
View File
@@ -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<Vec<String>> {
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<String> = 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::<String, _>(name) {
return value;
+284
View File
@@ -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<Option<Self>> {
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> {
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<i64> {
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());
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ fn transport(config: &AppConfig) -> AppResult<AsyncSmtpTransport<Tokio1Executor>
.map_err(|e| AppError::Mail(e.to_string()))?,
_ => AsyncSmtpTransport::<Tokio1Executor>::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()));
+235 -5
View File
@@ -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<dyn std::error::Error>> {
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<String> = 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::<Arc<AppState>>::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 == "/" {
+73 -15
View File
@@ -48,10 +48,16 @@ pub struct DomainSummary {
pub dns_checked: Option<String>,
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<i64>,
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<T> {
pub items: Vec<T>,
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<String>,
/// Free-text search.
pub q: Option<String>,
/// 1-based page number.
pub page: Option<usize>,
/// Page size; `0` returns everything.
pub page_size: Option<usize>,
/// Optional status filter (aliases): active | inactive | forward | catchall
pub status: Option<String>,
}
impl ListQuery {
/// Returns `(limit, offset, page, page_size)`; `limit == None` = unlimited.
pub fn paging(&self, default_size: usize) -> (Option<i64>, 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<String> {
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<String>,
pub modified: Option<String>,
/// 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<String>,
/// 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<String>,
pub local_part: Option<String>,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub name: String,
pub name: Option<String>,
pub password: Option<String>,
/// Quota in MB.
pub quota: Option<i64>,
#[serde(default = "default_true")]
pub active: bool,
pub active: Option<bool>,
pub smtp_active: Option<bool>,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub email_other: String,
pub phone: Option<String>,
pub email_other: Option<String>,
#[serde(default)]
pub send_welcome: bool,
}
@@ -118,20 +174,21 @@ pub struct AliasSummary {
pub is_mailbox: bool,
pub technical: bool,
pub status: Vec<String>,
pub catch_all: bool,
pub can_edit: bool,
pub can_delete: bool,
pub modified: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AliasInput {
pub address: Option<String>,
pub local_part: Option<String>,
#[serde(default)]
pub domain: String,
pub goto: Vec<String>,
#[serde(default)]
pub description: String,
#[serde(default = "default_true")]
pub active: bool,
#[serde(default)]
pub store_and_forward: bool,
pub goto: Option<Vec<String>>,
pub description: Option<String>,
pub active: Option<bool>,
#[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<String>,
}
#[derive(Debug, Serialize)]
+161
View File
@@ -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::<u8>().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<IpAddr> = xff
.split(',')
.filter_map(|part| part.trim().parse::<IpAddr>().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::<IpAddr>().ok())
{
return normalize(real);
}
peer
}
/// Extractor yielding the effective client IP.
pub struct ClientIp(pub IpAddr);
impl FromRequestParts<Arc<AppState>> for ClientIp {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let peer = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.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::<IpAddr>().unwrap()
);
assert_eq!(
resolve_client_ip(&proxies, "198.51.100.1".parse().unwrap(), &headers),
"198.51.100.1".parse::<IpAddr>().unwrap()
);
assert_eq!(
resolve_client_ip(&[], "127.0.0.1".parse().unwrap(), &headers),
"127.0.0.1".parse::<IpAddr>().unwrap()
);
}
}
+28
View File
@@ -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<char> = 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 {
+103 -25
View File
@@ -20,7 +20,7 @@ pub async fn list(
State(state): State<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<AdminSummary>>> {
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::<Vec<_>>();
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<AdminInput>,
) -> AppResult<Json<serde_json::Value>> {
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<AdminInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<String> = 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<String> = 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<Arc<AppState>>,
Path(path_user): Path<String>,
jar: CookieJar,
headers: HeaderMap,
Json(input): Json<ActiveInput>,
) -> AppResult<Json<serde_json::Value>> {
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})))
}
+1019 -765
View File
File diff suppressed because it is too large. Load diff
+69 -28
View File
@@ -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<AccountKind>,
}
fn admin_kind() -> AccountKind {
AccountKind::Admin
fn deserialize_kind<'de, D>(deserializer: D) -> Result<Option<AccountKind>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: Option<String> = 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<String>,
language: String,
totp_enabled: bool,
}
pub async fn login(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
ClientIp(client_ip): ClientIp,
jar: CookieJar,
Json(input): Json<LoginInput>,
) -> AppResult<Response> {
@@ -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<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
ClientIp(client_ip): ClientIp,
jar: CookieJar,
Json(input): Json<MfaInput>,
) -> AppResult<Response> {
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<Arc<AppState>>, jar: CookieJar) -> Respo
}
pub async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Json<MeResponse>> {
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<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
ClientIp(client_ip): ClientIp,
Json(input): Json<RecoveryRequestInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
ClientIp(client_ip): ClientIp,
Json(input): Json<RecoveryResetInput>,
) -> AppResult<Json<serde_json::Value>> {
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
+1 -1
View File
@@ -15,7 +15,7 @@ use std::{process::Stdio, sync::Arc};
use tokio::process::Command;
pub async fn download(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Response> {
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"));
+9 -9
View File
@@ -67,7 +67,7 @@ pub async fn dns_records(
State(state): State<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<DkimDnsResponse>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<DkimKeySummary>>> {
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<DkimKeyInput>,
) -> AppResult<Json<serde_json::Value>> {
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<DkimKeyInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<serde_json::Value>>> {
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<DkimSigningInput>,
) -> AppResult<Json<serde_json::Value>> {
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<DkimSigningInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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
+444 -172
View File
@@ -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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<DomainSummary>>> {
let session = routes::session(&state, &jar)?;
let session = routes::session(&state, &jar).await?;
session.require_admin()?;
let domains: Option<Vec<String>> = 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<Arc<AppState>>,
Path(domain_path): Path<String>,
jar: CookieJar,
) -> AppResult<Json<DomainSummary>> {
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<Vec<DomainSummary>> {
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<i64> {
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<DomainValues> {
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<DomainInput>,
) -> AppResult<Json<DomainSummary>> {
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<DomainInput>,
) -> AppResult<Json<serde_json::Value>> {
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(&current))?;
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<Arc<AppState>>,
Path(domain_path): Path<String>,
jar: CookieJar,
headers: HeaderMap,
Json(input): Json<ActiveInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Arc<AppState>>,
Path(domain_path): Path<String>,
jar: CookieJar,
headers: HeaderMap,
) -> AppResult<Json<serde_json::Value>> {
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<Json<DnsRefreshResult>> {
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,
+21 -6
View File
@@ -52,7 +52,7 @@ pub async fn list(
State(state): State<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<FetchmailSummary>>> {
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<FetchmailInput>,
) -> AppResult<Json<serde_json::Value>> {
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<FetchmailInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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");
}
}
+76 -57
View File
@@ -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<String>,
pub limit: Option<usize>,
}
pub async fn list(
State(state): State<Arc<AppState>>,
jar: CookieJar,
Query(f): Query<Filter>,
) -> AppResult<Json<Vec<LogEntry>>> {
let s = routes::session(&state, &jar)?;
s.require_admin()?;
if !state.config.features.logging {
return Ok(Json(Vec::new()));
Query(query): Query<ListQuery>,
) -> AppResult<Json<Page<LogEntry>>> {
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<String> = 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"),
+2 -2
View File
@@ -19,7 +19,7 @@ pub async fn send(
headers: HeaderMap,
Json(input): Json<SendMailInput>,
) -> AppResult<Json<serde_json::Value>> {
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<BroadcastInput>,
) -> AppResult<Json<serde_json::Value>> {
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"));
+777 -398
View File
File diff suppressed because it is too large. Load diff
+76 -9
View File
@@ -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<Arc<AppState>> {
"/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<Arc<AppState>> {
"/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<Arc<AppState>> {
"/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<Arc<AppState>> {
router.layer(DefaultBodyLimit::max(limit))
}
pub fn session(state: &AppState, jar: &CookieJar) -> AppResult<Session> {
pub async fn session(state: &AppState, jar: &CookieJar) -> AppResult<Session> {
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<Session> {
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<String> {
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<Session> {
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()),
+3 -3
View File
@@ -30,7 +30,7 @@ pub async fn list(
State(state): State<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<Preference>>> {
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<PreferenceInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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 {
+30 -16
View File
@@ -183,7 +183,7 @@ pub async fn list(
State(state): State<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<RspamdDkimStatus>> {
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<Json<serde_json::Value>> {
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<GenerateInput>,
) -> AppResult<Json<serde_json::Value>> {
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<AdoptInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<ActionInput>,
) -> AppResult<Json<serde_json::Value>> {
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<ActionInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<String> {
)
.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,
+28 -10
View File
@@ -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<Arc<AppState>>,
ClientIp(client_ip): ClientIp,
jar: CookieJar,
headers: HeaderMap,
Json(input): Json<PasswordChangeInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<TotpConfirm>,
) -> AppResult<Json<serde_json::Value>> {
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<TotpDisable>,
) -> AppResult<Json<serde_json::Value>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<TotpExceptionSummary>>> {
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<TotpExceptionInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<Vec<AppPasswordSummary>>> {
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<AppPasswordInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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=?"))
+11 -11
View File
@@ -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<crate::auth::Session> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<StatusResponse>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<ScriptsResponse>> {
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<String>,
jar: CookieJar,
) -> AppResult<Json<ScriptResponse>> {
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<ScriptBody>,
) -> AppResult<Json<ValidationResponse>> {
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<ScriptBody>,
) -> AppResult<Json<ActionResponse>> {
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<Json<ActionResponse>> {
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<Json<ActionResponse>> {
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<Json<ActionResponse>> {
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<RenameBody>,
) -> AppResult<Json<ActionResponse>> {
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)
+103 -64
View File
@@ -24,19 +24,98 @@ pub async fn overview(
State(state): State<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<serde_json::Value>> {
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<Vec<String>> = 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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<serde_json::Value>> {
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<Arc<AppState>>,
jar: CookieJar,
) -> AppResult<Json<serde_json::Value>> {
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::<Vec<_>>(),"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<i64> {
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<i64> {
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"))
}
+75 -24
View File
@@ -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<String>,
jar: CookieJar,
) -> AppResult<Json<Option<VacationSummary>>> {
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<VacationInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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<NaiveDateTime> {
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"
);
}
}
+676
View File
@@ -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 '<value>'
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<Col>,
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<TableDef> {
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<String>,
pub added_columns: Vec<String>,
pub created_indexes: Vec<String>,
pub warnings: Vec<String>,
pub version: Option<i64>,
}
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<SchemaReport> {
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<String> = def
.columns
.iter()
.map(|col| column_ddl(dialect, col, &quote))
.collect();
if !def.primary.is_empty() {
parts.push(format!(
"PRIMARY KEY ({})",
def.primary
.iter()
.map(|c| quote(c))
.collect::<Vec<_>>()
.join(",")
));
}
for (name, cols) in def.unique {
let cols = cols.iter().map(|c| quote(c)).collect::<Vec<_>>().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, &quote)
);
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::<Vec<_>>().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<bool> {
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<Option<i64>> {
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::<i64>().ok()))
}
async fn set_version(db: &Db) -> AppResult<i64> {
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<Vec<(String, i64)>> {
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)
}
+26 -2
View File
@@ -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}
}
+83
View File
@@ -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}
+1
View File
@@ -1,4 +1,5 @@
00-tokens.css
10-base.css
20-app.css
40-views.css
30-responsive.css
+3 -434
View File
@@ -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": []
}
+640 -58
View File
@@ -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"
}
]
}
+407 -326
View File
@@ -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 => `<svg class="icon" aria-hidden="true"><use href="assets/icons.${ASSET_ID}.svg#icon-${name}"></use></svg>`;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[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]) => `<option value="${value}" ${value === S.language ? 'selected' : ''}>${label}</option>`)
.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]) => `<option value="${value}" ${value === S.language ? 'selected' : ''}>${label}</option>`)
.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 = `<div class="empty">${te('Loading…')}</div>`;
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 = `<div class="panel"><div class="empty">${esc(t(error.message))}</div></div>`;
}
}
// ---------------------------------------------------------------- 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'
? `<form class="search" id="globalSearchForm" role="search"><span>${icon('search')}</span><input id="globalSearch" type="search" placeholder="${te('Search mailboxes and aliases…')}" value="${esc(S.route.query?.q || '')}"></form>`
: '<div></div>';
app.innerHTML = `<div class="shell">
<header class="topbar">
<div id="routeProgress" class="route-progress" hidden><span></span></div>
<div class="brand">${icon('mail')}<span>PostfixAdmin-RS</span></div>
<div class="search"><span>${icon('search')}</span><input id="globalSearch" placeholder="Filtruj bieżący widok…"></div>
<div class="brand"><button class="btn ghost nav-toggle" id="navToggle" aria-label="${te('Menu')}">${icon('menu')}</button>${icon('mail')}<span>PostfixAdmin-RS</span></div>
${searchBox}
<div class="top-actions">
${S.runtime?.database_read_only ? '<span class="badge off" title="Połączenie z bazą tylko do odczytu">READ ONLY</span>' : ''}
<select id="languageSelect" class="compact-select language-select" title="Język" aria-label="Język">${languageOptions()}</select>
<button class="btn theme-toggle" id="themeBtn" title="Zmień motyw" aria-label="Zmień motyw"></button>
<div class="who"><strong>${esc(S.me.username)}</strong><span>${esc(role)}</span></div>
<button class="btn ghost" id="logoutBtn" title="Wyloguj">${icon('log-out')}</button>
${S.runtime?.database_read_only ? `<span class="badge off" title="${te('Database connection is read-only')}">READ ONLY</span>` : ''}
<select id="languageSelect" class="compact-select language-select" aria-label="${te('Language')}">${languageOptions()}</select>
<button class="btn theme-toggle" id="themeBtn"></button>
<div class="who"><strong>${esc(S.me.username)}</strong><span>${esc(roleLabel())}</span></div>
<button class="btn ghost" id="logoutBtn" title="${te('Sign out')}" aria-label="${te('Sign out')}">${icon('log-out')}</button>
</div>
</header>
<div class="body">
<aside class="sidebar"><nav class="nav" id="nav"></nav></aside>
<main class="content" id="content"></main>
<main class="content" id="content" tabindex="-1"></main>
</div>
</div>`;
$('#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')}<span class="theme-fallback" aria-hidden="true">${current === 'light' ? '☾' : '☀'}</span>`;
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', `<div class="nav-section">${nextGroup}</div>`);
group = nextGroup;
for (const def of ROUTES) {
if (!def.label || !def.visible()) continue;
if (def.group !== group) {
nav.insertAdjacentHTML('beforeend', `<div class="nav-section">${te(def.group)}</div>`);
group = def.group;
}
const button = h('button', { 'data-page': page, onclick: () => go(page) });
button.innerHTML = `${icon(iconName)}<span>${label}</span>`;
if (page === S.page) button.classList.add('active');
nav.append(button);
nav.insertAdjacentHTML('beforeend', `<a href="${def.href ? def.href() : href(def.name)}" data-route="${def.name}">${icon(def.icon)}<span>${te(def.label)}</span></a>`);
}
}
async function go(page) {
S.page = page;
$$('#nav button').forEach(button => button.classList.toggle('active', button.dataset.page === page));
const content = $('#content');
content.innerHTML = '<div class="empty">Ładowanie…</div>';
try {
await window.PAGES[page]();
translateDom(content);
} catch (error) {
content.innerHTML = `<div class="panel"><div class="empty">${esc(translateText(error.message))}</div></div>`;
showToast(error.message, true);
function pageHead(title, description = '', buttons = '') {
return `<div class="page-head"><div><h1>${esc(title)}</h1>${description ? `<p>${esc(description)}</p>` : ''}</div><div class="actions">${buttons}</div></div>`;
}
function table(headers, rows, options = {}) {
const body = rows.length ? rows.join('') : `<tr><td colspan="${headers.length}" class="empty-cell">${esc(options.empty || t('Nothing to show.'))}</td></tr>`;
return `<div class="panel ${options.class || ''}">${options.head ? `<div class="panel-head">${options.head}</div>` : ''}<div class="table-wrap"><table><thead><tr>${headers.map(header => `<th>${esc(header)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></div>${options.foot || ''}</div>`;
}
/** 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 `<span class="badge ${active ? 'ok' : 'off'}">${esc(label)}</span>`;
return `<button class="badge toggle ${active ? 'ok' : 'off'}" ${attrs} title="${te(active ? 'Click to deactivate' : 'Click to activate')}">${esc(label)}</button>`;
}
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) =>
`<button class="btn mini ${current ? 'primary' : ''}" ${attr}="${p}" ${disabled ? 'disabled' : ''}>${label}</button>`;
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('<span class="muted">…</span>');
numbers.push(item(p, String(p), false, p === page));
last = p;
}
return `<div class="pager"><span class="muted">${te('{from}–{to} of {total}', { from: (page - 1) * pageSize + 1, to: Math.min(total, page * pageSize), total })}</span><div class="actions">${item(page - 1, '‹', page <= 1)}${numbers.join('')}${item(page + 1, '›', page >= pages)}</div></div>`;
}
function pageHead(title, description, buttons = '') {
return `<div class="page-head"><div><h1>${esc(translateText(title))}</h1><p>${esc(translateText(description))}</p></div><div class="actions">${buttons}</div></div>`;
}
function table(headers, rows) {
return `<div class="panel"><div class="table-wrap"><table><thead><tr>${headers.map(header => `<th>${esc(translateText(header))}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div></div>`;
}
// ---------------------------------------------------------------- modals
function closeModal() {
if (modal.open) modal.close();
modal.innerHTML = '';
}
function formModal(title, fields, values, onSubmit) {
modal.innerHTML = '';
const form = h('form');
form.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-close>${icon('x')}</button></div><div class="modal-body"></div><div class="modal-foot"><button class="btn" type="button" data-close>Anuluj</button><button class="btn primary" type="submit">Zapisz</button></div>`;
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 = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-close aria-label="${te('Close')}">${icon('x')}</button></div>${options.intro ? `<div class="modal-intro">${options.intro}</div>` : ''}<div class="modal-body"></div><div class="modal-error" hidden></div><div class="modal-foot"><button class="btn" type="button" data-close>${te('Cancel')}</button><button class="btn primary" type="submit">${esc(options.submitLabel || t('Save'))}</button></div>`;
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 = `<span>${esc(field.label)}</span>`;
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 = `<span class="field-label">${esc(field.label)}</span><div class="static-value">${field.html ?? esc(value ?? '')}</div>`;
body.append(wrapper);
continue;
}
if (field.type === 'checkbox') {
wrapper.innerHTML = `<label class="checkbox-row"><input type="checkbox" id="${id}" name="${field.name}" ${value ? 'checked' : ''} ${field.readonly ? 'disabled' : ''}><span>${esc(field.label)}</span></label>${field.help ? `<small class="field-help">${esc(field.help)}</small>` : ''}`;
body.append(wrapper);
continue;
}
if (field.type === 'checklist') {
const selected = new Set(value || []);
wrapper.classList.add('wide');
wrapper.innerHTML = `<span class="field-label">${esc(field.label)}</span><div class="checklist">${(field.options || []).map(([optValue, optLabel]) => `<label class="checkbox-row"><input type="checkbox" name="${field.name}" value="${esc(optValue)}" ${selected.has(optValue) ? 'checked' : ''}><span>${esc(optLabel)}</span></label>`).join('') || `<span class="muted">${te('Nothing to select.')}</span>`}</div>${field.help ? `<small class="field-help">${esc(field.help)}</small>` : ''}`;
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')}<span>${translateText('Generuj')}</span>`;
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')}<span>${te('Generate')}</span>`;
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 = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-close>${icon('x')}</button></div>
<div class="modal-body modal-body-single"><div class="codebox dns-record">${esc(text || '')}</div></div>
<div class="modal-foot">${options.copy === false ? '' : '<button class="btn" type="button" data-copy>Skopiuj</button>'}<button class="btn primary" type="button" data-close>Zamknij</button></div>`;
wrapper.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-close aria-label="${te('Close')}">${icon('x')}</button></div>
<div class="modal-body modal-body-single">${options.note ? `<p class="muted">${esc(options.note)}</p>` : ''}<div class="codebox">${esc(text || '')}</div></div>
<div class="modal-foot">${options.copy === false ? '' : `<button class="btn" type="button" data-copy>${te('Copy')}</button>`}<button class="btn primary" type="button" data-close>${te('Close')}</button></div>`;
$$('[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 = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
form.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-cancel aria-label="${te('Close')}">${icon('x')}</button></div>
<div class="modal-body modal-body-single"><div class="modal-message">${esc(message)}</div></div>
<div class="modal-foot"><button class="btn" type="button" data-cancel>Anuluj</button><button class="btn ${dangerClass}" type="submit">${esc(confirmLabel)}</button></div>`;
<div class="modal-foot"><button class="btn" type="button" data-cancel>${te('Cancel')}</button><button class="btn ${dangerClass}" type="submit">${esc(options.confirmLabel || t('Delete'))}</button></div>`;
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 = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
<div class="modal-body modal-body-single"><label class="field"><span>${esc(label)}</span><input name="value" type="${esc(options.type || 'text')}" autocomplete="${esc(options.autocomplete || 'off')}" ${options.required === false ? '' : 'required'}></label>${options.help ? `<div class="muted modal-help">${esc(options.help)}</div>` : ''}</div>
<div class="modal-foot"><button class="btn" type="button" data-cancel>Anuluj</button><button class="btn primary" type="submit">${esc(options.submitLabel || 'Dalej')}</button></div>`;
form.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-cancel aria-label="${te('Close')}">${icon('x')}</button></div>
<div class="modal-body modal-body-single"><label class="field"><span class="field-label">${esc(label)}</span><input name="value" type="${esc(options.type || 'text')}" autocomplete="${esc(options.autocomplete || 'off')}" inputmode="${esc(options.inputmode || 'text')}" required></label>${options.help ? `<div class="muted modal-help">${esc(options.help)}</div>` : ''}</div>
<div class="modal-foot"><button class="btn" type="button" data-cancel>${te('Cancel')}</button><button class="btn primary" type="submit">${esc(options.submitLabel || t('Continue'))}</button></div>`;
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: `<p class="muted">${te('A one-time code will be sent to the alternative e-mail address stored for the account.')}</p>` });
}
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 = `<div class="login-view"><section class="login-panel">
<div class="login-brand">${icon('mail')}<div><strong>PostfixAdmin-RS</strong><small>Panel administracyjny poczty</small></div></div>
<div class="login-brand">${icon('mail')}<div><strong>PostfixAdmin-RS</strong><small>${te('Mail server administration')}</small></div></div>
<form class="login-form" id="loginForm">
<label class="field"><span>Login</span><input name="username" type="email" required autofocus autocomplete="username"></label>
<label class="field"><span>Hasło</span><input name="password" type="password" required autocomplete="current-password"></label>
<label class="field"><span>Typ konta</span><select name="kind"><option value="admin">Administrator</option><option value="user">Użytkownik skrzynki</option></select></label>
<button class="btn primary" type="submit">Zaloguj</button>
<div class="actions"><button class="btn ghost" type="button" id="recoverBtn">Odzyskaj hasło</button><button class="btn ghost" type="button" id="resetBtn">Mam kod</button></div>
<div id="loginErr" class="muted"></div>
${notice ? `<div class="notice">${esc(notice)}</div>` : ''}
<label class="field"><span class="field-label">${te('E-mail address')}</span><input name="username" type="email" required autofocus autocomplete="username"></label>
<label class="field"><span class="field-label">${te('Password')}</span><input name="password" type="password" required autocomplete="current-password"></label>
<details class="login-advanced"><summary>${te('Account type')}</summary>
<select name="kind"><option value="auto">${te('Automatic')}</option><option value="admin">${te('Administrator')}</option><option value="user">${te('Mailbox user')}</option></select>
</details>
<button class="btn primary" type="submit">${te('Sign in')}</button>
<div id="loginErr" class="login-error" role="alert"></div>
${runtimeRecovery ? `<div class="login-links"><button class="btn ghost" type="button" id="recoverBtn">${te('Forgot password?')}</button><button class="btn ghost" type="button" id="resetBtn">${te('I have a code')}</button></div>` : ''}
</form>
<div class="login-footer"><label class="login-language"><span>Język</span><select id="loginLanguage" class="compact-select">${languageOptions()}</select></label><span>PostfixAdmin-RS ${esc(window.PA_RS?.version || '')}</span></div>
<div class="login-footer"><label class="login-language"><span>${te('Language')}</span><select id="loginLanguage" class="compact-select">${languageOptions()}</select></label><span>v${esc(window.PA_RS?.version || '')}</span></div>
</section></div>`;
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,
};
})();
+993 -1199
View File
File diff suppressed because it is too large. Load diff
+14 -1
View File
@@ -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();
})();