v0.1.0
This commit is contained in:
1 parent
cdca612a59
commit
eb25f8de4a
56 files changed
+7848
-4345
No files matched your search
@@ -6,3 +6,6 @@
|
||||
.dev
|
||||
__pycache__
|
||||
*.zip
|
||||
.vendor
|
||||
.cargo
|
||||
.xfer
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
Executable
+160
@@ -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()
|
||||
Executable
+34
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+69
-28
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¤t))?;
|
||||
let table = state.db.table(Table::Domain);
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let sql = if has_expiry {
|
||||
state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,password_expiry=?,modified=CURRENT_TIMESTAMP WHERE domain=?"))
|
||||
} else {
|
||||
state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,modified=CURRENT_TIMESTAMP WHERE domain=?"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.description)
|
||||
.bind(input.aliases.unwrap_or(state.config.defaults.aliases))
|
||||
.bind(input.mailboxes.unwrap_or(state.config.defaults.mailboxes))
|
||||
.bind(input.maxquota.unwrap_or(state.config.defaults.maxquota_mb))
|
||||
.bind(input.quota.unwrap_or(state.config.defaults.domain_quota_mb))
|
||||
.bind(if state.config.features.transport {
|
||||
input
|
||||
.transport
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.defaults.transport_default)
|
||||
} else {
|
||||
&state.config.defaults.transport_default
|
||||
})
|
||||
.bind(input.backupmx)
|
||||
.bind(input.active);
|
||||
if has_expiry {
|
||||
q = q.bind(input.password_expiry.unwrap_or(0));
|
||||
let has_description = state.db.has_column(Table::Domain, "description").await?;
|
||||
let mut sets = vec![
|
||||
"aliases=?",
|
||||
"mailboxes=?",
|
||||
"maxquota=?",
|
||||
"quota=?",
|
||||
"transport=?",
|
||||
"backupmx=?",
|
||||
"active=?",
|
||||
"modified=CURRENT_TIMESTAMP",
|
||||
];
|
||||
if has_description {
|
||||
sets.push("description=?");
|
||||
}
|
||||
q = q.bind(&domain_name);
|
||||
q.execute(state.db.pool()).await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postedit,
|
||||
&[("domain", &domain_name)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if has_expiry {
|
||||
sets.push("password_expiry=?");
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {table} SET {} WHERE domain=?",
|
||||
sets.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(values.aliases)
|
||||
.bind(values.mailboxes)
|
||||
.bind(values.maxquota)
|
||||
.bind(values.quota)
|
||||
.bind(&values.transport)
|
||||
.bind(values.backupmx)
|
||||
.bind(values.active);
|
||||
if has_description {
|
||||
q = q.bind(&values.description);
|
||||
}
|
||||
if has_expiry {
|
||||
q = q.bind(values.password_expiry);
|
||||
}
|
||||
q.bind(&domain_name).execute(state.db.pool()).await?;
|
||||
run_postedit(&state, &domain_name).await;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
@@ -322,33 +518,105 @@ pub async fn update(
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActiveInput {
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// `editactive.php?table=domain`
|
||||
pub async fn set_active(
|
||||
State(state): State<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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+76
-9
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, "e))
|
||||
.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, "e)
|
||||
);
|
||||
if !dry_run {
|
||||
sqlx::query(&sql).execute(db.pool()).await.map_err(|e| {
|
||||
AppError::config(format!("adding column {raw}.{} failed: {e}", col.name))
|
||||
})?;
|
||||
db.forget_column_cache();
|
||||
}
|
||||
report.added_columns.push(format!("{raw}.{}", col.name));
|
||||
}
|
||||
}
|
||||
for (name, cols) in def.indexes {
|
||||
let index = format!("{}{}", db.prefix(), name);
|
||||
if index_exists(db, &raw, &index).await? {
|
||||
continue;
|
||||
}
|
||||
let cols = cols.iter().map(|c| quote(c)).collect::<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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
00-tokens.css
|
||||
10-base.css
|
||||
20-app.css
|
||||
40-views.css
|
||||
30-responsive.css
|
||||
+3
-434
@@ -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
@@ -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
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[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
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -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();
|
||||
})();
|
||||
Reference in new issue
Block a user