first commit
This commit is contained in:
commit
2eb8aac027
91 files changed
+20139
No files matched your search
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
# Public-only development cache; no private keys are stored here.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
|
||||||
|
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1dRueVQWCbGkePvgTCD091Wu+938BezqbvngojiMD/k/cm0VDeTwsql3G/EBjRTh+xaWW0jpXnBP+7EAL2QmN8zhIWVx2HqUMCjEBYlkfEWuynzaOiMWiuZxF9wffWjzTAtdZ0NITc+4BLUv4mgOt1F9ab+xyp0baqJ/Y72nPvmsj9HhQCHa4BH5Dg82CKZhnIfeaKIPaz8ok8Lkt"
|
||||||
|
"QZIqekl3gksAHPFi7VWx11zM+LoshgAEydkexIVVjO69+Emxxvpt5qUeBllyZRuppHpEk4WsJAKIQ5LwuXK2TjWnlooW9W2m+Ky1HaMgXR4jhmsgiKqpoIGjYAA2n/XwZH9QwIDAQAB"
|
||||||
|
) ;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.gru.one.pl IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3KnEH52dSHFn7clYX2Ax+Nn5wqHqUaqX4ssyHVQ/TKKet5WqKiNgO0ZN7z3YsynyP9TY0Yc9sBjXreE5mPLVpZ7inXWCry77nxAhs53uEpvNiOMJUlNn5SAXe9FSyRHL57n1F1CuMCObO8rspBmSxdO5nBGecVvVTsT16S55xzF+XWPG82sHJABI3s5DJiFcTaPo0SAeBRIadcyj64Cf4C4G94SgYxwr/6uOc0ORUMXPE6IOv8pc+SBLPGfqwmcJLX28bJbRQkkqTFjrxf5LwVHbGqlnu57fX7zDxnNxRMiDuIZpN+fOM91L5ooWqqB2BKHaYmWMVsZUV8hHDHpkgwIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.gruszczynski.cc IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmwDJcelxX4OY1YjfebRk/J27l/TzsD0X8oqkJQk2On8r0V+9GimWT4pXjQkiGwasnqFAVnhhoWhcItHavDpYLx9YSwbbqiu2qgL6dCTtmybqWTJT/elHJ5GH/TU6IP42BMvkkrbNl0gBplEa8Pb0TAQ+jWR3S/kktaHWRlZ25DqqrmZqlpLIGoBDYE32utoKFVr4HcAy+N9f/eYUZCSHBggA3JZkEQdA8Gw8lu8sQeQ9doKgNST4wd2bG9lqDtjkRz1bPBFGlwJlgyGk2z/Lj269uNWrFgjKsS5rBydX+OSfnQBzzyEFbbUVQHQWf7bmdUYKGzuRWIqWWELowQMZ0wIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.gruszczynski.eu.org IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqLUFx19836yQGXz93dGTy8i+/pTflwn10EbuOCsuiDkBzaBDH++cfrwKEBpBAfd3IYoIC8Loou6h//1HzC/KS1v5v8tCfqe6G0XviMMApn/nFPeS06MqRp9Gfa6FYRX24KYbZqeRUAbbFvI0pj5MhwZzTBEz+NIxgb0We9STMskXOF1d77a3bjzAEo+2MCFQaOtl81L/XOmviW0X4Krn9cmJCtJ7ELrwEb2HoQ9HRXmgGz9RZQ36PCmLA+6sI4s1CSIthZjxj3C7hPh65KdNRaZmjzaBYl69WTwPkMZwEjnS13l666TUabQ7CvbcmIuz/Y8KPwieJG9CJHQneUd6hwIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.linuxiarz.pl IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1c27ccUk+Epuf3AVWSJ4s5Xd9N8SpYmkBv3Vkfy4qvI79921Zuon4zc4vtToEx1WCSROwUltD89G1aJpdj+virjLHph1thQ8W4fI4bkGmpyLtHobJbG1hkQX6QrOkCMqbic11qIfEoqheHYYjTouOycyCxOjNgMHVarlmplI6gnj+Bn0cG8ny0LhRamR6K7TsE2XQPUWkXzrGfoh/EjCiM9+WGquWuwIdYP20c7wW4sqOAg99Tzl2u6uwWhVViY+B21ignCUXXWcEX6hnVDL/pwhGWaFbe8W3C8HkVtD9FS4SyiOO7U8XMdmPbmq8BBeenBSlWKPdDo2n9vplyF7QIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.mail.linuxiarz.pl IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoZFCXiIqH8xyjp9g9btbu4Rb4EKJmdPHsdNIdOk0N9SVId3hqMRuXytUwZ3nY+DASj8Ui9TYLvFCglRPg39xQz3g0hGwEN1b/UgyJeSRk6vFoL3pM4uEsgQTCbDWAXwZRs1om+zNa3v2YTmTUHdYmgbwSJa2M/OyziRyxAZQVtIahQcj4U0vxYc+dFb1TZnjrvvWxNEkWuGfpDDinIAUgLtgubtLFx/u5uIM69zxuCktOHVT2Vw5xsCIJK63evuJ7EYkKphK0L4yy9kmx2MYP5TXCXI0/kOQCSmyhcxisonhHIy1DOABXdbFHI/qqSnr07JPrnXeicd8VMciFORtJwIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.michniewicz.no IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7OPv4dhJbdmbLakpf0HQenFar9M9YtfL2WmJXjmdnIat3LAmOFkzWD1Nk+PDEAY7/giRcnlF/fDwEQX52ghHPG+O93fhaYttxgkrQClEn6J4/r7gJpquZ3jop6bL+ECvpDvI+dfLWzgl+712C0CKjJXx+ffhKp+BzeFLUHJsIKaEltP9WH/EsHaMD2lF2tRAr53Bi4+YtJK9eAEfp3H0tj2xjH1QBRafstxGuP1IN0keEqK587tIbAMawJqcr5HEEu8ZVMbSp25Bga1fU0a1XsIdAnkMuO0PaTmuaFdsAgvkXsgP6s7aHJ3GwCRS7Tm4AAEfkscwwy/kYjBAEKNEWQIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.szczepaniak.eu.org IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+ctTZY/mkFLThluRfniesi4DXUamd8TzDaef8hQBXaRrO5AUc96ayfFxkbxxtx1hIKFzhekJySt5d5LmmQHhzBqzpaye/QJ1SM9zNiRRXyJldeVh+aRjKQw0uXu9+U+aWtBQjpYHoKTMtDXDRz1eNlQv77veBNsMGkZmYZ5DTP1qW8bROoSR/GXJ4k+lp9djFs0sKqoKh0NSBDrZ0wDYweYKYfK/aq97RHPt6F+Mpa221IFDhAbY1vxvWpPquC0bKVuFzRmRoiE1wXGxvMAFCB5ty/w08lTC/98+a7rieosBz2Dw1bouKbL/dLTxgSbW/c6gs2YMCv7V6Nb3scANwIDAQAB"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.unitra.eu.org IN TXT "v=DKIM1; k=rsa; p=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1KGsfC31xjPVJeExRaeM6gXDW0edZtQNJjKrCJftFFRYRp+MNl/6FUkvhMF1d1viezLrGbp15KeHSRaEylgO9F4XpjmHVlgL8jEBhoLngUsFB1urOvFFslqXKTSYJ0qIvmldJPmKKksfW/Q/EqRgc2Q0fbNHEytYGiuXrrIDypt3Of3IGwSn0xKC+NhO1wysYaWLU+D3heqjtessLIQPViqQRvJHuhQknJX294SzKTXEbuG6cGRVLofXRy3TVkFu4hUSMQXc1nhY7XKj+TSNsHN/ZoQyBvCD73nRbmGONOzVGTF44RRNqBn3JMfZqCAI7TSdIcOtb9pMdwc6tibQutgtNF5GBTZAwag8gzBIGTm6slue4B4ANQVTaHLxoiGrXJzxKrOw6hymBsJJ0iQyMwWvA2vWt/jw1sOLPJSFBHcoQWbbTiEw6B5Tg44s88uKMvBatNHiaU8HPOUjy4l3+xrfl5U1H3pUEIQGFylEfixSMHUpJOUEPanrKAbx9m6NedYobwrX+a9HUE7K5+T+oOBKyM561zyR8NHC58wDOb80WxJ66VcCl9PWJaJJcMUIiB3FR8kdk5WPAsWrPbzA8nBdF3LEhPWJvf1LSjgOplYK1mrFzUSF+CcyvWgDHSesdYp6NIZT9bjTPsUqV24InYq082nPDKjYp79aMQtdgfsCAwEAAQ=="
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mail._domainkey.unitraklub.pl IN TXT "v=DKIM1; k=rsa; p=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0WcZpdErzwDHXwIr9JwnMNkOJT1DZOroBRRc0jBTeBes3FKi07ORXgFFrF/3N9n555/LR1fFWuYdr/Y+AbPgGQKS2O5w5mvcC+VOQV7oHavkZehusmu+Xk2ygpD8tq3KsXyS3fKB3fAD93nLhoqm+sNLL7EYfJI1XJjDrmC/omUNXsgxL/pdhmAsgJ60MBZ824TxGDJYEemN0G/C+ZJcNyNnMx768Tpud7AA+FKRZyxGIdcSYBvVloHTAKYc6jpvLb7jlJcIm8S7XIEemErOuc+xorfcYLr+cf52+Nm/14YoA8xn6WNqETF5gc1niaLETEolran+yak4BtmpyrvfcOPz2pOqmmvoj/CBgEAtuqY0jBubDMxVGsj4SL8xRqVQao0gL2mqcjSeIAIpA1FfmYuz1bHr2K5g3Dsoe7c9QbNW7wbTux+o/EVvYKN0EYPbFDEzppjND7G4JHnuxoGkj+syVrFq6GIl1TLZ7xf6Uir9uX/UwTKsxM4Fvr+P56wxqc/mO1OhwXgjEAlOkN256JWB5yPzC4aegkWNxBrxiY191uAuLrjJ6zmMyJf8AczBphPk8pxg8JlFJZPMevWl/1FgA+ljwc21i7aDJxELpBifAoDhp7j7rbLQqYM76lC+NBNZcTnqI+as2xBqh5358NxoOleMRh13H4Z2TrupvXsCAwEAAQ=="
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Public-only development cache generated over SSH.
|
||||||
|
danio298.com /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/danio298.com/mail.private
|
||||||
|
gru.one.pl /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/gru.one.pl/mail.private
|
||||||
|
gruszczynski.cc /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/gruszczynski.cc/mail.private
|
||||||
|
gruszczynski.eu.org /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/gruszczynski.eu.org/mail.private
|
||||||
|
linuxiarz.pl /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/linuxiarz.pl/mail.private
|
||||||
|
mail.linuxiarz.pl /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/mail.linuxiarz.pl/mail.private
|
||||||
|
michniewicz.no /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/michniewicz.no/mail.private
|
||||||
|
szczepaniak.eu.org /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/szczepaniak.eu.org/mail.private
|
||||||
|
unitra.eu.org /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/unitra.eu.org/mail.private
|
||||||
|
unitraklub.pl /home/mg/PostfixAdminRS/.dev/rspamd-remote-public/keys/unitraklub.pl/mail.private
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Public-only development cache generated over SSH.
|
||||||
|
danio298.com mail
|
||||||
|
gru.one.pl mail
|
||||||
|
gruszczynski.cc mail
|
||||||
|
gruszczynski.eu.org mail
|
||||||
|
linuxiarz.pl mail
|
||||||
|
mail.linuxiarz.pl mail
|
||||||
|
michniewicz.no mail
|
||||||
|
szczepaniak.eu.org mail
|
||||||
|
unitra.eu.org mail
|
||||||
|
unitraklub.pl mail
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# PostfixAdmin RS development environment
|
||||||
|
# Copy this file to .env and edit the values marked CHANGE_ME.
|
||||||
|
# The real .env is ignored by git and excluded from release ZIP files.
|
||||||
|
#
|
||||||
|
# cp .env.example .env
|
||||||
|
# $EDITOR .env
|
||||||
|
# bash dev.sh
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Development database mode
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# mock = local SQLite mock database
|
||||||
|
# ssh = connect to the real PostfixAdmin DB through an SSH tunnel
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_MODE=ssh
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SSH tunnel -> host.linuxiarz.pl
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
POSTFIXADMIN_RS_SSH_HOST=host.linuxiarz.pl
|
||||||
|
POSTFIXADMIN_RS_SSH_USER=CHANGE_ME
|
||||||
|
POSTFIXADMIN_RS_SSH_PORT=22
|
||||||
|
|
||||||
|
# Optional: use an explicit SSH private key instead of ssh-agent/default key.
|
||||||
|
# POSTFIXADMIN_RS_SSH_IDENTITY=/home/mg/.ssh/id_ed25519
|
||||||
|
|
||||||
|
# Optional: explicit known_hosts file. Normal SSH host-key verification remains enabled.
|
||||||
|
# POSTFIXADMIN_RS_SSH_KNOWN_HOSTS=/home/mg/.ssh/known_hosts
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Production PostfixAdmin database behind the SSH server
|
||||||
|
# Values below match the PostfixAdmin config supplied for this project.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_BACKEND=mysql
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_HOST=127.0.0.1
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_PORT=3306
|
||||||
|
POSTFIXADMIN_RS_SSH_LOCAL_PORT=13306
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_USER=postfix
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_NAME=postfix
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# dev.sh forces read-only access in SSH mode by default.
|
||||||
|
# Do NOT enable this for initial tests.
|
||||||
|
# POSTFIXADMIN_RS_DEV_DB_WRITES=1
|
||||||
|
|
||||||
|
# Keep Rspamd filesystem integration disabled while the app is running locally.
|
||||||
|
# Enabling it would affect the machine running PostfixAdmin RS, not the SSH host.
|
||||||
|
POSTFIXADMIN_RS_DEV_RSPAMD_ENABLED=false
|
||||||
|
|
||||||
|
# Optional logging: trace | debug | info or a regular RUST_LOG filter.
|
||||||
|
RUST_LOG=debug
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Application overrides (normally not needed for SSH development mode)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# POSTFIXADMIN_RS__DATABASE__PASSWORD=CHANGE_ME
|
||||||
|
# POSTFIXADMIN_RS__SMTP__PASSWORD=
|
||||||
|
# POSTFIXADMIN_RS__SECURITY__COOKIE_SECURE=false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target/
|
||||||
|
/dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Zgodnosc z PostfixAdmin
|
||||||
|
|
||||||
|
Stan: **0.0.1-dev**. Celem jest replacement runtime PHP z zachowaniem istniejacej bazy PostfixAdmin i SQL map uzywanych przez Postfix/Dovecot.
|
||||||
|
|
||||||
|
| 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.1-dev 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).
|
||||||
Generated
+3305
File diff suppressed because it is too large.
Load diff
+45
@@ -0,0 +1,45 @@
|
|||||||
|
[package]
|
||||||
|
name = "postfixadmin-rs"
|
||||||
|
version = "0.0.1-dev"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.99.1"
|
||||||
|
default-run = "postfixadmin-rs"
|
||||||
|
license = "GPL-2.0-or-later"
|
||||||
|
description = "Rust replacement for PostfixAdmin, compatible with the PostfixAdmin database schema"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
figment = { version = "0.10", features = ["env", "toml"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
|
||||||
|
tower-http = { version = "0.6", features = ["trace", "limit", "timeout", "set-header"] }
|
||||||
|
tower = { version = "0.5", features = ["limit"] }
|
||||||
|
dashmap = "6"
|
||||||
|
rand = "0.9"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
subtle = "2.6"
|
||||||
|
data-encoding = "2.9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
pwhash = "1"
|
||||||
|
regex = "1.12"
|
||||||
|
once_cell = "1.20"
|
||||||
|
thiserror = "2"
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder"] }
|
||||||
|
trust-dns-resolver = { version = "0.23", default-features = false, features = ["tokio-runtime", "system-config"] }
|
||||||
|
urlencoding = "2.1"
|
||||||
|
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "any", "mysql", "postgres", "sqlite", "chrono"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
http-body-util = "0.1"
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||||
|
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Library General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Notice
|
||||||
|
|
||||||
|
PostfixAdmin-RS is a compatibility-focused rewrite based on the user-supplied PostfixAdmin source tree. PostfixAdmin is licensed under GNU GPL v2 or later; this project is distributed as GPL-2.0-or-later.
|
||||||
|
|
||||||
|
The frontend layout/build conventions and SVG sprite approach were adapted from the user-supplied RustBox project as requested. The old PostfixAdmin visual theme was not copied.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# PostfixAdmin 4.0.1 -> PostfixAdmin-RS 0.0.1-dev parity audit
|
||||||
|
|
||||||
|
Audit basis: the supplied `postfixadmin-master` source identifies itself as PostfixAdmin **4.0.1** in `config.inc.php`.
|
||||||
|
The goal of 0.0.1-dev is runtime replacement for normal mail administration, not binary/API compatibility with PHP.
|
||||||
|
|
||||||
|
## Runtime features already ported
|
||||||
|
|
||||||
|
| PostfixAdmin area | Rust status |
|
||||||
|
|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Not yet 1:1 with PHP PostfixAdmin
|
||||||
|
|
||||||
|
These are real parity gaps; 0.0.1-dev must not be described as 100% complete while they remain.
|
||||||
|
|
||||||
|
| Area | Gap / intended Rust replacement |
|
||||||
|
|---|---|
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
## Public PHP entry points checked
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# PostfixAdmin-RS
|
||||||
|
|
||||||
|
Rustowy panel administracyjny dla Postfix/Dovecot, rozwijany jako zamiennik runtime PHP PostfixAdmin i zgodny z jego istniejaca baza danych.
|
||||||
|
|
||||||
|
Aktualna linia developerska: **0.0.1-dev**. 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.
|
||||||
|
|
||||||
|
## Development bez produkcyjnej bazy
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env
|
||||||
|
bash dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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.1-dev
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
PostfixAdmin-RS 0.0.1-dev
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 1.99.1 compiler/test/clippy gate and the regression checklist in TESTING.md pass.
|
||||||
|
|
||||||
|
Developer gate on a machine with Rust 1.99.1:
|
||||||
|
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 1.99.1 compiler gate: NOT RUN; toolchain absent and download attempt failed because the execution environment cannot resolve external hosts.
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# Security model
|
||||||
|
|
||||||
|
## Domyslne zasady
|
||||||
|
|
||||||
|
- Panel nasluchuje domyslnie tylko na `127.0.0.1`.
|
||||||
|
- Produkcyjny cookie sesji: `Secure`, `HttpOnly`, `SameSite=Strict`.
|
||||||
|
- Kazda mutacja po zalogowaniu wymaga CSRF.
|
||||||
|
- Sesje/challenge MFA maja losowe tokeny kryptograficzne.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Development mock DB
|
||||||
|
|
||||||
|
`database.dev_mock=true` jest akceptowane tylko z `environment="development"` i SQLite. `dev.sh` tworzy `.dev/postfixadmin-rs.sqlite` z publicznymi testowymi credentialami. Tego pliku ani tych kont nie wolno przenosic do produkcji; `.dev/` jest ignorowane przez Git i nie trafia do paczki release.
|
||||||
|
|
||||||
|
## Legacy hasla
|
||||||
|
|
||||||
|
Formaty MD5, SHA1, DES, cleartext i podobne sa wspierane tylko dla zgodnosci. Nie nalezy automatycznie migrowac mailbox hashy do Argon2, jesli Dovecot/Postfix SASL nie obsluguje tego samego schematu.
|
||||||
|
|
||||||
|
Weryfikator rozpoznaje samopisujace hashe i moze obslugiwac mieszana baze podczas migracji. Nowe hashe sa generowane przez `password.scheme`. Recovery tokeny sa zawsze Argon2id i nie dziedzicza legacy schematu.
|
||||||
|
|
||||||
|
Fallback `doveadm pw` nie uzywa shella. Haslo jest przekazywane przez stdin, nie przez argv procesu.
|
||||||
|
|
||||||
|
## Hooki
|
||||||
|
|
||||||
|
Hook to lista `[program, arg1, arg2, ...]`. Program jest wykonywany bez `sh -c`; placeholdery sa podstawiane jawnie. `allow_legacy_shell_hooks` nie wlacza arbitralnej powloki.
|
||||||
|
|
||||||
|
## Alias i fetchmail
|
||||||
|
|
||||||
|
Niebezpieczne cele aliasow (pipe/command) sa domyslnie blokowane. `fetchmail_extra_options=false` blokuje arbitrary MDA/extra options.
|
||||||
|
|
||||||
|
## Sekrety
|
||||||
|
|
||||||
|
Hasla DB/SMTP trzymaj poza TOML, np.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTFIXADMIN_RS__DATABASE__PASSWORD='...'
|
||||||
|
POSTFIXADMIN_RS__SMTP__PASSWORD='...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Nie commituj `.env`.
|
||||||
|
|
||||||
|
## Reverse proxy i IP
|
||||||
|
|
||||||
|
Obecna wersja uzywa bezposredniego TCP peer do rate limit i TOTP exception. Za reverse proxy nie wlaczaj TOTP IP exceptions, dopoki nie ma jawnie skonfigurowanego trusted-proxy header handling.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Uruchamiaj jako dedykowany, nieuprzywilejowany user. Przyznaj tylko potrzebny dostep do `doveadm`, `openssl`, `mysqldump`/`pg_dump` i wymaganych socketow/uslug. Nie uruchamiaj jako root.
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
# PostfixAdmin-RS 0.0.1-dev - test checklist
|
||||||
|
|
||||||
|
## 1. Static / compiler gate
|
||||||
|
|
||||||
|
Run on Rust 1.99.1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --check
|
||||||
|
cargo check --all-targets
|
||||||
|
cargo test --all-targets
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start the isolated development database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTFIXADMIN_RS_DEV_RESET=1 bash dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected test accounts are printed by `dev.sh`.
|
||||||
|
|
||||||
|
## 2. 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.
|
||||||
|
- Switching EN <-> PL does not log the user out and reloads the current page.
|
||||||
|
- API endpoints and JSON error payloads remain English.
|
||||||
|
- Theme selector still works independently of the language selector.
|
||||||
|
- Forms, confirmations and prompts use application modals, not browser `prompt/confirm` dialogs.
|
||||||
|
|
||||||
|
## 3. Mock DB regression
|
||||||
|
|
||||||
|
As global admin test: domains, mailboxes, aliases, alias domains, admins, fetchmail, DKIM, TOTP/application passwords and logs. Verify permission restrictions with domain-admin and mailbox-user test accounts.
|
||||||
|
|
||||||
|
## 4. Production DB through SSH - read-only first
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env
|
||||||
|
bash dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` is prefilled for `host.linuxiarz.pl`; set the SSH username and database password before starting. SSH mode forces `database.read_only=true` unless `POSTFIXADMIN_RS_DEV_DB_WRITES=1` is explicitly set.
|
||||||
|
|
||||||
|
In read-only mode verify:
|
||||||
|
|
||||||
|
- login for global admin, domain admin and mailbox user,
|
||||||
|
- domain/mailbox/alias lists,
|
||||||
|
- quota/status fields,
|
||||||
|
- schema warnings and password scheme detection,
|
||||||
|
- fetchmail/vacation/DKIM list pages that exist in the production schema,
|
||||||
|
- every write attempt returns an English API error and does not alter data.
|
||||||
|
|
||||||
|
Do not enable writes until a database backup exists and a dedicated test domain/account has been selected.
|
||||||
|
|
||||||
|
## 5. Production DB write test
|
||||||
|
|
||||||
|
Only after backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_MODE=ssh POSTFIXADMIN_RS_DEV_DB_WRITES=1 bash dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a dedicated test domain. Exercise create/update/delete for one domain, mailbox and alias; verify Postfix/Dovecot behaviour and the PostfixAdmin log table. Do not change `password.scheme` during this test.
|
||||||
|
|
||||||
|
|
||||||
|
## Mailbox-user regression
|
||||||
|
|
||||||
|
Zaloguj sie jako mailbox-user i sprawdz:
|
||||||
|
|
||||||
|
- nawigacja nie pokazuje Domains, Mailboxes, Administrators, Logs, Broadcast ani System; DKIM moze byc widoczny tylko jako read-only, gdy istnieje backend DKIM i `[dkim].user_read_only=true`;
|
||||||
|
- Overview nie pokazuje panelu `Enabled modules`, tylko dane wlasnej skrzynki, self-alias i quota progress;
|
||||||
|
- `/mailboxes` zwraca tylko zalogowana skrzynke, a `/aliases` tylko jej self-alias;
|
||||||
|
- Aliases pokazuje domene jako read-only i nie pozwala zmienic adresu/domeny;
|
||||||
|
- Security od razu pokazuje formularz zmiany hasla, bez technicznego opisu cookies/CSRF;
|
||||||
|
- Vacation pokazuje formularz wlasnego autorespondera tylko gdy `features.vacation=true` i `vacation_control=true`;
|
||||||
|
- Filters pojawia sie tylko gdy `[sieve].enabled=true`;
|
||||||
|
- DKIM usera pokazuje tylko rekordy jego domeny, z pelnym `selector._domainkey.domain IN TXT "..."`, bez klucza prywatnego i bez akcji zapisu.
|
||||||
|
|
||||||
|
ManageSieve mozna przetestowac przez ten sam SSH host bez IMAP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTFIXADMIN_RS_DEV_DB_MODE=ssh \
|
||||||
|
POSTFIXADMIN_RS_DEV_SIEVE_ENABLED=true \
|
||||||
|
bash dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
W tym samym trybie `dev.sh` domyslnie synchronizuje publiczne rekordy Rspamd DKIM przez SSH. Prywatne klucze pozostaja na serwerze. W logu startowym oczekuj linii `DKIM: Rspamd public DNS records synced over SSH`.
|
||||||
|
|
||||||
|
Pierwszy test Sieve pozostaw read-only. Do PUT/DELETE/activate dodaj dopiero po wyborze konta testowego:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTFIXADMIN_RS_DEV_SIEVE_WRITES=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Sprawdz list/get/check, a nastepnie create/save/activate/rename/delete. Po zmianie hasla w Security ponownie sprawdz Sieve w tej samej sesji.
|
||||||
|
|
||||||
|
## 6. Rspamd DKIM
|
||||||
|
|
||||||
|
Test first with the development mock. On the mail host test `rspamadm configtest`, generation/adoption, DNS TXT display, mapping updates and reload. Keep a backup of `dkim_signing.conf` and existing key files.
|
||||||
|
|
||||||
|
## Current readiness
|
||||||
|
|
||||||
|
The source tree contains development mock coverage and a guarded production-DB tunnel path. It should be treated as **test-ready, not production-ready**, until the compiler/test/clippy gate above passes on Rust 1.99.1 and the production schema has completed the read-only regression checklist.
|
||||||
|
|
||||||
|
## MySQL/MariaDB through sqlx::Any
|
||||||
|
|
||||||
|
PostfixAdmin commonly stores boolean flags such as `active`, `superadmin`,
|
||||||
|
`backupmx`, `smtp_active`, Fetchmail SSL flags and Vacation state as
|
||||||
|
`TINYINT(1)`. SQLx `Any` does not map MySQL `TINY` result metadata directly.
|
||||||
|
PostfixAdmin-RS therefore casts these flags to a portable signed integer in
|
||||||
|
SELECT projections before decoding them.
|
||||||
|
|
||||||
|
If a test build reports `Any driver does not support MySql type ... Tiny`,
|
||||||
|
include the failing endpoint and SQL-related log line in the bug report; raw
|
||||||
|
flag columns must not be selected without the normalization helper.
|
||||||
|
|
||||||
|
## UI and quota checks
|
||||||
|
|
||||||
|
- Switch domains in Mailboxes and Aliases: a top loading bar and spinner must be visible while requests are in flight.
|
||||||
|
- Mailbox quota renders a progress bar. When `quota2`/`quota` usage is available it is used as the numerator; otherwise configured mailbox quota is compared with the domain max quota.
|
||||||
|
- Medium/high quota thresholds default to 55/90 percent and are configurable under `[defaults]`.
|
||||||
|
- Theme icon must render after upgrades; the icon sprite is served under a content-hashed URL.
|
||||||
|
|
||||||
|
## Debian package
|
||||||
|
|
||||||
|
Run `./scripts/build-deb.sh`. Expected output is `packages/postfixadmin-rs_0.0.1~dev_<arch>.deb` plus `.sha256`. Install on a disposable Debian-compatible host first and configure `/etc/postfixadmin-rs/config.toml` before enabling the service. Verify `getent passwd postfixadmin`, `systemctl cat postfixadmin-rs` (User/Group must be `postfixadmin`), and permissions `root:postfixadmin 0640` on the config file.
|
||||||
|
|
||||||
|
## Local Rust quality gate
|
||||||
|
|
||||||
|
Run this before packaging or committing changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/quality.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It formats the Rust sources first, then runs `cargo check`, `cargo test`, and
|
||||||
|
`cargo clippy --all-targets -- -D warnings`. The Debian builder runs the same
|
||||||
|
format/test/lint sequence inside its writable Docker worktree.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn fnv1a64(parts: &[&[u8]]) -> u64 {
|
||||||
|
let mut hash = 0xcbf29ce484222325u64;
|
||||||
|
for part in parts {
|
||||||
|
for byte in *part {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle_manifest(root: &Path, directory: &Path, manifest: &Path, extension: &str) -> String {
|
||||||
|
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||||
|
let manifest_text = fs::read_to_string(manifest)
|
||||||
|
.unwrap_or_else(|error| panic!("{}: {error}", manifest.display()));
|
||||||
|
let mut bundle = String::new();
|
||||||
|
for raw in manifest_text.lines() {
|
||||||
|
let name = raw.trim();
|
||||||
|
if name.is_empty() || name.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
name.ends_with(extension)
|
||||||
|
&& !name.contains('/')
|
||||||
|
&& !name.contains('\\')
|
||||||
|
&& !name.contains(".."),
|
||||||
|
"invalid {extension} manifest entry: {name}"
|
||||||
|
);
|
||||||
|
let path = directory.join(name);
|
||||||
|
assert!(
|
||||||
|
path.starts_with(root),
|
||||||
|
"asset path escaped project root: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
let source =
|
||||||
|
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||||
|
if !bundle.is_empty() {
|
||||||
|
bundle.push('\n');
|
||||||
|
}
|
||||||
|
bundle.push_str(&source);
|
||||||
|
if !source.ends_with('\n') {
|
||||||
|
bundle.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
|
||||||
|
let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
|
||||||
|
let css_dir = root.join("src/web/css");
|
||||||
|
let js_dir = root.join("src/web/js");
|
||||||
|
for path in [
|
||||||
|
root.join("src/web/index.html"),
|
||||||
|
root.join("src/web/icons.svg"),
|
||||||
|
root.join("src/web/i18n/en.json"),
|
||||||
|
root.join("src/web/i18n/pl.json"),
|
||||||
|
] {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
let css = bundle_manifest(&root, &css_dir, &css_dir.join("manifest.txt"), ".css");
|
||||||
|
let js = bundle_manifest(&root, &js_dir, &js_dir.join("manifest.txt"), ".js");
|
||||||
|
let icons = fs::read(root.join("src/web/icons.svg")).expect("read icons.svg");
|
||||||
|
let en = fs::read(root.join("src/web/i18n/en.json")).expect("read en catalog");
|
||||||
|
let pl = fs::read(root.join("src/web/i18n/pl.json")).expect("read pl catalog");
|
||||||
|
let index = fs::read(root.join("src/web/index.html")).expect("read index.html");
|
||||||
|
let asset_id = fnv1a64(&[css.as_bytes(), js.as_bytes(), &icons, &en, &pl, &index]);
|
||||||
|
println!("cargo:rustc-env=POSTFIXADMIN_RS_ASSET_ID={asset_id:016x}");
|
||||||
|
fs::write(out.join("postfixadmin-rs.css"), css).expect("write css bundle");
|
||||||
|
fs::write(out.join("postfixadmin-rs.js"), js).expect("write js bundle");
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8080
|
||||||
|
environment = "development"
|
||||||
|
default_language = "en"
|
||||||
|
base_path = "/"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
backend = "sqlite"
|
||||||
|
path = ".dev/postfixadmin-rs.sqlite"
|
||||||
|
dev_mock = true
|
||||||
|
read_only = false
|
||||||
|
use_ssl = false
|
||||||
|
verify_server_cert = true
|
||||||
|
prefix = ""
|
||||||
|
max_connections = 1
|
||||||
|
|
||||||
|
[password]
|
||||||
|
# Development default. Existing hashes are auto-detected independently of this setting.
|
||||||
|
scheme = "ARGON2ID"
|
||||||
|
doveadm = "/usr/bin/doveadm"
|
||||||
|
authlib_default_flavor = "md5raw"
|
||||||
|
min_length = 5
|
||||||
|
min_letters = 3
|
||||||
|
min_digits = 2
|
||||||
|
|
||||||
|
[smtp]
|
||||||
|
host = "localhost"
|
||||||
|
port = 25
|
||||||
|
kind = "plain"
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
client_name = "postfixadmin-rs.dev"
|
||||||
|
admin_email = "admin@example.test"
|
||||||
|
admin_name = "Postmaster"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
quota = true
|
||||||
|
domain_quota = true
|
||||||
|
transport = true
|
||||||
|
vacation = true
|
||||||
|
vacation_control = true
|
||||||
|
vacation_control_admin = true
|
||||||
|
edit_alias = true
|
||||||
|
alias_control = true
|
||||||
|
alias_control_admin = true
|
||||||
|
special_alias_control = true
|
||||||
|
alias_domain = true
|
||||||
|
backup = false
|
||||||
|
sendmail = false
|
||||||
|
sendmail_all_admins = false
|
||||||
|
logging = true
|
||||||
|
fetchmail = true
|
||||||
|
fetchmail_extra_options = false
|
||||||
|
emailcheck_resolve_domain = false
|
||||||
|
emailcheck_localaliasonly = false
|
||||||
|
show_status = true
|
||||||
|
show_undeliverable = true
|
||||||
|
show_popimap = true
|
||||||
|
show_vacation = true
|
||||||
|
show_disabled = true
|
||||||
|
show_expired = true
|
||||||
|
totp = true
|
||||||
|
app_passwords = true
|
||||||
|
dkim = true
|
||||||
|
dkim_all_admins = true
|
||||||
|
forgotten_user_password_reset = true
|
||||||
|
forgotten_admin_password_reset = true
|
||||||
|
used_quotas = true
|
||||||
|
new_quota_table = true
|
||||||
|
xmlrpc = false
|
||||||
|
password_expiration = true
|
||||||
|
domain_dns_status_check = 1
|
||||||
|
smtp_active_flag = true
|
||||||
|
generate_password = true
|
||||||
|
show_password = false
|
||||||
|
edit_mailbox_name = true
|
||||||
|
domain_list_hide_maxquota = false
|
||||||
|
domain_list_hide_password_expiry = false
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
aliases = 100
|
||||||
|
mailboxes = 100
|
||||||
|
maxquota_mb = 1024
|
||||||
|
domain_quota_mb = 10240
|
||||||
|
quota_multiplier = 1024000
|
||||||
|
quota_level_med_pct = 55
|
||||||
|
quota_level_high_pct = 90
|
||||||
|
page_size = 25
|
||||||
|
domain_path = true
|
||||||
|
domain_in_mailbox = false
|
||||||
|
maildir_template = ""
|
||||||
|
create_mailbox_subdirs = []
|
||||||
|
create_mailbox_subdirs_prefix = ""
|
||||||
|
transport_default = "virtual"
|
||||||
|
transport_options = ["virtual", "local", "relay"]
|
||||||
|
vacation_domain = "autoreply.example.test"
|
||||||
|
default_aliases = { abuse = "admin@example.test", hostmaster = "admin@example.test", postmaster = "admin@example.test", webmaster = "admin@example.test" }
|
||||||
|
welcome_text = "Development mailbox created by PostfixAdmin RS.\n"
|
||||||
|
recipient_delimiter = "+"
|
||||||
|
show_undeliverable_exceptions = []
|
||||||
|
show_custom_domains = []
|
||||||
|
|
||||||
|
motd_user = ""
|
||||||
|
motd_admin = ""
|
||||||
|
motd_superadmin = ""
|
||||||
|
|
||||||
|
[security]
|
||||||
|
session_timeout_minutes = 1440
|
||||||
|
cookie_secure = false
|
||||||
|
login_attempts = 30
|
||||||
|
login_window_seconds = 300
|
||||||
|
request_body_limit_mb = 4
|
||||||
|
allow_legacy_shell_hooks = false
|
||||||
|
allow_unsafe_alias_destinations = false
|
||||||
|
|
||||||
|
[hooks]
|
||||||
|
mailbox_postcreation = []
|
||||||
|
mailbox_postedit = []
|
||||||
|
mailbox_postdeletion = []
|
||||||
|
mailbox_postpassword = []
|
||||||
|
mailbox_post_totp_change = []
|
||||||
|
mailbox_post_totp_exception_add = []
|
||||||
|
mailbox_post_totp_exception_delete = []
|
||||||
|
mailbox_post_app_password = []
|
||||||
|
domain_postcreation = []
|
||||||
|
domain_postedit = []
|
||||||
|
domain_postdeletion = []
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
mysql_dump = "/usr/bin/mysqldump"
|
||||||
|
pg_dump = "/usr/bin/pg_dump"
|
||||||
|
|
||||||
|
[sieve]
|
||||||
|
# Optional ManageSieve filters for mailbox users. No IMAP access is used.
|
||||||
|
enabled = false
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 4190
|
||||||
|
tls_mode = "starttls" # starttls | implicit | none
|
||||||
|
allow_insecure_auth = false
|
||||||
|
timeout_secs = 10
|
||||||
|
read_only = false
|
||||||
|
|
||||||
|
[dkim]
|
||||||
|
# UI opened first when both implementations are enabled: postfixadmin | rspamd
|
||||||
|
default_backend = "postfixadmin"
|
||||||
|
|
||||||
|
[rspamd_dkim]
|
||||||
|
enabled = true
|
||||||
|
rspamadm = "/usr/bin/rspamadm"
|
||||||
|
key_dir = ".dev/rspamd/dkim"
|
||||||
|
signing_config = ".dev/rspamd/dkim_signing.conf"
|
||||||
|
selector_map = ".dev/rspamd/postfixadmin-rs-selectors.map"
|
||||||
|
path_map = ".dev/rspamd/postfixadmin-rs-paths.map"
|
||||||
|
default_selector = "mail"
|
||||||
|
key_bits = 2048
|
||||||
|
reload_command = []
|
||||||
|
dev_mock = true
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8080
|
||||||
|
environment = "production"
|
||||||
|
default_language = "en"
|
||||||
|
base_path = "/"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
backend = "mysql" # mysql | pgsql | sqlite
|
||||||
|
host = "localhost"
|
||||||
|
port = 3306
|
||||||
|
user = "postfix"
|
||||||
|
password = "change-me"
|
||||||
|
name = "postfix"
|
||||||
|
# path = "/etc/postfix/sqlite/postfixadmin.db"
|
||||||
|
use_ssl = false
|
||||||
|
verify_server_cert = true
|
||||||
|
prefix = ""
|
||||||
|
max_connections = 10
|
||||||
|
|
||||||
|
read_only = false
|
||||||
|
[password]
|
||||||
|
scheme = "php_crypt:MD5"
|
||||||
|
doveadm = "/usr/bin/doveadm"
|
||||||
|
authlib_default_flavor = "md5raw"
|
||||||
|
min_length = 5
|
||||||
|
min_letters = 3
|
||||||
|
min_digits = 2
|
||||||
|
|
||||||
|
[smtp]
|
||||||
|
host = "localhost"
|
||||||
|
port = 25
|
||||||
|
kind = "plain" # plain | starttls | tls
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
client_name = ""
|
||||||
|
admin_email = ""
|
||||||
|
admin_name = "Postmaster"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
quota = false
|
||||||
|
domain_quota = true
|
||||||
|
transport = false
|
||||||
|
vacation = false
|
||||||
|
vacation_control = true
|
||||||
|
vacation_control_admin = true
|
||||||
|
edit_alias = true
|
||||||
|
alias_control = true
|
||||||
|
alias_control_admin = true
|
||||||
|
special_alias_control = false
|
||||||
|
alias_domain = true
|
||||||
|
backup = false
|
||||||
|
sendmail = true
|
||||||
|
sendmail_all_admins = false
|
||||||
|
logging = true
|
||||||
|
fetchmail = true
|
||||||
|
fetchmail_extra_options = false
|
||||||
|
emailcheck_resolve_domain = true
|
||||||
|
emailcheck_localaliasonly = false
|
||||||
|
show_status = true
|
||||||
|
show_undeliverable = true
|
||||||
|
show_popimap = true
|
||||||
|
show_vacation = false
|
||||||
|
show_disabled = false
|
||||||
|
show_expired = false
|
||||||
|
totp = false
|
||||||
|
app_passwords = false
|
||||||
|
dkim = false
|
||||||
|
dkim_all_admins = false
|
||||||
|
forgotten_user_password_reset = true
|
||||||
|
forgotten_admin_password_reset = false
|
||||||
|
used_quotas = false
|
||||||
|
new_quota_table = true
|
||||||
|
xmlrpc = false
|
||||||
|
password_expiration = false
|
||||||
|
domain_dns_status_check = 1
|
||||||
|
smtp_active_flag = false
|
||||||
|
generate_password = true
|
||||||
|
show_password = false
|
||||||
|
edit_mailbox_name = true
|
||||||
|
domain_list_hide_maxquota = false
|
||||||
|
domain_list_hide_password_expiry = false
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
aliases = 10
|
||||||
|
mailboxes = 10
|
||||||
|
maxquota_mb = 10
|
||||||
|
domain_quota_mb = 2048
|
||||||
|
quota_multiplier = 1024000
|
||||||
|
quota_level_med_pct = 55
|
||||||
|
quota_level_high_pct = 90
|
||||||
|
page_size = 10
|
||||||
|
domain_path = true
|
||||||
|
domain_in_mailbox = false
|
||||||
|
maildir_template = ""
|
||||||
|
create_mailbox_subdirs = []
|
||||||
|
create_mailbox_subdirs_prefix = ""
|
||||||
|
transport_default = "virtual"
|
||||||
|
transport_options = ["virtual", "local", "relay"]
|
||||||
|
vacation_domain = "autoreply.change-this-to-your.domain.tld"
|
||||||
|
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 = ""
|
||||||
|
show_undeliverable_exceptions = ["unixmail.domain.ext", "exchangeserver.domain.ext"]
|
||||||
|
show_custom_domains = ["subdomain.domain.ext", "domain2.ext"]
|
||||||
|
motd_user = ""
|
||||||
|
motd_admin = ""
|
||||||
|
motd_superadmin = ""
|
||||||
|
|
||||||
|
[security]
|
||||||
|
session_timeout_minutes = 1440
|
||||||
|
cookie_secure = true
|
||||||
|
login_attempts = 8
|
||||||
|
login_window_seconds = 300
|
||||||
|
request_body_limit_mb = 2
|
||||||
|
allow_legacy_shell_hooks = false
|
||||||
|
allow_unsafe_alias_destinations = false
|
||||||
|
|
||||||
|
[hooks]
|
||||||
|
mailbox_postcreation = []
|
||||||
|
mailbox_postedit = []
|
||||||
|
mailbox_postdeletion = []
|
||||||
|
mailbox_postpassword = []
|
||||||
|
mailbox_post_totp_change = []
|
||||||
|
mailbox_post_totp_exception_add = []
|
||||||
|
mailbox_post_totp_exception_delete = []
|
||||||
|
mailbox_post_app_password = []
|
||||||
|
domain_postcreation = []
|
||||||
|
domain_postedit = []
|
||||||
|
domain_postdeletion = []
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
mysql_dump = "/usr/bin/mysqldump"
|
||||||
|
pg_dump = "/usr/bin/pg_dump"
|
||||||
|
|
||||||
|
[sieve]
|
||||||
|
# Optional ManageSieve filters for mailbox users. No IMAP access is used.
|
||||||
|
enabled = false
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 4190
|
||||||
|
tls_mode = "starttls" # starttls | implicit | none
|
||||||
|
allow_insecure_auth = false
|
||||||
|
timeout_secs = 10
|
||||||
|
read_only = false
|
||||||
|
|
||||||
|
[dkim]
|
||||||
|
# UI opened first when both implementations are enabled: postfixadmin | rspamd
|
||||||
|
default_backend = "postfixadmin"
|
||||||
|
|
||||||
|
# Optional Rspamd DKIM host integration. Uses selector_map/path_map instead of
|
||||||
|
# growing a domain { ... } block for every domain.
|
||||||
|
[rspamd_dkim]
|
||||||
|
enabled = false
|
||||||
|
rspamadm = "/usr/bin/rspamadm"
|
||||||
|
key_dir = "/var/lib/rspamd/dkim"
|
||||||
|
signing_config = "/etc/rspamd/local.d/dkim_signing.conf"
|
||||||
|
selector_map = "/var/lib/rspamd/dkim/postfixadmin-rs-selectors.map"
|
||||||
|
path_map = "/var/lib/rspamd/dkim/postfixadmin-rs-paths.map"
|
||||||
|
default_selector = "mail"
|
||||||
|
key_bits = 2048
|
||||||
|
# Prefer a tightly scoped sudoers rule for reload rather than running the web app as root.
|
||||||
|
# reload_command = ["/usr/bin/sudo", "-n", "/usr/bin/systemctl", "reload", "rspamd"]
|
||||||
|
reload_command = []
|
||||||
|
dev_mock = false
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8080
|
||||||
|
environment = "production"
|
||||||
|
default_language = "en"
|
||||||
|
base_path = "/"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
backend = "mysql" # mysql | pgsql | sqlite
|
||||||
|
host = "localhost"
|
||||||
|
port = 3306
|
||||||
|
user = "postfix"
|
||||||
|
password = "" # set POSTFIXADMIN_RS__DATABASE__PASSWORD in .env
|
||||||
|
name = "postfix"
|
||||||
|
# path = "/etc/postfix/sqlite/postfixadmin.db"
|
||||||
|
use_ssl = false
|
||||||
|
verify_server_cert = true
|
||||||
|
prefix = ""
|
||||||
|
max_connections = 10
|
||||||
|
read_only = false
|
||||||
|
|
||||||
|
[password]
|
||||||
|
scheme = "php_crypt:MD5"
|
||||||
|
doveadm = "/usr/bin/doveadm"
|
||||||
|
authlib_default_flavor = "md5raw"
|
||||||
|
min_length = 5
|
||||||
|
min_letters = 3
|
||||||
|
min_digits = 2
|
||||||
|
|
||||||
|
[smtp]
|
||||||
|
host = "localhost"
|
||||||
|
port = 25
|
||||||
|
kind = "plain" # plain | starttls | tls
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
client_name = ""
|
||||||
|
admin_email = ""
|
||||||
|
admin_name = "Postmaster"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
quota = true
|
||||||
|
domain_quota = true
|
||||||
|
transport = false
|
||||||
|
vacation = true
|
||||||
|
vacation_control = true
|
||||||
|
vacation_control_admin = true
|
||||||
|
edit_alias = true
|
||||||
|
alias_control = true
|
||||||
|
alias_control_admin = true
|
||||||
|
special_alias_control = false
|
||||||
|
alias_domain = true
|
||||||
|
backup = false
|
||||||
|
sendmail = true
|
||||||
|
sendmail_all_admins = false
|
||||||
|
logging = true
|
||||||
|
fetchmail = true
|
||||||
|
fetchmail_extra_options = false
|
||||||
|
emailcheck_resolve_domain = true
|
||||||
|
emailcheck_localaliasonly = false
|
||||||
|
show_status = true
|
||||||
|
show_undeliverable = true
|
||||||
|
show_popimap = true
|
||||||
|
show_vacation = false
|
||||||
|
show_disabled = false
|
||||||
|
show_expired = false
|
||||||
|
totp = false
|
||||||
|
app_passwords = false
|
||||||
|
dkim = true
|
||||||
|
dkim_all_admins = false
|
||||||
|
forgotten_user_password_reset = true
|
||||||
|
forgotten_admin_password_reset = false
|
||||||
|
used_quotas = true
|
||||||
|
new_quota_table = true
|
||||||
|
xmlrpc = false
|
||||||
|
password_expiration = false
|
||||||
|
domain_dns_status_check = 1
|
||||||
|
smtp_active_flag = false
|
||||||
|
generate_password = true
|
||||||
|
show_password = false
|
||||||
|
edit_mailbox_name = true
|
||||||
|
domain_list_hide_maxquota = false
|
||||||
|
domain_list_hide_password_expiry = false
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
aliases = 10
|
||||||
|
mailboxes = 10
|
||||||
|
maxquota_mb = 10
|
||||||
|
domain_quota_mb = 2048
|
||||||
|
quota_multiplier = 1024000
|
||||||
|
quota_level_med_pct = 55
|
||||||
|
quota_level_high_pct = 90
|
||||||
|
page_size = 10
|
||||||
|
domain_path = true
|
||||||
|
domain_in_mailbox = false
|
||||||
|
maildir_template = ""
|
||||||
|
create_mailbox_subdirs = []
|
||||||
|
create_mailbox_subdirs_prefix = ""
|
||||||
|
transport_default = "virtual"
|
||||||
|
transport_options = ["virtual", "local", "relay"]
|
||||||
|
vacation_domain = "autoreply.change-this-to-your.domain.tld"
|
||||||
|
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 = ""
|
||||||
|
show_undeliverable_exceptions = ["unixmail.domain.ext", "exchangeserver.domain.ext"]
|
||||||
|
show_custom_domains = ["subdomain.domain.ext", "domain2.ext"]
|
||||||
|
motd_user = ""
|
||||||
|
motd_admin = ""
|
||||||
|
motd_superadmin = ""
|
||||||
|
|
||||||
|
[security]
|
||||||
|
session_timeout_minutes = 1440
|
||||||
|
cookie_secure = true
|
||||||
|
login_attempts = 8
|
||||||
|
login_window_seconds = 300
|
||||||
|
request_body_limit_mb = 2
|
||||||
|
allow_legacy_shell_hooks = false
|
||||||
|
allow_unsafe_alias_destinations = false
|
||||||
|
|
||||||
|
[hooks]
|
||||||
|
mailbox_postcreation = []
|
||||||
|
mailbox_postedit = []
|
||||||
|
mailbox_postdeletion = []
|
||||||
|
mailbox_postpassword = []
|
||||||
|
mailbox_post_totp_change = []
|
||||||
|
mailbox_post_totp_exception_add = []
|
||||||
|
mailbox_post_totp_exception_delete = []
|
||||||
|
mailbox_post_app_password = []
|
||||||
|
domain_postcreation = []
|
||||||
|
domain_postedit = []
|
||||||
|
domain_postdeletion = []
|
||||||
|
|
||||||
|
[backup]
|
||||||
|
mysql_dump = "/usr/bin/mysqldump"
|
||||||
|
pg_dump = "/usr/bin/pg_dump"
|
||||||
|
|
||||||
|
[sieve]
|
||||||
|
# Dovecot/Pigeonhole ManageSieve on the same mail host.
|
||||||
|
# In workstation development dev.sh tunnels 127.0.0.1:14190 -> remote 127.0.0.1:4190.
|
||||||
|
enabled = true
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 14190
|
||||||
|
tls_mode = "none"
|
||||||
|
allow_insecure_auth = true # loopback-only in development tunnel
|
||||||
|
timeout_secs = 10
|
||||||
|
read_only = false
|
||||||
|
|
||||||
|
[dkim]
|
||||||
|
# UI opened first when both implementations are enabled: postfixadmin | rspamd
|
||||||
|
default_backend = "postfixadmin"
|
||||||
|
# Mailbox users can see public DNS records for their own domain, but cannot modify DKIM.
|
||||||
|
# Keys/selectors remain per-domain in PostfixAdmin or Rspamd; there is no shared DKIM key.
|
||||||
|
user_read_only = true
|
||||||
|
|
||||||
|
[rspamd_dkim]
|
||||||
|
enabled = true
|
||||||
|
rspamadm = "/usr/bin/rspamadm"
|
||||||
|
key_dir = "/var/lib/rspamd/dkim"
|
||||||
|
signing_config = "/etc/rspamd/local.d/dkim_signing.conf"
|
||||||
|
selector_map = "/var/lib/rspamd/dkim/postfixadmin-rs-selectors.map"
|
||||||
|
path_map = "/var/lib/rspamd/dkim/postfixadmin-rs-paths.map"
|
||||||
|
default_selector = "mail"
|
||||||
|
key_bits = 2048
|
||||||
|
# Do not use a shell here. Configure a narrowly-scoped sudoers rule if the service user
|
||||||
|
# needs to reload Rspamd, for example:
|
||||||
|
# reload_command = ["/usr/bin/sudo", "-n", "/usr/bin/systemctl", "reload", "rspamd"]
|
||||||
|
reload_command = []
|
||||||
|
dev_mock = false
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
ENV_FILE="${POSTFIXADMIN_RS_ENV_FILE:-$ROOT/.env}"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
echo "error: Rust/Cargo is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEV_DB_MODE="${POSTFIXADMIN_RS_DEV_DB_MODE:-mock}"
|
||||||
|
DEV_DB="$ROOT/.dev/postfixadmin-rs.sqlite"
|
||||||
|
TUNNEL_PID=""
|
||||||
|
mkdir -p "$ROOT/.dev"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$TUNNEL_PID" ] && kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||||
|
kill "$TUNNEL_PID" 2>/dev/null || true
|
||||||
|
wait "$TUNNEL_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
wait_for_port() {
|
||||||
|
local port="$1"
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 80); do
|
||||||
|
if ! kill -0 "$TUNNEL_PID" 2>/dev/null; then
|
||||||
|
echo "error: SSH tunnel exited before local port became ready" >&2
|
||||||
|
wait "$TUNNEL_PID" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if command -v nc >/dev/null 2>&1; then
|
||||||
|
if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then return 0; fi
|
||||||
|
elif (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
|
||||||
|
exec 3>&- 3<&-
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
echo "error: timed out waiting for SSH tunnel on 127.0.0.1:$port" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${POSTFIXADMIN_RS_DEV_RESET:-0}" = "1" ]; then
|
||||||
|
rm -f "$DEV_DB" "$DEV_DB-shm" "$DEV_DB-wal"
|
||||||
|
rm -rf "$ROOT/.dev/rspamd" "$ROOT/.dev/rspamd-remote-public"
|
||||||
|
echo "Development mock DB and Rspamd DKIM state reset."
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${RUST_LOG:-}" in
|
||||||
|
""|debug) export RUST_LOG="warn,postfixadmin_rs=debug,tower_http=info" ;;
|
||||||
|
trace) export RUST_LOG="warn,postfixadmin_rs=trace,tower_http=debug" ;;
|
||||||
|
info) export RUST_LOG="warn,postfixadmin_rs=info,tower_http=info" ;;
|
||||||
|
*) : ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$DEV_DB_MODE" in
|
||||||
|
mock)
|
||||||
|
export POSTFIXADMIN_RS_CONFIG="${POSTFIXADMIN_RS_CONFIG:-$ROOT/config.dev.toml}"
|
||||||
|
;;
|
||||||
|
ssh)
|
||||||
|
if ! command -v ssh >/dev/null 2>&1; then
|
||||||
|
echo "error: ssh client is required for POSTFIXADMIN_RS_DEV_DB_MODE=ssh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${POSTFIXADMIN_RS_SSH_HOST:?set POSTFIXADMIN_RS_SSH_HOST}"
|
||||||
|
SSH_USER="${POSTFIXADMIN_RS_SSH_USER:-$USER}"
|
||||||
|
SSH_PORT="${POSTFIXADMIN_RS_SSH_PORT:-22}"
|
||||||
|
DB_BACKEND="${POSTFIXADMIN_RS_DEV_DB_BACKEND:-mysql}"
|
||||||
|
case "$DB_BACKEND" in
|
||||||
|
mysql|mysqli) DEFAULT_REMOTE_PORT=3306; DEFAULT_LOCAL_PORT=13306 ;;
|
||||||
|
pgsql|postgres) DEFAULT_REMOTE_PORT=5432; DEFAULT_LOCAL_PORT=15432 ;;
|
||||||
|
*) echo "error: POSTFIXADMIN_RS_DEV_DB_BACKEND must be mysql, mysqli, pgsql or postgres" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
REMOTE_DB_HOST="${POSTFIXADMIN_RS_DEV_DB_HOST:-127.0.0.1}"
|
||||||
|
REMOTE_DB_PORT="${POSTFIXADMIN_RS_DEV_DB_PORT:-$DEFAULT_REMOTE_PORT}"
|
||||||
|
LOCAL_DB_PORT="${POSTFIXADMIN_RS_SSH_LOCAL_PORT:-$DEFAULT_LOCAL_PORT}"
|
||||||
|
DB_USER="${POSTFIXADMIN_RS_DEV_DB_USER:-postfix}"
|
||||||
|
DB_NAME="${POSTFIXADMIN_RS_DEV_DB_NAME:-postfix}"
|
||||||
|
DB_PASSWORD="${POSTFIXADMIN_RS_DEV_DB_PASSWORD:-${POSTFIXADMIN_RS__DATABASE__PASSWORD:-}}"
|
||||||
|
DEV_SIEVE_ENABLED="${POSTFIXADMIN_RS_DEV_SIEVE_ENABLED:-${POSTFIXADMIN_RS_DEV_SIEVE_TUNNEL:-true}}"
|
||||||
|
SIEVE_REMOTE_HOST="${POSTFIXADMIN_RS_DEV_SIEVE_REMOTE_HOST:-127.0.0.1}"
|
||||||
|
SIEVE_REMOTE_PORT="${POSTFIXADMIN_RS_DEV_SIEVE_REMOTE_PORT:-4190}"
|
||||||
|
SIEVE_LOCAL_PORT="${POSTFIXADMIN_RS_DEV_SIEVE_LOCAL_PORT:-14190}"
|
||||||
|
DEV_RSPAMD_PUBLIC_SYNC="${POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC:-true}"
|
||||||
|
RSPAMD_SYNC_OK=0
|
||||||
|
RSPAMD_REMOTE_KEY_DIR="${POSTFIXADMIN_RS_DEV_RSPAMD_KEY_DIR:-/var/lib/rspamd/dkim}"
|
||||||
|
RSPAMD_REMOTE_SIGNING_CONFIG="${POSTFIXADMIN_RS_DEV_RSPAMD_SIGNING_CONFIG:-/etc/rspamd/local.d/dkim_signing.conf}"
|
||||||
|
RSPAMD_REMOTE_SELECTOR_MAP="${POSTFIXADMIN_RS_DEV_RSPAMD_SELECTOR_MAP:-/var/lib/rspamd/dkim/postfixadmin-rs-selectors.map}"
|
||||||
|
RSPAMD_REMOTE_PATH_MAP="${POSTFIXADMIN_RS_DEV_RSPAMD_PATH_MAP:-/var/lib/rspamd/dkim/postfixadmin-rs-paths.map}"
|
||||||
|
RSPAMD_DEFAULT_SELECTOR="${POSTFIXADMIN_RS_DEV_RSPAMD_DEFAULT_SELECTOR:-mail}"
|
||||||
|
RSPAMD_PUBLIC_DIR="$ROOT/.dev/rspamd-remote-public"
|
||||||
|
|
||||||
|
export POSTFIXADMIN_RS_CONFIG="${POSTFIXADMIN_RS_CONFIG:-${POSTFIXADMIN_RS_SSH_CONFIG:-$ROOT/config.from-user.toml}}"
|
||||||
|
export POSTFIXADMIN_RS__ENVIRONMENT=development
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__BACKEND="$DB_BACKEND"
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__HOST=127.0.0.1
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__PORT="$LOCAL_DB_PORT"
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__USER="$DB_USER"
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__NAME="$DB_NAME"
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__PASSWORD="$DB_PASSWORD"
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__DEV_MOCK=false
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__READ_ONLY=true
|
||||||
|
export POSTFIXADMIN_RS__SECURITY__COOKIE_SECURE=false
|
||||||
|
# Rspamd integration on the workstation is read from a public-only SSH cache below.
|
||||||
|
# No private key bytes are copied from the mail server.
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__ENABLED=false
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__ENABLED="$DEV_SIEVE_ENABLED"
|
||||||
|
if [ "$DEV_SIEVE_ENABLED" = "true" ] || [ "$DEV_SIEVE_ENABLED" = "1" ]; then
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__HOST=127.0.0.1
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__PORT="$SIEVE_LOCAL_PORT"
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__TLS_MODE=none
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__ALLOW_INSECURE_AUTH=true
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__READ_ONLY=true
|
||||||
|
if [ "${POSTFIXADMIN_RS_DEV_SIEVE_WRITES:-0}" = "1" ]; then
|
||||||
|
export POSTFIXADMIN_RS__SIEVE__READ_ONLY=false
|
||||||
|
echo "WARNING: production ManageSieve writes are ENABLED for this development session." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${POSTFIXADMIN_RS_DEV_DB_WRITES:-0}" = "1" ]; then
|
||||||
|
export POSTFIXADMIN_RS__DATABASE__READ_ONLY=false
|
||||||
|
echo "WARNING: production DB writes are ENABLED for this development session." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
SSH_COMMON_ARGS=(-p "$SSH_PORT" -o BatchMode=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3)
|
||||||
|
if [ -n "${POSTFIXADMIN_RS_SSH_IDENTITY:-}" ]; then
|
||||||
|
SSH_COMMON_ARGS+=(-i "$POSTFIXADMIN_RS_SSH_IDENTITY")
|
||||||
|
fi
|
||||||
|
if [ -n "${POSTFIXADMIN_RS_SSH_KNOWN_HOSTS:-}" ]; then
|
||||||
|
SSH_COMMON_ARGS+=(-o "UserKnownHostsFile=${POSTFIXADMIN_RS_SSH_KNOWN_HOSTS}")
|
||||||
|
fi
|
||||||
|
SSH_ARGS=(-N -T -L "127.0.0.1:${LOCAL_DB_PORT}:${REMOTE_DB_HOST}:${REMOTE_DB_PORT}" -o ExitOnForwardFailure=yes "${SSH_COMMON_ARGS[@]}")
|
||||||
|
if [ "$DEV_SIEVE_ENABLED" = "true" ] || [ "$DEV_SIEVE_ENABLED" = "1" ]; then
|
||||||
|
SSH_ARGS+=(-L "127.0.0.1:${SIEVE_LOCAL_PORT}:${SIEVE_REMOTE_HOST}:${SIEVE_REMOTE_PORT}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh "${SSH_ARGS[@]}" "${SSH_USER}@${POSTFIXADMIN_RS_SSH_HOST}" &
|
||||||
|
TUNNEL_PID=$!
|
||||||
|
wait_for_port "$LOCAL_DB_PORT"
|
||||||
|
if [ "$DEV_SIEVE_ENABLED" = "true" ] || [ "$DEV_SIEVE_ENABLED" = "1" ]; then
|
||||||
|
wait_for_port "$SIEVE_LOCAL_PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DEV_RSPAMD_PUBLIC_SYNC" = "true" ] || [ "$DEV_RSPAMD_PUBLIC_SYNC" = "1" ]; then
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "warning: python3 is required for public-only Rspamd DKIM sync; disabling it for this session" >&2
|
||||||
|
DEV_RSPAMD_PUBLIC_SYNC=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$DEV_RSPAMD_PUBLIC_SYNC" = "true" ] || [ "$DEV_RSPAMD_PUBLIC_SYNC" = "1" ]; then
|
||||||
|
rm -rf "$RSPAMD_PUBLIC_DIR"
|
||||||
|
SYNC_ARGS=(
|
||||||
|
--ssh "$(command -v ssh)"
|
||||||
|
--host "$POSTFIXADMIN_RS_SSH_HOST"
|
||||||
|
--user "$SSH_USER"
|
||||||
|
--port "$SSH_PORT"
|
||||||
|
--signing-config "$RSPAMD_REMOTE_SIGNING_CONFIG"
|
||||||
|
--selector-map "$RSPAMD_REMOTE_SELECTOR_MAP"
|
||||||
|
--path-map "$RSPAMD_REMOTE_PATH_MAP"
|
||||||
|
--key-dir "$RSPAMD_REMOTE_KEY_DIR"
|
||||||
|
--default-selector "$RSPAMD_DEFAULT_SELECTOR"
|
||||||
|
--output-dir "$RSPAMD_PUBLIC_DIR"
|
||||||
|
)
|
||||||
|
if [ -n "${POSTFIXADMIN_RS_SSH_IDENTITY:-}" ]; then
|
||||||
|
SYNC_ARGS+=(--identity "$POSTFIXADMIN_RS_SSH_IDENTITY")
|
||||||
|
fi
|
||||||
|
if [ -n "${POSTFIXADMIN_RS_SSH_KNOWN_HOSTS:-}" ]; then
|
||||||
|
SYNC_ARGS+=(--known-hosts "$POSTFIXADMIN_RS_SSH_KNOWN_HOSTS")
|
||||||
|
fi
|
||||||
|
if python3 "$ROOT/scripts/sync-rspamd-dkim-readonly.py" "${SYNC_ARGS[@]}"; then
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__ENABLED=true
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__KEY_DIR="$RSPAMD_PUBLIC_DIR/keys"
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__SIGNING_CONFIG="$RSPAMD_PUBLIC_DIR/dkim_signing.conf"
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__SELECTOR_MAP="$RSPAMD_PUBLIC_DIR/selectors.map"
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__PATH_MAP="$RSPAMD_PUBLIC_DIR/paths.map"
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__DEFAULT_SELECTOR="$RSPAMD_DEFAULT_SELECTOR"
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__RSPAMADM=/bin/false
|
||||||
|
RSPAMD_SYNC_OK=1
|
||||||
|
else
|
||||||
|
echo "warning: Rspamd DKIM public sync failed; Rspamd DKIM view is disabled for this dev session" >&2
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__ENABLED=false
|
||||||
|
fi
|
||||||
|
elif [ "${POSTFIXADMIN_RS_DEV_RSPAMD_ENABLED:-false}" = "true" ] || [ "${POSTFIXADMIN_RS_DEV_RSPAMD_ENABLED:-false}" = "1" ]; then
|
||||||
|
# Explicit escape hatch for testing a Rspamd installation local to the workstation.
|
||||||
|
export POSTFIXADMIN_RS__RSPAMD_DKIM__ENABLED=true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "error: POSTFIXADMIN_RS_DEV_DB_MODE must be mock or ssh" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ -f "$POSTFIXADMIN_RS_CONFIG" ] || {
|
||||||
|
echo "error: config not found: $POSTFIXADMIN_RS_CONFIG" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "PostfixAdmin RS 0.0.1-dev: http://127.0.0.1:8080"
|
||||||
|
echo "Config: $POSTFIXADMIN_RS_CONFIG"
|
||||||
|
if [ "$DEV_DB_MODE" = "mock" ]; then
|
||||||
|
echo "DB: SQLite mock ($DEV_DB)"
|
||||||
|
echo "Admin: admin@example.test / admin123"
|
||||||
|
echo "D-admin: domain-admin@example.test / domain123"
|
||||||
|
echo "User: user@example.test / user123"
|
||||||
|
echo "Rspamd: DEV mock (.dev/rspamd)"
|
||||||
|
echo "Reset: POSTFIXADMIN_RS_DEV_RESET=1 bash dev.sh"
|
||||||
|
else
|
||||||
|
echo "DB: production through SSH tunnel 127.0.0.1:${LOCAL_DB_PORT} -> ${REMOTE_DB_HOST}:${REMOTE_DB_PORT}"
|
||||||
|
echo "SSH: ${SSH_USER}@${POSTFIXADMIN_RS_SSH_HOST}:${SSH_PORT}"
|
||||||
|
if [ "${DEV_SIEVE_ENABLED:-false}" = "true" ] || [ "${DEV_SIEVE_ENABLED:-false}" = "1" ]; then
|
||||||
|
echo "Sieve: 127.0.0.1:${SIEVE_LOCAL_PORT} -> ${SIEVE_REMOTE_HOST}:${SIEVE_REMOTE_PORT} ($([ "${POSTFIXADMIN_RS_DEV_SIEVE_WRITES:-0}" = "1" ] && echo READ/WRITE || echo READ-ONLY))"
|
||||||
|
fi
|
||||||
|
if [ "${RSPAMD_SYNC_OK:-0}" = "1" ]; then
|
||||||
|
echo "DKIM: Rspamd public DNS records synced over SSH (private keys stay remote)"
|
||||||
|
fi
|
||||||
|
if [ "${POSTFIXADMIN_RS_DEV_DB_WRITES:-0}" = "1" ]; then
|
||||||
|
echo "Access: READ/WRITE (explicitly enabled)"
|
||||||
|
else
|
||||||
|
echo "Access: READ-ONLY (default)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -f "$ENV_FILE" ] && echo "Env: $ENV_FILE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
cargo run --quiet --bin postfixadmin-rs -- --check-config
|
||||||
|
cargo run --bin postfixadmin-rs -- "$@"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Migracja PHP PostfixAdmin -> PostfixAdmin-RS
|
||||||
|
|
||||||
|
1. Backup DB oraz konfiguracji PostfixAdmin/Postfix/Dovecot.
|
||||||
|
2. Skopiuj `config.from-user.toml` jako `/etc/postfixadmin-rs/config.toml`.
|
||||||
|
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`.
|
||||||
|
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.
|
||||||
|
9. Przelacz reverse proxy z PHP/FPM na binarke Rust.
|
||||||
|
10. Zachowaj rollback do PHP do czasu przejscia testow produkcyjnych; dopiero potem usun PHP/FPM.
|
||||||
|
|
||||||
|
Nie uruchamiaj automatycznych migracji hasel w trakcie tej zmiany.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Rspamd DKIM integration (0.0.1-dev)
|
||||||
|
|
||||||
|
PostfixAdmin-RS can manage file-based DKIM signing for Rspamd without growing a large `domain { ... }` block.
|
||||||
|
It uses Rspamd's supported `selector_map` and `path_map` mechanism. DKIM is always per-domain: every domain maps to its own selector and private-key path. `default_selector` is only the default selector name used for new mappings; it is not a shared key.
|
||||||
|
|
||||||
|
## Managed layout
|
||||||
|
|
||||||
|
New keys are stored as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/lib/rspamd/dkim/<domain>/<selector>.private
|
||||||
|
/var/lib/rspamd/dkim/<domain>/<selector>.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.txt` sidecar contains the DNS TXT record returned by `rspamadm dkim_keygen`.
|
||||||
|
The application maintains:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/lib/rspamd/dkim/postfixadmin-rs-selectors.map
|
||||||
|
/var/lib/rspamd/dkim/postfixadmin-rs-paths.map
|
||||||
|
```
|
||||||
|
|
||||||
|
and a small marked block in `/etc/rspamd/local.d/dkim_signing.conf`:
|
||||||
|
|
||||||
|
```ucl
|
||||||
|
# BEGIN PostfixAdmin-RS managed DKIM maps
|
||||||
|
selector_map = "/var/lib/rspamd/dkim/postfixadmin-rs-selectors.map";
|
||||||
|
path_map = "/var/lib/rspamd/dkim/postfixadmin-rs-paths.map";
|
||||||
|
# END PostfixAdmin-RS managed DKIM maps
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing settings such as `auth_only`, `sign_local`, `use_domain`, and `try_fallback` are left untouched.
|
||||||
|
If `selector_map` or `path_map` already exists outside the managed block, PostfixAdmin-RS refuses to edit the file instead of creating ambiguous Rspamd configuration.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
Do not run the web service as root. The service account needs write access only to the DKIM key directory, the two map files, and (if automatic bootstrap is desired) `dkim_signing.conf`.
|
||||||
|
Rspamd needs read access to private keys.
|
||||||
|
|
||||||
|
A typical layout is the dedicated `postfixadmin` service user whose files inherit the `_rspamd` group from a setgid DKIM directory. Keep private keys at mode `0640`.
|
||||||
|
|
||||||
|
Reload is deliberately an argv-only configured command. If automatic reload is wanted, use a tightly scoped sudoers rule that permits only `systemctl reload rspamd`, then configure:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[rspamd_dkim]
|
||||||
|
reload_command = ["/usr/bin/sudo", "-n", "/usr/bin/systemctl", "reload", "rspamd"]
|
||||||
|
```
|
||||||
|
|
||||||
|
PostfixAdmin-RS never invokes `sh -c` for this integration.
|
||||||
|
|
||||||
|
## Existing keys
|
||||||
|
|
||||||
|
Use **Podepnij klucz** in the DKIM page. The file must already exist below `rspamd_dkim.key_dir`; arbitrary files outside that tree cannot be adopted or deleted.
|
||||||
|
This is useful for existing layouts such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/var/lib/rspamd/dkim/example.org/default.private
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the selector to the selector already published in DNS (for example `mail`). The filename does not need to match the selector when adopting an existing key.
|
||||||
|
|
||||||
|
## Generation flow
|
||||||
|
|
||||||
|
1. Validate domain/selector and admin scope.
|
||||||
|
2. Generate an RSA key with `rspamadm dkim_keygen`.
|
||||||
|
3. Store private key with restrictive permissions and save the DNS record sidecar.
|
||||||
|
4. Atomically update selector/path maps.
|
||||||
|
5. Run `rspamadm configtest`.
|
||||||
|
6. Reload Rspamd if a reload command is configured.
|
||||||
|
|
||||||
|
If configuration testing fails while bootstrapping the managed block, the previous `dkim_signing.conf` is restored.
|
||||||
|
|
||||||
|
## Import obecnego `domain { ... }`
|
||||||
|
|
||||||
|
Global-admin moze uzyc **Importuj istniejące domain{}**. Parser bierze proste domenowe bloki z obecnego `dkim_signing.conf`, odczytuje `path` i `selector`, odrzuca sciezki spoza `rspamd_dkim.key_dir`, a nastepnie przenosi poprawne wpisy do map.
|
||||||
|
|
||||||
|
Przed migracja powstaje kopia `<key_dir>/dkim_signing.conf.pre-postfixadmin-rs`. Tylko poprawnie zaimportowane bloki domenowe sa usuwane z aktywnej konfiguracji. Jezeli zapis lub `rspamadm configtest` nie przejdzie, aplikacja przywraca poprzednia konfiguracje i mapy. To jest istotne, bo istniejace per-domain `path`/`selector` moga miec pierwszenstwo przed wartosciami z map.
|
||||||
|
|
||||||
|
Rspamd obserwuje lokalne pliki map i odswieza je po zmianach, wiec po jednorazowym zaladowaniu konfiguracji `selector_map/path_map` kolejne zmiany map nie wymagaja przebudowy wielkiego bloku `domain { ... }`. Opcjonalny reload pozostaje dostepny, gdy chcesz wymusic natychmiastowe przeladowanie procesu.
|
||||||
|
|
||||||
|
## Mailbox-user read-only view
|
||||||
|
|
||||||
|
When `[dkim].user_read_only = true`, a mailbox user can open DKIM and see the complete public DNS record for their own domain. The API may return records from both the PostfixAdmin DKIM tables and Rspamd, but it never exposes private-key bytes or private-key paths to mailbox users.
|
||||||
|
|
||||||
|
In SSH development mode the application cannot directly read `/var/lib/rspamd/dkim` on the remote mail server. `dev.sh` therefore creates a temporary public-only cache under `.dev/rspamd-remote-public`: it reads the remote maps/config and derives the public key with OpenSSL on the remote host. Only the final public TXT record is copied back; private key bytes never leave the mail server. Disable this development helper with `POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC=false`.
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
EXCLUDED_DIRS = {'.git', '.dev', 'target', 'dist', 'packages', '__pycache__'}
|
||||||
|
EXCLUDED_NAMES = {'.env'}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f'usage: {Path(sys.argv[0]).name} OUTPUT.zip', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
output = Path(sys.argv[1]).resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
prefix = f'PostfixAdmin-RS-{project_version()}'
|
||||||
|
with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||||
|
for path in sorted(ROOT.rglob('*')):
|
||||||
|
if not path.is_file() or path.resolve() == output:
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(ROOT)
|
||||||
|
if any(part in EXCLUDED_DIRS for part in rel.parts) or rel.name in EXCLUDED_NAMES:
|
||||||
|
continue
|
||||||
|
archive.write(path, f'{prefix}/{rel.as_posix()}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def project_version() -> str:
|
||||||
|
import tomllib
|
||||||
|
with (ROOT / 'Cargo.toml').open('rb') as handle:
|
||||||
|
return tomllib.load(handle)['package']['version']
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
efb760202f137507083cff11b0c6198ea498891876c70845ca63c64b9375acbd /packages/postfixadmin-rs_0.0.1~dev_amd64.deb
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Environment overrides for PostfixAdmin-RS.
|
||||||
|
# Nested TOML keys use double underscores, for example:
|
||||||
|
# POSTFIXADMIN_RS__DATABASE__PASSWORD=change-me
|
||||||
|
# RUST_LOG=postfixadmin_rs=info,tower_http=info
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PostfixAdmin-RS mail administration panel
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=postfixadmin
|
||||||
|
Group=postfixadmin
|
||||||
|
WorkingDirectory=/var/lib/postfixadmin-rs
|
||||||
|
Environment=POSTFIXADMIN_RS_CONFIG=/etc/postfixadmin-rs/config.toml
|
||||||
|
EnvironmentFile=-/etc/default/postfixadmin-rs
|
||||||
|
ExecStart=/usr/bin/postfixadmin-rs
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3s
|
||||||
|
UMask=0077
|
||||||
|
StateDirectory=postfixadmin-rs
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.99.1"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
Executable
+151
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT="$ROOT/packages"
|
||||||
|
IMAGE="${POSTFIXADMIN_RS_DEB_IMAGE:-debian:12-slim}"
|
||||||
|
TOOLCHAIN="$(sed -n 's/^channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/rust-toolchain.toml" | head -n1)"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "error: docker is required" >&2; exit 1; }
|
||||||
|
[ -n "$TOOLCHAIN" ] || { echo "error: cannot read Rust toolchain" >&2; exit 1; }
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
printf 'Building Debian package with %s / Rust %s\n' "$IMAGE" "$TOOLCHAIN"
|
||||||
|
printf 'Output: %s\n' "$OUT"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
|
-e RUSTUP_HOME=/opt/rustup \
|
||||||
|
-e CARGO_HOME=/opt/cargo \
|
||||||
|
-e POSTFIXADMIN_RS_TOOLCHAIN="$TOOLCHAIN" \
|
||||||
|
-v "$ROOT:/src:ro" \
|
||||||
|
-v "$OUT:/packages" \
|
||||||
|
-v postfixadmin-rs-cargo-registry:/opt/cargo/registry \
|
||||||
|
-v postfixadmin-rs-cargo-git:/opt/cargo/git \
|
||||||
|
-v postfixadmin-rs-rustup:/opt/rustup \
|
||||||
|
"$IMAGE" bash -euxo pipefail -c '
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev dpkg-dev python3
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
if [ ! -x /opt/cargo/bin/rustup ]; then
|
||||||
|
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal
|
||||||
|
fi
|
||||||
|
export PATH="/opt/cargo/bin:$PATH"
|
||||||
|
rustup toolchain install "$POSTFIXADMIN_RS_TOOLCHAIN" --profile minimal
|
||||||
|
rustup default "$POSTFIXADMIN_RS_TOOLCHAIN"
|
||||||
|
rustup component add rustfmt clippy --toolchain "$POSTFIXADMIN_RS_TOOLCHAIN"
|
||||||
|
|
||||||
|
rm -rf /work/postfixadmin-rs /work/pkg
|
||||||
|
mkdir -p /work/postfixadmin-rs
|
||||||
|
cp -a /src/. /work/postfixadmin-rs/
|
||||||
|
cd /work/postfixadmin-rs
|
||||||
|
|
||||||
|
python3 ./scripts/validate-release-static.py
|
||||||
|
cargo generate-lockfile
|
||||||
|
cargo fmt --all
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo test
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
VERSION="$(sed -n "s/^version[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" Cargo.toml | head -n1)"
|
||||||
|
DEB_VERSION="${VERSION/-dev/~dev}"
|
||||||
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
PKG=/work/pkg
|
||||||
|
|
||||||
|
install -d "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/etc/postfixadmin-rs" "$PKG/etc/default" "$PKG/lib/systemd/system" "$PKG/usr/share/doc/postfixadmin-rs"
|
||||||
|
install -m 0755 target/release/postfixadmin-rs "$PKG/usr/bin/postfixadmin-rs"
|
||||||
|
install -m 0640 config.example.toml "$PKG/etc/postfixadmin-rs/config.toml"
|
||||||
|
install -m 0644 packaging/postfixadmin-rs.default "$PKG/etc/default/postfixadmin-rs"
|
||||||
|
install -m 0644 packaging/postfixadmin-rs.service "$PKG/lib/systemd/system/postfixadmin-rs.service"
|
||||||
|
install -m 0644 README.md SECURITY.md COMPATIBILITY.md PORT-PARITY.md "$PKG/usr/share/doc/postfixadmin-rs/"
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/control" <<CONTROL
|
||||||
|
Package: postfixadmin-rs
|
||||||
|
Version: $DEB_VERSION
|
||||||
|
Section: admin
|
||||||
|
Priority: optional
|
||||||
|
Architecture: $ARCH
|
||||||
|
Maintainer: local build <root@localhost>
|
||||||
|
Depends: adduser, libc6 (>= 2.36), libgcc-s1, libssl3, ca-certificates
|
||||||
|
Description: Rust web administration panel for Postfix virtual mail data
|
||||||
|
PostfixAdmin-RS is a Rust administration panel compatible with PostfixAdmin
|
||||||
|
database layouts. This is a development package built locally.
|
||||||
|
CONTROL
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/conffiles" <<CONFFILES
|
||||||
|
/etc/postfixadmin-rs/config.toml
|
||||||
|
/etc/default/postfixadmin-rs
|
||||||
|
CONFFILES
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/postinst" <<'POSTINST'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Dedicated unprivileged runtime identity. Preserve an existing account if present.
|
||||||
|
if ! getent group postfixadmin >/dev/null 2>&1; then
|
||||||
|
addgroup --system postfixadmin >/dev/null
|
||||||
|
fi
|
||||||
|
if ! getent passwd postfixadmin >/dev/null 2>&1; then
|
||||||
|
adduser --system \
|
||||||
|
--ingroup postfixadmin \
|
||||||
|
--home /var/lib/postfixadmin-rs \
|
||||||
|
--no-create-home \
|
||||||
|
--disabled-login \
|
||||||
|
--shell /usr/sbin/nologin \
|
||||||
|
postfixadmin >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Runtime/state directory is writable by the service account.
|
||||||
|
install -d -o postfixadmin -g postfixadmin -m 0750 /var/lib/postfixadmin-rs
|
||||||
|
|
||||||
|
# Configuration stays root-owned: the service can read secrets but cannot edit them.
|
||||||
|
install -d -o root -g postfixadmin -m 0750 /etc/postfixadmin-rs
|
||||||
|
if [ -f /etc/postfixadmin-rs/config.toml ]; then
|
||||||
|
chown root:postfixadmin /etc/postfixadmin-rs/config.toml
|
||||||
|
chmod 0640 /etc/postfixadmin-rs/config.toml
|
||||||
|
fi
|
||||||
|
if [ -f /etc/default/postfixadmin-rs ]; then
|
||||||
|
chown root:postfixadmin /etc/default/postfixadmin-rs
|
||||||
|
chmod 0640 /etc/default/postfixadmin-rs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
printf "%s\\n" "PostfixAdmin-RS installed. Configure /etc/postfixadmin-rs/config.toml, then run: systemctl enable --now postfixadmin-rs"
|
||||||
|
exit 0
|
||||||
|
POSTINST
|
||||||
|
chmod 0755 "$PKG/DEBIAN/postinst"
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/prerm" <<'PRERM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "${1:-}" = remove ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop postfixadmin-rs.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
PRERM
|
||||||
|
chmod 0755 "$PKG/DEBIAN/prerm"
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/postrm" <<'POSTRM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
POSTRM
|
||||||
|
chmod 0755 "$PKG/DEBIAN/postrm"
|
||||||
|
|
||||||
|
grep -qx "User=postfixadmin" "$PKG/lib/systemd/system/postfixadmin-rs.service"
|
||||||
|
grep -qx "Group=postfixadmin" "$PKG/lib/systemd/system/postfixadmin-rs.service"
|
||||||
|
test -f "$PKG/etc/postfixadmin-rs/config.toml"
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build "$PKG" "/packages/postfixadmin-rs_${DEB_VERSION}_${ARCH}.deb"
|
||||||
|
sha256sum "/packages/postfixadmin-rs_${DEB_VERSION}_${ARCH}.deb" > "/packages/postfixadmin-rs_${DEB_VERSION}_${ARCH}.deb.sha256"
|
||||||
|
'
|
||||||
|
|
||||||
|
printf '\nPackages created:\n'
|
||||||
|
find "$OUT" -maxdepth 1 -type f -name 'postfixadmin-rs_*.deb*' -printf ' %f\n' | sort
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
python3 ./scripts/validate-release-static.py
|
||||||
|
command -v cargo >/dev/null 2>&1 || { echo "error: Rust/Cargo is required" >&2; exit 1; }
|
||||||
|
[ -f Cargo.lock ] || cargo generate-lockfile
|
||||||
|
cargo fmt --all
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo test --locked
|
||||||
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
|
cargo build --release --locked
|
||||||
|
"$ROOT/target/release/postfixadmin-rs" --version
|
||||||
|
printf 'Built: %s\n' "$ROOT/target/release/postfixadmin-rs"
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
./scripts/build-release.sh
|
||||||
|
VERSION="$(python3 - <<'PY'
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
with Path('Cargo.toml').open('rb') as fh:
|
||||||
|
print(tomllib.load(fh)['package']['version'])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
OUT_DIR="${1:-$ROOT/dist}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
ARCHIVE="$OUT_DIR/PostfixAdmin-RS-$VERSION.zip"
|
||||||
|
CHECKSUM="$ARCHIVE.sha256"
|
||||||
|
python3 make_zip.py "$ARCHIVE"
|
||||||
|
(
|
||||||
|
cd "$(dirname "$ARCHIVE")"
|
||||||
|
sha256sum "$(basename "$ARCHIVE")" > "$(basename "$CHECKSUM")"
|
||||||
|
)
|
||||||
|
printf 'Release archive: %s\n' "$ARCHIVE"
|
||||||
|
printf 'SHA256: '
|
||||||
|
cat "$CHECKSUM"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
printf '%s\n' '==> cargo fmt'
|
||||||
|
cargo fmt --all
|
||||||
|
printf '%s\n' '==> cargo check'
|
||||||
|
cargo check
|
||||||
|
printf '%s\n' '==> cargo test'
|
||||||
|
cargo test
|
||||||
|
printf '%s\n' '==> cargo clippy --all-targets -- -D warnings'
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
printf '%s\n' 'quality gate: OK'
|
||||||
Executable
+217
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a local, public-only Rspamd DKIM cache from a remote SSH host.
|
||||||
|
|
||||||
|
The helper never downloads private key bytes. It reads only Rspamd mapping/config
|
||||||
|
metadata and derives the public key on the remote host with OpenSSL.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Entry:
|
||||||
|
domain: str
|
||||||
|
selector: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--ssh", default="ssh")
|
||||||
|
p.add_argument("--host", required=True)
|
||||||
|
p.add_argument("--user", required=True)
|
||||||
|
p.add_argument("--port", type=int, default=22)
|
||||||
|
p.add_argument("--identity", default="")
|
||||||
|
p.add_argument("--known-hosts", default="")
|
||||||
|
p.add_argument("--signing-config", required=True)
|
||||||
|
p.add_argument("--selector-map", required=True)
|
||||||
|
p.add_argument("--path-map", required=True)
|
||||||
|
p.add_argument("--key-dir", required=True)
|
||||||
|
p.add_argument("--default-selector", default="mail")
|
||||||
|
p.add_argument("--output-dir", required=True)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_base(a: argparse.Namespace) -> list[str]:
|
||||||
|
out = [a.ssh, "-T", "-p", str(a.port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]
|
||||||
|
if a.identity:
|
||||||
|
out += ["-i", a.identity]
|
||||||
|
if a.known_hosts:
|
||||||
|
out += ["-o", f"UserKnownHostsFile={a.known_hosts}"]
|
||||||
|
out.append(f"{a.user}@{a.host}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def remote(a: argparse.Namespace, command: str, *, check: bool = False) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
return subprocess.run(ssh_base(a) + [command], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check)
|
||||||
|
|
||||||
|
|
||||||
|
def read_remote_text(a: argparse.Namespace, path: str) -> str:
|
||||||
|
cmd = f"test -r {shlex.quote(path)} && cat -- {shlex.quote(path)} || true"
|
||||||
|
cp = remote(a, cmd)
|
||||||
|
return cp.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_map(text: str) -> dict[str, str]:
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
result[parts[0].lower()] = parts[1]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def unquote_assignment(line: str, key: str) -> str | None:
|
||||||
|
m = re.match(rf"^\s*{re.escape(key)}\s*=\s*\"((?:\\.|[^\"])*)\"\s*;?\s*,?\s*$", line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return bytes(m.group(1), "utf-8").decode("unicode_escape")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_legacy(text: str, default_selector: str) -> list[Entry]:
|
||||||
|
entries: list[Entry] = []
|
||||||
|
in_domain = False
|
||||||
|
depth = 0
|
||||||
|
domain: str | None = None
|
||||||
|
selector = ""
|
||||||
|
path = ""
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.split("#", 1)[0].strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if not in_domain:
|
||||||
|
if line.startswith("domain") and "{" in line:
|
||||||
|
in_domain = True
|
||||||
|
depth = 1
|
||||||
|
continue
|
||||||
|
if depth == 1 and domain is None and "{" in line:
|
||||||
|
name = line.split("{", 1)[0].strip().rstrip(",").strip('"')
|
||||||
|
if name and "=" not in name:
|
||||||
|
domain = name.lower()
|
||||||
|
selector = ""
|
||||||
|
path = ""
|
||||||
|
if domain is not None and depth >= 2:
|
||||||
|
selector = unquote_assignment(line, "selector") or selector
|
||||||
|
path = unquote_assignment(line, "path") or path
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
if domain is not None and depth == 1 and "}" in line:
|
||||||
|
if path:
|
||||||
|
entries.append(Entry(domain, selector or default_selector, path))
|
||||||
|
domain = None
|
||||||
|
selector = ""
|
||||||
|
path = ""
|
||||||
|
if depth <= 0:
|
||||||
|
break
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def under_key_dir(path: str, key_dir: str) -> bool:
|
||||||
|
normalized = posixpath.normpath(path)
|
||||||
|
normalized_root = posixpath.normpath(key_dir)
|
||||||
|
if not normalized.startswith("/") or not normalized_root.startswith("/"):
|
||||||
|
return False
|
||||||
|
p = pathlib.PurePosixPath(normalized)
|
||||||
|
root = pathlib.PurePosixPath(normalized_root)
|
||||||
|
return p == root or root in p.parents
|
||||||
|
|
||||||
|
|
||||||
|
def remote_sidecar(a: argparse.Namespace, private_path: str) -> str:
|
||||||
|
sidecar = str(pathlib.PurePosixPath(private_path).with_suffix(".txt"))
|
||||||
|
text = read_remote_text(a, sidecar).strip()
|
||||||
|
return text if "DKIM1" in text.upper() and "p=" in text else ""
|
||||||
|
|
||||||
|
|
||||||
|
def remote_public_record(a: argparse.Namespace, private_path: str, domain: str, selector: str) -> str:
|
||||||
|
existing = remote_sidecar(a, private_path)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
q = shlex.quote(private_path)
|
||||||
|
cmd = (
|
||||||
|
"pub=$("
|
||||||
|
f"(openssl pkey -in {q} -pubout -outform DER 2>/dev/null "
|
||||||
|
f"|| openssl rsa -in {q} -pubout -outform DER 2>/dev/null) "
|
||||||
|
"| openssl base64 -A 2>/dev/null"
|
||||||
|
") && printf '__PA_DKIM__%s\\n' \"$pub\""
|
||||||
|
)
|
||||||
|
cp = remote(a, cmd)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return ""
|
||||||
|
text = cp.stdout.decode("ascii", "ignore")
|
||||||
|
marker = "__PA_DKIM__"
|
||||||
|
line = next((line for line in text.splitlines() if line.startswith(marker)), "")
|
||||||
|
public = line[len(marker):].strip()
|
||||||
|
if not public or not re.fullmatch(r"[A-Za-z0-9+/=]+", public):
|
||||||
|
return ""
|
||||||
|
return f'{selector}._domainkey.{domain} IN TXT "v=DKIM1; k=rsa; p={public}"'
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
a = parse_args()
|
||||||
|
out = pathlib.Path(a.output_dir).resolve()
|
||||||
|
keys = out / "keys"
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
keys.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
signing = read_remote_text(a, a.signing_config)
|
||||||
|
selectors = parse_map(read_remote_text(a, a.selector_map))
|
||||||
|
paths = parse_map(read_remote_text(a, a.path_map))
|
||||||
|
|
||||||
|
for entry in parse_legacy(signing, a.default_selector):
|
||||||
|
selectors.setdefault(entry.domain, entry.selector)
|
||||||
|
paths.setdefault(entry.domain, entry.path)
|
||||||
|
|
||||||
|
local_selectors: dict[str, str] = {}
|
||||||
|
local_paths: dict[str, str] = {}
|
||||||
|
synced = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for domain in sorted(set(selectors) | set(paths)):
|
||||||
|
selector = selectors.get(domain, a.default_selector)
|
||||||
|
raw_path = paths.get(domain)
|
||||||
|
if not raw_path:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
remote_path = raw_path.replace("$domain", domain).replace("$selector", selector)
|
||||||
|
if not under_key_dir(remote_path, a.key_dir):
|
||||||
|
print(f"warning: skip {domain}: key path is outside {a.key_dir}", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
record = remote_public_record(a, remote_path, domain, selector)
|
||||||
|
if not record:
|
||||||
|
print(f"warning: skip {domain}: unable to derive public DKIM record", file=sys.stderr)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
domain_dir = keys / domain
|
||||||
|
domain_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_private = domain_dir / f"{selector}.private"
|
||||||
|
local_private.with_suffix(".txt").write_text(record.strip() + "\n", encoding="utf-8")
|
||||||
|
local_selectors[domain] = selector
|
||||||
|
local_paths[domain] = str(local_private)
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
(out / "selectors.map").write_text(
|
||||||
|
"# Public-only development cache generated over SSH.\n" + "".join(f"{k} {v}\n" for k, v in sorted(local_selectors.items())),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(out / "paths.map").write_text(
|
||||||
|
"# Public-only development cache generated over SSH.\n" + "".join(f"{k} {v}\n" for k, v in sorted(local_paths.items())),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(out / "dkim_signing.conf").write_text("# Public-only development cache; no private keys are stored here.\n", encoding="utf-8")
|
||||||
|
print(f"Rspamd DKIM public cache: {synced} domain(s), {skipped} skipped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+260
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
errors.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
def load_toml(path: Path):
|
||||||
|
try:
|
||||||
|
with path.open('rb') as handle:
|
||||||
|
return tomllib.load(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
fail(f'{path.relative_to(ROOT)}: invalid TOML: {exc}')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
cargo = load_toml(ROOT / 'Cargo.toml')
|
||||||
|
toolchain = load_toml(ROOT / 'rust-toolchain.toml')
|
||||||
|
package = cargo.get('package', {})
|
||||||
|
if package.get('edition') != '2024':
|
||||||
|
fail('Cargo.toml must use edition 2024')
|
||||||
|
if package.get('rust-version') != '1.99.1':
|
||||||
|
fail('Cargo.toml rust-version must be 1.99.1')
|
||||||
|
if package.get('version') != '0.0.1-dev':
|
||||||
|
fail('Cargo.toml version must be 0.0.1-dev during the development phase')
|
||||||
|
if toolchain.get('toolchain', {}).get('channel') != '1.99.1':
|
||||||
|
fail('rust-toolchain.toml must pin 1.99.1')
|
||||||
|
|
||||||
|
for path in ROOT.glob('config*.toml'):
|
||||||
|
load_toml(path)
|
||||||
|
|
||||||
|
for path in (ROOT / 'src/web/i18n').glob('*.json'):
|
||||||
|
try:
|
||||||
|
json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except Exception as exc:
|
||||||
|
fail(f'{path.relative_to(ROOT)}: invalid JSON: {exc}')
|
||||||
|
|
||||||
|
for kind, extension in [('css', '.css'), ('js', '.js')]:
|
||||||
|
directory = ROOT / f'src/web/{kind}'
|
||||||
|
manifest = directory / 'manifest.txt'
|
||||||
|
entries = []
|
||||||
|
for raw in manifest.read_text(encoding='utf-8').splitlines():
|
||||||
|
name = raw.strip()
|
||||||
|
if not name or name.startswith('#'):
|
||||||
|
continue
|
||||||
|
if '/' in name or '\\' in name or '..' in name or not name.endswith(extension):
|
||||||
|
fail(f'{manifest.relative_to(ROOT)}: unsafe entry {name!r}')
|
||||||
|
continue
|
||||||
|
entries.append(name)
|
||||||
|
if not (directory / name).is_file():
|
||||||
|
fail(f'{manifest.relative_to(ROOT)}: missing {name}')
|
||||||
|
actual = sorted(p.name for p in directory.glob(f'*{extension}'))
|
||||||
|
if sorted(entries) != actual:
|
||||||
|
fail(f'{manifest.relative_to(ROOT)} does not list exactly all {extension} files: manifest={sorted(entries)}, files={actual}')
|
||||||
|
|
||||||
|
index = (ROOT / 'src/web/index.html').read_text(encoding='utf-8')
|
||||||
|
if re.search(r'<script(?![^>]*\bsrc=)[^>]*>', index, flags=re.I):
|
||||||
|
fail('index.html contains an inline script')
|
||||||
|
if re.search(r'\sstyle\s*=', index, flags=re.I):
|
||||||
|
fail('index.html contains inline style attributes')
|
||||||
|
|
||||||
|
main = (ROOT / 'src/main.rs').read_text(encoding='utf-8')
|
||||||
|
csp_match = re.search(r'content-security-policy.*?HeaderValue::from_static\("([^"]+)"\)', main, flags=re.S)
|
||||||
|
if not csp_match:
|
||||||
|
fail('main.rs: CSP header not found')
|
||||||
|
else:
|
||||||
|
csp = csp_match.group(1)
|
||||||
|
if "'unsafe-inline'" in csp or "'unsafe-eval'" in csp:
|
||||||
|
fail('CSP must not contain unsafe-inline/unsafe-eval')
|
||||||
|
|
||||||
|
for path in ROOT.rglob('*'):
|
||||||
|
if not path.is_file() or any(part in {'target', 'dist', '.git'} for part in path.parts):
|
||||||
|
continue
|
||||||
|
if path.suffix.lower() == '.php':
|
||||||
|
fail(f'PHP file present: {path.relative_to(ROOT)}')
|
||||||
|
|
||||||
|
rust_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src').rglob('*.rs'))
|
||||||
|
for forbidden in ['Command::new("sh")', 'Command::new("bash")', '.arg("-c")']:
|
||||||
|
if forbidden in rust_text:
|
||||||
|
fail(f'forbidden shell execution marker: {forbidden}')
|
||||||
|
|
||||||
|
required_markers = [
|
||||||
|
'php_crypt:MD5',
|
||||||
|
'hash_internal_secret',
|
||||||
|
'/auth/recovery/request',
|
||||||
|
'/alias-domains',
|
||||||
|
'/dkim/signing',
|
||||||
|
'/security/totp-exceptions',
|
||||||
|
'/security/app-passwords',
|
||||||
|
'/aliases/delete-inactive/{domain}',
|
||||||
|
'/preferences/{key}',
|
||||||
|
'SUPPORTED_SCHEMES',
|
||||||
|
'database.dev_mock',
|
||||||
|
'quota_level_med_pct',
|
||||||
|
'quota_level_high_pct',
|
||||||
|
'generate_password',
|
||||||
|
'show_password',
|
||||||
|
'content-security-policy',
|
||||||
|
]
|
||||||
|
project_text = rust_text + '\n' + (ROOT / 'src/routes/mod.rs').read_text(encoding='utf-8')
|
||||||
|
for marker in required_markers:
|
||||||
|
if marker not in project_text:
|
||||||
|
fail(f'missing expected compatibility marker: {marker}')
|
||||||
|
|
||||||
|
user_config = (ROOT / 'config.from-user.toml').read_text(encoding='utf-8')
|
||||||
|
if re.search(r'^password\s*=\s*"[^"\n]+"', user_config, flags=re.M):
|
||||||
|
fail('config.from-user.toml contains a database password')
|
||||||
|
env_example = (ROOT / '.env.example').read_text(encoding='utf-8')
|
||||||
|
if 'POSTFIXADMIN_RS__DATABASE__PASSWORD=CHANGE_ME' not in env_example:
|
||||||
|
fail('.env.example must contain a non-secret placeholder')
|
||||||
|
|
||||||
|
|
||||||
|
js_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src/web/js').glob('*.js'))
|
||||||
|
if 'style=' in js_text or '.style.' in js_text:
|
||||||
|
fail('frontend JS contains inline style mutations that are blocked by CSP')
|
||||||
|
for forbidden in ['prompt(', 'confirm(', 'alert(']:
|
||||||
|
if forbidden in js_text:
|
||||||
|
fail(f'native browser dialog remains in frontend: {forbidden}')
|
||||||
|
for marker in ['/rspamd-dkim/generate', 'selector_map', 'path_map', 'RspamdDkimConfig']:
|
||||||
|
if marker not in project_text + '\n' + (ROOT / 'src/config.rs').read_text(encoding='utf-8'):
|
||||||
|
fail(f'missing Rspamd DKIM integration marker: {marker}')
|
||||||
|
|
||||||
|
node = shutil.which('node')
|
||||||
|
if node:
|
||||||
|
for path in sorted((ROOT / 'src/web/js').glob('*.js')):
|
||||||
|
result = subprocess.run([node, '--check', str(path)], capture_output=True, text=True)
|
||||||
|
if result.returncode:
|
||||||
|
fail(f'{path.relative_to(ROOT)}: node --check failed: {result.stderr.strip()}')
|
||||||
|
|
||||||
|
for script in [ROOT / 'dev.sh', ROOT / 'scripts/build-release.sh', ROOT / 'scripts/package-release.sh', ROOT / 'scripts/build-deb.sh']:
|
||||||
|
result = subprocess.run(['bash', '-n', str(script)], capture_output=True, text=True)
|
||||||
|
if result.returncode:
|
||||||
|
fail(f'{script.relative_to(ROOT)}: bash -n failed: {result.stderr.strip()}')
|
||||||
|
|
||||||
|
# UI asset cache busting: the SVG sprite is immutable only under a content-hashed URL.
|
||||||
|
ui_rs = (ROOT / 'src/ui.rs').read_text(encoding='utf-8')
|
||||||
|
build_rs = (ROOT / 'build.rs').read_text(encoding='utf-8')
|
||||||
|
core_js = (ROOT / 'src/web/js/00-core.js').read_text(encoding='utf-8')
|
||||||
|
for marker in ['icons.{ASSET_ID}.svg', 'pa-rs-asset']:
|
||||||
|
if marker not in ui_rs + index + core_js:
|
||||||
|
fail(f'missing versioned icon asset marker: {marker}')
|
||||||
|
if 'src/web/icons.svg' not in build_rs or 'let icons = fs::read' not in build_rs:
|
||||||
|
fail('asset id must include icons.svg content')
|
||||||
|
|
||||||
|
# Local Debian packaging must stay Docker-based and emit only into ./packages.
|
||||||
|
deb_script = (ROOT / 'scripts/build-deb.sh').read_text(encoding='utf-8')
|
||||||
|
for marker in ['docker run --rm', 'debian:12-slim', 'dpkg-deb --root-owner-group', 'OUT="$ROOT/packages"']:
|
||||||
|
if marker not in deb_script:
|
||||||
|
fail(f'missing Debian packaging marker: {marker}')
|
||||||
|
|
||||||
|
# Development safety: mock DB must not be accepted as a production backend.
|
||||||
|
config_rs = (ROOT / 'src/config.rs').read_text(encoding='utf-8')
|
||||||
|
if 'database.dev_mock is allowed only with environment = development' not in config_rs:
|
||||||
|
fail('missing development mock environment guard')
|
||||||
|
if 'database.dev_mock requires database.backend = sqlite' not in config_rs:
|
||||||
|
fail('missing development mock SQLite guard')
|
||||||
|
|
||||||
|
passwords_rs = (ROOT / 'src/passwords.rs').read_text(encoding='utf-8')
|
||||||
|
for marker in ['verifies_postfixadmin_reference_vectors', 'verifies_postfixadmin_b64_crypt_vectors', 'php_crypt:MD5', 'courier:md5raw', 'ARGON2ID.B64']:
|
||||||
|
if marker not in passwords_rs:
|
||||||
|
fail(f'missing password compatibility marker/test: {marker}')
|
||||||
|
|
||||||
|
# sqlx::Any + MySQL compatibility: PostfixAdmin stores flags as TINYINT(1),
|
||||||
|
# which the SQLx Any bridge does not expose directly. Keep the normalization
|
||||||
|
# helper and make sure the login path uses it.
|
||||||
|
db_rs = (ROOT / 'src/db.rs').read_text(encoding='utf-8')
|
||||||
|
for marker in ['pub fn cast_i64(&self, expr: &str)', 'CAST({expr} AS SIGNED)', 'self.flag("active", "active")', 'self.flag("superadmin", "superadmin")']:
|
||||||
|
if marker not in db_rs:
|
||||||
|
fail(f'missing MySQL TINYINT/sqlx::Any compatibility marker: {marker}')
|
||||||
|
|
||||||
|
# UI internationalization / development tunnel safety.
|
||||||
|
config_dev = load_toml(ROOT / 'config.dev.toml')
|
||||||
|
if config_dev.get('default_language') != 'en':
|
||||||
|
fail('config.dev.toml must default to English')
|
||||||
|
for lang in ['en', 'pl']:
|
||||||
|
if not (ROOT / f'src/web/i18n/{lang}.json').is_file():
|
||||||
|
fail(f'missing UI language catalog: {lang}')
|
||||||
|
if 'initLanguage' not in js_text or 'languageSelect' not in js_text:
|
||||||
|
fail('frontend language selector/i18n bootstrap is missing')
|
||||||
|
branding_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src/web').rglob('*') if p.is_file())
|
||||||
|
if ('· bez ' + 'PHP') in branding_text or ('Panel poczty bez ' + 'PHP') in branding_text:
|
||||||
|
fail('deprecated PHP-free branding text remains')
|
||||||
|
if 'POSTFIXADMIN_RS_DEV_DB_MODE' not in (ROOT / 'dev.sh').read_text(encoding='utf-8'):
|
||||||
|
fail('development SSH DB mode is missing')
|
||||||
|
if 'POSTFIXADMIN_RS_DEV_DB_WRITES' not in (ROOT / 'dev.sh').read_text(encoding='utf-8'):
|
||||||
|
fail('development production-DB write opt-in is missing')
|
||||||
|
if 'read_only' not in config_rs or 'Database is read-only in this development session' not in project_text:
|
||||||
|
fail('read-only production DB guard is missing')
|
||||||
|
|
||||||
|
|
||||||
|
# Mailbox-user UX and optional ManageSieve integration.
|
||||||
|
sieve_rs = (ROOT / 'src/routes/sieve.rs').read_text(encoding='utf-8')
|
||||||
|
auth_rs = (ROOT / 'src/auth.rs').read_text(encoding='utf-8')
|
||||||
|
routes_mod = (ROOT / 'src/routes/mod.rs').read_text(encoding='utf-8')
|
||||||
|
dev_sh = (ROOT / 'dev.sh').read_text(encoding='utf-8')
|
||||||
|
pages_js = (ROOT / 'src/web/js/10-pages.js').read_text(encoding='utf-8')
|
||||||
|
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']:
|
||||||
|
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']:
|
||||||
|
if marker not in config_rs:
|
||||||
|
fail(f'missing ManageSieve config marker: {marker}')
|
||||||
|
for marker in ['mail_password: Option<Arc<str>>', 'pub fn mail_password', 'update_mail_password']:
|
||||||
|
if marker not in auth_rs:
|
||||||
|
fail(f'missing in-memory ManageSieve session credential marker: {marker}')
|
||||||
|
for marker in ['POSTFIXADMIN_RS_DEV_SIEVE_ENABLED', 'POSTFIXADMIN_RS_DEV_SIEVE_LOCAL_PORT', 'POSTFIXADMIN_RS_DEV_SIEVE_WRITES']:
|
||||||
|
if marker not in dev_sh:
|
||||||
|
fail(f'missing ManageSieve development tunnel marker: {marker}')
|
||||||
|
for marker in ['POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC', 'sync-rspamd-dkim-readonly.py', 'rspamd-remote-public']:
|
||||||
|
if marker not in dev_sh:
|
||||||
|
fail(f'missing read-only Rspamd DKIM SSH sync marker: {marker}')
|
||||||
|
if 'common_record' in config_rs or 'common_selector' in config_rs or 'common_record' in user_config or 'common_selector' in user_config:
|
||||||
|
fail('shared/common DKIM configuration must not exist; DKIM is per-domain')
|
||||||
|
for marker in ['/dkim/records', 'user_read_only', 'read_only_dns_records']:
|
||||||
|
if marker not in project_text + '\n' + pages_js + '\n' + config_rs:
|
||||||
|
fail(f'missing mailbox-user read-only DKIM marker: {marker}')
|
||||||
|
sync_script = ROOT / 'scripts/sync-rspamd-dkim-readonly.py'
|
||||||
|
if not sync_script.is_file():
|
||||||
|
fail('missing public-only Rspamd DKIM SSH sync helper')
|
||||||
|
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']:
|
||||||
|
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:
|
||||||
|
fail('technical session-security blurb must not remain in the frontend')
|
||||||
|
if 'Aktywne moduły' in pages_js:
|
||||||
|
# Allowed only in the administrator branch after the explicit user return.
|
||||||
|
user_branch = pages_js.find("if (S.me.kind === 'user')")
|
||||||
|
module_panel = pages_js.find('Aktywne moduły')
|
||||||
|
if user_branch < 0 or module_panel < user_branch:
|
||||||
|
fail('mailbox-user overview exposes the active-modules panel')
|
||||||
|
if 'ManageSieve credentials are not available; sign in again' not in sieve_rs:
|
||||||
|
fail('ManageSieve routes must require a fresh in-memory mailbox credential')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
print(f'ERROR: {error}', file=sys.stderr)
|
||||||
|
print(f'static validation: FAILED ({len(errors)} issue(s))', file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print('static validation: OK')
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use data_encoding::BASE32_NOPAD;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::AppConfig,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{AccountKind, Role},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub username: String,
|
||||||
|
pub kind: AccountKind,
|
||||||
|
pub role: Role,
|
||||||
|
pub allowed_domains: Arc<[String]>,
|
||||||
|
pub csrf: String,
|
||||||
|
mail_password: Option<Arc<str>>,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn can_manage_domain(&self, domain: &str) -> bool {
|
||||||
|
self.role.is_global()
|
||||||
|
|| self
|
||||||
|
.allowed_domains
|
||||||
|
.iter()
|
||||||
|
.any(|d| d == domain || d == "ALL")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_admin(&self) -> AppResult<()> {
|
||||||
|
if self.role.is_admin() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Administrator role required"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_global(&self) -> AppResult<()> {
|
||||||
|
if self.role.is_global() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Global administrator role required"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_domain(&self, domain: &str) -> AppResult<()> {
|
||||||
|
self.require_admin()?;
|
||||||
|
if self.can_manage_domain(domain) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden(
|
||||||
|
"Domain is outside this administrator's scope",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mail_password(&self) -> Option<&str> {
|
||||||
|
self.mail_password.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PendingLogin {
|
||||||
|
pub username: String,
|
||||||
|
pub kind: AccountKind,
|
||||||
|
pub role: Role,
|
||||||
|
pub allowed_domains: Arc<[String]>,
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub mail_password: Option<Arc<str>>,
|
||||||
|
pub expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthStore {
|
||||||
|
sessions: Arc<DashMap<String, Session>>,
|
||||||
|
pending: Arc<DashMap<String, PendingLogin>>,
|
||||||
|
attempts: Arc<DashMap<String, VecDeque<Instant>>>,
|
||||||
|
timeout: Duration,
|
||||||
|
login_window: Duration,
|
||||||
|
max_attempts: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthStore {
|
||||||
|
pub fn new(config: &AppConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(DashMap::new()),
|
||||||
|
pending: Arc::new(DashMap::new()),
|
||||||
|
attempts: Arc::new(DashMap::new()),
|
||||||
|
timeout: Duration::from_secs(
|
||||||
|
config.security.session_timeout_minutes.saturating_mul(60),
|
||||||
|
),
|
||||||
|
login_window: Duration::from_secs(config.security.login_window_seconds),
|
||||||
|
max_attempts: config.security.login_attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(
|
||||||
|
&self,
|
||||||
|
username: String,
|
||||||
|
kind: AccountKind,
|
||||||
|
role: Role,
|
||||||
|
allowed_domains: Arc<[String]>,
|
||||||
|
mail_password: Option<Arc<str>>,
|
||||||
|
) -> (String, Session) {
|
||||||
|
let token = random_token(32);
|
||||||
|
let session = Session {
|
||||||
|
username,
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
allowed_domains,
|
||||||
|
csrf: random_token(32),
|
||||||
|
mail_password,
|
||||||
|
expires_at: Instant::now() + self.timeout,
|
||||||
|
};
|
||||||
|
self.sessions.insert(token.clone(), session.clone());
|
||||||
|
(token, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session(&self, token: &str) -> Option<Session> {
|
||||||
|
let item = self.sessions.get(token)?;
|
||||||
|
if Instant::now() >= item.expires_at {
|
||||||
|
drop(item);
|
||||||
|
self.sessions.remove(token);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(item.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke(&self, token: &str) {
|
||||||
|
self.sessions.remove(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_mail_password(&self, token: &str, password: String) {
|
||||||
|
if let Some(mut session) = self.sessions.get_mut(token)
|
||||||
|
&& session.kind == AccountKind::User
|
||||||
|
{
|
||||||
|
session.mail_password = Some(Arc::<str>::from(password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_pending(
|
||||||
|
&self,
|
||||||
|
username: String,
|
||||||
|
kind: AccountKind,
|
||||||
|
role: Role,
|
||||||
|
allowed_domains: Arc<[String]>,
|
||||||
|
ip: IpAddr,
|
||||||
|
mail_password: Option<Arc<str>>,
|
||||||
|
) -> String {
|
||||||
|
let token = random_token(32);
|
||||||
|
self.pending.insert(
|
||||||
|
token.clone(),
|
||||||
|
PendingLogin {
|
||||||
|
username,
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
allowed_domains,
|
||||||
|
ip,
|
||||||
|
mail_password,
|
||||||
|
expires_at: Instant::now() + Duration::from_secs(300),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
token
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_pending(&self, token: &str, ip: IpAddr) -> Option<PendingLogin> {
|
||||||
|
let (_, pending) = self.pending.remove(token)?;
|
||||||
|
if pending.ip != ip || Instant::now() >= pending.expires_at {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_allowed(&self, ip: IpAddr, username: &str) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let key = format!("{ip}|{}", username.to_ascii_lowercase());
|
||||||
|
let mut entry = self.attempts.entry(key).or_default();
|
||||||
|
while entry
|
||||||
|
.front()
|
||||||
|
.is_some_and(|t| now.duration_since(*t) > self.login_window)
|
||||||
|
{
|
||||||
|
entry.pop_front();
|
||||||
|
}
|
||||||
|
if entry.len() >= self.max_attempts {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.push_back(now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_attempts(&self, ip: IpAddr, username: &str) {
|
||||||
|
self.attempts
|
||||||
|
.remove(&format!("{ip}|{}", username.to_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_csrf(session: &Session, provided: Option<&str>) -> AppResult<()> {
|
||||||
|
let Some(provided) = provided else {
|
||||||
|
return Err(AppError::forbidden("Missing CSRF token"));
|
||||||
|
};
|
||||||
|
let a = session.csrf.as_bytes();
|
||||||
|
let b = provided.as_bytes();
|
||||||
|
if a.len() == b.len() && bool::from(a.ct_eq(b)) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Invalid CSRF token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_totp(secret: &str, code: &str) -> bool {
|
||||||
|
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let normalized = secret.trim().replace([' ', '-'], "").to_ascii_uppercase();
|
||||||
|
let Ok(key) = BASE32_NOPAD.decode(normalized.as_bytes()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let now = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(v) => v.as_secs(),
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let target = code.parse::<u32>().unwrap_or(u32::MAX);
|
||||||
|
for offset in [-1i64, 0, 1] {
|
||||||
|
let counter = (now / 30) as i64 + offset;
|
||||||
|
if counter < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if totp_code(&key, counter as u64) == target {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn totp_code(key: &[u8], counter: u64) -> u32 {
|
||||||
|
let mut mac = Hmac::<Sha1>::new_from_slice(key).expect("HMAC accepts arbitrary key size");
|
||||||
|
mac.update(&counter.to_be_bytes());
|
||||||
|
let digest = mac.finalize().into_bytes();
|
||||||
|
let offset = (digest[19] & 0x0f) as usize;
|
||||||
|
let binary = ((u32::from(digest[offset]) & 0x7f) << 24)
|
||||||
|
| (u32::from(digest[offset + 1]) << 16)
|
||||||
|
| (u32::from(digest[offset + 2]) << 8)
|
||||||
|
| u32::from(digest[offset + 3]);
|
||||||
|
binary % 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random_token(bytes: usize) -> String {
|
||||||
|
let mut buf = vec![0u8; bytes];
|
||||||
|
rand::rng().fill_bytes(&mut buf);
|
||||||
|
URL_SAFE_NO_PAD.encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn rfc6238_sha1_vector_internal() {
|
||||||
|
let key = b"12345678901234567890";
|
||||||
|
assert_eq!(totp_code(key, 59 / 30), 287082);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1101
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,800 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
use sqlx::{
|
||||||
|
AnyPool, Row,
|
||||||
|
any::{AnyPoolOptions, AnyRow},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{AppConfig, TableNames},
|
||||||
|
error::AppResult,
|
||||||
|
models::{AccountKind, LoginRecord},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Dialect {
|
||||||
|
MySql,
|
||||||
|
Postgres,
|
||||||
|
Sqlite,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Table {
|
||||||
|
Admin,
|
||||||
|
Alias,
|
||||||
|
AliasDomain,
|
||||||
|
Config,
|
||||||
|
Domain,
|
||||||
|
DomainAdmins,
|
||||||
|
Fetchmail,
|
||||||
|
Log,
|
||||||
|
Mailbox,
|
||||||
|
Vacation,
|
||||||
|
VacationNotification,
|
||||||
|
Quota,
|
||||||
|
Quota2,
|
||||||
|
Dkim,
|
||||||
|
DkimSigning,
|
||||||
|
TotpException,
|
||||||
|
MailboxAppPassword,
|
||||||
|
AdminPreferences,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Db {
|
||||||
|
pool: AnyPool,
|
||||||
|
dialect: Dialect,
|
||||||
|
prefix: Arc<str>,
|
||||||
|
tables: Arc<TableNames>,
|
||||||
|
column_cache: Arc<DashMap<String, bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub async fn connect(config: &AppConfig) -> AppResult<Self> {
|
||||||
|
sqlx::any::install_default_drivers();
|
||||||
|
let dialect = match config.database.backend.as_str() {
|
||||||
|
"pgsql" | "postgres" => Dialect::Postgres,
|
||||||
|
"sqlite" => Dialect::Sqlite,
|
||||||
|
_ => Dialect::MySql,
|
||||||
|
};
|
||||||
|
let url = config.database_url()?;
|
||||||
|
let pool = AnyPoolOptions::new()
|
||||||
|
.max_connections(config.database.max_connections)
|
||||||
|
.connect(&url)
|
||||||
|
.await?;
|
||||||
|
Ok(Self {
|
||||||
|
pool,
|
||||||
|
dialect,
|
||||||
|
prefix: Arc::from(config.database.prefix.clone()),
|
||||||
|
tables: Arc::new(config.database.tables.clone()),
|
||||||
|
column_cache: Arc::new(DashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool(&self) -> &AnyPool {
|
||||||
|
&self.pool
|
||||||
|
}
|
||||||
|
pub fn dialect(&self) -> Dialect {
|
||||||
|
self.dialect
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn table(&self, key: Table) -> String {
|
||||||
|
let raw = match key {
|
||||||
|
Table::Admin => &self.tables.admin,
|
||||||
|
Table::Alias => &self.tables.alias,
|
||||||
|
Table::AliasDomain => &self.tables.alias_domain,
|
||||||
|
Table::Config => &self.tables.config,
|
||||||
|
Table::Domain => &self.tables.domain,
|
||||||
|
Table::DomainAdmins => &self.tables.domain_admins,
|
||||||
|
Table::Fetchmail => &self.tables.fetchmail,
|
||||||
|
Table::Log => &self.tables.log,
|
||||||
|
Table::Mailbox => &self.tables.mailbox,
|
||||||
|
Table::Vacation => &self.tables.vacation,
|
||||||
|
Table::VacationNotification => &self.tables.vacation_notification,
|
||||||
|
Table::Quota => &self.tables.quota,
|
||||||
|
Table::Quota2 => &self.tables.quota2,
|
||||||
|
Table::Dkim => &self.tables.dkim,
|
||||||
|
Table::DkimSigning => &self.tables.dkim_signing,
|
||||||
|
Table::TotpException => &self.tables.totp_exception_address,
|
||||||
|
Table::MailboxAppPassword => &self.tables.mailbox_app_password,
|
||||||
|
Table::AdminPreferences => &self.tables.admin_preferences,
|
||||||
|
};
|
||||||
|
self.quote_ident(&format!("{}{}", self.prefix, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quote_ident(&self, ident: &str) -> String {
|
||||||
|
match self.dialect {
|
||||||
|
Dialect::MySql => format!("`{ident}`"),
|
||||||
|
Dialect::Postgres | Dialect::Sqlite => format!("\"{ident}\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn q(&self, template: &str) -> String {
|
||||||
|
if self.dialect != Dialect::Postgres {
|
||||||
|
return template.to_string();
|
||||||
|
}
|
||||||
|
let mut n = 0usize;
|
||||||
|
let mut out = String::with_capacity(template.len() + 16);
|
||||||
|
for ch in template.chars() {
|
||||||
|
if ch == '?' {
|
||||||
|
n += 1;
|
||||||
|
out.push('$');
|
||||||
|
out.push_str(&n.to_string());
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cast_text(&self, expr: &str) -> String {
|
||||||
|
match self.dialect {
|
||||||
|
Dialect::MySql => format!("CAST({expr} AS CHAR)"),
|
||||||
|
Dialect::Postgres | Dialect::Sqlite => format!("CAST({expr} AS TEXT)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize integer-like flag columns before they enter sqlx::Any.
|
||||||
|
///
|
||||||
|
/// MySQL represents PostfixAdmin boolean flags as TINYINT(1). sqlx::Any
|
||||||
|
/// intentionally supports only a common subset of backend type metadata and
|
||||||
|
/// cannot map MySQL's TINY type directly. Casting at the SQL boundary keeps
|
||||||
|
/// the API portable across MySQL/MariaDB, PostgreSQL and SQLite.
|
||||||
|
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)"),
|
||||||
|
Dialect::Sqlite => format!("CAST({expr} AS INTEGER)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flag(&self, expr: &str, alias: &str) -> String {
|
||||||
|
format!("{} AS {alias}", self.cast_i64(expr))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_column(&self, table: Table, column: &str) -> AppResult<bool> {
|
||||||
|
let physical = match table {
|
||||||
|
Table::Admin => &self.tables.admin,
|
||||||
|
Table::Alias => &self.tables.alias,
|
||||||
|
Table::AliasDomain => &self.tables.alias_domain,
|
||||||
|
Table::Config => &self.tables.config,
|
||||||
|
Table::Domain => &self.tables.domain,
|
||||||
|
Table::DomainAdmins => &self.tables.domain_admins,
|
||||||
|
Table::Fetchmail => &self.tables.fetchmail,
|
||||||
|
Table::Log => &self.tables.log,
|
||||||
|
Table::Mailbox => &self.tables.mailbox,
|
||||||
|
Table::Vacation => &self.tables.vacation,
|
||||||
|
Table::VacationNotification => &self.tables.vacation_notification,
|
||||||
|
Table::Quota => &self.tables.quota,
|
||||||
|
Table::Quota2 => &self.tables.quota2,
|
||||||
|
Table::Dkim => &self.tables.dkim,
|
||||||
|
Table::DkimSigning => &self.tables.dkim_signing,
|
||||||
|
Table::TotpException => &self.tables.totp_exception_address,
|
||||||
|
Table::MailboxAppPassword => &self.tables.mailbox_app_password,
|
||||||
|
Table::AdminPreferences => &self.tables.admin_preferences,
|
||||||
|
};
|
||||||
|
let table_name = format!("{}{}", self.prefix, physical);
|
||||||
|
let cache_key = format!("{table_name}.{column}");
|
||||||
|
if let Some(value) = self.column_cache.get(&cache_key) {
|
||||||
|
return Ok(*value);
|
||||||
|
}
|
||||||
|
let exists = match self.dialect {
|
||||||
|
Dialect::MySql => {
|
||||||
|
let sql = self.q("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1");
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&table_name)
|
||||||
|
.bind(column)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
Dialect::Postgres => {
|
||||||
|
let sql = self.q("SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ? LIMIT 1");
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&table_name)
|
||||||
|
.bind(column)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
Dialect::Sqlite => {
|
||||||
|
let pragma = format!("PRAGMA table_info({})", self.quote_ident(&table_name));
|
||||||
|
let rows = sqlx::query(&pragma).fetch_all(&self.pool).await?;
|
||||||
|
rows.iter().any(|r| row_string(r, "name") == column)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.column_cache.insert(cache_key, exists);
|
||||||
|
Ok(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_table(&self, table: Table) -> bool {
|
||||||
|
let name = self.table(table);
|
||||||
|
sqlx::query(&format!("SELECT 1 FROM {name} LIMIT 1"))
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(&self) -> AppResult<()> {
|
||||||
|
sqlx::query("SELECT 1").execute(&self.pool).await?;
|
||||||
|
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.
|
||||||
|
pub async fn initialize_dev_mock(&self, config: &AppConfig) -> AppResult<()> {
|
||||||
|
if !config.database.dev_mock || self.dialect != Dialect::Sqlite {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 admin_preferences = self.table(Table::AdminPreferences);
|
||||||
|
|
||||||
|
let statements = vec![
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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 ''
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS {quota2} (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
messages INTEGER NOT NULL DEFAULT 0
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
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 ''
|
||||||
|
)"#
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for statement in statements {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_schema(&self, config: &AppConfig) -> AppResult<Vec<String>> {
|
||||||
|
let required = [
|
||||||
|
Table::Admin,
|
||||||
|
Table::Domain,
|
||||||
|
Table::DomainAdmins,
|
||||||
|
Table::Alias,
|
||||||
|
Table::Mailbox,
|
||||||
|
];
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
for table in required {
|
||||||
|
if !self.has_table(table).await {
|
||||||
|
warnings.push(format!("missing required table {}", self.table(table)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let optional = [
|
||||||
|
(config.features.logging, Table::Log, "logging"),
|
||||||
|
(
|
||||||
|
config.features.alias_domain,
|
||||||
|
Table::AliasDomain,
|
||||||
|
"alias_domain",
|
||||||
|
),
|
||||||
|
(config.features.fetchmail, Table::Fetchmail, "fetchmail"),
|
||||||
|
(config.features.vacation, Table::Vacation, "vacation"),
|
||||||
|
(
|
||||||
|
config.features.vacation,
|
||||||
|
Table::VacationNotification,
|
||||||
|
"vacation_notification",
|
||||||
|
),
|
||||||
|
(config.features.dkim, Table::Dkim, "dkim"),
|
||||||
|
(config.features.dkim, Table::DkimSigning, "dkim_signing"),
|
||||||
|
(
|
||||||
|
config.features.totp,
|
||||||
|
Table::TotpException,
|
||||||
|
"totp_exception_address",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
config.features.app_passwords,
|
||||||
|
Table::MailboxAppPassword,
|
||||||
|
"mailbox_app_password",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (enabled, table, feature) in optional {
|
||||||
|
if enabled && !self.has_table(table).await {
|
||||||
|
warnings.push(format!(
|
||||||
|
"feature {feature} is enabled but table {} is missing",
|
||||||
|
self.table(table)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.features.totp {
|
||||||
|
for (table, label) in [(Table::Admin, "admin"), (Table::Mailbox, "mailbox")] {
|
||||||
|
if self.has_table(table).await && !self.has_column(table, "totp_secret").await? {
|
||||||
|
warnings.push(format!("TOTP enabled but {label}.totp_secret is missing"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.features.smtp_active_flag
|
||||||
|
&& self.has_table(Table::Mailbox).await
|
||||||
|
&& !self.has_column(Table::Mailbox, "smtp_active").await?
|
||||||
|
{
|
||||||
|
warnings.push("smtp_active_flag enabled but mailbox.smtp_active is missing".into());
|
||||||
|
}
|
||||||
|
if config.features.password_expiration {
|
||||||
|
if self.has_table(Table::Mailbox).await
|
||||||
|
&& !self.has_column(Table::Mailbox, "password_expiry").await?
|
||||||
|
{
|
||||||
|
warnings.push(
|
||||||
|
"password_expiration enabled but mailbox.password_expiry is missing".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.has_table(Table::Domain).await
|
||||||
|
&& !self.has_column(Table::Domain, "password_expiry").await?
|
||||||
|
{
|
||||||
|
warnings.push(
|
||||||
|
"password_expiration enabled but domain.password_expiry is missing".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (enabled, table, label) in [
|
||||||
|
(
|
||||||
|
config.features.forgotten_user_password_reset,
|
||||||
|
Table::Mailbox,
|
||||||
|
"user password recovery",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
config.features.forgotten_admin_password_reset,
|
||||||
|
Table::Admin,
|
||||||
|
"admin password recovery",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
if enabled && self.has_table(table).await {
|
||||||
|
for column in ["token", "token_validity", "email_other"] {
|
||||||
|
if !self.has_column(table, column).await? {
|
||||||
|
warnings.push(format!(
|
||||||
|
"{label} enabled but {}.{column} is missing",
|
||||||
|
self.table(table)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_record(
|
||||||
|
&self,
|
||||||
|
kind: AccountKind,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<Option<LoginRecord>> {
|
||||||
|
let table_key = match kind {
|
||||||
|
AccountKind::Admin => Table::Admin,
|
||||||
|
AccountKind::User => Table::Mailbox,
|
||||||
|
};
|
||||||
|
let table = self.table(table_key);
|
||||||
|
let active_expr = self.flag("active", "active");
|
||||||
|
let super_expr = if kind == AccountKind::Admin
|
||||||
|
&& self
|
||||||
|
.has_column(Table::Admin, "superadmin")
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
self.flag("superadmin", "superadmin")
|
||||||
|
} else {
|
||||||
|
"0 AS superadmin".to_string()
|
||||||
|
};
|
||||||
|
let totp_expr = if self
|
||||||
|
.has_column(table_key, "totp_secret")
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"totp_secret"
|
||||||
|
} else {
|
||||||
|
"NULL AS totp_secret"
|
||||||
|
};
|
||||||
|
let sql = self.q(&format!("SELECT password, {active_expr}, {super_expr}, {totp_expr} FROM {table} WHERE username = ? LIMIT 1"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| LoginRecord {
|
||||||
|
password: row_string(&row, "password"),
|
||||||
|
active: row_bool(&row, "active"),
|
||||||
|
superadmin: row_bool(&row, "superadmin"),
|
||||||
|
totp_secret: row_opt_string(&row, "totp_secret"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn allowed_domains(&self, username: &str) -> AppResult<Vec<String>> {
|
||||||
|
let table = self.table(Table::DomainAdmins);
|
||||||
|
let sql = self.q(&format!(
|
||||||
|
"SELECT domain FROM {table} WHERE username = ? AND active = ? ORDER BY domain"
|
||||||
|
));
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.bind(true)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(|r| row_string(r, "domain")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn totp_exception(&self, ip: &str, username: &str) -> AppResult<bool> {
|
||||||
|
let table = self.table(Table::TotpException);
|
||||||
|
let domain = username.split_once('@').map(|(_, d)| d).unwrap_or("");
|
||||||
|
let sql = self.q(&format!(
|
||||||
|
"SELECT id FROM {table} WHERE ip = ? AND (username = ? OR username = ? OR username IS NULL) LIMIT 1"
|
||||||
|
));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(ip)
|
||||||
|
.bind(username)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn log(
|
||||||
|
&self,
|
||||||
|
config: &AppConfig,
|
||||||
|
username: &str,
|
||||||
|
domain: &str,
|
||||||
|
action: &str,
|
||||||
|
data: &str,
|
||||||
|
) {
|
||||||
|
if !config.features.logging {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let table = self.table(Table::Log);
|
||||||
|
let sql = self.q(&format!("INSERT INTO {table} (timestamp, username, domain, action, data) VALUES (CURRENT_TIMESTAMP, ?, ?, ?, ?)"));
|
||||||
|
if let Err(error) = sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.bind(domain)
|
||||||
|
.bind(action)
|
||||||
|
.bind(data)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, %username, %domain, %action, "failed to write PostfixAdmin log");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_admin_pref(&self, username: &str, key: &str, value: &str) -> AppResult<()> {
|
||||||
|
self.delete_admin_pref(username, key).await?;
|
||||||
|
let table = self.table(Table::AdminPreferences);
|
||||||
|
let sql = self.q(&format!(
|
||||||
|
"INSERT INTO {table} (username,pref_key,pref_value) VALUES (?,?,?)"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.bind(key)
|
||||||
|
.bind(value)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_admin_pref(&self, username: &str, key: &str) -> AppResult<()> {
|
||||||
|
let table = self.table(Table::AdminPreferences);
|
||||||
|
let sql = self.q(&format!(
|
||||||
|
"DELETE FROM {table} WHERE username=? AND pref_key=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.bind(key)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row_string(row: &AnyRow, name: &str) -> String {
|
||||||
|
row.try_get::<String, _>(name).unwrap_or_default()
|
||||||
|
}
|
||||||
|
pub fn row_opt_string(row: &AnyRow, name: &str) -> Option<String> {
|
||||||
|
row.try_get::<Option<String>, _>(name).unwrap_or_else(|_| {
|
||||||
|
let s = row_string(row, name);
|
||||||
|
if s.is_empty() { None } else { Some(s) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn row_i64(row: &AnyRow, name: &str) -> i64 {
|
||||||
|
row.try_get::<i64, _>(name)
|
||||||
|
.or_else(|_| row.try_get::<i32, _>(name).map(i64::from))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
pub fn row_opt_i64(row: &AnyRow, name: &str) -> Option<i64> {
|
||||||
|
if let Ok(v) = row.try_get::<Option<i64>, _>(name) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<i32>, _>(name) {
|
||||||
|
return v.map(i64::from);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
pub fn row_bool(row: &AnyRow, name: &str) -> bool {
|
||||||
|
row.try_get::<bool, _>(name)
|
||||||
|
.or_else(|_| row.try_get::<i64, _>(name).map(|v| v != 0))
|
||||||
|
.or_else(|_| row.try_get::<i32, _>(name).map(|v| v != 0))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
pub fn row_opt_bool(row: &AnyRow, name: &str) -> Option<bool> {
|
||||||
|
if let Ok(v) = row.try_get::<Option<bool>, _>(name) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = row.try_get::<Option<i64>, _>(name) {
|
||||||
|
return v.map(|x| x != 0);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("{message}")]
|
||||||
|
Http {
|
||||||
|
status: StatusCode,
|
||||||
|
code: &'static str,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("mail error: {0}")]
|
||||||
|
Mail(String),
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorBody<'a> {
|
||||||
|
error: ErrorEnvelope<'a>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorEnvelope<'a> {
|
||||||
|
code: &'a str,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppError {
|
||||||
|
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
code: "bad_request",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::UNAUTHORIZED,
|
||||||
|
code: "unauthorized",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
code: "forbidden",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
code: "not_found",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn conflict(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::CONFLICT,
|
||||||
|
code: "conflict",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn too_many(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
code: "rate_limited",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn bad_gateway(message: impl Into<String>) -> Self {
|
||||||
|
Self::Http {
|
||||||
|
status: StatusCode::BAD_GATEWAY,
|
||||||
|
code: "upstream_error",
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn config(message: impl Into<String>) -> Self {
|
||||||
|
Self::Config(message.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code, message) = match self {
|
||||||
|
AppError::Http {
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
} => (status, code, message),
|
||||||
|
AppError::Database(error) => {
|
||||||
|
tracing::error!(%error, "database request failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"database_error",
|
||||||
|
"Database operation failed".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppError::Io(error) => {
|
||||||
|
tracing::error!(%error, "I/O request failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"io_error",
|
||||||
|
"I/O operation failed".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppError::Mail(error) => {
|
||||||
|
tracing::error!(%error, "SMTP request failed");
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"smtp_error",
|
||||||
|
"SMTP operation failed".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppError::Config(error) => {
|
||||||
|
tracing::error!(%error, "configuration error");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "config_error", error)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(ErrorBody {
|
||||||
|
error: ErrorEnvelope { code, message },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use std::{collections::HashMap, process::Stdio, time::Duration};
|
||||||
|
|
||||||
|
use tokio::{io::AsyncWriteExt, process::Command, time::timeout};
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
pub async fn run(command: &[String], vars: &[(&str, &str)], stdin: Option<&[u8]>) -> AppResult<()> {
|
||||||
|
if command.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let vars: HashMap<&str, &str> = vars.iter().copied().collect();
|
||||||
|
let expand = |value: &str| {
|
||||||
|
let mut out = value.to_string();
|
||||||
|
for (key, replacement) in &vars {
|
||||||
|
out = out.replace(&format!("{{{key}}}"), replacement);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let program = expand(&command[0]);
|
||||||
|
if program.is_empty() {
|
||||||
|
return Err(AppError::config("hook program cannot be empty"));
|
||||||
|
}
|
||||||
|
let mut cmd = Command::new(program);
|
||||||
|
for arg in command.iter().skip(1) {
|
||||||
|
cmd.arg(expand(arg));
|
||||||
|
}
|
||||||
|
cmd.kill_on_drop(true)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if stdin.is_some() {
|
||||||
|
cmd.stdin(Stdio::piped());
|
||||||
|
} else {
|
||||||
|
cmd.stdin(Stdio::null());
|
||||||
|
}
|
||||||
|
let mut child = cmd.spawn()?;
|
||||||
|
if let (Some(data), Some(mut pipe)) = (stdin, child.stdin.take()) {
|
||||||
|
pipe.write_all(data).await?;
|
||||||
|
}
|
||||||
|
let output = timeout(Duration::from_secs(30), child.wait_with_output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::config("hook timed out after 30 seconds"))??;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"hook failed with {}: {}",
|
||||||
|
output.status,
|
||||||
|
stderr.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use lettre::{
|
||||||
|
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||||
|
message::Mailbox,
|
||||||
|
transport::smtp::{authentication::Credentials, extension::ClientId},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::AppConfig,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn transport(config: &AppConfig) -> AppResult<AsyncSmtpTransport<Tokio1Executor>> {
|
||||||
|
let smtp = &config.smtp;
|
||||||
|
let mut builder = match smtp.kind.as_str() {
|
||||||
|
"starttls" => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
||||||
|
.map_err(|e| AppError::Mail(e.to_string()))?,
|
||||||
|
"tls" => AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
||||||
|
.map_err(|e| AppError::Mail(e.to_string()))?,
|
||||||
|
_ => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host),
|
||||||
|
}
|
||||||
|
.port(smtp.port);
|
||||||
|
|
||||||
|
if !smtp.client_name.trim().is_empty() {
|
||||||
|
builder = builder.hello_name(ClientId::Domain(smtp.client_name.trim().to_string()));
|
||||||
|
}
|
||||||
|
if !smtp.username.is_empty() {
|
||||||
|
builder = builder.credentials(Credentials::new(
|
||||||
|
smtp.username.clone(),
|
||||||
|
smtp.password.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sender_named(
|
||||||
|
config: &AppConfig,
|
||||||
|
fallback: &str,
|
||||||
|
display_name: Option<&str>,
|
||||||
|
) -> AppResult<Mailbox> {
|
||||||
|
let address = if config.smtp.admin_email.is_empty() {
|
||||||
|
fallback
|
||||||
|
} else {
|
||||||
|
&config.smtp.admin_email
|
||||||
|
};
|
||||||
|
let parsed = address
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| AppError::bad_request(format!("invalid sender address: {e}")))?;
|
||||||
|
let name = display_name
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(&config.smtp.admin_name);
|
||||||
|
Ok(Mailbox::new(Some(name.to_string()), parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_text(
|
||||||
|
config: &AppConfig,
|
||||||
|
from_fallback: &str,
|
||||||
|
to: &str,
|
||||||
|
subject: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
send_text_named(config, from_fallback, None, to, subject, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_text_named(
|
||||||
|
config: &AppConfig,
|
||||||
|
from_fallback: &str,
|
||||||
|
from_name: Option<&str>,
|
||||||
|
to: &str,
|
||||||
|
subject: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let to: Mailbox = to
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| AppError::bad_request(format!("invalid recipient: {e}")))?;
|
||||||
|
let message = Message::builder()
|
||||||
|
.from(sender_named(config, from_fallback, from_name)?)
|
||||||
|
.to(to)
|
||||||
|
.subject(subject)
|
||||||
|
.body(body.to_owned())
|
||||||
|
.map_err(|e| AppError::Mail(e.to_string()))?;
|
||||||
|
transport(config)?
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Mail(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod hooks;
|
||||||
|
mod mailer;
|
||||||
|
mod models;
|
||||||
|
mod passwords;
|
||||||
|
mod routes;
|
||||||
|
mod sieve;
|
||||||
|
mod state;
|
||||||
|
mod ui;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
http::{HeaderName, HeaderValue, StatusCode},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use tower_http::{set_header::SetResponseHeaderLayer, timeout::TimeoutLayer, trace::TraceLayer};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthStore,
|
||||||
|
config::AppConfig,
|
||||||
|
db::Db,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Err(error) = run().await {
|
||||||
|
eprintln!("postfixadmin-rs: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> AppResult<()> {
|
||||||
|
if std::env::args().any(|arg| arg == "--version" || arg == "-V") {
|
||||||
|
println!("postfixadmin-rs {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("postfixadmin_rs=info,tower_http=info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Arc::new(AppConfig::load()?);
|
||||||
|
let db = Db::connect(&config).await?;
|
||||||
|
if config.database.dev_mock {
|
||||||
|
db.initialize_dev_mock(&config).await?;
|
||||||
|
}
|
||||||
|
db.ping().await?;
|
||||||
|
let warnings = db.check_schema(&config).await?;
|
||||||
|
for warning in &warnings {
|
||||||
|
tracing::warn!(%warning, "schema compatibility warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
if std::env::args().any(|arg| arg == "--check-config") {
|
||||||
|
println!("configuration: ok");
|
||||||
|
println!("database: ok");
|
||||||
|
if config.database.dev_mock {
|
||||||
|
println!("database mode: development mock");
|
||||||
|
}
|
||||||
|
if config.database.read_only {
|
||||||
|
println!("database access: read-only");
|
||||||
|
}
|
||||||
|
for warning in warnings {
|
||||||
|
println!("warning: {warning}");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
auth: AuthStore::new(&config),
|
||||||
|
config: config.clone(),
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
|
||||||
|
let api = routes::router(&state);
|
||||||
|
let inner = Router::<Arc<AppState>>::new()
|
||||||
|
.nest("/api", api)
|
||||||
|
.route("/assets/{*path}", get(ui::asset))
|
||||||
|
.fallback(ui::index)
|
||||||
|
.layer(TimeoutLayer::with_status_code(
|
||||||
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("content-security-policy"),
|
||||||
|
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("x-content-type-options"),
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("x-frame-options"),
|
||||||
|
HeaderValue::from_static("DENY"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("referrer-policy"),
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
HeaderName::from_static("permissions-policy"),
|
||||||
|
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||||
|
))
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
let base = config.normalized_base_path();
|
||||||
|
let app: Router = if base == "/" {
|
||||||
|
inner.with_state(state.clone())
|
||||||
|
} else {
|
||||||
|
Router::<Arc<AppState>>::new()
|
||||||
|
.nest(&base, inner)
|
||||||
|
.with_state(state.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", config.host, config.port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|error| AppError::config(format!("invalid listen address: {error}")))?;
|
||||||
|
tracing::info!(%addr, base_path = %base, "PostfixAdmin-RS listening");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| AppError::Io(std::io::Error::other(error)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AccountKind {
|
||||||
|
Admin,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Role {
|
||||||
|
GlobalAdmin,
|
||||||
|
Admin,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
pub fn is_admin(self) -> bool {
|
||||||
|
matches!(self, Self::GlobalAdmin | Self::Admin)
|
||||||
|
}
|
||||||
|
pub fn is_global(self) -> bool {
|
||||||
|
self == Self::GlobalAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LoginRecord {
|
||||||
|
pub password: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub superadmin: bool,
|
||||||
|
pub totp_secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DomainSummary {
|
||||||
|
pub domain: String,
|
||||||
|
pub description: String,
|
||||||
|
pub aliases: i64,
|
||||||
|
pub mailboxes: i64,
|
||||||
|
pub maxquota: i64,
|
||||||
|
pub quota: i64,
|
||||||
|
pub transport: String,
|
||||||
|
pub backupmx: bool,
|
||||||
|
pub active: bool,
|
||||||
|
pub password_expiry: Option<i64>,
|
||||||
|
pub dns_active: Option<bool>,
|
||||||
|
pub dns_checked: Option<String>,
|
||||||
|
pub alias_count: i64,
|
||||||
|
pub mailbox_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DomainInput {
|
||||||
|
pub domain: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
pub aliases: Option<i64>,
|
||||||
|
pub mailboxes: Option<i64>,
|
||||||
|
pub maxquota: Option<i64>,
|
||||||
|
pub quota: Option<i64>,
|
||||||
|
pub transport: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backupmx: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
pub password_expiry: Option<i64>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub default_aliases: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MailboxSummary {
|
||||||
|
pub username: String,
|
||||||
|
pub name: String,
|
||||||
|
pub maildir: String,
|
||||||
|
pub quota: i64,
|
||||||
|
pub local_part: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub smtp_active: Option<bool>,
|
||||||
|
pub password_expiry: Option<String>,
|
||||||
|
pub phone: String,
|
||||||
|
pub email_other: String,
|
||||||
|
pub used_quota_bytes: Option<i64>,
|
||||||
|
pub vacation_active: bool,
|
||||||
|
pub created: Option<String>,
|
||||||
|
pub modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct MailboxInput {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub local_part: Option<String>,
|
||||||
|
pub domain: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub quota: Option<i64>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
pub smtp_active: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email_other: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub send_welcome: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AliasSummary {
|
||||||
|
pub address: String,
|
||||||
|
pub goto: Vec<String>,
|
||||||
|
pub domain: String,
|
||||||
|
pub description: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub is_mailbox: bool,
|
||||||
|
pub status: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AliasInput {
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub local_part: Option<String>,
|
||||||
|
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,
|
||||||
|
#[serde(default)]
|
||||||
|
pub save_goto_default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AliasDomainInput {
|
||||||
|
pub alias_domain: String,
|
||||||
|
pub target_domain: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AliasDomainSummary {
|
||||||
|
pub alias_domain: String,
|
||||||
|
pub target_domain: String,
|
||||||
|
pub description: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminSummary {
|
||||||
|
pub username: String,
|
||||||
|
pub superadmin: bool,
|
||||||
|
pub active: bool,
|
||||||
|
pub phone: String,
|
||||||
|
pub email_other: String,
|
||||||
|
pub domains: Vec<String>,
|
||||||
|
pub totp_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AdminInput {
|
||||||
|
pub username: String,
|
||||||
|
pub password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub superadmin: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email_other: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub domains: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FetchmailInput {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub mailbox: String,
|
||||||
|
pub src_server: String,
|
||||||
|
pub src_port: Option<i64>,
|
||||||
|
pub src_auth: Option<String>,
|
||||||
|
pub src_user: String,
|
||||||
|
pub src_password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub src_folder: String,
|
||||||
|
#[serde(default = "default_poll_time")]
|
||||||
|
pub poll_time: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fetchall: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep: bool,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub usessl: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sslcertck: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sslcertpath: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sslfingerprint: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_options: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mda: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
fn default_poll_time() -> i64 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FetchmailSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub domain: String,
|
||||||
|
pub mailbox: String,
|
||||||
|
pub src_server: String,
|
||||||
|
pub src_port: Option<i64>,
|
||||||
|
pub src_auth: Option<String>,
|
||||||
|
pub src_user: String,
|
||||||
|
pub src_folder: String,
|
||||||
|
pub poll_time: i64,
|
||||||
|
pub fetchall: bool,
|
||||||
|
pub keep: bool,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub usessl: bool,
|
||||||
|
pub sslcertck: bool,
|
||||||
|
pub sslcertpath: String,
|
||||||
|
pub sslfingerprint: String,
|
||||||
|
pub extra_options: String,
|
||||||
|
pub returned_text: String,
|
||||||
|
pub mda: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct VacationInput {
|
||||||
|
pub email: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interval_time: i64,
|
||||||
|
pub active_from: String,
|
||||||
|
pub active_until: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct VacationSummary {
|
||||||
|
pub email: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
pub interval_time: i64,
|
||||||
|
pub active_from: String,
|
||||||
|
pub active_until: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DkimKeyInput {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub domain_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default = "default_selector")]
|
||||||
|
pub selector: String,
|
||||||
|
pub private_key: Option<String>,
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
}
|
||||||
|
fn default_selector() -> String {
|
||||||
|
"default".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DkimKeySummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub domain_name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub selector: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub has_private_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DkimSigningInput {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub author: String,
|
||||||
|
pub dkim_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub username: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub action: String,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppPasswordSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AppPasswordInput {
|
||||||
|
pub description: String,
|
||||||
|
pub password: String,
|
||||||
|
pub current_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TotpExceptionInput {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub ip: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TotpExceptionSummary {
|
||||||
|
pub id: i64,
|
||||||
|
pub ip: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PasswordChangeInput {
|
||||||
|
pub current_password: String,
|
||||||
|
pub new_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SendMailInput {
|
||||||
|
pub to: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct BroadcastInput {
|
||||||
|
pub name: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub message: String,
|
||||||
|
pub domains: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mailboxes_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
use std::{process::Stdio, str::FromStr};
|
||||||
|
|
||||||
|
use argon2::{
|
||||||
|
Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use md5::{Digest as _, Md5};
|
||||||
|
use pwhash::{
|
||||||
|
HashSetup,
|
||||||
|
bcrypt::{self as pw_bcrypt, BcryptSetup, BcryptVariant},
|
||||||
|
sha1_crypt, sha256_crypt, sha512_crypt, unix, unix_crypt,
|
||||||
|
};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use sha2::{Sha256, Sha512};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use tokio::{io::AsyncWriteExt, process::Command};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::AppConfig,
|
||||||
|
db::{Db, Dialect, Table, row_i64},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRYPT64: &[u8; 64] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
/// Password schemes intentionally kept compatible with PostfixAdmin/Dovecot/Courier.
|
||||||
|
/// Unknown `dovecot:METHOD` values are delegated to `doveadm pw` without a shell.
|
||||||
|
pub const SUPPORTED_SCHEMES: &[&str] = &[
|
||||||
|
"ARGON2ID",
|
||||||
|
"ARGON2ID.B64",
|
||||||
|
"ARGON2I",
|
||||||
|
"ARGON2I.B64",
|
||||||
|
"BLF-CRYPT",
|
||||||
|
"BLF-CRYPT.B64",
|
||||||
|
"CRYPT",
|
||||||
|
"SHA512-CRYPT",
|
||||||
|
"SHA512-CRYPT.B64",
|
||||||
|
"SHA512.B64",
|
||||||
|
"SHA512",
|
||||||
|
"SHA256-CRYPT",
|
||||||
|
"SHA256-CRYPT.B64",
|
||||||
|
"SHA256",
|
||||||
|
"SHA1-CRYPT",
|
||||||
|
"SHA1",
|
||||||
|
"SHA1.B64",
|
||||||
|
"SHA",
|
||||||
|
"SSHA",
|
||||||
|
"MD5-CRYPT",
|
||||||
|
"md5crypt",
|
||||||
|
"PLAIN-MD5",
|
||||||
|
"MD5",
|
||||||
|
"MD5RAW",
|
||||||
|
"PLAIN",
|
||||||
|
"CLEAR",
|
||||||
|
"cleartext",
|
||||||
|
"SYSTEM",
|
||||||
|
"AUTHLIB",
|
||||||
|
"courier:md5",
|
||||||
|
"courier:md5raw",
|
||||||
|
"courier:sha1",
|
||||||
|
"courier:ssha",
|
||||||
|
"courier:sha256",
|
||||||
|
"php_crypt",
|
||||||
|
"php_crypt:DES",
|
||||||
|
"php_crypt:MD5",
|
||||||
|
"php_crypt:BLOWFISH",
|
||||||
|
"php_crypt:SHA256",
|
||||||
|
"php_crypt:SHA512",
|
||||||
|
"dovecot:METHOD",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn supported_schemes() -> &'static [&'static str] {
|
||||||
|
SUPPORTED_SCHEMES
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_new_password(config: &AppConfig, password: &str) -> AppResult<()> {
|
||||||
|
if password.chars().count() < config.password.min_length {
|
||||||
|
return Err(AppError::bad_request(format!(
|
||||||
|
"Password must contain at least {} characters",
|
||||||
|
config.password.min_length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let letters = password.chars().filter(|c| c.is_alphabetic()).count();
|
||||||
|
let digits = password.chars().filter(|c| c.is_ascii_digit()).count();
|
||||||
|
if letters < config.password.min_letters {
|
||||||
|
return Err(AppError::bad_request(format!(
|
||||||
|
"Password must contain at least {} letters",
|
||||||
|
config.password.min_letters
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if digits < config.password.min_digits {
|
||||||
|
return Err(AppError::bad_request(format!(
|
||||||
|
"Password must contain at least {} digits",
|
||||||
|
config.password.min_digits
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
if password.is_empty() || stored.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(result) = verify_native(password, stored) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let configured = config.password.scheme.trim();
|
||||||
|
let mechanism = configured.to_ascii_uppercase();
|
||||||
|
|
||||||
|
// Legacy PostfixAdmin formats without a self-identifying prefix.
|
||||||
|
if (mechanism == "MD5" || mechanism == "MD5RAW") && is_hex_digest(stored, 32) {
|
||||||
|
return ct_eq(hex_md5(password).as_bytes(), stored.as_bytes());
|
||||||
|
}
|
||||||
|
let legacy_des = mechanism == "PHP_CRYPT:DES"
|
||||||
|
|| mechanism == "SYSTEM"
|
||||||
|
|| (mechanism == "AUTHLIB"
|
||||||
|
&& config
|
||||||
|
.password
|
||||||
|
.authlib_default_flavor
|
||||||
|
.eq_ignore_ascii_case("crypt"));
|
||||||
|
if legacy_des && stored.len() == 13 && stored.bytes().all(is_crypt_char) {
|
||||||
|
return unix_crypt::verify(password, stored);
|
||||||
|
}
|
||||||
|
if matches!(mechanism.as_str(), "CLEAR" | "CLEARTEXT") {
|
||||||
|
return ct_eq(password.as_bytes(), stored.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = prefixed_method(stored)
|
||||||
|
.map(|value| value.to_ascii_uppercase())
|
||||||
|
.or_else(|| mechanism.strip_prefix("DOVECOT:").map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| mechanism.clone());
|
||||||
|
|
||||||
|
verify_with_doveadm(config, password, stored, &method, username)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn hash(config: &AppConfig, password: &str, username: &str) -> AppResult<String> {
|
||||||
|
validate_new_password(config, password)?;
|
||||||
|
hash_unchecked(config, password, username).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes an application-internal bearer secret with Argon2id regardless of the
|
||||||
|
/// mailbox password scheme. Used for single-use recovery tokens only.
|
||||||
|
pub fn hash_internal_secret(secret: &str) -> AppResult<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(secret.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::config(format!("argon2 failed: {e}")))?
|
||||||
|
.to_string();
|
||||||
|
Ok(format!("{{ARGON2ID}}{hash}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implements PostfixAdmin `$CONF['encrypt']`-style schemes.
|
||||||
|
pub async fn hash_unchecked(
|
||||||
|
config: &AppConfig,
|
||||||
|
password: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let configured = config.password.scheme.trim();
|
||||||
|
if configured.is_empty() {
|
||||||
|
return Err(AppError::config("password.scheme cannot be empty"));
|
||||||
|
}
|
||||||
|
let mechanism = configured.to_ascii_uppercase();
|
||||||
|
|
||||||
|
if mechanism == "PHP_CRYPT" || mechanism.starts_with("PHP_CRYPT:") {
|
||||||
|
return hash_php_crypt(configured, password);
|
||||||
|
}
|
||||||
|
if mechanism == "AUTHLIB" {
|
||||||
|
return hash_authlib(config, password);
|
||||||
|
}
|
||||||
|
if let Some(flavor) = mechanism.strip_prefix("COURIER:") {
|
||||||
|
return hash_courier(flavor, password);
|
||||||
|
}
|
||||||
|
if let Some(method) = mechanism.strip_prefix("DOVECOT:") {
|
||||||
|
if let Some(value) = hash_native_named(method, password)? {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
return hash_with_doveadm(config, password, method, username).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(value) = hash_native_named(&mechanism, password)? {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostfixAdmin historically delegates unsupported Dovecot-compatible methods.
|
||||||
|
hash_with_doveadm(config, password, &mechanism, username).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_native_named(mechanism: &str, password: &str) -> AppResult<Option<String>> {
|
||||||
|
let value = match mechanism {
|
||||||
|
"ARGON2ID" => format!("{{ARGON2ID}}{}", hash_argon(password, Algorithm::Argon2id)?),
|
||||||
|
"ARGON2I" => format!("{{ARGON2I}}{}", hash_argon(password, Algorithm::Argon2i)?),
|
||||||
|
"ARGON2ID.B64" => {
|
||||||
|
let inner = hash_argon(password, Algorithm::Argon2id)?;
|
||||||
|
format!("{{ARGON2ID.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||||
|
}
|
||||||
|
"ARGON2I.B64" => {
|
||||||
|
let inner = hash_argon(password, Algorithm::Argon2i)?;
|
||||||
|
format!("{{ARGON2I.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||||
|
}
|
||||||
|
"BLF-CRYPT" | "BCRYPT" => format!("{{BLF-CRYPT}}{}", bcrypt_hash(password, 10)?),
|
||||||
|
"BLF-CRYPT.B64" => {
|
||||||
|
let inner = bcrypt_hash(password, 10)?;
|
||||||
|
format!("{{BLF-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||||
|
}
|
||||||
|
"CRYPT" | "SYSTEM" => format!("{{CRYPT}}{}", bcrypt_hash(password, 10)?),
|
||||||
|
"SHA512-CRYPT" => format!("{{SHA512-CRYPT}}{}", sha512_hash(password, None)?),
|
||||||
|
"SHA512-CRYPT.B64" | "SHA512.B64" => {
|
||||||
|
let inner = sha512_hash(password, None)?;
|
||||||
|
format!("{{SHA512-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||||
|
}
|
||||||
|
"SHA512" => format!(
|
||||||
|
"{{SHA512}}{}",
|
||||||
|
STANDARD.encode(Sha512::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"SHA256-CRYPT" => format!("{{SHA256-CRYPT}}{}", sha256_hash(password, None)?),
|
||||||
|
"SHA256-CRYPT.B64" => {
|
||||||
|
let inner = sha256_hash(password, None)?;
|
||||||
|
format!("{{SHA256-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||||
|
}
|
||||||
|
"SHA256" => format!(
|
||||||
|
"{{SHA256}}{}",
|
||||||
|
STANDARD.encode(Sha256::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"SHA1-CRYPT" => format!(
|
||||||
|
"{{SHA1-CRYPT}}{}",
|
||||||
|
sha1_crypt::hash(password)
|
||||||
|
.map_err(|e| AppError::config(format!("SHA1-CRYPT failed: {e}")))?
|
||||||
|
),
|
||||||
|
"SHA1" => format!(
|
||||||
|
"{{SHA1}}{}",
|
||||||
|
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"SHA1.B64" => format!(
|
||||||
|
"{{SHA1.B64}}{}",
|
||||||
|
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"SHA" => format!(
|
||||||
|
"{{SHA}}{}",
|
||||||
|
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"SSHA" => hash_ssha(password),
|
||||||
|
"MD5-CRYPT" => {
|
||||||
|
let salt = random_crypt_salt(8);
|
||||||
|
format!("{{MD5-CRYPT}}{}", md5_crypt(password, &salt))
|
||||||
|
}
|
||||||
|
"MD5CRYPT" => {
|
||||||
|
let salt = random_crypt_salt(8);
|
||||||
|
md5_crypt(password, &salt)
|
||||||
|
}
|
||||||
|
"PLAIN-MD5" => format!("{{PLAIN-MD5}}{}", hex_md5(password)),
|
||||||
|
// Historical `$CONF['encrypt'] = 'md5'` is unprefixed hex MD5.
|
||||||
|
"MD5" => hex_md5(password),
|
||||||
|
"MD5RAW" => format!("{{MD5RAW}}{}", hex_md5(password)),
|
||||||
|
"PLAIN" => format!("{{PLAIN}}{password}"),
|
||||||
|
"CLEAR" => format!("{{CLEAR}}{password}"),
|
||||||
|
// Historical PostfixAdmin `cleartext` stores the password without a prefix.
|
||||||
|
"CLEARTEXT" => password.to_owned(),
|
||||||
|
_ => return Ok(None),
|
||||||
|
};
|
||||||
|
Ok(Some(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_courier(flavor: &str, password: &str) -> AppResult<String> {
|
||||||
|
let value = match flavor {
|
||||||
|
"MD5" => format!(
|
||||||
|
"{{MD5}}{}",
|
||||||
|
STANDARD.encode(Md5::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"MD5RAW" => format!("{{MD5RAW}}{}", hex_md5(password)),
|
||||||
|
"SHA" | "SHA1" => format!(
|
||||||
|
"{{SHA}}{}",
|
||||||
|
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
"SSHA" => hash_ssha(password),
|
||||||
|
"SHA256" => format!(
|
||||||
|
"{{SHA256}}{}",
|
||||||
|
STANDARD.encode(Sha256::digest(password.as_bytes()))
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"unsupported Courier password flavor: {flavor}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_authlib(config: &AppConfig, password: &str) -> AppResult<String> {
|
||||||
|
let flavor = config.password.authlib_default_flavor.trim();
|
||||||
|
let canonical = flavor.to_ascii_uppercase();
|
||||||
|
let body = match canonical.as_str() {
|
||||||
|
"MD5RAW" => hex_md5(password),
|
||||||
|
"MD5" => STANDARD.encode(Md5::digest(password.as_bytes())),
|
||||||
|
"SHA" | "SHA1" => STANDARD.encode(Sha1::digest(password.as_bytes())),
|
||||||
|
"CRYPT" => des_crypt_hash(password, "authlib crypt")?,
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"authlib_default_flavor '{flavor}' is unsupported; use md5raw, md5, SHA or crypt"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(format!("{{{flavor}}}{body}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
|
fn des_crypt_hash(password: &str, label: &str) -> AppResult<String> {
|
||||||
|
unix_crypt::hash(password).map_err(|e| AppError::config(format!("{label} failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_php_crypt(specification: &str, password: &str) -> AppResult<String> {
|
||||||
|
let parts: Vec<&str> = specification.split(':').collect();
|
||||||
|
let method = parts
|
||||||
|
.get(1)
|
||||||
|
.copied()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("SHA512")
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
let difficulty = parts.get(2).copied().filter(|s| !s.is_empty());
|
||||||
|
let prefix = parts.get(3).copied().unwrap_or("");
|
||||||
|
|
||||||
|
let hash = match method.as_str() {
|
||||||
|
"DES" => des_crypt_hash(password, "php_crypt:DES")?,
|
||||||
|
"MD5" => md5_crypt(password, &random_crypt_salt(8)),
|
||||||
|
"BLOWFISH" => {
|
||||||
|
let cost = parse_range_u32(difficulty, 10, 4, 31, "BLOWFISH cost")?;
|
||||||
|
bcrypt_hash(password, cost)?
|
||||||
|
}
|
||||||
|
"SHA256" => {
|
||||||
|
let rounds = parse_optional_rounds(difficulty, 1000, 999_999_999, "SHA256 rounds")?;
|
||||||
|
sha256_hash(password, rounds)?
|
||||||
|
}
|
||||||
|
"SHA512" => {
|
||||||
|
let rounds = parse_optional_rounds(difficulty, 1000, 999_999_999, "SHA512 rounds")?;
|
||||||
|
sha512_hash(password, rounds)?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"unsupported php_crypt method: {method}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(format!("{prefix}{hash}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_argon(password: &str, algorithm: Algorithm) -> AppResult<String> {
|
||||||
|
let params = if algorithm == Algorithm::Argon2i {
|
||||||
|
Params::new(32_768, 4, 1, None)
|
||||||
|
} else {
|
||||||
|
Params::new(65_536, 3, 1, None)
|
||||||
|
}
|
||||||
|
.map_err(|e| AppError::config(format!("invalid Argon2 parameters: {e}")))?;
|
||||||
|
let argon = Argon2::new(algorithm, Version::V0x13, params);
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
argon
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::config(format!("Argon2 failed: {e}")))
|
||||||
|
.map(|hash| hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bcrypt_hash(password: &str, cost: u32) -> AppResult<String> {
|
||||||
|
pw_bcrypt::hash_with(
|
||||||
|
BcryptSetup {
|
||||||
|
cost: Some(cost),
|
||||||
|
variant: Some(BcryptVariant::V2y),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::config(format!("bcrypt failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)] // Legacy PostfixAdmin compatibility; SHA256-CRYPT is not recommended for new deployments.
|
||||||
|
fn sha256_hash(password: &str, rounds: Option<u32>) -> AppResult<String> {
|
||||||
|
let result = match rounds {
|
||||||
|
Some(rounds) => sha256_crypt::hash_with(
|
||||||
|
HashSetup {
|
||||||
|
salt: None,
|
||||||
|
rounds: Some(rounds),
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
),
|
||||||
|
None => sha256_crypt::hash(password),
|
||||||
|
};
|
||||||
|
result.map_err(|e| AppError::config(format!("SHA256-CRYPT failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha512_hash(password: &str, rounds: Option<u32>) -> AppResult<String> {
|
||||||
|
let result = match rounds {
|
||||||
|
Some(rounds) => sha512_crypt::hash_with(
|
||||||
|
HashSetup {
|
||||||
|
salt: None,
|
||||||
|
rounds: Some(rounds),
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
),
|
||||||
|
None => sha512_crypt::hash(password),
|
||||||
|
};
|
||||||
|
result.map_err(|e| AppError::config(format!("SHA512-CRYPT failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ssha(password: &str) -> String {
|
||||||
|
let mut salt = [0u8; 4];
|
||||||
|
rand::rng().fill_bytes(&mut salt);
|
||||||
|
let mut digest = Sha1::new();
|
||||||
|
digest.update(password.as_bytes());
|
||||||
|
digest.update(salt);
|
||||||
|
let mut bytes = digest.finalize().to_vec();
|
||||||
|
bytes.extend_from_slice(&salt);
|
||||||
|
format!("{{SSHA}}{}", STANDARD.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `Some(result)` when the stored representation is self-identifying.
|
||||||
|
/// Returns `None` for legacy unprefixed formats that require configuration.
|
||||||
|
fn verify_native(password: &str, stored: &str) -> Option<bool> {
|
||||||
|
if let Some(payload) = stored
|
||||||
|
.strip_prefix("{PLAIN}")
|
||||||
|
.or_else(|| stored.strip_prefix("{CLEAR}"))
|
||||||
|
.or_else(|| stored.strip_prefix("{CLEARTEXT}"))
|
||||||
|
{
|
||||||
|
return Some(ct_eq(password.as_bytes(), payload.as_bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored.starts_with("$1$")
|
||||||
|
|| stored.starts_with("$2")
|
||||||
|
|| stored.starts_with("$5$")
|
||||||
|
|| stored.starts_with("$6$")
|
||||||
|
|| stored.starts_with("$sha1$")
|
||||||
|
|| stored.starts_with('_')
|
||||||
|
{
|
||||||
|
return Some(unix::verify(password, stored));
|
||||||
|
}
|
||||||
|
let (method, payload) = split_prefixed(stored)?;
|
||||||
|
let method = method.to_ascii_uppercase();
|
||||||
|
let result = match method.as_str() {
|
||||||
|
"PLAIN" | "CLEAR" | "CLEARTEXT" => ct_eq(password.as_bytes(), payload.as_bytes()),
|
||||||
|
"MD5-CRYPT" => md5_crypt_verify(password, payload),
|
||||||
|
"BLF-CRYPT" | "BCRYPT" => pw_bcrypt::verify(password, payload),
|
||||||
|
"CRYPT" => unix::verify(password, payload),
|
||||||
|
"SHA1-CRYPT" => sha1_crypt::verify(password, payload),
|
||||||
|
"SHA256-CRYPT" => sha256_crypt::verify(password, payload),
|
||||||
|
"SHA512-CRYPT" => sha512_crypt::verify(password, payload),
|
||||||
|
"BLF-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Bcrypt),
|
||||||
|
"SHA256-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Sha256),
|
||||||
|
"SHA512-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Sha512),
|
||||||
|
"MD5" => ct_eq(
|
||||||
|
STANDARD.encode(Md5::digest(password.as_bytes())).as_bytes(),
|
||||||
|
payload.as_bytes(),
|
||||||
|
),
|
||||||
|
"MD5RAW" | "PLAIN-MD5" => ct_eq(hex_md5(password).as_bytes(), payload.as_bytes()),
|
||||||
|
"SHA" | "SHA1" | "SHA1.B64" => ct_eq(
|
||||||
|
STANDARD
|
||||||
|
.encode(Sha1::digest(password.as_bytes()))
|
||||||
|
.as_bytes(),
|
||||||
|
payload.as_bytes(),
|
||||||
|
),
|
||||||
|
"SHA256" => ct_eq(
|
||||||
|
STANDARD
|
||||||
|
.encode(Sha256::digest(password.as_bytes()))
|
||||||
|
.as_bytes(),
|
||||||
|
payload.as_bytes(),
|
||||||
|
),
|
||||||
|
"SHA512" => ct_eq(
|
||||||
|
STANDARD
|
||||||
|
.encode(Sha512::digest(password.as_bytes()))
|
||||||
|
.as_bytes(),
|
||||||
|
payload.as_bytes(),
|
||||||
|
),
|
||||||
|
"SSHA" => verify_ssha(password, payload),
|
||||||
|
"ARGON2I" | "ARGON2ID" | "ARGON2D" => verify_argon(password, payload),
|
||||||
|
"ARGON2I.B64" | "ARGON2ID.B64" | "ARGON2D.B64" => STANDARD
|
||||||
|
.decode(payload)
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| String::from_utf8(raw).ok())
|
||||||
|
.is_some_and(|hash| verify_argon(password, &hash)),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UnixKind {
|
||||||
|
Bcrypt,
|
||||||
|
Sha256,
|
||||||
|
Sha512,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_b64_unix(password: &str, payload: &str, kind: UnixKind) -> bool {
|
||||||
|
let Ok(raw) = STANDARD.decode(payload) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(hash) = String::from_utf8(raw) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match kind {
|
||||||
|
UnixKind::Bcrypt => pw_bcrypt::verify(password, &hash),
|
||||||
|
UnixKind::Sha256 => sha256_crypt::verify(password, &hash),
|
||||||
|
UnixKind::Sha512 => sha512_crypt::verify(password, &hash),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_argon(password: &str, payload: &str) -> bool {
|
||||||
|
let Ok(parsed) = PasswordHash::new(payload) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_ssha(password: &str, payload: &str) -> bool {
|
||||||
|
let Ok(decoded) = STANDARD.decode(payload) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if decoded.len() < 21 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (expected, salt) = decoded.split_at(20);
|
||||||
|
let mut digest = Sha1::new();
|
||||||
|
digest.update(password.as_bytes());
|
||||||
|
digest.update(salt);
|
||||||
|
let actual = digest.finalize();
|
||||||
|
ct_eq(&actual[..], expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_crypt_char(byte: u8) -> bool {
|
||||||
|
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_prefixed(stored: &str) -> Option<(&str, &str)> {
|
||||||
|
let rest = stored.strip_prefix('{')?;
|
||||||
|
let end = rest.find('}')?;
|
||||||
|
let method = &rest[..end];
|
||||||
|
if method.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((method, &rest[end + 1..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefixed_method(stored: &str) -> Option<&str> {
|
||||||
|
split_prefixed(stored).map(|(method, _)| method)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_with_doveadm(
|
||||||
|
config: &AppConfig,
|
||||||
|
password: &str,
|
||||||
|
stored: &str,
|
||||||
|
method: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
validate_doveadm_method(method)?;
|
||||||
|
let mut command = doveadm_command(config)?;
|
||||||
|
command.args(["-s", method, "-t", stored]);
|
||||||
|
if method.eq_ignore_ascii_case("DIGEST-MD5") {
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(AppError::config("dovecot:DIGEST-MD5 requires username"));
|
||||||
|
}
|
||||||
|
command.args(["-u", username]);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin.write_all(password.as_bytes()).await?;
|
||||||
|
stdin.write_all(b"\n").await?;
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output().await?;
|
||||||
|
Ok(output.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hash_with_doveadm(
|
||||||
|
config: &AppConfig,
|
||||||
|
password: &str,
|
||||||
|
scheme: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
validate_doveadm_method(scheme)?;
|
||||||
|
let mut command = doveadm_command(config)?;
|
||||||
|
command.args(["-s", scheme]);
|
||||||
|
if scheme.eq_ignore_ascii_case("DIGEST-MD5") {
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(AppError::config("dovecot:DIGEST-MD5 requires username"));
|
||||||
|
}
|
||||||
|
command.args(["-u", username]);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped());
|
||||||
|
let mut child = command.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
// doveadm getpass() asks twice when generating a hash.
|
||||||
|
stdin.write_all(password.as_bytes()).await?;
|
||||||
|
stdin.write_all(b"\n").await?;
|
||||||
|
stdin.write_all(password.as_bytes()).await?;
|
||||||
|
stdin.write_all(b"\n").await?;
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"doveadm pw failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(AppError::config("doveadm pw returned an empty hash"));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doveadm_command(config: &AppConfig) -> AppResult<Command> {
|
||||||
|
let mut parts = config.password.doveadm.split_whitespace();
|
||||||
|
let program = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| AppError::config("password.doveadm is empty"))?;
|
||||||
|
let trailing: Vec<&str> = parts.collect();
|
||||||
|
let has_pw = trailing.contains(&"pw");
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command.args(&trailing);
|
||||||
|
if !has_pw {
|
||||||
|
command.arg("pw");
|
||||||
|
}
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_doveadm_method(method: &str) -> AppResult<()> {
|
||||||
|
if method.is_empty()
|
||||||
|
|| !method
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || matches!(b, b'-' | b'.'))
|
||||||
|
{
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"invalid doveadm password method: {method}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the mailbox password-expiry timestamp using the parent domain policy.
|
||||||
|
pub async fn refresh_mailbox_expiry(config: &AppConfig, db: &Db, username: &str) -> AppResult<()> {
|
||||||
|
if !db.has_column(Table::Mailbox, "password_expiry").await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some((_, domain)) = username.rsplit_once('@') else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let days = if config.features.password_expiration
|
||||||
|
&& db.has_column(Table::Domain, "password_expiry").await?
|
||||||
|
{
|
||||||
|
let table = db.table(Table::Domain);
|
||||||
|
let sql = db.q(&format!(
|
||||||
|
"SELECT password_expiry FROM {table} WHERE domain=? LIMIT 1"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await?
|
||||||
|
.as_ref()
|
||||||
|
.map(|row| row_i64(row, "password_expiry"))
|
||||||
|
.unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let days = if (1..=36_500).contains(&days) {
|
||||||
|
days
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let expr = if days == 0 {
|
||||||
|
"'9999-12-31 23:59:59'".to_owned()
|
||||||
|
} else {
|
||||||
|
match db.dialect() {
|
||||||
|
Dialect::MySql => format!("DATE_ADD(CURRENT_TIMESTAMP, INTERVAL {days} DAY)"),
|
||||||
|
Dialect::Postgres => format!("CURRENT_TIMESTAMP + INTERVAL '{days} days'"),
|
||||||
|
Dialect::Sqlite => format!("datetime('now','+{days} days')"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mailbox = db.table(Table::Mailbox);
|
||||||
|
let sql = db.q(&format!(
|
||||||
|
"UPDATE {mailbox} SET password_expiry={expr} WHERE username=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql).bind(username).execute(db.pool()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_optional_rounds(
|
||||||
|
value: Option<&str>,
|
||||||
|
min: u32,
|
||||||
|
max: u32,
|
||||||
|
label: &str,
|
||||||
|
) -> AppResult<Option<u32>> {
|
||||||
|
match value {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(raw) => {
|
||||||
|
let parsed = u32::from_str(raw)
|
||||||
|
.map_err(|_| AppError::config(format!("invalid {label}: {raw}")))?;
|
||||||
|
if !(min..=max).contains(&parsed) {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"invalid {label}: {parsed}; expected {min}..={max}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Some(parsed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range_u32(
|
||||||
|
value: Option<&str>,
|
||||||
|
default: u32,
|
||||||
|
min: u32,
|
||||||
|
max: u32,
|
||||||
|
label: &str,
|
||||||
|
) -> AppResult<u32> {
|
||||||
|
Ok(parse_optional_rounds(value, min, max, label)?.unwrap_or(default))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hex_digest(value: &str, len: usize) -> bool {
|
||||||
|
value.len() == len && value.bytes().all(|b| b.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_md5(password: &str) -> String {
|
||||||
|
format!("{:x}", Md5::digest(password.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
a.len() == b.len() && bool::from(a.ct_eq(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_crypt_salt(len: usize) -> String {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let mut bytes = vec![0u8; len];
|
||||||
|
rng.fill_bytes(&mut bytes);
|
||||||
|
bytes
|
||||||
|
.into_iter()
|
||||||
|
.map(|b| CRYPT64[(b as usize) & 63] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn md5_crypt_verify(password: &str, stored: &str) -> bool {
|
||||||
|
let mut parts = stored.split('$');
|
||||||
|
if parts.next() != Some("") || parts.next() != Some("1") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(salt) = parts.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
ct_eq(md5_crypt(password, salt).as_bytes(), stored.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native MD5-CRYPT retained for exact PostfixAdmin 3.x/4.x compatibility.
|
||||||
|
pub fn md5_crypt(password: &str, raw_salt: &str) -> String {
|
||||||
|
let password = password.as_bytes();
|
||||||
|
let salt = raw_salt.split('$').next().unwrap_or(raw_salt).as_bytes();
|
||||||
|
let salt = &salt[..salt.len().min(8)];
|
||||||
|
|
||||||
|
let mut ctx = Md5::new();
|
||||||
|
ctx.update(password);
|
||||||
|
ctx.update(b"$1$");
|
||||||
|
ctx.update(salt);
|
||||||
|
|
||||||
|
let mut alt = Md5::new();
|
||||||
|
alt.update(password);
|
||||||
|
alt.update(salt);
|
||||||
|
alt.update(password);
|
||||||
|
let alt = alt.finalize();
|
||||||
|
|
||||||
|
let mut remaining = password.len();
|
||||||
|
while remaining > 16 {
|
||||||
|
ctx.update(&alt[..]);
|
||||||
|
remaining -= 16;
|
||||||
|
}
|
||||||
|
ctx.update(&alt[..remaining]);
|
||||||
|
|
||||||
|
let mut n = password.len();
|
||||||
|
while n > 0 {
|
||||||
|
if n & 1 != 0 {
|
||||||
|
ctx.update([0u8]);
|
||||||
|
} else if let Some(first) = password.first() {
|
||||||
|
ctx.update([*first]);
|
||||||
|
}
|
||||||
|
n >>= 1;
|
||||||
|
}
|
||||||
|
let mut digest: [u8; 16] = ctx.finalize().into();
|
||||||
|
|
||||||
|
for i in 0..1000usize {
|
||||||
|
let mut loop_ctx = Md5::new();
|
||||||
|
if i & 1 != 0 {
|
||||||
|
loop_ctx.update(password);
|
||||||
|
} else {
|
||||||
|
loop_ctx.update(digest);
|
||||||
|
}
|
||||||
|
if i % 3 != 0 {
|
||||||
|
loop_ctx.update(salt);
|
||||||
|
}
|
||||||
|
if i % 7 != 0 {
|
||||||
|
loop_ctx.update(password);
|
||||||
|
}
|
||||||
|
if i & 1 != 0 {
|
||||||
|
loop_ctx.update(digest);
|
||||||
|
} else {
|
||||||
|
loop_ctx.update(password);
|
||||||
|
}
|
||||||
|
digest = loop_ctx.finalize().into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut encoded = String::with_capacity(22);
|
||||||
|
b64_from_24(digest[0], digest[6], digest[12], 4, &mut encoded);
|
||||||
|
b64_from_24(digest[1], digest[7], digest[13], 4, &mut encoded);
|
||||||
|
b64_from_24(digest[2], digest[8], digest[14], 4, &mut encoded);
|
||||||
|
b64_from_24(digest[3], digest[9], digest[15], 4, &mut encoded);
|
||||||
|
b64_from_24(digest[4], digest[10], digest[5], 4, &mut encoded);
|
||||||
|
b64_from_24(0, 0, digest[11], 2, &mut encoded);
|
||||||
|
format!("$1${}${encoded}", String::from_utf8_lossy(salt))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn b64_from_24(b2: u8, b1: u8, b0: u8, count: usize, out: &mut String) {
|
||||||
|
let mut value = (u32::from(b2) << 16) | (u32::from(b1) << 8) | u32::from(b0);
|
||||||
|
for _ in 0..count {
|
||||||
|
out.push(CRYPT64[(value & 0x3f) as usize] as char);
|
||||||
|
value >>= 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn md5crypt_known_vector() {
|
||||||
|
let hash = md5_crypt("password", "hfT7jp2q");
|
||||||
|
assert_eq!(hash, "$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.");
|
||||||
|
assert!(md5_crypt_verify("password", &hash));
|
||||||
|
assert!(!md5_crypt_verify("wrong", &hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifies_postfixadmin_reference_vectors() {
|
||||||
|
let cases = [
|
||||||
|
("{SHA1}cojt0Pw//L6ToM8G41aOKFIWh7w=", true),
|
||||||
|
("{SHA1.B64}cojt0Pw//L6ToM8G41aOKFIWh7w=", true),
|
||||||
|
("{PLAIN}test123", true),
|
||||||
|
("{CLEAR}test123", true),
|
||||||
|
("{CLEARTEXT}test123", true),
|
||||||
|
("{SHA256}7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=", true),
|
||||||
|
(
|
||||||
|
"{SHA512}2u9JU7l4M2XK1mFSI3IFBsxGxRZ80Wq1APpZeqCP+WTrJPsZaH8012Zfd4/LbFNY/ApbgeFmLPkPc6JnHFP5kQ==",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("{PLAIN-MD5}cc03e747a6afbbcbf8be7668acfebee5", true),
|
||||||
|
("{MD5-CRYPT}$1$AIjpWveQ$2s3eEAbZiqkJhMYUIVR240", true),
|
||||||
|
(
|
||||||
|
"{BLF-CRYPT}$2y$05$cEEZv2h/NtLXII.emi2TP.rMZyB7VRSkyToXWBqqz6cXDoyay166q",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{SHA512-CRYPT}$6$MViNQUSbWyXWL9wZ$63VsBU2a/ZFb9f/dK4EmaXABE9jAcNltR7y6a2tXLKoV5F5jMezno.2KpmtD3U0FDjfa7A.pkCluVMlZJ.F64.",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{CRYPT}$2y$05$ORqzr0AagWr25v3ixHD5QuMXympIoNTbipEFZz6aAmovGNoij2vDO",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{SHA256-CRYPT}$5$CFly6wzfn2az3U8j$EhfQPTdjpMGAisfCjCKektLke5GGEmtdLVaCZSmsKw2",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("{SSHA}ZkqrSEAhvd0FTHaK1IxAQCRa5LWbxGQY", true),
|
||||||
|
(
|
||||||
|
"{ARGON2ID}$argon2id$v=19$m=65536,t=3,p=1$eaXP376O9/VxleLw9OQIxg$jOoDyECeRRV4eta3eSN/j0RdBgqaA1VBGAA/pbviI20",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ARGON2I}$argon2i$v=19$m=32768,t=4,p=1$xoOcAGa27k0Sr6ZPbA9ODw$wl/KAZVmJooD/35IFG5oGwyQiAREXrLss5BPS1PDKfA",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"{ARGON2ID.B64}JGFyZ29uMmlkJHY9MTkkbT02NTUzNix0PTMscD0xJEljdG9DWko1T04zWlYzM3I0TVMrNEEkMUVtNTJRWkdsRlJzNnBsRXpwVmtMeVd4dVNPRUZ2dUZnaVNhTmNlb08rOA==",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
("{MD5}zAPnR6avu8v4vnZorP6+5Q==", true),
|
||||||
|
("{MD5RAW}cc03e747a6afbbcbf8be7668acfebee5", true),
|
||||||
|
];
|
||||||
|
for (stored, expected) in cases {
|
||||||
|
assert_eq!(verify_native("test123", stored), Some(expected), "{stored}");
|
||||||
|
assert_eq!(
|
||||||
|
verify_native("not-the-password", stored),
|
||||||
|
Some(false),
|
||||||
|
"{stored}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifies_postfixadmin_b64_crypt_vectors() {
|
||||||
|
let cases = [
|
||||||
|
"{SHA512-CRYPT.B64}JDYkR2JwY3NiZXNMWk9DdERXbiRYdXlhdEZTdy9oa3lyUFE0d24wenpGQTZrSlpTUE9QVWdPcjVRUC40bTRMTjEzdy81aWMvWTdDZllRMWVqSWlhNkd3Q2Z0ZnNjZEFpam9OWjl3OU5tLw==",
|
||||||
|
"{SHA256-CRYPT.B64}JDUkUTZZS1ZzZS5sSVJoLndodCR6TWNOUVFVVkhtTmM1ME1SQk9TR3BEeGpRY2M1TzJTQ1lkbWhPN1YxeHlD",
|
||||||
|
"{BLF-CRYPT.B64}JDJ5JDA1JEhlR0lBeGFHR2tNUGxjRWpyeFc0eU9oRjZZZ1NuTWVOTXFxNWp4bmFwVjUwdGU3c2x2L1VT",
|
||||||
|
];
|
||||||
|
for stored in cases {
|
||||||
|
assert_eq!(verify_native("test123", stored), Some(true), "{stored}");
|
||||||
|
assert_eq!(verify_native("wrong", stored), Some(false), "{stored}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn verifies_legacy_php_crypt_des_vector() {
|
||||||
|
assert!(unix_crypt::verify("test123", "VXAXutUnpVYg6"));
|
||||||
|
assert!(!unix_crypt::verify("wrong", "VXAXutUnpVYg6"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_bool, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{AdminInput, AdminSummary},
|
||||||
|
passwords, routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<AdminSummary>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
session.require_global()?;
|
||||||
|
let admin = state.db.table(Table::Admin);
|
||||||
|
let da = state.db.table(Table::DomainAdmins);
|
||||||
|
let phone = if state.db.has_column(Table::Admin, "phone").await? {
|
||||||
|
"phone"
|
||||||
|
} else {
|
||||||
|
"'' AS phone"
|
||||||
|
};
|
||||||
|
let email_other = if state.db.has_column(Table::Admin, "email_other").await? {
|
||||||
|
"email_other"
|
||||||
|
} else {
|
||||||
|
"'' AS email_other"
|
||||||
|
};
|
||||||
|
let totp = if state.db.has_column(Table::Admin, "totp_secret").await? {
|
||||||
|
"totp_secret"
|
||||||
|
} else {
|
||||||
|
"NULL AS totp_secret"
|
||||||
|
};
|
||||||
|
let superadmin = state.db.flag("superadmin", "superadmin");
|
||||||
|
let active = state.db.flag("active", "active");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT username,{superadmin},{active},{phone},{email_other},{totp} FROM {admin} ORDER BY username"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query(&sql).fetch_all(state.db.pool()).await?;
|
||||||
|
let mut result = Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let username = row_string(&row, "username");
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"SELECT domain FROM {da} WHERE username=? AND active=? ORDER BY domain"
|
||||||
|
));
|
||||||
|
let domains = sqlx::query(&q)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(true)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| row_string(r, "domain"))
|
||||||
|
.collect();
|
||||||
|
result.push(AdminSummary {
|
||||||
|
username,
|
||||||
|
superadmin: row_bool(&row, "superadmin"),
|
||||||
|
active: row_bool(&row, "active"),
|
||||||
|
phone: row_string(&row, "phone"),
|
||||||
|
email_other: row_string(&row, "email_other"),
|
||||||
|
domains,
|
||||||
|
totp_enabled: !row_string(&row, "totp_secret").is_empty(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(mut input): Json<AdminInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_global()?;
|
||||||
|
input.username = validation::resolvable_email(&state.config, &input.username).await?;
|
||||||
|
let password = input
|
||||||
|
.password
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("Password is required"))?;
|
||||||
|
let hash = passwords::hash(&state.config, password, &input.username).await?;
|
||||||
|
let admin = state.db.table(Table::Admin);
|
||||||
|
let check = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {admin} WHERE username=? LIMIT 1"));
|
||||||
|
if sqlx::query(&check)
|
||||||
|
.bind(&input.username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::conflict("Administrator already exists"));
|
||||||
|
}
|
||||||
|
validate_domains(&state, &input.domains, input.superadmin).await?;
|
||||||
|
let has_phone = state.db.has_column(Table::Admin, "phone").await?;
|
||||||
|
let has_other = state.db.has_column(Table::Admin, "email_other").await?;
|
||||||
|
let mut cols = vec![
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
"superadmin",
|
||||||
|
"created",
|
||||||
|
"modified",
|
||||||
|
"active",
|
||||||
|
];
|
||||||
|
let mut vals = vec!["?", "?", "?", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP", "?"];
|
||||||
|
if has_phone {
|
||||||
|
cols.insert(3, "phone");
|
||||||
|
vals.insert(3, "?");
|
||||||
|
}
|
||||||
|
if has_other {
|
||||||
|
let pos = if has_phone { 4 } else { 3 };
|
||||||
|
cols.insert(pos, "email_other");
|
||||||
|
vals.insert(pos, "?");
|
||||||
|
}
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"INSERT INTO {admin} ({}) VALUES ({})",
|
||||||
|
cols.join(","),
|
||||||
|
vals.join(",")
|
||||||
|
));
|
||||||
|
let mut q = sqlx::query(&sql)
|
||||||
|
.bind(&input.username)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(input.superadmin);
|
||||||
|
if has_phone {
|
||||||
|
q = q.bind(&input.phone);
|
||||||
|
}
|
||||||
|
if has_other {
|
||||||
|
q = q.bind(&input.email_other);
|
||||||
|
}
|
||||||
|
q.bind(input.active).execute(state.db.pool()).await?;
|
||||||
|
sync_domains(&state, &input.username, &input.domains, input.superadmin).await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
"",
|
||||||
|
"create_admin",
|
||||||
|
&input.username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"ok":true,"username":input.username}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path_user): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(mut input): Json<AdminInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_global()?;
|
||||||
|
let username = validation::email(&path_user)?;
|
||||||
|
input.username = validation::email(&input.username)?;
|
||||||
|
if input.username != username {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Renaming administrators is not supported",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_domains(&state, &input.domains, input.superadmin).await?;
|
||||||
|
let admin = state.db.table(Table::Admin);
|
||||||
|
let exists = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {admin} WHERE username=? LIMIT 1"));
|
||||||
|
if sqlx::query(&exists)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AppError::not_found("Administrator not found"));
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
Some(passwords::hash(&state.config, password, &username).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut sets = vec![
|
||||||
|
"superadmin=?".to_string(),
|
||||||
|
"active=?".to_string(),
|
||||||
|
"modified=CURRENT_TIMESTAMP".to_string(),
|
||||||
|
];
|
||||||
|
if has_phone {
|
||||||
|
sets.push("phone=?".into());
|
||||||
|
}
|
||||||
|
if has_other {
|
||||||
|
sets.push("email_other=?".into());
|
||||||
|
}
|
||||||
|
if new_hash.is_some() {
|
||||||
|
sets.push("password=?".into());
|
||||||
|
}
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"UPDATE {admin} SET {} WHERE username=?",
|
||||||
|
sets.join(",")
|
||||||
|
));
|
||||||
|
let mut q = sqlx::query(&sql).bind(input.superadmin).bind(input.active);
|
||||||
|
if has_phone {
|
||||||
|
q = q.bind(&input.phone);
|
||||||
|
}
|
||||||
|
if has_other {
|
||||||
|
q = q.bind(&input.email_other);
|
||||||
|
}
|
||||||
|
if let Some(hash) = new_hash {
|
||||||
|
q = q.bind(hash);
|
||||||
|
}
|
||||||
|
q.bind(&username).execute(state.db.pool()).await?;
|
||||||
|
sync_domains(&state, &username, &input.domains, input.superadmin).await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
"",
|
||||||
|
"edit_admin",
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path_user): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_global()?;
|
||||||
|
let username = validation::email(&path_user)?;
|
||||||
|
if username.eq_ignore_ascii_case(&session.username) {
|
||||||
|
return Err(AppError::conflict(
|
||||||
|
"You cannot delete the currently logged-in administrator",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let da = state.db.table(Table::DomainAdmins);
|
||||||
|
let admin = state.db.table(Table::Admin);
|
||||||
|
let sql = state.db.q(&format!("DELETE FROM {da} WHERE username=?"));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let sql = state.db.q(&format!("DELETE FROM {admin} WHERE username=?"));
|
||||||
|
if sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.rows_affected()
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
return Err(AppError::not_found("Administrator not found"));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
"",
|
||||||
|
"delete_admin",
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_domains(state: &AppState, domains: &[String], superadmin: bool) -> AppResult<()> {
|
||||||
|
if superadmin {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let domain = validation::domain(raw)?;
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||||
|
if sqlx::query(&sql)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request(format!("Unknown domain: {raw}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_domains(
|
||||||
|
state: &AppState,
|
||||||
|
username: &str,
|
||||||
|
domains: &[String],
|
||||||
|
superadmin: bool,
|
||||||
|
) -> 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()
|
||||||
|
};
|
||||||
|
for domain in assigned {
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"INSERT INTO {table} (username,domain,created,active) VALUES (?,?,CURRENT_TIMESTAMP,?)"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.bind(domain)
|
||||||
|
.bind(true)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,465 @@
|
|||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{ConnectInfo, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::verify_totp,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{AccountKind, Role},
|
||||||
|
passwords, routes,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginInput {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default = "admin_kind")]
|
||||||
|
pub kind: AccountKind,
|
||||||
|
}
|
||||||
|
fn admin_kind() -> AccountKind {
|
||||||
|
AccountKind::Admin
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MfaInput {
|
||||||
|
pub challenge: String,
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LoginResponse {
|
||||||
|
authenticated: bool,
|
||||||
|
mfa_required: bool,
|
||||||
|
challenge: Option<String>,
|
||||||
|
csrf: Option<String>,
|
||||||
|
username: String,
|
||||||
|
role: Option<Role>,
|
||||||
|
kind: AccountKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(crate) struct MeResponse {
|
||||||
|
username: String,
|
||||||
|
role: Role,
|
||||||
|
kind: AccountKind,
|
||||||
|
csrf: String,
|
||||||
|
domains: Vec<String>,
|
||||||
|
language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(input): Json<LoginInput>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let username = input.username.trim().to_ascii_lowercase();
|
||||||
|
if username.is_empty() || input.password.is_empty() {
|
||||||
|
return Err(AppError::unauthorized("Invalid credentials"));
|
||||||
|
}
|
||||||
|
if !state.auth.login_allowed(peer.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
|
||||||
|
&& passwords::verify(&state.config, &input.password, &record.password, &username).await
|
||||||
|
} else {
|
||||||
|
let _ = passwords::verify(
|
||||||
|
&state.config,
|
||||||
|
&input.password,
|
||||||
|
"$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.",
|
||||||
|
&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 domains = if input.kind == AccountKind::Admin {
|
||||||
|
state.db.allowed_domains(&username).await?
|
||||||
|
} else {
|
||||||
|
username
|
||||||
|
.split_once('@')
|
||||||
|
.map(|(_, d)| vec![d.to_string()])
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let role = match input.kind {
|
||||||
|
AccountKind::User => Role::User,
|
||||||
|
AccountKind::Admin if record.superadmin || domains.iter().any(|d| d == "ALL") => {
|
||||||
|
Role::GlobalAdmin
|
||||||
|
}
|
||||||
|
AccountKind::Admin => Role::Admin,
|
||||||
|
};
|
||||||
|
let domains: Arc<[String]> = domains.into();
|
||||||
|
|
||||||
|
let mail_password = if input.kind == AccountKind::User && state.config.sieve.enabled {
|
||||||
|
Some(Arc::<str>::from(input.password.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let needs_mfa = state.config.features.totp
|
||||||
|
&& record.totp_secret.as_deref().is_some_and(|s| !s.is_empty())
|
||||||
|
&& !state
|
||||||
|
.db
|
||||||
|
.totp_exception(&peer.ip().to_string(), &username)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if needs_mfa {
|
||||||
|
let challenge = state.auth.create_pending(
|
||||||
|
username.clone(),
|
||||||
|
input.kind,
|
||||||
|
role,
|
||||||
|
domains,
|
||||||
|
peer.ip(),
|
||||||
|
mail_password.clone(),
|
||||||
|
);
|
||||||
|
return Ok((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(LoginResponse {
|
||||||
|
authenticated: false,
|
||||||
|
mfa_required: true,
|
||||||
|
challenge: Some(challenge),
|
||||||
|
csrf: None,
|
||||||
|
username,
|
||||||
|
role: None,
|
||||||
|
kind: input.kind,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (token, session) =
|
||||||
|
state
|
||||||
|
.auth
|
||||||
|
.create_session(username.clone(), input.kind, role, domains, mail_password);
|
||||||
|
let jar = add_session_cookie(&state, jar, token);
|
||||||
|
Ok((
|
||||||
|
jar,
|
||||||
|
Json(LoginResponse {
|
||||||
|
authenticated: true,
|
||||||
|
mfa_required: false,
|
||||||
|
challenge: None,
|
||||||
|
csrf: Some(session.csrf),
|
||||||
|
username,
|
||||||
|
role: Some(role),
|
||||||
|
kind: input.kind,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mfa(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(input): Json<MfaInput>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let pending = state
|
||||||
|
.auth
|
||||||
|
.take_pending(&input.challenge, peer.ip())
|
||||||
|
.ok_or_else(|| AppError::unauthorized("MFA challenge expired"))?;
|
||||||
|
let record = state
|
||||||
|
.db
|
||||||
|
.login_record(pending.kind, &pending.username)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::unauthorized("Account no longer exists"))?;
|
||||||
|
let secret = record
|
||||||
|
.totp_secret
|
||||||
|
.ok_or_else(|| AppError::unauthorized("TOTP is not configured"))?;
|
||||||
|
if !verify_totp(&secret, input.code.trim()) {
|
||||||
|
return Err(AppError::unauthorized("Invalid TOTP code"));
|
||||||
|
}
|
||||||
|
let (token, session) = state.auth.create_session(
|
||||||
|
pending.username.clone(),
|
||||||
|
pending.kind,
|
||||||
|
pending.role,
|
||||||
|
pending.allowed_domains,
|
||||||
|
pending.mail_password,
|
||||||
|
);
|
||||||
|
let jar = add_session_cookie(&state, jar, token);
|
||||||
|
Ok((
|
||||||
|
jar,
|
||||||
|
Json(LoginResponse {
|
||||||
|
authenticated: true,
|
||||||
|
mfa_required: false,
|
||||||
|
challenge: None,
|
||||||
|
csrf: Some(session.csrf),
|
||||||
|
username: pending.username,
|
||||||
|
role: Some(pending.role),
|
||||||
|
kind: pending.kind,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
|
||||||
|
if let Some(cookie) = jar.get("pa_session") {
|
||||||
|
state.auth.revoke(cookie.value());
|
||||||
|
}
|
||||||
|
let mut removal = Cookie::new("pa_session", "");
|
||||||
|
removal.set_path(state.config.normalized_base_path());
|
||||||
|
(jar.remove(removal), StatusCode::NO_CONTENT).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Json<MeResponse>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
Ok(Json(MeResponse {
|
||||||
|
username: session.username,
|
||||||
|
role: session.role,
|
||||||
|
kind: session.kind,
|
||||||
|
csrf: session.csrf,
|
||||||
|
domains: session.allowed_domains.to_vec(),
|
||||||
|
language: state.config.default_language.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_session_cookie(state: &AppState, jar: CookieJar, token: String) -> CookieJar {
|
||||||
|
let mut cookie = Cookie::new("pa_session", token);
|
||||||
|
cookie.set_http_only(true);
|
||||||
|
cookie.set_same_site(SameSite::Strict);
|
||||||
|
cookie.set_secure(state.config.security.cookie_secure);
|
||||||
|
cookie.set_path(state.config.normalized_base_path());
|
||||||
|
jar.add(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RecoveryRequestInput {
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default = "user_kind")]
|
||||||
|
pub kind: AccountKind,
|
||||||
|
}
|
||||||
|
fn user_kind() -> AccountKind {
|
||||||
|
AccountKind::User
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RecoveryResetInput {
|
||||||
|
pub username: String,
|
||||||
|
pub token: String,
|
||||||
|
pub new_password: String,
|
||||||
|
#[serde(default = "user_kind")]
|
||||||
|
pub kind: AccountKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recovery_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
Json(input): Json<RecoveryRequestInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
routes::require_writable(&state)?;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
let started = Instant::now();
|
||||||
|
let username = input.username.trim().to_ascii_lowercase();
|
||||||
|
let enabled = match input.kind {
|
||||||
|
AccountKind::User => {
|
||||||
|
state.config.features.forgotten_user_password_reset
|
||||||
|
&& state.config.hooks.mailbox_postpassword.is_empty()
|
||||||
|
}
|
||||||
|
AccountKind::Admin => state.config.features.forgotten_admin_password_reset,
|
||||||
|
};
|
||||||
|
if !enabled {
|
||||||
|
return Err(AppError::forbidden("Password recovery is disabled"));
|
||||||
|
}
|
||||||
|
if !state
|
||||||
|
.auth
|
||||||
|
.login_allowed(peer.ip(), &format!("recovery:{username}"))
|
||||||
|
{
|
||||||
|
return Err(AppError::too_many("Too many recovery requests"));
|
||||||
|
}
|
||||||
|
let table_key = match input.kind {
|
||||||
|
AccountKind::User => crate::db::Table::Mailbox,
|
||||||
|
AccountKind::Admin => crate::db::Table::Admin,
|
||||||
|
};
|
||||||
|
let has_token = state
|
||||||
|
.db
|
||||||
|
.has_column(table_key, "token")
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& state
|
||||||
|
.db
|
||||||
|
.has_column(table_key, "token_validity")
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_email = state
|
||||||
|
.db
|
||||||
|
.has_column(table_key, "email_other")
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_token {
|
||||||
|
let table = state.db.table(table_key);
|
||||||
|
let email_expr = if has_email {
|
||||||
|
"email_other"
|
||||||
|
} else {
|
||||||
|
"'' AS email_other"
|
||||||
|
};
|
||||||
|
let active_expr = state.db.flag("active", "active");
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"SELECT {active_expr},{email_expr} FROM {table} WHERE username=? LIMIT 1"
|
||||||
|
));
|
||||||
|
if let Some(row) = sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
&& crate::db::row_bool(&row, "active")
|
||||||
|
{
|
||||||
|
let token = crate::auth::random_token(18);
|
||||||
|
let token_hash = passwords::hash_internal_secret(&token)?;
|
||||||
|
let expiry = match state.db.dialect() {
|
||||||
|
crate::db::Dialect::MySql => "DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)",
|
||||||
|
crate::db::Dialect::Postgres => "CURRENT_TIMESTAMP + INTERVAL '1 hour'",
|
||||||
|
crate::db::Dialect::Sqlite => "datetime('now','+1 hour')",
|
||||||
|
};
|
||||||
|
let cooldown = match state.db.dialect() {
|
||||||
|
crate::db::Dialect::MySql => "DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)",
|
||||||
|
crate::db::Dialect::Postgres => "CURRENT_TIMESTAMP - INTERVAL '5 minutes'",
|
||||||
|
crate::db::Dialect::Sqlite => "datetime('now','-5 minutes')",
|
||||||
|
};
|
||||||
|
let update = state.db.q(&format!("UPDATE {table} SET token=?,token_validity={expiry},modified=CURRENT_TIMESTAMP WHERE username=? AND active=? AND (token IS NULL OR token='' OR modified<={cooldown})"));
|
||||||
|
if sqlx::query(&update)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(true)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.rows_affected()
|
||||||
|
== 1
|
||||||
|
{
|
||||||
|
let to = crate::db::row_string(&row, "email_other");
|
||||||
|
if !to.trim().is_empty() {
|
||||||
|
let base = state.config.normalized_base_path();
|
||||||
|
let body = format!(
|
||||||
|
"Password recovery code for {username}: {token}\n\nThe code expires in one hour. Open {base} and use the password recovery form."
|
||||||
|
);
|
||||||
|
let from = recovery_sender(&state.config, &username);
|
||||||
|
if let Err(error) = crate::mailer::send_text(
|
||||||
|
&state.config,
|
||||||
|
&from,
|
||||||
|
&to,
|
||||||
|
"Password recovery",
|
||||||
|
&body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error,%username,"password recovery mail failed");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(%username,"password recovery requested but no email_other is configured");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
if elapsed < Duration::from_secs(2) {
|
||||||
|
tokio::time::sleep(Duration::from_secs(2) - elapsed).await;
|
||||||
|
}
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"ok":true,"message":"If the account is eligible, a recovery code has been sent."}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recovery_reset(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
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}"))
|
||||||
|
{
|
||||||
|
return Err(AppError::too_many("Too many reset attempts"));
|
||||||
|
}
|
||||||
|
let enabled = match input.kind {
|
||||||
|
AccountKind::User => {
|
||||||
|
state.config.features.forgotten_user_password_reset
|
||||||
|
&& state.config.hooks.mailbox_postpassword.is_empty()
|
||||||
|
}
|
||||||
|
AccountKind::Admin => state.config.features.forgotten_admin_password_reset,
|
||||||
|
};
|
||||||
|
if !enabled {
|
||||||
|
return Err(AppError::forbidden("Password recovery is disabled"));
|
||||||
|
}
|
||||||
|
let table_key = match input.kind {
|
||||||
|
AccountKind::User => crate::db::Table::Mailbox,
|
||||||
|
AccountKind::Admin => crate::db::Table::Admin,
|
||||||
|
};
|
||||||
|
if !state.db.has_column(table_key, "token").await?
|
||||||
|
|| !state.db.has_column(table_key, "token_validity").await?
|
||||||
|
{
|
||||||
|
return Err(AppError::conflict(
|
||||||
|
"Database schema does not support password recovery",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let table = state.db.table(table_key);
|
||||||
|
let now = match state.db.dialect() {
|
||||||
|
crate::db::Dialect::Sqlite => "datetime('now')",
|
||||||
|
_ => "CURRENT_TIMESTAMP",
|
||||||
|
};
|
||||||
|
let sql=state.db.q(&format!("SELECT token FROM {table} WHERE username=? AND active=? AND token IS NOT NULL AND token<>'' AND token_validity>{now} LIMIT 1"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(true)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::unauthorized("Invalid or expired recovery code"))?;
|
||||||
|
if !passwords::verify(
|
||||||
|
&state.config,
|
||||||
|
&input.token,
|
||||||
|
&crate::db::row_string(&row, "token"),
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(AppError::unauthorized("Invalid or expired recovery code"));
|
||||||
|
}
|
||||||
|
let hash = passwords::hash(&state.config, &input.new_password, &username).await?;
|
||||||
|
let sql=state.db.q(&format!("UPDATE {table} SET password=?,token='',token_validity='2000-01-01 00:00:00',modified=CURRENT_TIMESTAMP WHERE username=?"));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
if input.kind == AccountKind::User {
|
||||||
|
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.auth
|
||||||
|
.clear_attempts(peer.ip(), &format!("reset:{username}"));
|
||||||
|
let domain = username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&username,
|
||||||
|
domain,
|
||||||
|
"password_recovery",
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery_sender(config: &crate::config::AppConfig, username: &str) -> String {
|
||||||
|
if !config.smtp.admin_email.trim().is_empty() {
|
||||||
|
return config.smtp.admin_email.trim().to_owned();
|
||||||
|
}
|
||||||
|
let domain = username
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, d)| d)
|
||||||
|
.filter(|d| !d.is_empty())
|
||||||
|
.unwrap_or("localhost");
|
||||||
|
format!("postmaster@{domain}")
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::{
|
||||||
|
db::Dialect,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, HeaderValue, header},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
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)?;
|
||||||
|
s.require_global()?;
|
||||||
|
if !state.config.features.backup {
|
||||||
|
return Err(AppError::forbidden("Backup is disabled"));
|
||||||
|
}
|
||||||
|
if state.db.dialect() == Dialect::Sqlite {
|
||||||
|
let path = state
|
||||||
|
.config
|
||||||
|
.database
|
||||||
|
.path
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&state.config.database.name);
|
||||||
|
let bytes = tokio::fs::read(path).await?;
|
||||||
|
return response(bytes, "postfixadmin.sqlite");
|
||||||
|
}
|
||||||
|
let mut cmd = if state.db.dialect() == Dialect::Postgres {
|
||||||
|
let mut c = Command::new(&state.config.backup.pg_dump);
|
||||||
|
c.arg("--no-owner")
|
||||||
|
.arg("--no-privileges")
|
||||||
|
.arg("--host")
|
||||||
|
.arg(&state.config.database.host)
|
||||||
|
.arg("--port")
|
||||||
|
.arg(state.config.database.port.to_string())
|
||||||
|
.arg("--username")
|
||||||
|
.arg(&state.config.database.user)
|
||||||
|
.arg(&state.config.database.name);
|
||||||
|
c.env("PGPASSWORD", &state.config.database.password);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let mut c = Command::new(&state.config.backup.mysql_dump);
|
||||||
|
c.arg("--single-transaction")
|
||||||
|
.arg("--skip-lock-tables")
|
||||||
|
.arg("--host")
|
||||||
|
.arg(&state.config.database.host)
|
||||||
|
.arg("--port")
|
||||||
|
.arg(state.config.database.port.to_string())
|
||||||
|
.arg("--user")
|
||||||
|
.arg(&state.config.database.user)
|
||||||
|
.arg(&state.config.database.name);
|
||||||
|
c.env("MYSQL_PWD", &state.config.database.password);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
let out = cmd
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !out.status.success() {
|
||||||
|
tracing::error!(stderr=%String::from_utf8_lossy(&out.stderr),"database dump failed");
|
||||||
|
return Err(AppError::config("Database dump command failed"));
|
||||||
|
}
|
||||||
|
response(out.stdout, "postfixadmin.sql")
|
||||||
|
}
|
||||||
|
fn response(bytes: Vec<u8>, name: &str) -> AppResult<Response> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/octet-stream"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
HeaderValue::from_str(&format!("attachment; filename=\"{name}\""))
|
||||||
|
.map_err(|_| AppError::config("invalid backup filename"))?,
|
||||||
|
);
|
||||||
|
Ok((headers, Body::from(bytes)).into_response())
|
||||||
|
}
|
||||||
|
use axum::response::IntoResponse;
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
use std::{process::Stdio, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::{io::AsyncWriteExt, process::Command};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_i64, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{DkimKeyInput, DkimKeySummary, DkimSigningInput},
|
||||||
|
routes::{self, rspamd_dkim},
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub(crate) struct DkimDnsRecord {
|
||||||
|
pub source: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub selector: String,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub record: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DkimDnsRecord {
|
||||||
|
pub(crate) fn from_value(source: &str, domain: &str, selector: &str, value: &str) -> Self {
|
||||||
|
let value = value.trim().trim_matches('"').to_string();
|
||||||
|
let name = format!("{selector}._domainkey.{domain}");
|
||||||
|
let escaped = value.replace('"', "\\\"");
|
||||||
|
Self {
|
||||||
|
source: source.to_string(),
|
||||||
|
domain: domain.to_string(),
|
||||||
|
selector: selector.to_string(),
|
||||||
|
name: name.clone(),
|
||||||
|
value,
|
||||||
|
record: format!("{name} IN TXT \"{escaped}\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_complete(source: &str, domain: &str, selector: &str, record: &str) -> Self {
|
||||||
|
let name = format!("{selector}._domainkey.{domain}");
|
||||||
|
Self {
|
||||||
|
source: source.to_string(),
|
||||||
|
domain: domain.to_string(),
|
||||||
|
selector: selector.to_string(),
|
||||||
|
name,
|
||||||
|
value: extract_txt_value(record).unwrap_or_else(|| record.trim().to_string()),
|
||||||
|
record: record.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DkimDnsResponse {
|
||||||
|
read_only: bool,
|
||||||
|
records: Vec<DkimDnsRecord>,
|
||||||
|
warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dns_records(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<DkimDnsResponse>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
if !session.role.is_admin() && !state.config.dkim.user_read_only {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"DKIM read-only view is disabled for mailbox users",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let domains: Vec<String> = session
|
||||||
|
.allowed_domains
|
||||||
|
.iter()
|
||||||
|
.filter(|domain| domain.as_str() != "ALL")
|
||||||
|
.map(|domain| domain.to_ascii_lowercase())
|
||||||
|
.collect();
|
||||||
|
let mut records = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
if state.config.features.dkim && state.db.has_table(Table::Dkim).await {
|
||||||
|
let table = state.db.table(Table::Dkim);
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"SELECT domain_name,selector,public_key FROM {table} ORDER BY domain_name,selector"
|
||||||
|
))
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
let domain = row_string(&row, "domain_name").to_ascii_lowercase();
|
||||||
|
if !session.role.is_global() && !domains.iter().any(|item| item == &domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let selector = row_string(&row, "selector");
|
||||||
|
let public_key = row_string(&row, "public_key");
|
||||||
|
if let Some(value) = public_key_to_dns_value(&public_key) {
|
||||||
|
records.push(DkimDnsRecord::from_value(
|
||||||
|
"postfixadmin",
|
||||||
|
&domain,
|
||||||
|
&selector,
|
||||||
|
&value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.config.rspamd_dkim.enabled {
|
||||||
|
match rspamd_dkim::read_only_dns_records(&state.config.rspamd_dkim, &domains).await {
|
||||||
|
Ok(mut values) => records.append(&mut values),
|
||||||
|
Err(_) => warnings
|
||||||
|
.push("Rspamd DKIM records are not readable on this application node".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records.sort_by(|a, b| {
|
||||||
|
(&a.domain, &a.selector, &a.source).cmp(&(&b.domain, &b.selector, &b.source))
|
||||||
|
});
|
||||||
|
records
|
||||||
|
.dedup_by(|a, b| a.domain == b.domain && a.selector == b.selector && a.record == b.record);
|
||||||
|
|
||||||
|
Ok(Json(DkimDnsResponse {
|
||||||
|
read_only: !session.role.is_admin() || state.config.database.read_only,
|
||||||
|
records,
|
||||||
|
warnings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_keys(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<DkimKeySummary>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
if !state.db.has_table(Table::Dkim).await {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let table = state.db.table(Table::Dkim);
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"SELECT id,domain_name,description,selector,private_key,public_key FROM {table} ORDER BY domain_name,selector"
|
||||||
|
))
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.filter(|row| session.can_manage_domain(&row_string(row, "domain_name")))
|
||||||
|
.map(|row| DkimKeySummary {
|
||||||
|
id: row_i64(row, "id"),
|
||||||
|
domain_name: row_string(row, "domain_name"),
|
||||||
|
description: row_string(row, "description"),
|
||||||
|
selector: row_string(row, "selector"),
|
||||||
|
public_key: row_string(row, "public_key"),
|
||||||
|
has_private_key: !row_string(row, "private_key").is_empty(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_key(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(mut input): Json<DkimKeyInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
input.domain_name = validation::domain(&input.domain_name)?;
|
||||||
|
session.require_domain(&input.domain_name)?;
|
||||||
|
validate_selector(&input.selector)?;
|
||||||
|
let (private, public) = match (input.private_key.take(), input.public_key.take()) {
|
||||||
|
(Some(private), Some(public)) => (private, public),
|
||||||
|
(None, None) => generate_keypair().await?,
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Provide both private_key and public_key, or neither to auto-generate",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if private.len() > 32768 || public.len() > 32768 {
|
||||||
|
return Err(AppError::bad_request("DKIM key is too large"));
|
||||||
|
}
|
||||||
|
let table = state.db.table(Table::Dkim);
|
||||||
|
let query = state.db.q(&format!(
|
||||||
|
"INSERT INTO {table} (domain_name,description,selector,private_key,public_key,created,modified) VALUES (?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
|
||||||
|
));
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(&input.domain_name)
|
||||||
|
.bind(&input.description)
|
||||||
|
.bind(&input.selector)
|
||||||
|
.bind(private)
|
||||||
|
.bind(public)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&input.domain_name,
|
||||||
|
"create_dkim_entry",
|
||||||
|
&input.description,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_key(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(mut input): Json<DkimKeyInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
let table = state.db.table(Table::Dkim);
|
||||||
|
let get = state.db.q(&format!(
|
||||||
|
"SELECT domain_name,private_key,public_key FROM {table} WHERE id=?"
|
||||||
|
));
|
||||||
|
let row = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("DKIM key not found"))?;
|
||||||
|
session.require_domain(&row_string(&row, "domain_name"))?;
|
||||||
|
input.domain_name = validation::domain(&input.domain_name)?;
|
||||||
|
session.require_domain(&input.domain_name)?;
|
||||||
|
validate_selector(&input.selector)?;
|
||||||
|
let private = input
|
||||||
|
.private_key
|
||||||
|
.take()
|
||||||
|
.unwrap_or_else(|| row_string(&row, "private_key"));
|
||||||
|
let public = input
|
||||||
|
.public_key
|
||||||
|
.take()
|
||||||
|
.unwrap_or_else(|| row_string(&row, "public_key"));
|
||||||
|
let query = state.db.q(&format!(
|
||||||
|
"UPDATE {table} SET domain_name=?,description=?,selector=?,private_key=?,public_key=?,modified=CURRENT_TIMESTAMP WHERE id=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(&input.domain_name)
|
||||||
|
.bind(&input.description)
|
||||||
|
.bind(&input.selector)
|
||||||
|
.bind(private)
|
||||||
|
.bind(public)
|
||||||
|
.bind(id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_key(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
let table = state.db.table(Table::Dkim);
|
||||||
|
let get = state.db.q(&format!(
|
||||||
|
"SELECT domain_name,description FROM {table} WHERE id=?"
|
||||||
|
));
|
||||||
|
let row = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("DKIM key not found"))?;
|
||||||
|
let domain = row_string(&row, "domain_name");
|
||||||
|
session.require_domain(&domain)?;
|
||||||
|
let query = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&domain,
|
||||||
|
"delete_dkim_entry",
|
||||||
|
&row_string(&row, "description"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_signing(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<serde_json::Value>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
let signing = state.db.table(Table::DkimSigning);
|
||||||
|
let keys = state.db.table(Table::Dkim);
|
||||||
|
if !state.db.has_table(Table::DkimSigning).await {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let rows = sqlx::query(&format!(
|
||||||
|
"SELECT s.id,s.author,s.dkim_id,k.domain_name,k.selector,k.description FROM {signing} s LEFT JOIN {keys} k ON k.id=s.dkim_id ORDER BY k.domain_name,k.selector,s.author"
|
||||||
|
))
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
let author = row_string(row, "author");
|
||||||
|
let domain = author
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, d)| d.to_string())
|
||||||
|
.unwrap_or_else(|| author.clone());
|
||||||
|
if !session.can_manage_domain(&domain) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"id": row_i64(row,"id"),
|
||||||
|
"author": author,
|
||||||
|
"dkim_id": row_i64(row,"dkim_id"),
|
||||||
|
"domain_name": row_string(row,"domain_name"),
|
||||||
|
"selector": row_string(row,"selector"),
|
||||||
|
"description": row_string(row,"description")
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_signing(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<DkimSigningInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
let author = validate_author(&input.author)?;
|
||||||
|
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||||
|
session.require_domain(domain)?;
|
||||||
|
require_key_scope(&state, &session, input.dkim_id).await?;
|
||||||
|
let table = state.db.table(Table::DkimSigning);
|
||||||
|
let query = state.db.q(&format!(
|
||||||
|
"INSERT INTO {table} (author,dkim_id,created,modified) VALUES (?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
|
||||||
|
));
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(&author)
|
||||||
|
.bind(input.dkim_id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
domain,
|
||||||
|
"create_dkim_signing_entry",
|
||||||
|
&author,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_signing(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<DkimSigningInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
let table = state.db.table(Table::DkimSigning);
|
||||||
|
let get = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT author FROM {table} WHERE id=?"));
|
||||||
|
let row = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("DKIM signing record not found"))?;
|
||||||
|
let old = row_string(&row, "author");
|
||||||
|
let old_domain = old.rsplit_once('@').map(|(_, d)| d).unwrap_or(&old);
|
||||||
|
session.require_domain(old_domain)?;
|
||||||
|
let author = validate_author(&input.author)?;
|
||||||
|
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||||
|
session.require_domain(domain)?;
|
||||||
|
require_key_scope(&state, &session, input.dkim_id).await?;
|
||||||
|
let query = state.db.q(&format!(
|
||||||
|
"UPDATE {table} SET author=?,dkim_id=?,modified=CURRENT_TIMESTAMP WHERE id=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(&author)
|
||||||
|
.bind(input.dkim_id)
|
||||||
|
.bind(id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_signing(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
dkim_role(&state, &session)?;
|
||||||
|
let table = state.db.table(Table::DkimSigning);
|
||||||
|
let get = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT author FROM {table} WHERE id=?"));
|
||||||
|
let row = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("DKIM signing record not found"))?;
|
||||||
|
let author = row_string(&row, "author");
|
||||||
|
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||||
|
session.require_domain(domain)?;
|
||||||
|
let query = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||||
|
sqlx::query(&query)
|
||||||
|
.bind(id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dkim_role(state: &AppState, session: &crate::auth::Session) -> AppResult<()> {
|
||||||
|
if !state.config.features.dkim {
|
||||||
|
return Err(AppError::forbidden("DKIM management is disabled"));
|
||||||
|
}
|
||||||
|
if state.config.features.dkim_all_admins {
|
||||||
|
session.require_admin()
|
||||||
|
} else {
|
||||||
|
session.require_global()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selector(selector: &str) -> AppResult<()> {
|
||||||
|
if selector.is_empty()
|
||||||
|
|| selector.len() > 63
|
||||||
|
|| !selector
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
|
{
|
||||||
|
Err(AppError::bad_request("Invalid DKIM selector"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_author(value: &str) -> AppResult<String> {
|
||||||
|
if value.contains('@') {
|
||||||
|
validation::email(value)
|
||||||
|
} else {
|
||||||
|
validation::domain(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_key_scope(
|
||||||
|
state: &AppState,
|
||||||
|
session: &crate::auth::Session,
|
||||||
|
id: i64,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let table = state.db.table(Table::Dkim);
|
||||||
|
let query = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT domain_name FROM {table} WHERE id=?"));
|
||||||
|
let row = sqlx::query(&query)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::bad_request("Unknown DKIM key"))?;
|
||||||
|
session.require_domain(&row_string(&row, "domain_name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_keypair() -> AppResult<(String, String)> {
|
||||||
|
let output = Command::new("openssl")
|
||||||
|
.args([
|
||||||
|
"genpkey",
|
||||||
|
"-algorithm",
|
||||||
|
"RSA",
|
||||||
|
"-pkeyopt",
|
||||||
|
"rsa_keygen_bits:2048",
|
||||||
|
])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"openssl key generation failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let private = String::from_utf8(output.stdout)
|
||||||
|
.map_err(|_| AppError::config("openssl returned non-UTF8 private key"))?;
|
||||||
|
let mut child = Command::new("openssl")
|
||||||
|
.args(["pkey", "-pubout"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin.write_all(private.as_bytes()).await?;
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"openssl public key extraction failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let public = String::from_utf8(output.stdout)
|
||||||
|
.map_err(|_| AppError::config("openssl returned non-UTF8 public key"))?;
|
||||||
|
Ok((private.trim().into(), public.trim().into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_key_to_dns_value(public_key: &str) -> Option<String> {
|
||||||
|
let value = public_key.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if value.to_ascii_lowercase().contains("v=dkim1") && value.to_ascii_lowercase().contains("p=") {
|
||||||
|
return Some(
|
||||||
|
extract_txt_value(value).unwrap_or_else(|| value.trim_matches('"').to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let encoded: String = value
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.starts_with("-----BEGIN") && !line.starts_with("-----END"))
|
||||||
|
.collect();
|
||||||
|
if encoded.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("v=DKIM1; k=rsa; p={encoded}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_txt_value(record: &str) -> Option<String> {
|
||||||
|
let trimmed = record.trim();
|
||||||
|
let start = trimmed.find('"')? + 1;
|
||||||
|
let end = trimmed.rfind('"')?;
|
||||||
|
if end <= start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed[start..end].replace("\\\"", "\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pem_public_key_becomes_dns_value() {
|
||||||
|
let pem = "-----BEGIN PUBLIC KEY-----\nABC123\nDEF456\n-----END PUBLIC KEY-----";
|
||||||
|
assert_eq!(
|
||||||
|
public_key_to_dns_value(pem).as_deref(),
|
||||||
|
Some("v=DKIM1; k=rsa; p=ABC123DEF456")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dns_record_is_complete() {
|
||||||
|
let record =
|
||||||
|
DkimDnsRecord::from_value("rspamd", "example.org", "mail", "v=DKIM1; k=rsa; p=ABC");
|
||||||
|
assert_eq!(record.name, "mail._domainkey.example.org");
|
||||||
|
assert_eq!(
|
||||||
|
record.record,
|
||||||
|
"mail._domainkey.example.org IN TXT \"v=DKIM1; k=rsa; p=ABC\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
use std::{
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
sync::Arc,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::{net::UdpSocket, time::timeout};
|
||||||
|
use trust_dns_resolver::TokioAsyncResolver;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_bool, row_i64, row_opt_bool, row_opt_i64, row_opt_string, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
hooks,
|
||||||
|
models::{DomainInput, DomainSummary},
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<DomainSummary>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
let domain = state.db.table(Table::Domain);
|
||||||
|
let alias = state.db.table(Table::Alias);
|
||||||
|
let mailbox = state.db.table(Table::Mailbox);
|
||||||
|
let has_expiry = state
|
||||||
|
.db
|
||||||
|
.has_column(Table::Domain, "password_expiry")
|
||||||
|
.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 expiry_expr = if has_expiry {
|
||||||
|
"d.password_expiry"
|
||||||
|
} else {
|
||||||
|
"NULL AS password_expiry"
|
||||||
|
};
|
||||||
|
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) {
|
||||||
|
(true, true) => format!(
|
||||||
|
"{}, {} AS dns_checked",
|
||||||
|
state.db.flag("d.dns_active", "dns_active"),
|
||||||
|
state.db.cast_text("d.dns_checked")
|
||||||
|
),
|
||||||
|
(true, false) => format!(
|
||||||
|
"{}, NULL AS dns_checked",
|
||||||
|
state.db.flag("d.dns_active", "dns_active")
|
||||||
|
),
|
||||||
|
(false, _) => "NULL AS dns_active, NULL AS dns_checked".into(),
|
||||||
|
};
|
||||||
|
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'"
|
||||||
|
);
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
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(')');
|
||||||
|
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")
|
||||||
|
{
|
||||||
|
query = query.bind(d.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rows = query.fetch_all(state.db.pool()).await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|r| DomainSummary {
|
||||||
|
domain: row_string(r, "domain"),
|
||||||
|
description: row_string(r, "description"),
|
||||||
|
aliases: row_i64(r, "aliases"),
|
||||||
|
mailboxes: row_i64(r, "mailboxes"),
|
||||||
|
maxquota: row_i64(r, "maxquota"),
|
||||||
|
quota: row_i64(r, "quota"),
|
||||||
|
transport: row_string(r, "transport"),
|
||||||
|
backupmx: row_bool(r, "backupmx"),
|
||||||
|
active: row_bool(r, "active"),
|
||||||
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(mut input): Json<DomainInput>,
|
||||||
|
) -> AppResult<Json<DomainSummary>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request("Unsupported transport"));
|
||||||
|
}
|
||||||
|
let table = state.db.table(Table::Domain);
|
||||||
|
let exists_sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||||
|
if sqlx::query(&exists_sql)
|
||||||
|
.bind(&input.domain)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
q.execute(state.db.pool()).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 goto = if target.contains('@') {
|
||||||
|
target.clone()
|
||||||
|
} else {
|
||||||
|
format!("{target}@{}", input.domain)
|
||||||
|
};
|
||||||
|
let sql = state.db.q(&format!("INSERT INTO {alias_table} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(address)
|
||||||
|
.bind(goto)
|
||||||
|
.bind(&input.domain)
|
||||||
|
.bind(true)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.domain_postcreation,
|
||||||
|
&[("domain", &input.domain)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&input.domain,
|
||||||
|
"create_domain",
|
||||||
|
&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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(domain_path): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<DomainInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_global()?;
|
||||||
|
let domain_name = validation::domain(&domain_path)?;
|
||||||
|
if 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())
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AppError::not_found("Domain not found"));
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
q = q.bind(&domain_name);
|
||||||
|
q.execute(state.db.pool()).await?;
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.domain_postedit,
|
||||||
|
&[("domain", &domain_name)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&domain_name,
|
||||||
|
"edit_domain",
|
||||||
|
&domain_name,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
session.require_global()?;
|
||||||
|
let domain_name = validation::domain(&domain_path)?;
|
||||||
|
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)
|
||||||
|
.bind(&domain_name)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::conflict(
|
||||||
|
"Domain is still the target of an alias domain",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let like = format!("%@{domain_name}");
|
||||||
|
for (table, col, extra_like) in [
|
||||||
|
(Table::DomainAdmins, "domain", false),
|
||||||
|
(Table::Alias, "domain", false),
|
||||||
|
(Table::Mailbox, "domain", false),
|
||||||
|
(Table::AliasDomain, "alias_domain", false),
|
||||||
|
(Table::Vacation, "domain", false),
|
||||||
|
(Table::VacationNotification, "on_vacation", true),
|
||||||
|
(Table::Quota, "username", true),
|
||||||
|
(Table::Quota2, "username", true),
|
||||||
|
(Table::Fetchmail, "mailbox", true),
|
||||||
|
(Table::Log, "domain", false),
|
||||||
|
] {
|
||||||
|
if !state.db.has_table(table).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t = state.db.table(table);
|
||||||
|
let sql = if extra_like {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.q(&format!("DELETE FROM {t} WHERE {col}=? OR {col} LIKE ?"))
|
||||||
|
} else {
|
||||||
|
state.db.q(&format!("DELETE FROM {t} WHERE {col}=?"))
|
||||||
|
};
|
||||||
|
let mut q = sqlx::query(&sql).bind(&domain_name);
|
||||||
|
if extra_like {
|
||||||
|
q = q.bind(&like);
|
||||||
|
}
|
||||||
|
q.execute(state.db.pool()).await?;
|
||||||
|
}
|
||||||
|
let table = state.db.table(Table::Domain);
|
||||||
|
let sql = state.db.q(&format!("DELETE FROM {table} WHERE domain=?"));
|
||||||
|
if sqlx::query(&sql)
|
||||||
|
.bind(&domain_name)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.rows_affected()
|
||||||
|
!= 1
|
||||||
|
{
|
||||||
|
return Err(AppError::not_found("Domain not found"));
|
||||||
|
}
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.domain_postdeletion,
|
||||||
|
&[("domain", &domain_name)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&domain_name,
|
||||||
|
"delete_domain",
|
||||||
|
&domain_name,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DnsRefreshResult {
|
||||||
|
domain: String,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dns_refresh(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(domain_path): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<DnsRefreshResult>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let domain = validation::domain(&domain_path)?;
|
||||||
|
session.require_domain(&domain)?;
|
||||||
|
if state.config.features.domain_dns_status_check == 0 {
|
||||||
|
return Err(AppError::bad_request("DNS status checks are disabled"));
|
||||||
|
}
|
||||||
|
if !state.db.has_column(Table::Domain, "dns_active").await?
|
||||||
|
|| !state.db.has_column(Table::Domain, "dns_checked").await?
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Database schema has no dns_active/dns_checked columns",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let active = check_dns(&domain, state.config.features.domain_dns_status_check).await;
|
||||||
|
let table = state.db.table(Table::Domain);
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"UPDATE {table} SET dns_active=?, dns_checked=CURRENT_TIMESTAMP WHERE domain=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(active)
|
||||||
|
.bind(&domain)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(DnsRefreshResult { domain, active }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_dns(domain: &str, mode: u8) -> bool {
|
||||||
|
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
if mode == 2 {
|
||||||
|
let Ok(mx) = resolver.mx_lookup(domain).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for record in mx.iter() {
|
||||||
|
let target = record.exchange().to_utf8();
|
||||||
|
if resolver
|
||||||
|
.lookup_ip(target)
|
||||||
|
.await
|
||||||
|
.is_ok_and(|ips| ips.iter().next().is_some())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(ns) = resolver.ns_lookup(domain).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for name in ns.iter() {
|
||||||
|
let target = name.to_utf8();
|
||||||
|
let Ok(ips) = resolver.lookup_ip(target).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for ip in ips.iter() {
|
||||||
|
if authoritative_soa(domain, ip).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authoritative_soa(domain: &str, ip: IpAddr) -> bool {
|
||||||
|
let id: u16 = rand::random();
|
||||||
|
let mut packet = Vec::with_capacity(512);
|
||||||
|
packet.extend_from_slice(&id.to_be_bytes());
|
||||||
|
packet.extend_from_slice(&0u16.to_be_bytes());
|
||||||
|
packet.extend_from_slice(&1u16.to_be_bytes());
|
||||||
|
packet.extend_from_slice(&[0u8; 6]);
|
||||||
|
for label in domain.trim_end_matches('.').split('.') {
|
||||||
|
if label.is_empty() || label.len() > 63 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
packet.push(label.len() as u8);
|
||||||
|
packet.extend_from_slice(label.as_bytes());
|
||||||
|
}
|
||||||
|
packet.push(0);
|
||||||
|
packet.extend_from_slice(&6u16.to_be_bytes());
|
||||||
|
packet.extend_from_slice(&1u16.to_be_bytes());
|
||||||
|
let bind = if ip.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" };
|
||||||
|
let Ok(socket) = UdpSocket::bind(bind).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if socket.connect(SocketAddr::new(ip, 53)).await.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if socket.send(&packet).await.ok() != Some(packet.len()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let Ok(Ok(size)) = timeout(Duration::from_secs(1), socket.recv(&mut buf)).await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if size < 12 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let rid = u16::from_be_bytes([buf[0], buf[1]]);
|
||||||
|
let flags = u16::from_be_bytes([buf[2], buf[3]]);
|
||||||
|
rid == id && flags & 0x8000 != 0 && flags & 0x0400 != 0 && flags & 0x000f == 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_bool, row_i64, row_opt_i64, row_opt_string, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{FetchmailInput, FetchmailSummary},
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct FetchmailColumns {
|
||||||
|
domain: bool,
|
||||||
|
src_port: bool,
|
||||||
|
usessl: bool,
|
||||||
|
sslcertck: bool,
|
||||||
|
sslcertpath: bool,
|
||||||
|
sslfingerprint: bool,
|
||||||
|
created: bool,
|
||||||
|
modified: bool,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchmailColumns {
|
||||||
|
async fn detect(state: &AppState) -> AppResult<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
domain: state.db.has_column(Table::Fetchmail, "domain").await?,
|
||||||
|
src_port: state.db.has_column(Table::Fetchmail, "src_port").await?,
|
||||||
|
usessl: state.db.has_column(Table::Fetchmail, "usessl").await?,
|
||||||
|
sslcertck: state.db.has_column(Table::Fetchmail, "sslcertck").await?,
|
||||||
|
sslcertpath: state.db.has_column(Table::Fetchmail, "sslcertpath").await?,
|
||||||
|
sslfingerprint: state
|
||||||
|
.db
|
||||||
|
.has_column(Table::Fetchmail, "sslfingerprint")
|
||||||
|
.await?,
|
||||||
|
created: state.db.has_column(Table::Fetchmail, "created").await?,
|
||||||
|
modified: state.db.has_column(Table::Fetchmail, "modified").await?,
|
||||||
|
active: state.db.has_column(Table::Fetchmail, "active").await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<FetchmailSummary>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
if !state.config.features.fetchmail {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let columns = FetchmailColumns::detect(&state).await?;
|
||||||
|
let table = state.db.table(Table::Fetchmail);
|
||||||
|
let domain = if columns.domain {
|
||||||
|
"domain"
|
||||||
|
} else {
|
||||||
|
"'' AS domain"
|
||||||
|
};
|
||||||
|
let port = if columns.src_port {
|
||||||
|
"src_port"
|
||||||
|
} else {
|
||||||
|
"NULL AS src_port"
|
||||||
|
};
|
||||||
|
let fetchall = state.db.flag("fetchall", "fetchall");
|
||||||
|
let keep = state.db.flag("keep", "keep");
|
||||||
|
let usessl = if columns.usessl {
|
||||||
|
state.db.flag("usessl", "usessl")
|
||||||
|
} else {
|
||||||
|
"0 AS usessl".into()
|
||||||
|
};
|
||||||
|
let sslcertck = if columns.sslcertck {
|
||||||
|
state.db.flag("sslcertck", "sslcertck")
|
||||||
|
} else {
|
||||||
|
"0 AS sslcertck".into()
|
||||||
|
};
|
||||||
|
let sslcertpath = if columns.sslcertpath {
|
||||||
|
"sslcertpath"
|
||||||
|
} else {
|
||||||
|
"'' AS sslcertpath"
|
||||||
|
};
|
||||||
|
let sslfingerprint = if columns.sslfingerprint {
|
||||||
|
"sslfingerprint"
|
||||||
|
} else {
|
||||||
|
"'' AS sslfingerprint"
|
||||||
|
};
|
||||||
|
let active = if columns.active {
|
||||||
|
state.db.flag("active", "active")
|
||||||
|
} else {
|
||||||
|
"1 AS active".into()
|
||||||
|
};
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id,{domain},mailbox,src_server,{port},src_auth,src_user,src_folder,poll_time,{fetchall},{keep},protocol,\
|
||||||
|
{usessl},{sslcertck},{sslcertpath},{sslfingerprint},extra_options,returned_text,mda,{active} \
|
||||||
|
FROM {table} ORDER BY mailbox,id"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query(&sql).fetch_all(state.db.pool()).await?;
|
||||||
|
let values = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| {
|
||||||
|
let mailbox = row_string(row, "mailbox");
|
||||||
|
if session.role.is_admin() {
|
||||||
|
mailbox
|
||||||
|
.rsplit_once('@')
|
||||||
|
.is_some_and(|(_, domain)| session.can_manage_domain(domain))
|
||||||
|
} else {
|
||||||
|
mailbox == session.username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(map)
|
||||||
|
.collect();
|
||||||
|
Ok(Json(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<FetchmailInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
require(&state, &session, &input.mailbox)?;
|
||||||
|
let mailbox = validation::email(&input.mailbox)?;
|
||||||
|
let (_, domain) = mailbox.rsplit_once('@').expect("validated email");
|
||||||
|
assert_mailbox(&state, &mailbox).await?;
|
||||||
|
validate(&state, &input, true)?;
|
||||||
|
|
||||||
|
let columns = FetchmailColumns::detect(&state).await?;
|
||||||
|
let table = state.db.table(Table::Fetchmail);
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut values = Vec::new();
|
||||||
|
if columns.domain {
|
||||||
|
names.push("domain");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
names.extend(["mailbox", "src_server"]);
|
||||||
|
values.extend(["?", "?"]);
|
||||||
|
if columns.src_port {
|
||||||
|
names.push("src_port");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
names.extend([
|
||||||
|
"src_auth",
|
||||||
|
"src_user",
|
||||||
|
"src_password",
|
||||||
|
"src_folder",
|
||||||
|
"poll_time",
|
||||||
|
"fetchall",
|
||||||
|
"keep",
|
||||||
|
"protocol",
|
||||||
|
]);
|
||||||
|
values.extend(["?", "?", "?", "?", "?", "?", "?", "?"]);
|
||||||
|
if columns.usessl {
|
||||||
|
names.push("usessl");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
if columns.sslcertck {
|
||||||
|
names.push("sslcertck");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
if columns.sslcertpath {
|
||||||
|
names.push("sslcertpath");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
if columns.sslfingerprint {
|
||||||
|
names.push("sslfingerprint");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
names.extend(["extra_options", "returned_text", "mda", "date"]);
|
||||||
|
values.extend(["?", "?", "?", "CURRENT_TIMESTAMP"]);
|
||||||
|
if columns.created {
|
||||||
|
names.push("created");
|
||||||
|
values.push("CURRENT_TIMESTAMP");
|
||||||
|
}
|
||||||
|
if columns.modified {
|
||||||
|
names.push("modified");
|
||||||
|
values.push("CURRENT_TIMESTAMP");
|
||||||
|
}
|
||||||
|
if columns.active {
|
||||||
|
names.push("active");
|
||||||
|
values.push("?");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"INSERT INTO {table} ({}) VALUES ({})",
|
||||||
|
names.join(","),
|
||||||
|
values.join(",")
|
||||||
|
));
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
if columns.domain {
|
||||||
|
query = query.bind(domain);
|
||||||
|
}
|
||||||
|
query = query.bind(&mailbox).bind(&input.src_server);
|
||||||
|
if columns.src_port {
|
||||||
|
query = query.bind(input.src_port.unwrap_or(0));
|
||||||
|
}
|
||||||
|
query = query
|
||||||
|
.bind(input.src_auth.as_deref().unwrap_or("password"))
|
||||||
|
.bind(&input.src_user)
|
||||||
|
.bind(&input.src_password)
|
||||||
|
.bind(&input.src_folder)
|
||||||
|
.bind(input.poll_time)
|
||||||
|
.bind(input.fetchall)
|
||||||
|
.bind(input.keep)
|
||||||
|
.bind(input.protocol.as_deref().unwrap_or("POP3"));
|
||||||
|
if columns.usessl {
|
||||||
|
query = query.bind(input.usessl);
|
||||||
|
}
|
||||||
|
if columns.sslcertck {
|
||||||
|
query = query.bind(input.sslcertck);
|
||||||
|
}
|
||||||
|
if columns.sslcertpath {
|
||||||
|
query = query.bind(&input.sslcertpath);
|
||||||
|
}
|
||||||
|
if columns.sslfingerprint {
|
||||||
|
query = query.bind(&input.sslfingerprint);
|
||||||
|
}
|
||||||
|
query = query.bind(&input.extra_options).bind("").bind(&input.mda);
|
||||||
|
if columns.active {
|
||||||
|
query = query.bind(input.active);
|
||||||
|
}
|
||||||
|
query.execute(state.db.pool()).await?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
domain,
|
||||||
|
"create_fetchmail",
|
||||||
|
&mailbox,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<FetchmailInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let table = state.db.table(Table::Fetchmail);
|
||||||
|
let get = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT mailbox FROM {table} WHERE id=?"));
|
||||||
|
let row = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Fetchmail record not found"))?;
|
||||||
|
let old = row_string(&row, "mailbox");
|
||||||
|
require(&state, &session, &old)?;
|
||||||
|
let mailbox = validation::email(&input.mailbox)?;
|
||||||
|
require(&state, &session, &mailbox)?;
|
||||||
|
assert_mailbox(&state, &mailbox).await?;
|
||||||
|
validate(&state, &input, false)?;
|
||||||
|
let (_, domain) = mailbox.rsplit_once('@').expect("validated email");
|
||||||
|
|
||||||
|
let columns = FetchmailColumns::detect(&state).await?;
|
||||||
|
let mut sets = Vec::new();
|
||||||
|
if columns.domain {
|
||||||
|
sets.push("domain=?");
|
||||||
|
}
|
||||||
|
sets.extend(["mailbox=?", "src_server=?"]);
|
||||||
|
if columns.src_port {
|
||||||
|
sets.push("src_port=?");
|
||||||
|
}
|
||||||
|
sets.extend(["src_auth=?", "src_user=?"]);
|
||||||
|
if !input.src_password.is_empty() {
|
||||||
|
sets.push("src_password=?");
|
||||||
|
}
|
||||||
|
sets.extend([
|
||||||
|
"src_folder=?",
|
||||||
|
"poll_time=?",
|
||||||
|
"fetchall=?",
|
||||||
|
"keep=?",
|
||||||
|
"protocol=?",
|
||||||
|
]);
|
||||||
|
if columns.usessl {
|
||||||
|
sets.push("usessl=?");
|
||||||
|
}
|
||||||
|
if columns.sslcertck {
|
||||||
|
sets.push("sslcertck=?");
|
||||||
|
}
|
||||||
|
if columns.sslcertpath {
|
||||||
|
sets.push("sslcertpath=?");
|
||||||
|
}
|
||||||
|
if columns.sslfingerprint {
|
||||||
|
sets.push("sslfingerprint=?");
|
||||||
|
}
|
||||||
|
sets.extend(["extra_options=?", "mda=?"]);
|
||||||
|
if columns.modified {
|
||||||
|
sets.push("modified=CURRENT_TIMESTAMP");
|
||||||
|
}
|
||||||
|
if columns.active {
|
||||||
|
sets.push("active=?");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("UPDATE {table} SET {} WHERE id=?", sets.join(",")));
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
if columns.domain {
|
||||||
|
query = query.bind(domain);
|
||||||
|
}
|
||||||
|
query = query.bind(&mailbox).bind(&input.src_server);
|
||||||
|
if columns.src_port {
|
||||||
|
query = query.bind(input.src_port.unwrap_or(0));
|
||||||
|
}
|
||||||
|
query = query
|
||||||
|
.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(&input.src_folder)
|
||||||
|
.bind(input.poll_time)
|
||||||
|
.bind(input.fetchall)
|
||||||
|
.bind(input.keep)
|
||||||
|
.bind(input.protocol.as_deref().unwrap_or("POP3"));
|
||||||
|
if columns.usessl {
|
||||||
|
query = query.bind(input.usessl);
|
||||||
|
}
|
||||||
|
if columns.sslcertck {
|
||||||
|
query = query.bind(input.sslcertck);
|
||||||
|
}
|
||||||
|
if columns.sslcertpath {
|
||||||
|
query = query.bind(&input.sslcertpath);
|
||||||
|
}
|
||||||
|
if columns.sslfingerprint {
|
||||||
|
query = query.bind(&input.sslfingerprint);
|
||||||
|
}
|
||||||
|
query = query.bind(&input.extra_options).bind(&input.mda);
|
||||||
|
if columns.active {
|
||||||
|
query = query.bind(input.active);
|
||||||
|
}
|
||||||
|
query.bind(id).execute(state.db.pool()).await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let table = state.db.table(Table::Fetchmail);
|
||||||
|
let get = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT mailbox FROM {table} WHERE id=?"));
|
||||||
|
let row = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Fetchmail record not found"))?;
|
||||||
|
require(&state, &session, &row_string(&row, "mailbox"))?;
|
||||||
|
let sql = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||||
|
sqlx::query(&sql).bind(id).execute(state.db.pool()).await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require(state: &AppState, session: &crate::auth::Session, mailbox: &str) -> AppResult<()> {
|
||||||
|
if !state.config.features.fetchmail {
|
||||||
|
return Err(AppError::forbidden("Fetchmail is disabled"));
|
||||||
|
}
|
||||||
|
let email = validation::email(mailbox)?;
|
||||||
|
if session.role.is_admin() {
|
||||||
|
let (_, domain) = email.rsplit_once('@').expect("validated email");
|
||||||
|
session.require_domain(domain)
|
||||||
|
} else if email == session.username {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Mailbox is outside this account"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(state: &AppState, input: &FetchmailInput, creating: bool) -> AppResult<()> {
|
||||||
|
if input.poll_time < 1 {
|
||||||
|
return Err(AppError::bad_request("poll_time must be positive"));
|
||||||
|
}
|
||||||
|
if input.src_server.trim().is_empty() || input.src_user.trim().is_empty() {
|
||||||
|
return Err(AppError::bad_request("Source server and user are required"));
|
||||||
|
}
|
||||||
|
if creating && input.src_password.is_empty() {
|
||||||
|
return Err(AppError::bad_request("Source password is required"));
|
||||||
|
}
|
||||||
|
if !state.config.features.fetchmail_extra_options
|
||||||
|
&& (!input.extra_options.trim().is_empty() || !input.mda.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"fetchmail extra options/MDA are disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_mailbox(state: &AppState, username: &str) -> AppResult<()> {
|
||||||
|
let table = state.db.table(Table::Mailbox);
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {table} WHERE username=? LIMIT 1"));
|
||||||
|
if sqlx::query(&sql)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
Err(AppError::bad_request("Target mailbox does not exist"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(row: &sqlx::any::AnyRow) -> FetchmailSummary {
|
||||||
|
FetchmailSummary {
|
||||||
|
id: row_i64(row, "id"),
|
||||||
|
domain: row_string(row, "domain"),
|
||||||
|
mailbox: row_string(row, "mailbox"),
|
||||||
|
src_server: row_string(row, "src_server"),
|
||||||
|
src_port: row_opt_i64(row, "src_port"),
|
||||||
|
src_auth: row_opt_string(row, "src_auth"),
|
||||||
|
src_user: row_string(row, "src_user"),
|
||||||
|
src_folder: row_string(row, "src_folder"),
|
||||||
|
poll_time: row_i64(row, "poll_time"),
|
||||||
|
fetchall: row_bool(row, "fetchall"),
|
||||||
|
keep: row_bool(row, "keep"),
|
||||||
|
protocol: row_opt_string(row, "protocol"),
|
||||||
|
usessl: row_bool(row, "usessl"),
|
||||||
|
sslcertck: row_bool(row, "sslcertck"),
|
||||||
|
sslcertpath: row_string(row, "sslcertpath"),
|
||||||
|
sslfingerprint: row_string(row, "sslfingerprint"),
|
||||||
|
extra_options: row_string(row, "extra_options"),
|
||||||
|
returned_text: row_string(row, "returned_text"),
|
||||||
|
mda: row_string(row, "mda"),
|
||||||
|
active: row_bool(row, "active"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use crate::{
|
||||||
|
db::{Table, row_string},
|
||||||
|
error::AppResult,
|
||||||
|
models::LogEntry,
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
let t = state.db.table(Table::Log);
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
qsql.push_str(" ORDER BY timestamp DESC");
|
||||||
|
let sql = state.db.q(&qsql);
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
if let Some(b) = bind {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
let rows = q.fetch_all(state.db.pool()).await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.take(f.limit.unwrap_or(200).min(1000))
|
||||||
|
.map(map)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn map(r: &sqlx::any::AnyRow) -> LogEntry {
|
||||||
|
LogEntry {
|
||||||
|
timestamp: row_string(r, "timestamp"),
|
||||||
|
username: row_string(r, "username"),
|
||||||
|
domain: row_string(r, "domain"),
|
||||||
|
action: row_string(r, "action"),
|
||||||
|
data: row_string(r, "data"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
use std::{collections::BTreeSet, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{Json, extract::State, http::HeaderMap};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
mailer,
|
||||||
|
models::{BroadcastInput, SendMailInput},
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn send(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<SendMailInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
if !state.config.features.sendmail {
|
||||||
|
return Err(AppError::forbidden("Send mail is disabled"));
|
||||||
|
}
|
||||||
|
if !session.role.is_global() && !state.config.features.sendmail_all_admins {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Send mail is restricted to global administrators",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = validation::email(&input.to)?;
|
||||||
|
if !session.role.is_global() {
|
||||||
|
let (_, domain) = to.rsplit_once('@').expect("validated email has domain");
|
||||||
|
session.require_domain(domain)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
mailer::send_text(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&to,
|
||||||
|
&input.subject,
|
||||||
|
&input.body,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let domain = to.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(&state.config, &session.username, domain, "send_mail", &to)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<BroadcastInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
if !state.config.features.sendmail {
|
||||||
|
return Err(AppError::forbidden("Broadcast mail is disabled"));
|
||||||
|
}
|
||||||
|
if !session.role.is_global() && !state.config.features.sendmail_all_admins {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Broadcast is restricted to global administrators",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.name.trim().is_empty()
|
||||||
|
|| input.subject.trim().is_empty()
|
||||||
|
|| input.message.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Sender name, subject and message are required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let domains = selected_domains(&state, &session, &input.domains).await?;
|
||||||
|
if domains.is_empty() {
|
||||||
|
return Err(AppError::bad_request("Select at least one domain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut recipients = BTreeSet::new();
|
||||||
|
let mailbox_table = state.db.table(Table::Mailbox);
|
||||||
|
let mailbox_sql = state.db.q(&format!(
|
||||||
|
"SELECT username FROM {mailbox_table} WHERE domain=? AND active=? ORDER BY username"
|
||||||
|
));
|
||||||
|
|
||||||
|
for domain in &domains {
|
||||||
|
let rows = sqlx::query(&mailbox_sql)
|
||||||
|
.bind(domain)
|
||||||
|
.bind(true)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
let address = row_string(&row, "username");
|
||||||
|
if let Ok(address) = validation::email(&address) {
|
||||||
|
recipients.insert(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !input.mailboxes_only {
|
||||||
|
let alias_table = state.db.table(Table::Alias);
|
||||||
|
let alias_sql = state.db.q(&format!(
|
||||||
|
"SELECT goto FROM {alias_table} WHERE domain=? AND active=? ORDER BY address"
|
||||||
|
));
|
||||||
|
for domain in &domains {
|
||||||
|
let rows = sqlx::query(&alias_sql)
|
||||||
|
.bind(domain)
|
||||||
|
.bind(true)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
for raw in row_string(&row, "goto").split(',') {
|
||||||
|
if let Ok(address) = validation::email(raw.trim()) {
|
||||||
|
recipients.insert(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sent = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
for to in recipients {
|
||||||
|
match mailer::send_text_named(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
Some(input.name.trim()),
|
||||||
|
&to,
|
||||||
|
&input.subject,
|
||||||
|
&input.message,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => sent += 1,
|
||||||
|
Err(error) => {
|
||||||
|
failed += 1;
|
||||||
|
tracing::warn!(%error, %to, "broadcast delivery failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for domain in &domains {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
domain,
|
||||||
|
"broadcast_message",
|
||||||
|
&input.name,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"ok": failed == 0,
|
||||||
|
"sent": sent,
|
||||||
|
"failed": failed
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn selected_domains(
|
||||||
|
state: &AppState,
|
||||||
|
session: &crate::auth::Session,
|
||||||
|
requested: &[String],
|
||||||
|
) -> AppResult<Vec<String>> {
|
||||||
|
if !requested.is_empty() {
|
||||||
|
let mut domains = Vec::with_capacity(requested.len());
|
||||||
|
for raw in requested {
|
||||||
|
let domain = validation::domain(raw)?;
|
||||||
|
session.require_domain(&domain)?;
|
||||||
|
if !domains.contains(&domain) {
|
||||||
|
domains.push(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.role.is_global() {
|
||||||
|
return Ok(session
|
||||||
|
.allowed_domains
|
||||||
|
.iter()
|
||||||
|
.filter(|domain| domain.as_str() != "ALL")
|
||||||
|
.cloned()
|
||||||
|
.collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = state.db.table(Table::Domain);
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"SELECT domain FROM {table} WHERE domain<>? AND active=? ORDER BY domain"
|
||||||
|
));
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.bind("ALL")
|
||||||
|
.bind(true)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(|row| row_string(row, "domain")).collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
use std::{process::Stdio, sync::Arc};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_bool, row_i64, row_opt_bool, row_opt_string, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
hooks, mailer,
|
||||||
|
models::{AccountKind, MailboxInput, MailboxSummary},
|
||||||
|
passwords, routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Filter {
|
||||||
|
pub domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(filter): Query<Filter>,
|
||||||
|
) -> AppResult<Json<Vec<MailboxSummary>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
let mailbox = state.db.table(Table::Mailbox);
|
||||||
|
let vacation = state.db.table(Table::Vacation);
|
||||||
|
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||||
|
let has_expiry = state
|
||||||
|
.db
|
||||||
|
.has_column(Table::Mailbox, "password_expiry")
|
||||||
|
.await?;
|
||||||
|
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||||
|
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||||
|
let has_created = state.db.has_column(Table::Mailbox, "created").await?;
|
||||||
|
let has_modified = state.db.has_column(Table::Mailbox, "modified").await?;
|
||||||
|
let active = state.db.flag("m.active", "active");
|
||||||
|
let smtp = if has_smtp {
|
||||||
|
state.db.flag("m.smtp_active", "smtp_active")
|
||||||
|
} else {
|
||||||
|
"NULL AS smtp_active".into()
|
||||||
|
};
|
||||||
|
let expiry = if has_expiry {
|
||||||
|
format!(
|
||||||
|
"{} AS password_expiry",
|
||||||
|
state.db.cast_text("m.password_expiry")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"NULL AS password_expiry".into()
|
||||||
|
};
|
||||||
|
let phone = if has_phone { "m.phone" } else { "'' AS phone" };
|
||||||
|
let email = if has_email {
|
||||||
|
"m.email_other"
|
||||||
|
} else {
|
||||||
|
"'' AS email_other"
|
||||||
|
};
|
||||||
|
let created = if has_created {
|
||||||
|
format!("{} AS created", state.db.cast_text("m.created"))
|
||||||
|
} else {
|
||||||
|
"NULL AS created".into()
|
||||||
|
};
|
||||||
|
let modified = if has_modified {
|
||||||
|
format!("{} AS modified", state.db.cast_text("m.modified"))
|
||||||
|
} else {
|
||||||
|
"NULL AS modified".into()
|
||||||
|
};
|
||||||
|
let vacation_expr = if state.db.has_table(Table::Vacation).await {
|
||||||
|
state.db.flag(
|
||||||
|
"CASE WHEN v.email IS NULL THEN 0 ELSE v.active END",
|
||||||
|
"vacation_active",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"0 AS vacation_active".into()
|
||||||
|
};
|
||||||
|
let vacation_join = if state.db.has_table(Table::Vacation).await {
|
||||||
|
format!("LEFT JOIN {vacation} v ON v.email=m.username")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let mut used_expr = "NULL AS used_quota_bytes".to_string();
|
||||||
|
let mut used_join = String::new();
|
||||||
|
if state.config.features.used_quotas {
|
||||||
|
let qt = if state.config.features.new_quota_table {
|
||||||
|
Table::Quota2
|
||||||
|
} else {
|
||||||
|
Table::Quota
|
||||||
|
};
|
||||||
|
if state.db.has_table(qt).await {
|
||||||
|
let q = state.db.table(qt);
|
||||||
|
if matches!(qt, Table::Quota2) {
|
||||||
|
used_expr = "q.bytes AS used_quota_bytes".into();
|
||||||
|
used_join = format!("LEFT JOIN {q} q ON q.username=m.username");
|
||||||
|
} else {
|
||||||
|
used_expr = "q.current AS used_quota_bytes".into();
|
||||||
|
used_join =
|
||||||
|
format!("LEFT JOIN {q} q ON q.username=m.username AND q.path='quota/storage'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut sql = format!(
|
||||||
|
"SELECT m.username,m.name,m.maildir,m.quota,m.local_part,m.domain,{active},{smtp},{expiry},{phone},{email},{created},{modified},{used_expr},{vacation_expr} FROM {mailbox} m {vacation_join} {used_join} WHERE 1=1"
|
||||||
|
);
|
||||||
|
let mut binds = Vec::new();
|
||||||
|
if session.kind == AccountKind::User {
|
||||||
|
sql.push_str(" AND m.username=?");
|
||||||
|
binds.push(session.username.clone());
|
||||||
|
} else if let Some(raw) = filter.domain {
|
||||||
|
let d = validation::domain(&raw)?;
|
||||||
|
session.require_domain(&d)?;
|
||||||
|
sql.push_str(" AND m.domain=?");
|
||||||
|
binds.push(d);
|
||||||
|
} else if !session.role.is_global() {
|
||||||
|
let ds: Vec<_> = session
|
||||||
|
.allowed_domains
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.as_str() != "ALL")
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if ds.is_empty() {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
sql.push_str(" AND m.domain IN (");
|
||||||
|
sql.push_str(&vec!["?"; ds.len()].join(","));
|
||||||
|
sql.push(')');
|
||||||
|
binds.extend(ds);
|
||||||
|
}
|
||||||
|
sql.push_str(" ORDER BY m.username");
|
||||||
|
let sql = state.db.q(&sql);
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for b in binds {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
let rows = q.fetch_all(state.db.pool()).await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|r| MailboxSummary {
|
||||||
|
username: row_string(r, "username"),
|
||||||
|
name: row_string(r, "name"),
|
||||||
|
maildir: row_string(r, "maildir"),
|
||||||
|
quota: row_i64(r, "quota"),
|
||||||
|
local_part: row_string(r, "local_part"),
|
||||||
|
domain: row_string(r, "domain"),
|
||||||
|
active: row_bool(r, "active"),
|
||||||
|
smtp_active: row_opt_bool(r, "smtp_active"),
|
||||||
|
password_expiry: row_opt_string(r, "password_expiry"),
|
||||||
|
phone: row_string(r, "phone"),
|
||||||
|
email_other: row_string(r, "email_other"),
|
||||||
|
used_quota_bytes: crate::db::row_opt_i64(r, "used_quota_bytes"),
|
||||||
|
vacation_active: row_bool(r, "vacation_active"),
|
||||||
|
created: row_opt_string(r, "created"),
|
||||||
|
modified: row_opt_string(r, "modified"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<MailboxInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
let domain = validation::resolvable_domain(&state.config, &input.domain).await?;
|
||||||
|
session.require_domain(&domain)?;
|
||||||
|
let local = if let Some(u) = input.username.as_deref() {
|
||||||
|
let e = validation::resolvable_email(&state.config, u).await?;
|
||||||
|
let (l, d) = e.rsplit_once('@').unwrap();
|
||||||
|
if d != domain {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Mailbox domain does not match domain",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
l.to_string()
|
||||||
|
} else {
|
||||||
|
validation::local_part(
|
||||||
|
input
|
||||||
|
.local_part
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("local_part or username is required"))?,
|
||||||
|
)?
|
||||||
|
};
|
||||||
|
let username = format!("{local}@{domain}");
|
||||||
|
let password = input
|
||||||
|
.password
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| AppError::bad_request("Password is required"))?;
|
||||||
|
enforce_mailbox_limit(&state, &domain).await?;
|
||||||
|
let mailbox = state.db.table(Table::Mailbox);
|
||||||
|
let alias = state.db.table(Table::Alias);
|
||||||
|
let check = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
|
||||||
|
if sqlx::query(&check)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::conflict("Mailbox already exists"));
|
||||||
|
}
|
||||||
|
let check = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {alias} WHERE address=? LIMIT 1"));
|
||||||
|
if sqlx::query(&check)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::conflict(
|
||||||
|
"An alias with this address already exists",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let quota_mb = mailbox_quota(&state, &domain, input.quota, None).await?;
|
||||||
|
let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier);
|
||||||
|
let maildir = maildir(&state.config, &domain, &local, &username)?;
|
||||||
|
let hash = passwords::hash(&state.config, password, &username).await?;
|
||||||
|
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||||
|
let has_expiry = state
|
||||||
|
.db
|
||||||
|
.has_column(Table::Mailbox, "password_expiry")
|
||||||
|
.await?;
|
||||||
|
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||||
|
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||||
|
let expiry = if has_expiry {
|
||||||
|
password_expiry_days(&state, &domain).await?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if has_email && !input.email_other.trim().is_empty() {
|
||||||
|
let _ = validation::email(&input.email_other)?;
|
||||||
|
}
|
||||||
|
let mut cols = vec![
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
"name",
|
||||||
|
"maildir",
|
||||||
|
"quota",
|
||||||
|
"local_part",
|
||||||
|
"domain",
|
||||||
|
"created",
|
||||||
|
"modified",
|
||||||
|
"active",
|
||||||
|
];
|
||||||
|
let mut vals = vec![
|
||||||
|
"?",
|
||||||
|
"?",
|
||||||
|
"?",
|
||||||
|
"?",
|
||||||
|
"?",
|
||||||
|
"?",
|
||||||
|
"?",
|
||||||
|
"CURRENT_TIMESTAMP",
|
||||||
|
"CURRENT_TIMESTAMP",
|
||||||
|
"?",
|
||||||
|
];
|
||||||
|
if has_smtp {
|
||||||
|
cols.push("smtp_active");
|
||||||
|
vals.push("?");
|
||||||
|
}
|
||||||
|
if has_phone {
|
||||||
|
cols.push("phone");
|
||||||
|
vals.push("?");
|
||||||
|
}
|
||||||
|
if has_email {
|
||||||
|
cols.push("email_other");
|
||||||
|
vals.push("?");
|
||||||
|
}
|
||||||
|
if has_expiry {
|
||||||
|
cols.push("password_expiry");
|
||||||
|
vals.push(if expiry > 0 {
|
||||||
|
"CURRENT_TIMESTAMP"
|
||||||
|
} else {
|
||||||
|
"'9999-12-31 23:59:59'"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"INSERT INTO {mailbox} ({}) VALUES ({})",
|
||||||
|
cols.join(","),
|
||||||
|
vals.join(",")
|
||||||
|
));
|
||||||
|
let mut q = sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(&input.name)
|
||||||
|
.bind(&maildir)
|
||||||
|
.bind(quota)
|
||||||
|
.bind(&local)
|
||||||
|
.bind(&domain)
|
||||||
|
.bind(input.active);
|
||||||
|
if has_smtp {
|
||||||
|
q = q.bind(input.smtp_active.unwrap_or(true));
|
||||||
|
}
|
||||||
|
if has_phone {
|
||||||
|
q = q.bind(&input.phone);
|
||||||
|
}
|
||||||
|
if has_email {
|
||||||
|
q = q.bind(&input.email_other);
|
||||||
|
}
|
||||||
|
q.execute(state.db.pool()).await?;
|
||||||
|
if has_expiry {
|
||||||
|
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||||
|
}
|
||||||
|
let has_desc = state.db.has_column(Table::Alias, "description").await?;
|
||||||
|
let sql = if has_desc {
|
||||||
|
state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES (?,?,?,'',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||||
|
} else {
|
||||||
|
state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||||
|
};
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&domain)
|
||||||
|
.bind(input.active)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let quota_text = quota.to_string();
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_postcreation,
|
||||||
|
&[
|
||||||
|
("username", &username),
|
||||||
|
("domain", &domain),
|
||||||
|
("maildir", &maildir),
|
||||||
|
("quota", "a_text),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let subdirs_created = match create_mailbox_subdirs(&state.config, &username).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, %username, "mailbox created but automatic subfolder creation failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if input.send_welcome {
|
||||||
|
let subject = "Welcome";
|
||||||
|
if let Err(e) = mailer::send_text(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&username,
|
||||||
|
subject,
|
||||||
|
&state.config.defaults.welcome_text,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%e,"welcome mail failed after mailbox creation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
&domain,
|
||||||
|
"create_mailbox",
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"ok":true,"username":username,"maildir":maildir,"quota_bytes":quota,"subdirs_created":subdirs_created}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path_user): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<MailboxInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
let username = validation::email(&path_user)?;
|
||||||
|
let (_, domain) = username.rsplit_once('@').unwrap();
|
||||||
|
session.require_domain(domain)?;
|
||||||
|
if let Some(u) = input.username.as_deref()
|
||||||
|
&& validation::email(u)? != username
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request("Renaming mailboxes is not supported"));
|
||||||
|
}
|
||||||
|
let mailbox = state.db.table(Table::Mailbox);
|
||||||
|
let exists = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
|
||||||
|
if sqlx::query(&exists)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(AppError::not_found("Mailbox not found"));
|
||||||
|
}
|
||||||
|
let quota_mb = mailbox_quota(&state, domain, input.quota, Some(&username)).await?;
|
||||||
|
let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier);
|
||||||
|
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||||
|
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||||
|
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||||
|
if has_email && !input.email_other.trim().is_empty() {
|
||||||
|
let _ = validation::email(&input.email_other)?;
|
||||||
|
}
|
||||||
|
let mut sets = vec![
|
||||||
|
"name=?",
|
||||||
|
"quota=?",
|
||||||
|
"active=?",
|
||||||
|
"modified=CURRENT_TIMESTAMP",
|
||||||
|
];
|
||||||
|
let new_hash = if let Some(p) = input.password.as_deref() {
|
||||||
|
Some(passwords::hash(&state.config, p, &username).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let password_changed = new_hash.is_some();
|
||||||
|
if has_smtp {
|
||||||
|
sets.push("smtp_active=?");
|
||||||
|
}
|
||||||
|
if has_phone {
|
||||||
|
sets.push("phone=?");
|
||||||
|
}
|
||||||
|
if has_email {
|
||||||
|
sets.push("email_other=?");
|
||||||
|
}
|
||||||
|
if new_hash.is_some() {
|
||||||
|
sets.push("password=?");
|
||||||
|
}
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"UPDATE {mailbox} SET {} WHERE username=?",
|
||||||
|
sets.join(",")
|
||||||
|
));
|
||||||
|
let mut q = sqlx::query(&sql)
|
||||||
|
.bind(&input.name)
|
||||||
|
.bind(quota)
|
||||||
|
.bind(input.active);
|
||||||
|
if has_smtp {
|
||||||
|
q = q.bind(input.smtp_active.unwrap_or(true));
|
||||||
|
}
|
||||||
|
if has_phone {
|
||||||
|
q = q.bind(&input.phone);
|
||||||
|
}
|
||||||
|
if has_email {
|
||||||
|
q = q.bind(&input.email_other);
|
||||||
|
}
|
||||||
|
if let Some(hash) = new_hash {
|
||||||
|
q = q.bind(hash);
|
||||||
|
}
|
||||||
|
q.bind(&username).execute(state.db.pool()).await?;
|
||||||
|
if password_changed {
|
||||||
|
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||||
|
}
|
||||||
|
let alias = state.db.table(Table::Alias);
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"UPDATE {alias} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=? AND goto=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(input.active)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let local = username.split('@').next().unwrap_or("");
|
||||||
|
let md = maildir(&state.config, domain, local, &username)?;
|
||||||
|
let quota_text = quota.to_string();
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_postedit,
|
||||||
|
&[
|
||||||
|
("username", &username),
|
||||||
|
("domain", domain),
|
||||||
|
("maildir", &md),
|
||||||
|
("quota", "a_text),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
domain,
|
||||||
|
"edit_mailbox",
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path_user): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
let username = validation::email(&path_user)?;
|
||||||
|
let (_, domain) = username.rsplit_once('@').unwrap();
|
||||||
|
session.require_domain(domain)?;
|
||||||
|
let mailbox = state.db.table(Table::Mailbox);
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT maildir FROM {mailbox} WHERE username=?"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Mailbox not found"))?;
|
||||||
|
let maildir = row_string(&row, "maildir");
|
||||||
|
for (table, col) in [
|
||||||
|
(Table::Fetchmail, "mailbox"),
|
||||||
|
(Table::Vacation, "email"),
|
||||||
|
(Table::Quota, "username"),
|
||||||
|
(Table::Quota2, "username"),
|
||||||
|
(Table::MailboxAppPassword, "username"),
|
||||||
|
(Table::TotpException, "username"),
|
||||||
|
] {
|
||||||
|
if state.db.has_table(table).await {
|
||||||
|
let t = state.db.table(table);
|
||||||
|
let sql = state.db.q(&format!("DELETE FROM {t} WHERE {col}=?"));
|
||||||
|
let _ = sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.db.has_table(Table::VacationNotification).await {
|
||||||
|
let t = state.db.table(Table::VacationNotification);
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"DELETE FROM {t} WHERE on_vacation=? OR notified=?"
|
||||||
|
));
|
||||||
|
let _ = sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let alias = state.db.table(Table::Alias);
|
||||||
|
let sql = state.db.q(&format!("DELETE FROM {alias} WHERE address=?"));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("DELETE FROM {mailbox} WHERE username=?"));
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.bind(&username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_postdeletion,
|
||||||
|
&[
|
||||||
|
("username", &username),
|
||||||
|
("domain", domain),
|
||||||
|
("maildir", &maildir),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
domain,
|
||||||
|
"delete_mailbox",
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maildir(
|
||||||
|
config: &crate::config::AppConfig,
|
||||||
|
domain: &str,
|
||||||
|
local: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let mut value = if !config.defaults.maildir_template.trim().is_empty() {
|
||||||
|
config
|
||||||
|
.defaults
|
||||||
|
.maildir_template
|
||||||
|
.replace("{domain}", domain)
|
||||||
|
.replace("{local_part}", local)
|
||||||
|
.replace("{username}", username)
|
||||||
|
} else if config.defaults.domain_path {
|
||||||
|
if config.defaults.domain_in_mailbox {
|
||||||
|
format!("{domain}/{username}/")
|
||||||
|
} else {
|
||||||
|
format!("{domain}/{local}/")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{username}/")
|
||||||
|
};
|
||||||
|
value = value.trim().to_string();
|
||||||
|
if value.is_empty() || value.starts_with('/') || value.contains("..") || value.contains('\0') {
|
||||||
|
return Err(AppError::config("generated maildir path is unsafe"));
|
||||||
|
}
|
||||||
|
if !value.ends_with('/') {
|
||||||
|
value.push('/');
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_mailbox_subdirs(
|
||||||
|
config: &crate::config::AppConfig,
|
||||||
|
username: &str,
|
||||||
|
) -> AppResult<bool> {
|
||||||
|
if config.defaults.create_mailbox_subdirs.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let program = config
|
||||||
|
.password
|
||||||
|
.doveadm
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| AppError::config("password.doveadm is empty"))?;
|
||||||
|
for subdir in &config.defaults.create_mailbox_subdirs {
|
||||||
|
let mailbox = format!(
|
||||||
|
"{}{}",
|
||||||
|
config.defaults.create_mailbox_subdirs_prefix,
|
||||||
|
subdir.trim()
|
||||||
|
);
|
||||||
|
let output = Command::new(program)
|
||||||
|
.args(["mailbox", "create", "-u", username, "-s", &mailbox])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"doveadm mailbox create failed for {mailbox}: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
async fn enforce_mailbox_limit(state: &AppState, domain: &str) -> AppResult<()> {
|
||||||
|
let d = state.db.table(Table::Domain);
|
||||||
|
let m = state.db.table(Table::Mailbox);
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT mailboxes FROM {d} WHERE domain=?"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Domain not found"))?;
|
||||||
|
let limit = row_i64(&row, "mailboxes");
|
||||||
|
if limit < 0 {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Mailboxes are disabled for this domain",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if limit == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT COUNT(*) AS n FROM {m} WHERE domain=?"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
if row_i64(&row, "n") >= limit {
|
||||||
|
return Err(AppError::conflict("Domain mailbox limit reached"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn mailbox_quota(
|
||||||
|
state: &AppState,
|
||||||
|
domain: &str,
|
||||||
|
requested: Option<i64>,
|
||||||
|
exclude: Option<&str>,
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
if !state.config.features.quota {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let d = state.db.table(Table::Domain);
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT maxquota,quota FROM {d} WHERE domain=?"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Domain not found"))?;
|
||||||
|
let max = row_i64(&row, "maxquota");
|
||||||
|
let q = requested.unwrap_or(max.max(0));
|
||||||
|
if q < 0 {
|
||||||
|
return Err(AppError::bad_request("Quota cannot be negative"));
|
||||||
|
}
|
||||||
|
if max > 0 && q > max {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Mailbox quota exceeds domain maxquota",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if state.config.features.domain_quota {
|
||||||
|
let domain_limit = row_i64(&row, "quota");
|
||||||
|
if domain_limit > 0 {
|
||||||
|
let m = state.db.table(Table::Mailbox);
|
||||||
|
let (sql, bind_exclude) = if exclude.is_some() {
|
||||||
|
(state.db.q(&format!("SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=? AND username<>?")),true)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
state.db.q(&format!(
|
||||||
|
"SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=?"
|
||||||
|
)),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut query = sqlx::query(&sql).bind(domain);
|
||||||
|
if bind_exclude {
|
||||||
|
query = query.bind(exclude.unwrap());
|
||||||
|
}
|
||||||
|
let r = query.fetch_one(state.db.pool()).await?;
|
||||||
|
let current = row_i64(&r, "total") / state.config.defaults.quota_multiplier.max(1);
|
||||||
|
if current.saturating_add(q) > domain_limit {
|
||||||
|
return Err(AppError::bad_request("Domain quota would be exceeded"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(q)
|
||||||
|
}
|
||||||
|
async fn password_expiry_days(state: &AppState, domain: &str) -> AppResult<i64> {
|
||||||
|
if !state.config.features.password_expiration {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let d = state.db.table(Table::Domain);
|
||||||
|
let sql = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT password_expiry FROM {d} WHERE domain=?"));
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(row
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| row_i64(r, "password_expiry"))
|
||||||
|
.unwrap_or(0))
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
http::HeaderMap,
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::{Session, verify_csrf},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod admins;
|
||||||
|
pub mod aliases;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod dkim;
|
||||||
|
pub mod domains;
|
||||||
|
pub mod fetchmail;
|
||||||
|
pub mod logs;
|
||||||
|
pub mod mail;
|
||||||
|
pub mod mailboxes;
|
||||||
|
pub mod preferences;
|
||||||
|
pub mod rspamd_dkim;
|
||||||
|
pub mod security;
|
||||||
|
pub mod sieve;
|
||||||
|
pub mod system;
|
||||||
|
pub mod vacation;
|
||||||
|
|
||||||
|
pub fn router(state: &AppState) -> Router<Arc<AppState>> {
|
||||||
|
let limit = state
|
||||||
|
.config
|
||||||
|
.security
|
||||||
|
.request_body_limit_mb
|
||||||
|
.saturating_mul(1024 * 1024);
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/health", get(system::health))
|
||||||
|
.route("/auth/login", post(auth::login))
|
||||||
|
.route("/auth/mfa", post(auth::mfa))
|
||||||
|
.route("/auth/logout", post(auth::logout))
|
||||||
|
.route("/auth/me", get(auth::me))
|
||||||
|
.route("/auth/recovery/request", post(auth::recovery_request))
|
||||||
|
.route("/auth/recovery/reset", post(auth::recovery_reset))
|
||||||
|
.route("/overview", get(system::overview))
|
||||||
|
.route("/admins", get(admins::list).post(admins::create))
|
||||||
|
.route(
|
||||||
|
"/admins/{username}",
|
||||||
|
put(admins::update).delete(admins::remove),
|
||||||
|
)
|
||||||
|
.route("/domains", get(domains::list).post(domains::create))
|
||||||
|
.route(
|
||||||
|
"/domains/{domain}",
|
||||||
|
put(domains::update).delete(domains::remove),
|
||||||
|
)
|
||||||
|
.route("/domains/{domain}/dns-refresh", post(domains::dns_refresh))
|
||||||
|
.route("/mailboxes", get(mailboxes::list).post(mailboxes::create))
|
||||||
|
.route(
|
||||||
|
"/mailboxes/{username}",
|
||||||
|
put(mailboxes::update).delete(mailboxes::remove),
|
||||||
|
)
|
||||||
|
.route("/aliases", get(aliases::list).post(aliases::create))
|
||||||
|
.route(
|
||||||
|
"/aliases/delete-inactive/{domain}",
|
||||||
|
post(aliases::delete_inactive),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/aliases/{address}",
|
||||||
|
put(aliases::update).delete(aliases::remove),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/alias-domains",
|
||||||
|
get(aliases::list_domains).post(aliases::create_domain),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/alias-domains/{domain}",
|
||||||
|
put(aliases::update_domain).delete(aliases::remove_domain),
|
||||||
|
)
|
||||||
|
.route("/fetchmail", get(fetchmail::list).post(fetchmail::create))
|
||||||
|
.route(
|
||||||
|
"/fetchmail/{id}",
|
||||||
|
put(fetchmail::update).delete(fetchmail::remove),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/vacation/{username}",
|
||||||
|
get(vacation::get)
|
||||||
|
.put(vacation::set)
|
||||||
|
.delete(vacation::remove),
|
||||||
|
)
|
||||||
|
.route("/logs", get(logs::list))
|
||||||
|
.route("/dkim/records", get(dkim::dns_records))
|
||||||
|
.route("/dkim/keys", get(dkim::list_keys).post(dkim::create_key))
|
||||||
|
.route(
|
||||||
|
"/dkim/keys/{id}",
|
||||||
|
put(dkim::update_key).delete(dkim::remove_key),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/dkim/signing",
|
||||||
|
get(dkim::list_signing).post(dkim::create_signing),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/dkim/signing/{id}",
|
||||||
|
put(dkim::update_signing).delete(dkim::remove_signing),
|
||||||
|
)
|
||||||
|
.route("/rspamd-dkim", get(rspamd_dkim::list))
|
||||||
|
.route("/rspamd-dkim/configure", post(rspamd_dkim::configure))
|
||||||
|
.route("/rspamd-dkim/generate", post(rspamd_dkim::generate))
|
||||||
|
.route("/rspamd-dkim/adopt", post(rspamd_dkim::adopt))
|
||||||
|
.route(
|
||||||
|
"/rspamd-dkim/import-legacy",
|
||||||
|
post(rspamd_dkim::import_legacy_domain_blocks),
|
||||||
|
)
|
||||||
|
.route("/rspamd-dkim/test", get(rspamd_dkim::test_config))
|
||||||
|
.route("/rspamd-dkim/reload", post(rspamd_dkim::reload))
|
||||||
|
.route("/rspamd-dkim/{domain}", delete(rspamd_dkim::unmap))
|
||||||
|
.route("/rspamd-dkim/{domain}/purge", post(rspamd_dkim::purge))
|
||||||
|
.route("/security/password", post(security::change_password))
|
||||||
|
.route("/security/totp/setup", post(security::totp_setup))
|
||||||
|
.route("/security/totp/confirm", post(security::totp_confirm))
|
||||||
|
.route("/security/totp", delete(security::totp_disable))
|
||||||
|
.route(
|
||||||
|
"/security/totp-exceptions",
|
||||||
|
get(security::list_exceptions).post(security::add_exception),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/security/totp-exceptions/{id}",
|
||||||
|
delete(security::remove_exception),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/security/app-passwords",
|
||||||
|
get(security::list_app_passwords).post(security::add_app_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/security/app-passwords/{id}",
|
||||||
|
delete(security::remove_app_password),
|
||||||
|
)
|
||||||
|
.route("/mail/send", post(mail::send))
|
||||||
|
.route("/mail/broadcast", post(mail::broadcast))
|
||||||
|
.route("/backup", get(backup::download))
|
||||||
|
.route("/preferences", get(preferences::list))
|
||||||
|
.route(
|
||||||
|
"/preferences/{key}",
|
||||||
|
put(preferences::set).delete(preferences::remove),
|
||||||
|
)
|
||||||
|
.route("/system/schema", get(system::schema))
|
||||||
|
.route("/system/features", get(system::features));
|
||||||
|
|
||||||
|
let router = if state.config.sieve.enabled {
|
||||||
|
router
|
||||||
|
.route("/sieve/status", get(sieve::status))
|
||||||
|
.route("/sieve/scripts", get(sieve::list_scripts))
|
||||||
|
.route(
|
||||||
|
"/sieve/scripts/{name}",
|
||||||
|
get(sieve::get_script)
|
||||||
|
.put(sieve::put_script)
|
||||||
|
.delete(sieve::delete_script),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/sieve/scripts/{name}/activate",
|
||||||
|
post(sieve::activate_script),
|
||||||
|
)
|
||||||
|
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
||||||
|
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
||||||
|
.route("/sieve/check", post(sieve::check_script))
|
||||||
|
} else {
|
||||||
|
router
|
||||||
|
};
|
||||||
|
|
||||||
|
router.layer(DefaultBodyLimit::max(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session(state: &AppState, jar: &CookieJar) -> AppResult<Session> {
|
||||||
|
let token = jar
|
||||||
|
.get("pa_session")
|
||||||
|
.map(|c| c.value())
|
||||||
|
.ok_or_else(|| AppError::unauthorized("Login required"))?;
|
||||||
|
state
|
||||||
|
.auth
|
||||||
|
.get_session(token)
|
||||||
|
.ok_or_else(|| AppError::unauthorized("Session expired"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require_writable(state: &AppState) -> AppResult<()> {
|
||||||
|
if state.config.database.read_only {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Database is read-only in this development session",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mutation_session(
|
||||||
|
state: &AppState,
|
||||||
|
jar: &CookieJar,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> AppResult<Session> {
|
||||||
|
let session = session(state, jar)?;
|
||||||
|
verify_csrf(
|
||||||
|
&session,
|
||||||
|
headers.get("x-csrf-token").and_then(|v| v.to_str().ok()),
|
||||||
|
)?;
|
||||||
|
require_writable(state)?;
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{Table, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Preference {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PreferenceInput {
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<Preference>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
if !state.db.has_table(Table::AdminPreferences).await {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let table = state.db.table(Table::AdminPreferences);
|
||||||
|
let sql = state.db.q(&format!(
|
||||||
|
"SELECT pref_key,pref_value FROM {table} WHERE username=? ORDER BY pref_key"
|
||||||
|
));
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.bind(&session.username)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|row| Preference {
|
||||||
|
key: row_string(row, "pref_key"),
|
||||||
|
value: row_string(row, "pref_value"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<PreferenceInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
validate_key(&key)?;
|
||||||
|
if input.value.len() > 2048 {
|
||||||
|
return Err(AppError::bad_request("Preference value is too long"));
|
||||||
|
}
|
||||||
|
if !state.db.has_table(Table::AdminPreferences).await {
|
||||||
|
return Err(AppError::conflict(
|
||||||
|
"admin_preferences table is not available",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_admin_pref(&session.username, &key, &input.value)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
session.require_admin()?;
|
||||||
|
validate_key(&key)?;
|
||||||
|
if state.db.has_table(Table::AdminPreferences).await {
|
||||||
|
state.db.delete_admin_pref(&session.username, &key).await?;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_key(key: &str) -> AppResult<()> {
|
||||||
|
if key.is_empty()
|
||||||
|
|| key.len() > 64
|
||||||
|
|| !key
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request("Invalid preference key"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,420 @@
|
|||||||
|
use crate::{
|
||||||
|
auth,
|
||||||
|
db::{Table, row_i64, row_opt_string, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
hooks,
|
||||||
|
models::{
|
||||||
|
AccountKind, AppPasswordInput, AppPasswordSummary, PasswordChangeInput, TotpExceptionInput,
|
||||||
|
TotpExceptionSummary,
|
||||||
|
},
|
||||||
|
passwords, routes,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use data_encoding::BASE32_NOPAD;
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TotpConfirm {
|
||||||
|
pub secret: String,
|
||||||
|
pub code: String,
|
||||||
|
pub current_password: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TotpDisable {
|
||||||
|
pub current_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn change_password(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<PasswordChangeInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let (table, col) = account_table(&s);
|
||||||
|
let t = state.db.table(table);
|
||||||
|
let q = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT password FROM {t} WHERE {col}=?"));
|
||||||
|
let r = sqlx::query(&q)
|
||||||
|
.bind(&s.username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||||
|
if !passwords::verify(
|
||||||
|
&state.config,
|
||||||
|
&input.current_password,
|
||||||
|
&row_string(&r, "password"),
|
||||||
|
&s.username,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(AppError::forbidden("Current password is incorrect"));
|
||||||
|
}
|
||||||
|
let hash = passwords::hash(&state.config, &input.new_password, &s.username).await?;
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"UPDATE {t} SET password=?,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(&s.username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
if s.kind == AccountKind::User {
|
||||||
|
passwords::refresh_mailbox_expiry(&state.config, &state.db, &s.username).await?;
|
||||||
|
}
|
||||||
|
let domain = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||||
|
if s.kind == AccountKind::User {
|
||||||
|
let hook_stdin = format!("{}\0{}\0", input.current_password, input.new_password);
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_postpassword,
|
||||||
|
&[("username", &s.username), ("domain", domain)],
|
||||||
|
Some(hook_stdin.as_bytes()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(cookie) = jar.get("pa_session") {
|
||||||
|
state
|
||||||
|
.auth
|
||||||
|
.update_mail_password(cookie.value(), input.new_password.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&s.username,
|
||||||
|
domain,
|
||||||
|
"edit_password",
|
||||||
|
&s.username,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn totp_setup(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
if !state.config.features.totp {
|
||||||
|
return Err(AppError::forbidden("TOTP is disabled"));
|
||||||
|
}
|
||||||
|
let (table, _) = account_table(&s);
|
||||||
|
if !state.db.has_column(table, "totp_secret").await? {
|
||||||
|
return Err(AppError::conflict("Database schema has no TOTP field"));
|
||||||
|
}
|
||||||
|
let mut bytes = [0u8; 20];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
let secret = BASE32_NOPAD.encode(&bytes);
|
||||||
|
let label = urlencoding::encode(&s.username);
|
||||||
|
let issuer = urlencoding::encode("PostfixAdmin-RS");
|
||||||
|
let uri = format!(
|
||||||
|
"otpauth://totp/{issuer}:{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30"
|
||||||
|
);
|
||||||
|
Ok(Json(serde_json::json!({"secret":secret,"otpauth_uri":uri})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn totp_confirm(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<TotpConfirm>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
if !state.config.features.totp {
|
||||||
|
return Err(AppError::forbidden("TOTP is disabled"));
|
||||||
|
}
|
||||||
|
verify_current(&state, &s, &input.current_password).await?;
|
||||||
|
if !auth::verify_totp(&input.secret, &input.code) {
|
||||||
|
return Err(AppError::bad_request("Invalid TOTP code"));
|
||||||
|
}
|
||||||
|
let (table, col) = account_table(&s);
|
||||||
|
let t = state.db.table(table);
|
||||||
|
if !state.db.has_column(table, "totp_secret").await? {
|
||||||
|
return Err(AppError::conflict("Database schema has no TOTP field"));
|
||||||
|
}
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"UPDATE {t} SET totp_secret=?,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(input.secret.trim().to_ascii_uppercase())
|
||||||
|
.bind(&s.username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let d = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_post_totp_change,
|
||||||
|
&[("username", &s.username), ("domain", d)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn totp_disable(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<TotpDisable>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
verify_current(&state, &s, &input.current_password).await?;
|
||||||
|
let (table, col) = account_table(&s);
|
||||||
|
let t = state.db.table(table);
|
||||||
|
if state.db.has_column(table, "totp_secret").await? {
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"UPDATE {t} SET totp_secret=NULL,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||||
|
));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(&s.username)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_exceptions(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<TotpExceptionSummary>>> {
|
||||||
|
let s = routes::session(&state, &jar)?;
|
||||||
|
if !state.config.features.totp || !state.db.has_table(Table::TotpException).await {
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let t = state.db.table(Table::TotpException);
|
||||||
|
let rows = if s.role.is_global() {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT id,ip,username,description FROM {t} ORDER BY id"
|
||||||
|
))
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
let q=state.db.q(&format!("SELECT id,ip,username,description FROM {t} WHERE username=? OR username IS NULL ORDER BY id"));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(&s.username)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|r| TotpExceptionSummary {
|
||||||
|
id: row_i64(r, "id"),
|
||||||
|
ip: row_string(r, "ip"),
|
||||||
|
username: row_opt_string(r, "username"),
|
||||||
|
description: row_string(r, "description"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_exception(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<TotpExceptionInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
if !state.config.features.totp {
|
||||||
|
return Err(AppError::forbidden("TOTP is disabled"));
|
||||||
|
}
|
||||||
|
let ip: std::net::IpAddr = input
|
||||||
|
.ip
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| AppError::bad_request("Invalid IP address"))?;
|
||||||
|
let username = if s.role.is_global() {
|
||||||
|
input.username.as_deref().unwrap_or(&s.username).to_string()
|
||||||
|
} else {
|
||||||
|
s.username.clone()
|
||||||
|
};
|
||||||
|
let t = state.db.table(Table::TotpException);
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"INSERT INTO {t} (ip,username,description) VALUES (?,?,?)"
|
||||||
|
));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.bind(&username)
|
||||||
|
.bind(&input.description)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
let ip_text = ip.to_string();
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_post_totp_exception_add,
|
||||||
|
&[("username", &username), ("ip", &ip_text)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_exception(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let t = state.db.table(Table::TotpException);
|
||||||
|
let get = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT username,ip FROM {t} WHERE id=?"));
|
||||||
|
let r = sqlx::query(&get)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("TOTP exception not found"))?;
|
||||||
|
let owner = row_opt_string(&r, "username");
|
||||||
|
if !s.role.is_global() && owner.as_deref() != Some(&s.username) {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"TOTP exception is outside this account",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let q = state.db.q(&format!("DELETE FROM {t} WHERE id=?"));
|
||||||
|
sqlx::query(&q).bind(id).execute(state.db.pool()).await?;
|
||||||
|
let ip_text = row_string(&r, "ip");
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_post_totp_exception_delete,
|
||||||
|
&[
|
||||||
|
("username", owner.as_deref().unwrap_or("")),
|
||||||
|
("ip", &ip_text),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_app_passwords(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Vec<AppPasswordSummary>>> {
|
||||||
|
let s = routes::session(&state, &jar)?;
|
||||||
|
if !state.config.features.app_passwords || !state.db.has_table(Table::MailboxAppPassword).await
|
||||||
|
{
|
||||||
|
return Ok(Json(Vec::new()));
|
||||||
|
}
|
||||||
|
let t = state.db.table(Table::MailboxAppPassword);
|
||||||
|
let rows = if s.role.is_global() {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT id,username,description FROM {t} ORDER BY username,id"
|
||||||
|
))
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"SELECT id,username,description FROM {t} WHERE username=? ORDER BY id"
|
||||||
|
));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(&s.username)
|
||||||
|
.fetch_all(state.db.pool())
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok(Json(
|
||||||
|
rows.iter()
|
||||||
|
.map(|r| AppPasswordSummary {
|
||||||
|
id: row_i64(r, "id"),
|
||||||
|
username: row_string(r, "username"),
|
||||||
|
description: row_string(r, "description"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_app_password(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<AppPasswordInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
if s.kind != AccountKind::User {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Application passwords are created by mailbox users",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !state.config.features.app_passwords {
|
||||||
|
return Err(AppError::forbidden("Application passwords are disabled"));
|
||||||
|
}
|
||||||
|
if input.description.trim().is_empty() {
|
||||||
|
return Err(AppError::bad_request("Description is required"));
|
||||||
|
}
|
||||||
|
verify_current(&state, &s, &input.current_password).await?;
|
||||||
|
let hash = passwords::hash(&state.config, &input.password, &s.username).await?;
|
||||||
|
let t = state.db.table(Table::MailboxAppPassword);
|
||||||
|
let q = state.db.q(&format!(
|
||||||
|
"INSERT INTO {t} (username,description,password_hash) VALUES (?,?,?)"
|
||||||
|
));
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(&s.username)
|
||||||
|
.bind(&input.description)
|
||||||
|
.bind(&hash)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
hooks::run(
|
||||||
|
&state.config.hooks.mailbox_post_app_password,
|
||||||
|
&[
|
||||||
|
("username", &s.username),
|
||||||
|
("description", &input.description),
|
||||||
|
],
|
||||||
|
Some(hash.as_bytes()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_app_password(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let t = state.db.table(Table::MailboxAppPassword);
|
||||||
|
let q = if s.role.is_global() {
|
||||||
|
state.db.q(&format!("DELETE FROM {t} WHERE id=?"))
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.q(&format!("DELETE FROM {t} WHERE id=? AND username=?"))
|
||||||
|
};
|
||||||
|
let mut x = sqlx::query(&q).bind(id);
|
||||||
|
if !s.role.is_global() {
|
||||||
|
x = x.bind(&s.username);
|
||||||
|
}
|
||||||
|
if x.execute(state.db.pool()).await?.rows_affected() != 1 {
|
||||||
|
return Err(AppError::not_found("Application password not found"));
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_table(s: &crate::auth::Session) -> (Table, &'static str) {
|
||||||
|
match s.kind {
|
||||||
|
AccountKind::Admin => (Table::Admin, "username"),
|
||||||
|
AccountKind::User => (Table::Mailbox, "username"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn verify_current(state: &AppState, s: &crate::auth::Session, p: &str) -> AppResult<()> {
|
||||||
|
let (t, c) = account_table(s);
|
||||||
|
let table = state.db.table(t);
|
||||||
|
let q = state
|
||||||
|
.db
|
||||||
|
.q(&format!("SELECT password FROM {table} WHERE {c}=?"));
|
||||||
|
let r = sqlx::query(&q)
|
||||||
|
.bind(&s.username)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||||
|
if passwords::verify(&state.config, p, &row_string(&r, "password"), &s.username).await {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::forbidden("Current password is incorrect"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::verify_csrf,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::AccountKind,
|
||||||
|
routes,
|
||||||
|
sieve::{ManageSieveClient, SieveCredentials, SieveError, SieveScript},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
connected: bool,
|
||||||
|
port: u16,
|
||||||
|
tls_mode: String,
|
||||||
|
implementation: Option<String>,
|
||||||
|
extensions: Vec<String>,
|
||||||
|
sasl: Vec<String>,
|
||||||
|
read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScriptsResponse {
|
||||||
|
scripts: Vec<SieveScript>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ScriptResponse {
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ScriptBody {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RenameBody {
|
||||||
|
new_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ValidationResponse {
|
||||||
|
valid: bool,
|
||||||
|
warning: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ActionResponse {
|
||||||
|
ok: bool,
|
||||||
|
warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn credentials(state: &AppState, session: &crate::auth::Session) -> AppResult<SieveCredentials> {
|
||||||
|
if !state.config.sieve.enabled {
|
||||||
|
return Err(AppError::forbidden("ManageSieve is disabled"));
|
||||||
|
}
|
||||||
|
if session.kind != AccountKind::User {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"ManageSieve is available to mailbox users only",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let password = session.mail_password().ok_or_else(|| {
|
||||||
|
AppError::unauthorized("ManageSieve credentials are not available; sign in again")
|
||||||
|
})?;
|
||||||
|
Ok(SieveCredentials {
|
||||||
|
host: state.config.sieve.host.clone(),
|
||||||
|
port: state.config.sieve.port,
|
||||||
|
tls_mode: state.config.sieve.tls_mode.clone(),
|
||||||
|
allow_insecure_auth: state.config.sieve.allow_insecure_auth,
|
||||||
|
username: session.username.clone(),
|
||||||
|
password: password.to_string(),
|
||||||
|
timeout: Duration::from_secs(state.config.sieve.timeout_secs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_error(error: SieveError) -> AppError {
|
||||||
|
match error {
|
||||||
|
SieveError::Protocol(message) | SieveError::ServerRejected(message) => {
|
||||||
|
AppError::bad_request(message)
|
||||||
|
}
|
||||||
|
SieveError::ScriptTooLarge => AppError::bad_request("Sieve script exceeds the 1 MiB limit"),
|
||||||
|
SieveError::InsecureAuthenticationBlocked => AppError::forbidden(
|
||||||
|
"ManageSieve plaintext authentication is disabled; use TLS/STARTTLS or explicitly opt in",
|
||||||
|
),
|
||||||
|
SieveError::AuthenticationFailed(message) => {
|
||||||
|
AppError::unauthorized(format!("ManageSieve authentication failed: {message}"))
|
||||||
|
}
|
||||||
|
SieveError::ConnectionFailed(message) | SieveError::TlsFailed(message) => {
|
||||||
|
AppError::bad_gateway(message)
|
||||||
|
}
|
||||||
|
SieveError::Timeout => AppError::bad_gateway("ManageSieve request timed out"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(state: &AppState, session: &crate::auth::Session) -> AppResult<ManageSieveClient> {
|
||||||
|
ManageSieveClient::connect(&credentials(state, session)?)
|
||||||
|
.await
|
||||||
|
.map_err(map_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mutation_session(
|
||||||
|
state: &AppState,
|
||||||
|
jar: &CookieJar,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> AppResult<crate::auth::Session> {
|
||||||
|
let session = routes::session(state, jar)?;
|
||||||
|
verify_csrf(
|
||||||
|
&session,
|
||||||
|
headers
|
||||||
|
.get("x-csrf-token")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
)?;
|
||||||
|
if state.config.sieve.read_only {
|
||||||
|
return Err(AppError::forbidden("ManageSieve is read-only"));
|
||||||
|
}
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<StatusResponse>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
let capabilities = client.capabilities().clone();
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(StatusResponse {
|
||||||
|
connected: true,
|
||||||
|
port: state.config.sieve.port,
|
||||||
|
tls_mode: state.config.sieve.tls_mode.clone(),
|
||||||
|
implementation: capabilities.implementation,
|
||||||
|
extensions: capabilities.extensions,
|
||||||
|
sasl: capabilities.sasl,
|
||||||
|
read_only: state.config.sieve.read_only,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_scripts(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<ScriptsResponse>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
let scripts = client.list_scripts().await.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ScriptsResponse { scripts }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<ScriptResponse>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
let content = client.get_script(&name).await.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ScriptResponse { name, content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(body): Json<ScriptBody>,
|
||||||
|
) -> AppResult<Json<ValidationResponse>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
let result = client.check_script(&body.content).await;
|
||||||
|
client.logout().await;
|
||||||
|
let response = match result {
|
||||||
|
Ok(warning) => ValidationResponse {
|
||||||
|
valid: true,
|
||||||
|
warning,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(SieveError::ServerRejected(message)) => ValidationResponse {
|
||||||
|
valid: false,
|
||||||
|
warning: None,
|
||||||
|
error: Some(message),
|
||||||
|
},
|
||||||
|
Err(error) => return Err(map_error(error)),
|
||||||
|
};
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ScriptBody>,
|
||||||
|
) -> AppResult<Json<ActionResponse>> {
|
||||||
|
let session = mutation_session(&state, &jar, &headers)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
let warning = client
|
||||||
|
.put_script(&name, &body.content)
|
||||||
|
.await
|
||||||
|
.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ActionResponse { ok: true, warning }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn activate_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<ActionResponse>> {
|
||||||
|
let session = mutation_session(&state, &jar, &headers)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
client
|
||||||
|
.activate_script(Some(&name))
|
||||||
|
.await
|
||||||
|
.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ActionResponse {
|
||||||
|
ok: true,
|
||||||
|
warning: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deactivate_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<ActionResponse>> {
|
||||||
|
let session = mutation_session(&state, &jar, &headers)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
client.activate_script(None).await.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ActionResponse {
|
||||||
|
ok: true,
|
||||||
|
warning: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<ActionResponse>> {
|
||||||
|
let session = mutation_session(&state, &jar, &headers)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
client.delete_script(&name).await.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ActionResponse {
|
||||||
|
ok: true,
|
||||||
|
warning: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename_script(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<RenameBody>,
|
||||||
|
) -> AppResult<Json<ActionResponse>> {
|
||||||
|
let session = mutation_session(&state, &jar, &headers)?;
|
||||||
|
let mut client = connect(&state, &session).await?;
|
||||||
|
client
|
||||||
|
.rename_script(&name, &body.new_name)
|
||||||
|
.await
|
||||||
|
.map_err(map_error)?;
|
||||||
|
client.logout().await;
|
||||||
|
Ok(Json(ActionResponse {
|
||||||
|
ok: true,
|
||||||
|
warning: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use crate::{
|
||||||
|
db::{Table, row_i64},
|
||||||
|
error::AppResult,
|
||||||
|
passwords, routes,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Health {
|
||||||
|
status: &'static str,
|
||||||
|
version: &'static str,
|
||||||
|
}
|
||||||
|
pub async fn health(State(state): State<Arc<AppState>>) -> AppResult<Json<Health>> {
|
||||||
|
state.db.ping().await?;
|
||||||
|
Ok(Json(Health {
|
||||||
|
status: "ok",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
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}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub async fn schema(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let s = routes::session(&state, &jar)?;
|
||||||
|
s.require_global()?;
|
||||||
|
let warnings = state.db.check_schema(&state.config).await?;
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"ok":warnings.is_empty(),"warnings":warnings,"database_backend":state.config.database.backend}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub async fn features(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let _ = routes::session(&state, &jar)?;
|
||||||
|
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")}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
use crate::{
|
||||||
|
db::{Table, row_bool, row_i64, row_string},
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{AccountKind, VacationInput, VacationSummary},
|
||||||
|
routes,
|
||||||
|
state::AppState,
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> AppResult<Json<Option<VacationSummary>>> {
|
||||||
|
let session = routes::session(&state, &jar)?;
|
||||||
|
let u = validation::email(&path)?;
|
||||||
|
authorize(&state, &session, &u, false)?;
|
||||||
|
if !state.config.features.vacation || !state.db.has_table(Table::Vacation).await {
|
||||||
|
return Ok(Json(None));
|
||||||
|
}
|
||||||
|
let t = state.db.table(Table::Vacation);
|
||||||
|
let af = state.db.cast_text("activefrom");
|
||||||
|
let au = state.db.cast_text("activeuntil");
|
||||||
|
let active = state.db.flag("active", "active");
|
||||||
|
let q=state.db.q(&format!("SELECT email,subject,body,interval_time,{af} AS activefrom,{au} AS activeuntil,{active} FROM {t} WHERE email=?"));
|
||||||
|
let row = sqlx::query(&q)
|
||||||
|
.bind(&u)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(Json(row.as_ref().map(map)))
|
||||||
|
}
|
||||||
|
pub async fn set(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(input): Json<VacationInput>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let u = validation::email(&path)?;
|
||||||
|
authorize(&state, &session, &u, true)?;
|
||||||
|
if validation::email(&input.email)? != u {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Vacation email does not match mailbox",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.subject.len() > 255 || input.body.len() > 65535 {
|
||||||
|
return Err(AppError::bad_request("Vacation message is too long"));
|
||||||
|
}
|
||||||
|
let (_, domain) = u.rsplit_once('@').unwrap();
|
||||||
|
let t = state.db.table(Table::Vacation);
|
||||||
|
let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?"));
|
||||||
|
let exists = sqlx::query(&check)
|
||||||
|
.bind(&u)
|
||||||
|
.fetch_optional(state.db.pool())
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
let q = if exists {
|
||||||
|
state.db.q(&format!("UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,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,?)"))
|
||||||
|
};
|
||||||
|
if exists {
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(&input.subject)
|
||||||
|
.bind(&input.body)
|
||||||
|
.bind(&input.active_from)
|
||||||
|
.bind(&input.active_until)
|
||||||
|
.bind(input.interval_time)
|
||||||
|
.bind(input.active)
|
||||||
|
.bind(&u)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
sqlx::query(&q)
|
||||||
|
.bind(&u)
|
||||||
|
.bind(&input.subject)
|
||||||
|
.bind(&input.body)
|
||||||
|
.bind(&input.active_from)
|
||||||
|
.bind(&input.active_until)
|
||||||
|
.bind(domain)
|
||||||
|
.bind(input.interval_time)
|
||||||
|
.bind(input.active)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(
|
||||||
|
&state.config,
|
||||||
|
&session.username,
|
||||||
|
domain,
|
||||||
|
"edit_vacation",
|
||||||
|
&u,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
pub async fn remove(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||||
|
let u = validation::email(&path)?;
|
||||||
|
authorize(&state, &session, &u, true)?;
|
||||||
|
let t = state.db.table(Table::Vacation);
|
||||||
|
let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?"));
|
||||||
|
sqlx::query(&q).bind(&u).execute(state.db.pool()).await?;
|
||||||
|
if state.db.has_table(Table::VacationNotification).await {
|
||||||
|
let n = state.db.table(Table::VacationNotification);
|
||||||
|
let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?"));
|
||||||
|
let _ = sqlx::query(&q).bind(&u).execute(state.db.pool()).await;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
|
}
|
||||||
|
fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -> AppResult<()> {
|
||||||
|
if !state.config.features.vacation {
|
||||||
|
return Err(AppError::forbidden("Vacation is disabled"));
|
||||||
|
}
|
||||||
|
if s.kind == AccountKind::User {
|
||||||
|
if !state.config.features.vacation_control || s.username != u {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Vacation control is disabled for this user",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
if write && !state.config.features.vacation_control_admin && !s.role.is_global() {
|
||||||
|
return Err(AppError::forbidden(
|
||||||
|
"Vacation control is disabled for domain administrators",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (_, d) = u.rsplit_once('@').unwrap();
|
||||||
|
s.require_domain(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
|
||||||
|
VacationSummary {
|
||||||
|
email: row_string(r, "email"),
|
||||||
|
subject: row_string(r, "subject"),
|
||||||
|
body: row_string(r, "body"),
|
||||||
|
interval_time: row_i64(r, "interval_time"),
|
||||||
|
active_from: row_string(r, "activefrom"),
|
||||||
|
active_until: row_string(r, "activeuntil"),
|
||||||
|
active: row_bool(r, "active"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
use super::error::SieveError;
|
||||||
|
|
||||||
|
const MAX_LINE_BYTES: usize = 64 * 1024;
|
||||||
|
const MAX_RESPONSE_LINES: usize = 256;
|
||||||
|
const MAX_LITERAL_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SieveCredentials {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
pub tls_mode: String,
|
||||||
|
pub allow_insecure_auth: bool,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SieveCapabilities {
|
||||||
|
pub implementation: Option<String>,
|
||||||
|
pub sasl: Vec<String>,
|
||||||
|
pub extensions: Vec<String>,
|
||||||
|
pub starttls: bool,
|
||||||
|
pub raw: BTreeMap<String, Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct SieveScript {
|
||||||
|
pub name: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ManageSieveClient {
|
||||||
|
stream: SieveStream,
|
||||||
|
timeout: Duration,
|
||||||
|
capabilities: SieveCapabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManageSieveClient {
|
||||||
|
pub async fn connect(creds: &SieveCredentials) -> Result<Self, SieveError> {
|
||||||
|
let connect_future = TcpStream::connect((creds.host.as_str(), creds.port));
|
||||||
|
let mut tcp = timeout(creds.timeout, connect_future)
|
||||||
|
.await
|
||||||
|
.map_err(|_| SieveError::Timeout)?
|
||||||
|
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||||
|
tcp.set_nodelay(true)
|
||||||
|
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let (mut stream, greeting) = match creds.tls_mode.as_str() {
|
||||||
|
"implicit" => {
|
||||||
|
let tls = async_native_tls::TlsConnector::new();
|
||||||
|
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
|
||||||
|
.await
|
||||||
|
.map_err(|_| SieveError::Timeout)?
|
||||||
|
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
|
||||||
|
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
|
||||||
|
(SieveStream::Tls(tls_stream), greeting)
|
||||||
|
}
|
||||||
|
"starttls" => {
|
||||||
|
let greeting = read_response(&mut tcp, creds.timeout).await?;
|
||||||
|
ensure_ok(&greeting)?;
|
||||||
|
let initial_caps = parse_capabilities(&greeting.lines);
|
||||||
|
if !initial_caps.starttls {
|
||||||
|
return Err(SieveError::TlsFailed(
|
||||||
|
"server does not advertise STARTTLS".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_all_timeout(&mut tcp, b"STARTTLS\r\n", creds.timeout).await?;
|
||||||
|
let reply = read_response(&mut tcp, creds.timeout).await?;
|
||||||
|
ensure_ok(&reply)?;
|
||||||
|
|
||||||
|
let tls = async_native_tls::TlsConnector::new();
|
||||||
|
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
|
||||||
|
.await
|
||||||
|
.map_err(|_| SieveError::Timeout)?
|
||||||
|
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
|
||||||
|
(SieveStream::Tls(tls_stream), greeting)
|
||||||
|
}
|
||||||
|
"none" => {
|
||||||
|
let greeting = read_response(&mut tcp, creds.timeout).await?;
|
||||||
|
(SieveStream::Plain(tcp), greeting)
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(SieveError::Protocol(format!(
|
||||||
|
"unsupported TLS mode: {other}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensure_ok(&greeting)?;
|
||||||
|
let capabilities = parse_capabilities(&greeting.lines);
|
||||||
|
|
||||||
|
if creds.tls_mode == "none" && !creds.allow_insecure_auth {
|
||||||
|
return Err(SieveError::InsecureAuthenticationBlocked);
|
||||||
|
}
|
||||||
|
if !capabilities
|
||||||
|
.sasl
|
||||||
|
.iter()
|
||||||
|
.any(|mechanism| mechanism.eq_ignore_ascii_case("PLAIN"))
|
||||||
|
{
|
||||||
|
return Err(SieveError::AuthenticationFailed(
|
||||||
|
"server does not advertise SASL PLAIN".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let plain = format!("\0{}\0{}", creds.username, creds.password);
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(plain.as_bytes());
|
||||||
|
let command = format!("AUTHENTICATE \"PLAIN\" \"{encoded}\"\r\n");
|
||||||
|
write_all_timeout(&mut stream, command.as_bytes(), creds.timeout).await?;
|
||||||
|
let auth = read_response(&mut stream, creds.timeout).await?;
|
||||||
|
if auth.status == ResponseStatus::No || auth.status == ResponseStatus::Bye {
|
||||||
|
return Err(SieveError::AuthenticationFailed(auth.human_message()));
|
||||||
|
}
|
||||||
|
ensure_ok(&auth)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
stream,
|
||||||
|
timeout: creds.timeout,
|
||||||
|
capabilities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capabilities(&self) -> &SieveCapabilities {
|
||||||
|
&self.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_scripts(&mut self) -> Result<Vec<SieveScript>, SieveError> {
|
||||||
|
let response = self.command("LISTSCRIPTS\r\n").await?;
|
||||||
|
ensure_ok(&response)?;
|
||||||
|
|
||||||
|
let mut scripts = Vec::new();
|
||||||
|
for line in response.lines {
|
||||||
|
if let Some(literal) = line.literal {
|
||||||
|
let name = String::from_utf8(literal)
|
||||||
|
.map_err(|_| SieveError::Protocol("script name is not UTF-8".into()))?;
|
||||||
|
scripts.push(SieveScript {
|
||||||
|
name,
|
||||||
|
active: line.after.trim().eq_ignore_ascii_case("ACTIVE"),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((name, rest)) = parse_quoted(&line.before) {
|
||||||
|
scripts.push(SieveScript {
|
||||||
|
name,
|
||||||
|
active: rest.trim().eq_ignore_ascii_case("ACTIVE"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(scripts)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_script(&mut self, name: &str) -> Result<String, SieveError> {
|
||||||
|
let command = format!("GETSCRIPT {}\r\n", quote(name)?);
|
||||||
|
let response = self.command(&command).await?;
|
||||||
|
ensure_ok(&response)?;
|
||||||
|
let Some(line) = response.lines.first() else {
|
||||||
|
return Err(SieveError::Protocol(
|
||||||
|
"GETSCRIPT returned no script body".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = if let Some(literal) = &line.literal {
|
||||||
|
literal.clone()
|
||||||
|
} else if let Some((value, _)) = parse_quoted(&line.before) {
|
||||||
|
value.into_bytes()
|
||||||
|
} else {
|
||||||
|
return Err(SieveError::Protocol(
|
||||||
|
"GETSCRIPT returned an unsupported string encoding".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
String::from_utf8(bytes).map_err(|_| SieveError::Protocol("script is not UTF-8".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_script(&mut self, script: &str) -> Result<Option<String>, SieveError> {
|
||||||
|
self.send_script_command("CHECKSCRIPT", None, script).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_script(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
script: &str,
|
||||||
|
) -> Result<Option<String>, SieveError> {
|
||||||
|
if script.is_empty() {
|
||||||
|
return Err(SieveError::Protocol(
|
||||||
|
"empty Sieve scripts are not accepted".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if script.len() > MAX_SCRIPT_BYTES {
|
||||||
|
return Err(SieveError::ScriptTooLarge);
|
||||||
|
}
|
||||||
|
|
||||||
|
let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len());
|
||||||
|
let response = self.command(&have_space).await?;
|
||||||
|
ensure_ok(&response)?;
|
||||||
|
|
||||||
|
self.send_script_command("PUTSCRIPT", Some(name), script)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn activate_script(&mut self, name: Option<&str>) -> Result<(), SieveError> {
|
||||||
|
let name = match name {
|
||||||
|
Some(name) => quote(name)?,
|
||||||
|
None => "\"\"".to_string(),
|
||||||
|
};
|
||||||
|
let response = self.command(&format!("SETACTIVE {name}\r\n")).await?;
|
||||||
|
ensure_ok(&response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_script(&mut self, name: &str) -> Result<(), SieveError> {
|
||||||
|
let response = self
|
||||||
|
.command(&format!("DELETESCRIPT {}\r\n", quote(name)?))
|
||||||
|
.await?;
|
||||||
|
ensure_ok(&response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename_script(&mut self, name: &str, new_name: &str) -> Result<(), SieveError> {
|
||||||
|
let response = self
|
||||||
|
.command(&format!(
|
||||||
|
"RENAMESCRIPT {} {}\r\n",
|
||||||
|
quote(name)?,
|
||||||
|
quote(new_name)?
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
ensure_ok(&response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(&mut self) {
|
||||||
|
let _ = self.command("LOGOUT\r\n").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command(&mut self, command: &str) -> Result<ProtocolResponse, SieveError> {
|
||||||
|
write_all_timeout(&mut self.stream, command.as_bytes(), self.timeout).await?;
|
||||||
|
read_response(&mut self.stream, self.timeout).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_script_command(
|
||||||
|
&mut self,
|
||||||
|
verb: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
script: &str,
|
||||||
|
) -> Result<Option<String>, SieveError> {
|
||||||
|
if script.len() > MAX_SCRIPT_BYTES {
|
||||||
|
return Err(SieveError::ScriptTooLarge);
|
||||||
|
}
|
||||||
|
let header = match name {
|
||||||
|
Some(name) => format!("{verb} {} {{{}+}}\r\n", quote(name)?, script.len()),
|
||||||
|
None => format!("{verb} {{{}+}}\r\n", script.len()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut command = Vec::with_capacity(header.len() + script.len() + 2);
|
||||||
|
command.extend_from_slice(header.as_bytes());
|
||||||
|
command.extend_from_slice(script.as_bytes());
|
||||||
|
command.extend_from_slice(b"\r\n");
|
||||||
|
write_all_timeout(&mut self.stream, &command, self.timeout).await?;
|
||||||
|
let response = read_response(&mut self.stream, self.timeout).await?;
|
||||||
|
ensure_ok(&response)?;
|
||||||
|
|
||||||
|
if response.code.as_deref() == Some("WARNINGS") {
|
||||||
|
Ok(Some(response.human_message()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum SieveStream {
|
||||||
|
Tls(async_native_tls::TlsStream<TcpStream>),
|
||||||
|
Plain(TcpStream),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for SieveStream {
|
||||||
|
fn poll_read(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
buf: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<std::io::Result<()>> {
|
||||||
|
match self.get_mut() {
|
||||||
|
Self::Tls(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||||
|
Self::Plain(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncWrite for SieveStream {
|
||||||
|
fn poll_write(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
buf: &[u8],
|
||||||
|
) -> Poll<std::io::Result<usize>> {
|
||||||
|
match self.get_mut() {
|
||||||
|
Self::Tls(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||||
|
Self::Plain(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
match self.get_mut() {
|
||||||
|
Self::Tls(stream) => Pin::new(stream).poll_flush(cx),
|
||||||
|
Self::Plain(stream) => Pin::new(stream).poll_flush(cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||||
|
match self.get_mut() {
|
||||||
|
Self::Tls(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||||
|
Self::Plain(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ResponseStatus {
|
||||||
|
Ok,
|
||||||
|
No,
|
||||||
|
Bye,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WireLine {
|
||||||
|
before: String,
|
||||||
|
literal: Option<Vec<u8>>,
|
||||||
|
after: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ProtocolResponse {
|
||||||
|
status: ResponseStatus,
|
||||||
|
code: Option<String>,
|
||||||
|
final_line: WireLine,
|
||||||
|
lines: Vec<WireLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolResponse {
|
||||||
|
fn human_message(&self) -> String {
|
||||||
|
if let Some(literal) = &self.final_line.literal {
|
||||||
|
return String::from_utf8_lossy(literal).trim().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = format!("{}{}", self.final_line.before, self.final_line.after);
|
||||||
|
|
||||||
|
if let Some(pos) = text.rfind('"') {
|
||||||
|
let prefix = &text[..pos];
|
||||||
|
if let Some(start) = prefix.rfind('"') {
|
||||||
|
return unescape_quoted(&text[start + 1..pos]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.trim().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_all_timeout<S: AsyncWrite + Unpin>(
|
||||||
|
stream: &mut S,
|
||||||
|
bytes: &[u8],
|
||||||
|
duration: Duration,
|
||||||
|
) -> Result<(), SieveError> {
|
||||||
|
timeout(duration, async {
|
||||||
|
stream.write_all(bytes).await?;
|
||||||
|
stream.flush().await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| SieveError::Timeout)?
|
||||||
|
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_response<S: AsyncRead + Unpin>(
|
||||||
|
stream: &mut S,
|
||||||
|
duration: Duration,
|
||||||
|
) -> Result<ProtocolResponse, SieveError> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for _ in 0..MAX_RESPONSE_LINES {
|
||||||
|
let line = timeout(duration, read_wire_line(stream))
|
||||||
|
.await
|
||||||
|
.map_err(|_| SieveError::Timeout)??;
|
||||||
|
if let Some((status, code)) = parse_status(&line) {
|
||||||
|
return Ok(ProtocolResponse {
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
final_line: line,
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
Err(SieveError::Protocol(
|
||||||
|
"response exceeded the maximum number of lines".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_wire_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<WireLine, SieveError> {
|
||||||
|
let before_bytes = read_crlf_line(stream).await?;
|
||||||
|
let before = String::from_utf8(before_bytes)
|
||||||
|
.map_err(|_| SieveError::Protocol("response line is not UTF-8".into()))?;
|
||||||
|
|
||||||
|
let Some(size) = trailing_literal_size(&before)? else {
|
||||||
|
return Ok(WireLine {
|
||||||
|
before,
|
||||||
|
literal: None,
|
||||||
|
after: String::new(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if size > MAX_LITERAL_BYTES {
|
||||||
|
return Err(SieveError::Protocol(format!(
|
||||||
|
"server literal exceeds {MAX_LITERAL_BYTES} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut literal = vec![0u8; size];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut literal)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let after_bytes = read_crlf_line(stream).await?;
|
||||||
|
let after = String::from_utf8(after_bytes)
|
||||||
|
.map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?;
|
||||||
|
|
||||||
|
Ok(WireLine {
|
||||||
|
before,
|
||||||
|
literal: Some(literal),
|
||||||
|
after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_crlf_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<Vec<u8>, SieveError> {
|
||||||
|
let mut out = Vec::with_capacity(128);
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
loop {
|
||||||
|
if out.len() >= MAX_LINE_BYTES {
|
||||||
|
return Err(SieveError::Protocol("response line is too long".into()));
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.read_exact(&mut byte)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||||
|
out.push(byte[0]);
|
||||||
|
if out.len() >= 2 && &out[out.len() - 2..] == b"\r\n" {
|
||||||
|
out.truncate(out.len() - 2);
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trailing_literal_size(line: &str) -> Result<Option<usize>, SieveError> {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
let Some(open) = trimmed.rfind('{') else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !trimmed.ends_with('}') {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let raw = &trimmed[open + 1..trimmed.len() - 1];
|
||||||
|
|
||||||
|
if raw.ends_with('+') {
|
||||||
|
return Err(SieveError::Protocol(
|
||||||
|
"server sent an invalid non-synchronizing literal".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let size = raw
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| SieveError::Protocol("invalid literal length".into()))?;
|
||||||
|
Ok(Some(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_status(line: &WireLine) -> Option<(ResponseStatus, Option<String>)> {
|
||||||
|
let text = line.before.trim_start();
|
||||||
|
let (status, rest) = if let Some(rest) = strip_token_ci(text, "OK") {
|
||||||
|
(ResponseStatus::Ok, rest)
|
||||||
|
} else if let Some(rest) = strip_token_ci(text, "NO") {
|
||||||
|
(ResponseStatus::No, rest)
|
||||||
|
} else {
|
||||||
|
let rest = strip_token_ci(text, "BYE")?;
|
||||||
|
(ResponseStatus::Bye, rest)
|
||||||
|
};
|
||||||
|
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let code = rest
|
||||||
|
.strip_prefix('(')
|
||||||
|
.and_then(|inside| inside.split_once(')'))
|
||||||
|
.map(|(inside, _)| {
|
||||||
|
inside
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or(inside)
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
});
|
||||||
|
Some((status, code))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_token_ci<'a>(text: &'a str, token: &str) -> Option<&'a str> {
|
||||||
|
if text.len() < token.len() || !text[..token.len()].eq_ignore_ascii_case(token) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = &text[token.len()..];
|
||||||
|
if rest.is_empty()
|
||||||
|
|| rest.chars().next().is_some_and(char::is_whitespace)
|
||||||
|
|| rest.starts_with('(')
|
||||||
|
{
|
||||||
|
Some(rest)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_ok(response: &ProtocolResponse) -> Result<(), SieveError> {
|
||||||
|
match response.status {
|
||||||
|
ResponseStatus::Ok => Ok(()),
|
||||||
|
ResponseStatus::No | ResponseStatus::Bye => {
|
||||||
|
Err(SieveError::ServerRejected(response.human_message()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_capabilities(lines: &[WireLine]) -> SieveCapabilities {
|
||||||
|
let mut raw = BTreeMap::new();
|
||||||
|
for line in lines {
|
||||||
|
if line.literal.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((name, rest)) = parse_quoted(&line.before) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = parse_quoted(rest).map(|(value, _)| value);
|
||||||
|
raw.insert(name.to_ascii_uppercase(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let implementation = raw.get("IMPLEMENTATION").cloned().flatten();
|
||||||
|
let sasl = raw
|
||||||
|
.get("SASL")
|
||||||
|
.and_then(Clone::clone)
|
||||||
|
.map(|value| value.split_whitespace().map(str::to_string).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let extensions = raw
|
||||||
|
.get("SIEVE")
|
||||||
|
.and_then(Clone::clone)
|
||||||
|
.map(|value| value.split_whitespace().map(str::to_string).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let starttls = raw.contains_key("STARTTLS");
|
||||||
|
|
||||||
|
SieveCapabilities {
|
||||||
|
implementation,
|
||||||
|
sasl,
|
||||||
|
extensions,
|
||||||
|
starttls,
|
||||||
|
raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote(value: &str) -> Result<String, SieveError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Ok("\"\"".into());
|
||||||
|
}
|
||||||
|
if value
|
||||||
|
.chars()
|
||||||
|
.any(|c| c == '\r' || c == '\n' || c == '\0' || c.is_control())
|
||||||
|
{
|
||||||
|
return Err(SieveError::Protocol(
|
||||||
|
"script name contains unsupported control characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"\"{}\"",
|
||||||
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_quoted(text: &str) -> Option<(String, &str)> {
|
||||||
|
let text = text.trim_start();
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
if bytes.first() != Some(&b'"') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut escaped = false;
|
||||||
|
for (idx, ch) in text[1..].char_indices() {
|
||||||
|
if escaped {
|
||||||
|
out.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ch {
|
||||||
|
'\\' => escaped = true,
|
||||||
|
'"' => {
|
||||||
|
let end = 1 + idx + ch.len_utf8();
|
||||||
|
return Some((out, &text[end..]));
|
||||||
|
}
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape_quoted(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut escaped = false;
|
||||||
|
for ch in text.chars() {
|
||||||
|
if escaped {
|
||||||
|
out.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quotes_script_names() {
|
||||||
|
assert_eq!(quote("main").unwrap(), "\"main\"");
|
||||||
|
assert_eq!(quote("a\\b\"c").unwrap(), "\"a\\\\b\\\"c\"");
|
||||||
|
assert!(quote("bad\nname").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_quoted_strings() {
|
||||||
|
let (value, rest) = parse_quoted(" \"main\\\"script\" ACTIVE").unwrap();
|
||||||
|
assert_eq!(value, "main\"script");
|
||||||
|
assert_eq!(rest.trim(), "ACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_literal_length_only_at_end_of_wire_prefix() {
|
||||||
|
assert_eq!(trailing_literal_size("{54}").unwrap(), Some(54));
|
||||||
|
assert_eq!(trailing_literal_size("NO (X) {31}").unwrap(), Some(31));
|
||||||
|
assert_eq!(trailing_literal_size("\"name\"").unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_capabilities() {
|
||||||
|
let lines = vec![
|
||||||
|
WireLine {
|
||||||
|
before: "\"IMPLEMENTATION\" \"Dovecot Pigeonhole\"".into(),
|
||||||
|
literal: None,
|
||||||
|
after: String::new(),
|
||||||
|
},
|
||||||
|
WireLine {
|
||||||
|
before: "\"SASL\" \"PLAIN\"".into(),
|
||||||
|
literal: None,
|
||||||
|
after: String::new(),
|
||||||
|
},
|
||||||
|
WireLine {
|
||||||
|
before: "\"SIEVE\" \"fileinto vacation\"".into(),
|
||||||
|
literal: None,
|
||||||
|
after: String::new(),
|
||||||
|
},
|
||||||
|
WireLine {
|
||||||
|
before: "\"STARTTLS\"".into(),
|
||||||
|
literal: None,
|
||||||
|
after: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let caps = parse_capabilities(&lines);
|
||||||
|
assert_eq!(caps.implementation.as_deref(), Some("Dovecot Pigeonhole"));
|
||||||
|
assert_eq!(caps.sasl, vec!["PLAIN"]);
|
||||||
|
assert_eq!(caps.extensions, vec!["fileinto", "vacation"]);
|
||||||
|
assert!(caps.starttls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plaintext_manage_sieve_round_trip_requires_explicit_opt_in() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut stream = BufReader::new(stream);
|
||||||
|
stream
|
||||||
|
.get_mut()
|
||||||
|
.write_all(b"\"IMPLEMENTATION\" \"PostfixAdmin-RS test\"\r\n\"SASL\" \"PLAIN\"\r\n\"SIEVE\" \"fileinto vacation\"\r\nOK\r\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
stream.read_line(&mut line).await.unwrap();
|
||||||
|
assert!(line.starts_with("AUTHENTICATE \"PLAIN\" "));
|
||||||
|
stream.get_mut().write_all(b"OK\r\n").await.unwrap();
|
||||||
|
|
||||||
|
line.clear();
|
||||||
|
stream.read_line(&mut line).await.unwrap();
|
||||||
|
assert_eq!(line, "LISTSCRIPTS\r\n");
|
||||||
|
stream
|
||||||
|
.get_mut()
|
||||||
|
.write_all(b"\"main\" ACTIVE\r\nOK\r\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
line.clear();
|
||||||
|
stream.read_line(&mut line).await.unwrap();
|
||||||
|
assert_eq!(line, "LOGOUT\r\n");
|
||||||
|
stream.get_mut().write_all(b"OK\r\n").await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let creds = SieveCredentials {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port,
|
||||||
|
tls_mode: "none".into(),
|
||||||
|
allow_insecure_auth: true,
|
||||||
|
username: "alice@example.com".into(),
|
||||||
|
password: "secret".into(),
|
||||||
|
timeout: Duration::from_secs(2),
|
||||||
|
};
|
||||||
|
let mut client = ManageSieveClient::connect(&creds).await.unwrap();
|
||||||
|
let scripts = client.list_scripts().await.unwrap();
|
||||||
|
assert_eq!(scripts.len(), 1);
|
||||||
|
assert_eq!(scripts[0].name, "main");
|
||||||
|
assert!(scripts[0].active);
|
||||||
|
client.logout().await;
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SieveError {
|
||||||
|
ConnectionFailed(String),
|
||||||
|
TlsFailed(String),
|
||||||
|
AuthenticationFailed(String),
|
||||||
|
InsecureAuthenticationBlocked,
|
||||||
|
Protocol(String),
|
||||||
|
ServerRejected(String),
|
||||||
|
Timeout,
|
||||||
|
ScriptTooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SieveError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::ConnectionFailed(msg) => write!(f, "ManageSieve connection failed: {msg}"),
|
||||||
|
Self::TlsFailed(msg) => write!(f, "ManageSieve TLS failed: {msg}"),
|
||||||
|
Self::AuthenticationFailed(msg) => {
|
||||||
|
write!(f, "ManageSieve authentication failed: {msg}")
|
||||||
|
}
|
||||||
|
Self::InsecureAuthenticationBlocked => write!(
|
||||||
|
f,
|
||||||
|
"ManageSieve plaintext authentication is blocked; use STARTTLS/implicit TLS or explicitly enable sieve.allow_insecure_auth"
|
||||||
|
),
|
||||||
|
Self::Protocol(msg) => write!(f, "ManageSieve protocol error: {msg}"),
|
||||||
|
Self::ServerRejected(msg) => {
|
||||||
|
write!(f, "ManageSieve server rejected the command: {msg}")
|
||||||
|
}
|
||||||
|
Self::Timeout => write!(f, "ManageSieve operation timed out"),
|
||||||
|
Self::ScriptTooLarge => {
|
||||||
|
write!(f, "Sieve script exceeds the PostfixAdmin-RS size limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SieveError {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
pub use client::{ManageSieveClient, SieveCredentials, SieveScript};
|
||||||
|
pub use error::SieveError;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{auth::AuthStore, config::AppConfig, db::Db};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<AppConfig>,
|
||||||
|
pub db: Db,
|
||||||
|
pub auth: AuthStore,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::state::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const INDEX: &str = include_str!("web/index.html");
|
||||||
|
const ICONS: &str = include_str!("web/icons.svg");
|
||||||
|
const EN: &str = include_str!("web/i18n/en.json");
|
||||||
|
const PL: &str = include_str!("web/i18n/pl.json");
|
||||||
|
const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/postfixadmin-rs.css"));
|
||||||
|
const JS: &str = include_str!(concat!(env!("OUT_DIR"), "/postfixadmin-rs.js"));
|
||||||
|
const ASSET_ID: &str = env!("POSTFIXADMIN_RS_ASSET_ID");
|
||||||
|
|
||||||
|
pub async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
|
||||||
|
let base = state.config.normalized_base_path();
|
||||||
|
let base = if base == "/" {
|
||||||
|
"/".into()
|
||||||
|
} else {
|
||||||
|
format!("{base}/")
|
||||||
|
};
|
||||||
|
Html(
|
||||||
|
INDEX
|
||||||
|
.replace("__BASE_PATH__", &base)
|
||||||
|
.replace("__ASSET_ID__", ASSET_ID)
|
||||||
|
.replace("__VERSION__", env!("CARGO_PKG_VERSION"))
|
||||||
|
.replace("__LANG__", &state.config.default_language),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub async fn asset(Path(path): Path<String>) -> Response {
|
||||||
|
let path = path.trim_start_matches('/');
|
||||||
|
let (body, ctype, immutable) = match path {
|
||||||
|
p if p == format!("app.{ASSET_ID}.css") => (CSS, "text/css; charset=utf-8", true),
|
||||||
|
p if p == format!("app.{ASSET_ID}.js") => {
|
||||||
|
(JS, "application/javascript; charset=utf-8", true)
|
||||||
|
}
|
||||||
|
p if p == format!("icons.{ASSET_ID}.svg") => (ICONS, "image/svg+xml", true),
|
||||||
|
"icons.svg" => (ICONS, "image/svg+xml", false),
|
||||||
|
"i18n/en.json" => (EN, "application/json; charset=utf-8", false),
|
||||||
|
"i18n/pl.json" => (PL, "application/json; charset=utf-8", false),
|
||||||
|
_ => ("", "text/plain", false),
|
||||||
|
};
|
||||||
|
if body.is_empty() {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
|
||||||
|
h.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static(if immutable {
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
} else {
|
||||||
|
"no-cache"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(h, body).into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use regex::Regex;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use trust_dns_resolver::{TokioAsyncResolver, proto::rr::RecordType};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::AppConfig,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
static LOCAL_PART: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$").expect("valid regex"));
|
||||||
|
static DOMAIN_LABEL: Lazy<Regex> = Lazy::new(|| {
|
||||||
|
Regex::new(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$").expect("valid regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn domain(value: &str) -> AppResult<String> {
|
||||||
|
let value = value.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||||
|
if value.is_empty() || value.len() > 253 || !value.contains('.') {
|
||||||
|
return Err(AppError::bad_request("Invalid domain"));
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.split('.')
|
||||||
|
.all(|label| label.len() <= 63 && DOMAIN_LABEL.is_match(label))
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request("Invalid domain"));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolvable_domain(config: &AppConfig, value: &str) -> AppResult<String> {
|
||||||
|
let domain = domain(value)?;
|
||||||
|
if !config.features.emailcheck_resolve_domain {
|
||||||
|
return Ok(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolver = TokioAsyncResolver::tokio_from_system_conf()
|
||||||
|
.map_err(|error| AppError::config(format!("DNS resolver setup failed: {error}")))?;
|
||||||
|
let name = domain.clone();
|
||||||
|
let lookup = async {
|
||||||
|
if resolver.lookup_ip(name.as_str()).await.is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if resolver.mx_lookup(name.as_str()).await.is_ok() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
resolver.lookup(name.as_str(), RecordType::NS).await.is_ok()
|
||||||
|
};
|
||||||
|
match timeout(Duration::from_secs(5), lookup).await {
|
||||||
|
Ok(true) => Ok(domain),
|
||||||
|
Ok(false) => Err(AppError::bad_request(format!(
|
||||||
|
"Domain has no A, AAAA, MX or NS record: {domain}"
|
||||||
|
))),
|
||||||
|
Err(_) => Err(AppError::bad_request(format!(
|
||||||
|
"DNS lookup timed out for domain: {domain}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn email(value: &str) -> AppResult<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
let Some((local, host)) = value.rsplit_once('@') else {
|
||||||
|
return Err(AppError::bad_request("Invalid email address"));
|
||||||
|
};
|
||||||
|
if local.is_empty() || local.len() > 64 || !LOCAL_PART.is_match(local) {
|
||||||
|
return Err(AppError::bad_request("Invalid email local part"));
|
||||||
|
}
|
||||||
|
let host = domain(host)?;
|
||||||
|
Ok(format!("{local}@{host}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolvable_email(config: &AppConfig, value: &str) -> AppResult<String> {
|
||||||
|
let email = email(value)?;
|
||||||
|
let (_, host) = email.rsplit_once('@').expect("validated email has domain");
|
||||||
|
resolvable_domain(config, host).await?;
|
||||||
|
Ok(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_part(value: &str) -> AppResult<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() || value.len() > 64 || !LOCAL_PART.is_match(value) {
|
||||||
|
return Err(AppError::bad_request("Invalid mailbox/alias local part"));
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alias_destination(value: &str, allow_unsafe: bool) -> AppResult<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() || value.contains(['\r', '\n', '\0']) || value.len() > 1000 {
|
||||||
|
return Err(AppError::bad_request("Invalid alias destination"));
|
||||||
|
}
|
||||||
|
if !allow_unsafe && (value.starts_with('|') || value.starts_with('/') || value.starts_with(':'))
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"Pipe/file alias destinations are disabled by the Rust security profile",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.contains('@') {
|
||||||
|
return email(value);
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn alias_destination_checked(
|
||||||
|
config: &AppConfig,
|
||||||
|
value: &str,
|
||||||
|
allow_unsafe: bool,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let destination = alias_destination(value, allow_unsafe)?;
|
||||||
|
if let Some((_, domain)) = destination.rsplit_once('@') {
|
||||||
|
resolvable_domain(config, domain).await?;
|
||||||
|
}
|
||||||
|
Ok(destination)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{color-scheme:dark;--bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310;--line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f;--accent:#8fb9a4;--accent2:#a4c9b6;--accentink:#102018;--danger:#e18f8a;--warn:#d0ad70;--success:#a7c892;--field:#121713;--selected:#28352e;--hover:#202621;--shadow:rgba(0,0,0,.32);--font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--mono:ui-monospace,SFMono-Regular,Consolas,monospace;--r:4px;--control:32px}html[data-theme=light]{color-scheme:light;--bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9;--line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d;--accent:#507b67;--accent2:#416b58;--accentink:#fff;--danger:#a94c48;--warn:#8a6930;--success:#587a49;--field:#fff;--selected:#dce8e0;--hover:#e8ede8;--shadow:rgba(31,39,33,.16)}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*{box-sizing:border-box;scrollbar-color:var(--line2) var(--panel);scrollbar-width:thin}html,body,#app{height:100%}body{margin:0;background:var(--bg);color:var(--text);font:13px/1.45 var(--font);overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}input,select,textarea{width:100%;min-height:var(--control);border:1px solid var(--line2);border-radius:3px;background:var(--field);padding:6px 8px;outline:0}textarea{min-height:96px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}button{cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:var(--control);padding:5px 10px;border:1px solid var(--line2);border-radius:3px;background:var(--panel2);color:var(--text);font-weight:650}.btn:hover{border-color:var(--accent)}.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accentink)}.btn.danger{color:var(--danger)}.btn.ghost{background:transparent;border-color:transparent}.icon{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}.muted{color:var(--muted)}.mono{font-family:var(--mono)}.badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:1px 7px;font-size:10px;color:var(--muted)}.badge.ok{color:var(--success)}.badge.off{color:var(--subtle)}.empty{padding:32px;text-align:center;color:var(--muted)}.boot{height:100%;display:grid;place-content:center;justify-items:center;gap:12px}.boot svg{width:36px;height:36px;fill:none;stroke:var(--accent);stroke-width:1.8}.toast{position:fixed;right:18px;bottom:18px;z-index:50;max-width:420px;padding:10px 12px;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel3);box-shadow:0 10px 30px var(--shadow)}.toast.error{color:var(--danger)}dialog{width:min(620px,calc(100vw - 30px));max-height:90vh;padding:0;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel);color:var(--text);box-shadow:0 20px 60px var(--shadow)}dialog::backdrop{background:rgba(0,0,0,.56)}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
.login-view{height:100%;display:grid;place-items:center;padding:24px}.login-panel{width:min(100%,370px);border:1px solid var(--line);background:var(--panel);border-radius:var(--r);box-shadow:0 18px 46px var(--shadow)}.login-brand{display:flex;gap:11px;align-items:center;padding:18px 20px;border-bottom:1px solid var(--line)}.login-brand svg{width:27px;height:27px;color:var(--accent)}.login-brand strong{display:block;font-size:16px}.login-brand small{color:var(--muted)}.login-form{display:grid;gap:13px;padding:20px}.field span{display:block;margin-bottom:5px;color:var(--muted);font-size:11px;font-weight:650}.login-footer{padding:0 20px 16px;color:var(--subtle);font:10px var(--mono);text-align:center}.shell{height:100%;display:grid;grid-template-rows:44px minmax(0,1fr)}.topbar{display:grid;grid-template-columns:230px 1fr auto;align-items:center;gap:12px;padding:0 10px;border-bottom:1px solid var(--line);background:var(--panel)}.brand{display:flex;align-items:center;gap:8px;font-weight:700}.brand svg{color:var(--accent)}.search{max-width:540px;position:relative}.search input{height:30px;min-height:30px;padding-left:30px}.search svg{position:absolute;left:9px;top:7px;color:var(--muted)}.top-actions{display:flex;align-items:center;gap:4px}.who{padding:0 8px;border-left:1px solid var(--line);line-height:1.2}.who strong{display:block;font-size:11px;max-width:190px;overflow:hidden;text-overflow:ellipsis}.who span{font-size:9px;color:var(--success)}.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)}.sidebar{min-height:0;padding:10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto}.nav{display:grid;gap:3px}.nav button{display:flex;align-items:center;gap:9px;width:100%;height:34px;padding:0 9px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);text-align:left}.nav button:hover{background:var(--hover);color:var(--text)}.nav button.active{background:var(--selected);color:var(--text);border-color:var(--line)}.nav button svg{width:16px;height:16px}.nav-section{margin:14px 8px 5px;color:var(--subtle);font-size:9px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.sidebar-foot{margin-top:18px;padding:10px 8px;border-top:1px solid var(--line);color:var(--subtle);font:9px var(--mono)}.content{min-width:0;min-height:0;overflow:auto;padding:20px}.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}.page-head h1{margin:0;font-size:20px;letter-spacing:-.3px}.page-head p{margin:3px 0 0;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap}.cards{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:10px}.card{padding:14px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}.card .k{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em}.card .v{margin-top:5px;font-size:25px;font-weight:720}.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden}.panel-head{display:flex;justify-content:space-between;align-items:center;min-height:40px;padding:0 11px;border-bottom:1px solid var(--line);font-weight:650}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;white-space:nowrap}th,td{height:38px;padding:0 10px;border-bottom:1px solid var(--line);text-align:left}th{position:sticky;top:0;background:var(--panel2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.04em}tbody tr:hover{background:var(--hover)}td.actions-cell{text-align:right}.mini{min-height:26px;height:26px;padding:2px 7px;font-size:11px}.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--line)}.modal-head h2{margin:0;font-size:15px}.modal-body{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:14px;overflow:auto}.modal-body .wide{grid-column:1/-1}.modal-foot{display:flex;justify-content:flex-end;gap:7px;padding:12px 14px;border-top:1px solid var(--line)}.codebox{padding:12px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);font:11px/1.5 var(--mono);white-space:pre-wrap;word-break:break-all}.toolbar{display:flex;gap:7px;align-items:center;margin-bottom:10px}.toolbar select{max-width:260px}.feature-grid{display:grid;grid-template-columns:repeat(3,minmax(180px,1fr));gap:8px}.feature{padding:10px;border:1px solid var(--line);background:var(--panel);border-radius:var(--r);display:flex;justify-content:space-between;gap:10px}
|
||||||
|
.spacer{height:12px}.panel-pad{padding:14px}.panel-margin{margin:10px}.small-value{font-size:17px!important}.section-title{margin:18px 2px 8px;color:var(--muted);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.stack-form{display:grid;gap:12px}.stack-form h3,.panel-pad h3{margin-top:0}.checkbox-row{display:flex!important;align-items:center;gap:8px}.checkbox-row input{width:auto;min-height:auto}.checkbox-row span{margin:0!important}.login-form .actions{justify-content:center}.login-form .btn.ghost{font-size:11px;color:var(--muted)}
|
||||||
|
|
||||||
|
.compact-select{width:auto;min-width:150px;max-width:240px}
|
||||||
|
.modal-body-single{grid-template-columns:1fr}.modal-message{font-size:13px;line-height:1.55;white-space:pre-wrap}.modal-help{margin-top:-4px}.dns-record{max-height:180px;overflow:auto}.status-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line .mono{font-size:11px}.panel-actions{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.path-cell{max-width:420px;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.notice{margin:10px 0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);font-size:11px;line-height:1.5}.notice.warning{border-color:var(--warn);color:var(--warn)}
|
||||||
|
.language-select{min-width:82px;max-width:96px;height:28px;min-height:28px;padding:2px 24px 2px 8px;font-size:10px}.login-footer{display:flex;align-items:center;justify-content:space-between;gap:10px}.login-language{display:flex;align-items:center;gap:6px;font:10px var(--sans);color:var(--muted)}.login-language span{white-space:nowrap}.login-language select{min-width:88px;height:26px;min-height:26px}
|
||||||
|
|
||||||
|
/* 0.0.1-dev domain-focused administration */
|
||||||
|
.theme-toggle{width:34px;min-width:34px;height:30px;min-height:30px;padding:0;border-color:var(--line);background:var(--panel2);color:var(--text)}
|
||||||
|
.theme-toggle:hover{background:var(--hover);border-color:var(--accent)}
|
||||||
|
.theme-toggle .icon{width:18px;height:18px;display:block;flex:0 0 auto}
|
||||||
|
.theme-fallback{display:none;font-size:17px;line-height:1}
|
||||||
|
@supports not (stroke: currentColor){.theme-toggle .icon{display:none}.theme-fallback{display:block}}
|
||||||
|
.actions-cell{white-space:nowrap}.actions-cell .btn+.btn{margin-left:6px}.action-btn .icon{width:14px;height:14px}
|
||||||
|
.domain-link{display:inline-flex;align-items:center;gap:7px;padding:5px 7px;border:0;border-radius:3px;background:transparent;color:var(--text);font:inherit;font-family:var(--mono)}
|
||||||
|
.domain-link:hover{background:var(--hover);color:var(--accent2)}.domain-link .icon:last-child{width:13px;height:13px;color:var(--subtle)}
|
||||||
|
.metric-inline{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.metric-inline .icon{width:14px;height:14px;color:var(--muted)}
|
||||||
|
.progress-cell{min-width:120px}.progress-meta{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:11px}.progress-meta .icon{width:13px;height:13px;color:var(--muted)}
|
||||||
|
.progress-track{display:block;width:100%;height:5px;border:0;border-radius:999px;overflow:hidden;background:var(--line);appearance:none;-webkit-appearance:none}.progress-track::-webkit-progress-bar{background:var(--line);border-radius:999px}.progress-track::-webkit-progress-value{background:var(--accent);border-radius:999px}.progress-track::-moz-progress-bar{background:var(--accent);border-radius:999px}
|
||||||
|
.progress-track.unlimited::-webkit-progress-value{background:linear-gradient(90deg,var(--line2),var(--accent),var(--line2));opacity:.55}.progress-track.unlimited::-moz-progress-bar{background:var(--accent);opacity:.55}
|
||||||
|
.list-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;margin:0 0 10px;padding:9px 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2)}
|
||||||
|
.domain-picker label{display:flex;align-items:center;gap:8px}.domain-picker label>span{color:var(--muted);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.domain-picker select{min-width:230px}
|
||||||
|
.list-summary{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:11px}.list-summary span{display:flex;align-items:center;gap:5px}.list-summary .icon{width:14px;height:14px}
|
||||||
|
.domain-cards .card{min-height:92px}.metric-card{display:grid;grid-template-columns:30px minmax(0,1fr);gap:10px;align-items:start}.metric-card>.icon{width:24px;height:24px;color:var(--accent);margin-top:2px}.metric-card .progress-cell{margin-top:7px;min-width:0}
|
||||||
|
.workspace-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}.workspace-grid .section-title{margin-top:0}.workspace-grid .panel{min-width:0}
|
||||||
|
.backend-tabs{display:flex;gap:7px;margin:0 0 12px;padding:4px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);width:max-content;max-width:100%}.backend-tab{display:flex;align-items:center;gap:7px;min-height:32px;padding:5px 10px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);font-weight:650}.backend-tab:hover{color:var(--text);background:var(--hover)}.backend-tab.active{border-color:var(--line2);background:var(--panel);color:var(--text)}.backend-tab .icon{width:15px;height:15px}
|
||||||
|
.backend-info{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:10px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);color:var(--muted);font-size:11px}.backend-info>div{display:flex;align-items:center;gap:7px;color:var(--text)}.backend-info .icon{width:16px;height:16px;color:var(--accent)}
|
||||||
|
@media(max-width:1100px){.workspace-grid{grid-template-columns:1fr}.domain-cards{grid-template-columns:repeat(2,minmax(150px,1fr))}}
|
||||||
|
@media(max-width:650px){.list-toolbar{align-items:stretch;flex-direction:column}.domain-picker label{align-items:stretch;flex-direction:column}.domain-picker select{min-width:0;max-width:none;width:100%}.list-summary{justify-content:space-between}.backend-tabs{width:100%}.backend-tab{flex:1}.backend-info{align-items:flex-start;flex-direction:column}}
|
||||||
|
|
||||||
|
/* 0.0.1-dev feedback, quota levels and cached icon fixes */
|
||||||
|
.topbar{position:relative}.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;z-index:10;background:transparent}.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1s ease-in-out infinite}.route-progress[hidden]{display:none}@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
|
||||||
|
.api-busy .domain-picker .domain-loading{display:inline-flex}.domain-loading{display:none;align-items:center;color:var(--accent)}.domain-loading .icon{width:14px;height:14px;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.theme-toggle{outline:1px solid color-mix(in srgb,var(--text) 18%,transparent)}.theme-toggle .icon{color:var(--accent2);stroke-width:2.2}.theme-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
.progress-meta{justify-content:flex-start}.progress-meta strong{margin-left:auto;color:var(--subtle);font-size:9px;font-weight:750}.progress-track.medium::-webkit-progress-value{background:var(--warn)}.progress-track.medium::-moz-progress-bar{background:var(--warn)}.progress-track.high::-webkit-progress-value{background:var(--danger)}.progress-track.high::-moz-progress-bar{background:var(--danger)}
|
||||||
|
.actions-cell .btn+.btn{margin-left:9px}.actions-cell .btn.danger{border-color:color-mix(in srgb,var(--danger) 36%,var(--line2))}
|
||||||
|
.input-action{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.input-action .btn{white-space:nowrap}
|
||||||
|
|
||||||
|
/* 0.0.1-dev mailbox-user workspace */
|
||||||
|
.user-overview-cards .mailbox-address{font-size:15px!important;font-family:var(--mono);overflow-wrap:anywhere}
|
||||||
|
.user-overview-cards .quota-card .progress-cell{min-width:0;margin-top:8px}
|
||||||
|
.user-account-grid .panel{min-width:0}.account-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}
|
||||||
|
.account-facts>div{display:grid;gap:3px;padding-bottom:8px;border-bottom:1px solid var(--line)}.account-facts span{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.account-facts strong{font-size:12px;font-weight:650;overflow-wrap:anywhere}
|
||||||
|
.delivery-targets{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}
|
||||||
|
.readonly-domain-value{display:flex;align-items:center;gap:7px;min-height:30px;padding:5px 9px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);color:var(--text)}.readonly-domain-value>.icon{width:14px;height:14px;color:var(--muted)}.readonly-domain-value>.icon:last-child{width:12px;height:12px;margin-left:2px;color:var(--subtle)}
|
||||||
|
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.form-grid .wide{grid-column:1/-1}.vacation-user-panel textarea{min-height:180px}
|
||||||
|
.security-grid h3{margin:0 0 12px}.security-actions{align-items:flex-start}
|
||||||
|
.sieve-status{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:10px;color:var(--muted);font-size:11px}
|
||||||
|
.sieve-layout{display:grid;grid-template-columns:240px minmax(0,1fr);gap:12px;min-height:470px}.sieve-list{display:flex;flex-direction:column}.sieve-list-body{display:grid;gap:4px;padding:7px}.sieve-list-foot{margin-top:auto;padding:8px;border-top:1px solid var(--line)}
|
||||||
|
.sieve-script{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:8px;border:1px solid transparent;border-radius:var(--r);background:transparent;color:var(--text);text-align:left}.sieve-script>span:first-child{display:flex;align-items:center;gap:7px;min-width:0}.sieve-script .icon{width:14px;height:14px;color:var(--muted)}.sieve-script:hover{background:var(--hover)}.sieve-script.selected{border-color:var(--line2);background:var(--panel2)}
|
||||||
|
.sieve-editor-panel{display:flex;flex-direction:column;min-width:0}.sieve-editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;border-bottom:1px solid var(--line)}.sieve-editor-head>div:first-child{display:flex;align-items:center;gap:8px}.sieve-editor{flex:1;width:100%;min-height:420px;resize:vertical;border:0;border-radius:0;background:var(--field);padding:14px;font:12px/1.55 var(--mono);color:var(--text);outline:none}.sieve-editor:focus{box-shadow:inset 0 0 0 1px var(--accent)}.sieve-empty{min-height:420px;display:grid;place-items:center}.sieve-capabilities{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.compact-empty{padding:14px 8px}
|
||||||
|
@media(max-width:900px){.sieve-layout{grid-template-columns:1fr}.sieve-list-body{grid-template-columns:repeat(2,minmax(0,1fr))}.sieve-editor-head{align-items:flex-start;flex-direction:column}.account-facts{grid-template-columns:1fr}}
|
||||||
|
@media(max-width:560px){.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.sieve-list-body{grid-template-columns:1fr}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@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,.sidebar-foot,.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}}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
00-tokens.css
|
||||||
|
10-base.css
|
||||||
|
20-app.css
|
||||||
|
30-responsive.css
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
{
|
||||||
|
"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 vacation",
|
||||||
|
"Vacation zapisany": "Vacation 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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"patterns": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="icon-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></symbol>
|
||||||
|
<symbol id="icon-mail-open" viewBox="0 0 24 24"><path d="M3 10.5 12 4l9 6.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="m3 11 9 6 9-6"/></symbol>
|
||||||
|
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
|
||||||
|
<symbol id="icon-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z"/></symbol>
|
||||||
|
<symbol id="icon-log-out" viewBox="0 0 24 24"><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/><path d="M21 19V5a2 2 0 0 0-2-2h-6"/></symbol>
|
||||||
|
<symbol id="icon-square-pen" viewBox="0 0 24 24"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4Z"/></symbol>
|
||||||
|
<symbol id="icon-folder-plus" viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M12 11v6M9 14h6"/></symbol>
|
||||||
|
<symbol id="icon-folder" viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></symbol>
|
||||||
|
<symbol id="icon-inbox" viewBox="0 0 24 24"><path d="M4 4h16l2 10v6H2v-6Z"/><path d="M2 14h5l2 3h6l2-3h5"/></symbol>
|
||||||
|
<symbol id="icon-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></symbol>
|
||||||
|
<symbol id="icon-file-text" viewBox="0 0 24 24"><path d="M6 2h9l5 5v15H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"/><path d="M14 2v6h6M8 13h8M8 17h6"/></symbol>
|
||||||
|
<symbol id="icon-archive" viewBox="0 0 24 24"><path d="M3 5h18v4H3Z"/><path d="M5 9v11h14V9M9 13h6"/></symbol>
|
||||||
|
<symbol id="icon-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/></symbol>
|
||||||
|
<symbol id="icon-shield-alert" viewBox="0 0 24 24"><path d="M12 3 4 6v6c0 5 3.4 8 8 9 4.6-1 8-4 8-9V6Z"/><path d="M12 8v5M12 17h.01"/></symbol>
|
||||||
|
<symbol id="icon-refresh-cw" viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M4 17v-5h5"/><path d="M6.1 8a7 7 0 0 1 11.2-1L20 12M4 12l2.7 5a7 7 0 0 0 11.2-1"/></symbol>
|
||||||
|
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||||
|
<symbol id="icon-arrow-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
|
||||||
|
<symbol id="icon-reply" viewBox="0 0 24 24"><path d="m9 17-6-5 6-5v3c7 0 10 3 12 7-3-2-6-3-12-2Z"/></symbol>
|
||||||
|
<symbol id="icon-reply-all" viewBox="0 0 24 24"><path d="m7 17-5-5 5-5v3c6 0 9 3 11 7-3-2-5-3-11-2Z"/><path d="m13 7 5 5-3 3"/></symbol>
|
||||||
|
<symbol id="icon-forward" viewBox="0 0 24 24"><path d="m15 17 6-5-6-5v3c-7 0-10 3-12 7 3-2 6-3 12-2Z"/></symbol>
|
||||||
|
<symbol id="icon-star" viewBox="0 0 24 24"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3l-5.6 2.9 1.1-6.2L3 9.6l6.2-.9Z"/></symbol>
|
||||||
|
<symbol id="icon-paperclip" viewBox="0 0 24 24"><path d="m20.5 11.5-8.8 8.8a6 6 0 0 1-8.5-8.5L13.5 1.5a4 4 0 0 1 5.7 5.7L8.8 17.6a2 2 0 1 1-2.8-2.8l9.5-9.5"/></symbol>
|
||||||
|
<symbol id="icon-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||||
|
<symbol id="icon-bold" viewBox="0 0 24 24"><path d="M8 5h6a4 4 0 0 1 0 8H8Z"/><path d="M8 13h7a4 4 0 0 1 0 8H8Z"/></symbol>
|
||||||
|
<symbol id="icon-italic" viewBox="0 0 24 24"><path d="M10 5h8M6 19h8M14 5l-4 14"/></symbol>
|
||||||
|
<symbol id="icon-underline" viewBox="0 0 24 24"><path d="M7 4v7a5 5 0 0 0 10 0V4M5 21h14"/></symbol>
|
||||||
|
<symbol id="icon-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||||
|
<symbol id="icon-list-ordered" viewBox="0 0 24 24"><path d="M9 6h12M9 12h12M9 18h12"/><path d="M3 5h2v3M3 11h2l-2 3h2M3 17h2v4H3"/></symbol>
|
||||||
|
<symbol id="icon-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"/><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1"/></symbol>
|
||||||
|
<symbol id="icon-remove-format" viewBox="0 0 24 24"><path d="M4 7V4h16v3M9 20h6M12 4v16"/><path d="m17 13 4 4M21 13l-4 4"/></symbol>
|
||||||
|
<symbol id="icon-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
|
||||||
|
|
||||||
|
<symbol id="icon-user-round" viewBox="0 0 24 24"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/></symbol>
|
||||||
|
<symbol id="icon-calendar-days" viewBox="0 0 24 24"><path d="M8 2v4M16 2v4M3 10h18"/><rect x="3" y="4" width="18" height="18" rx="2"/></symbol>
|
||||||
|
<symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="m21 15-5-5L5 19"/></symbol>
|
||||||
|
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 6h10M18 6h2M4 12h2M10 12h10M4 18h7M15 18h5"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="13" cy="18" r="2"/></symbol>
|
||||||
|
<symbol id="icon-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
||||||
|
<symbol id="icon-strikethrough" viewBox="0 0 24 24"><path d="M16 4H9a4 4 0 0 0-3.7 2.5M8 20h7a4 4 0 0 0 3.8-2.8M4 12h16"/></symbol>
|
||||||
|
<symbol id="icon-quote" viewBox="0 0 24 24"><path d="M9 11H5a4 4 0 0 1 4-4v10H5M19 11h-4a4 4 0 0 1 4-4v10h-4"/></symbol>
|
||||||
|
<symbol id="icon-align-left" viewBox="0 0 24 24"><path d="M4 6h16M4 10h10M4 14h16M4 18h10"/></symbol>
|
||||||
|
<symbol id="icon-align-center" viewBox="0 0 24 24"><path d="M4 6h16M7 10h10M4 14h16M7 18h10"/></symbol>
|
||||||
|
<symbol id="icon-align-right" viewBox="0 0 24 24"><path d="M4 6h16M10 10h10M4 14h16M10 18h10"/></symbol>
|
||||||
|
<symbol id="icon-undo" viewBox="0 0 24 24"><path d="m9 7-5 5 5 5"/><path d="M20 17a8 8 0 0 0-8-8H4"/></symbol>
|
||||||
|
<symbol id="icon-redo" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5"/><path d="M4 17a8 8 0 0 1 8-8h8"/></symbol>
|
||||||
|
|
||||||
|
<symbol id="icon-check-circle" viewBox="0 0 24 24"><path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||||
|
<symbol id="icon-circle-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></symbol>
|
||||||
|
<symbol id="icon-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
|
||||||
|
|
||||||
|
<symbol id="icon-printer" viewBox="0 0 24 24"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
|
||||||
|
<symbol id="icon-filter" viewBox="0 0 24 24"><path d="M4 5h16"/><path d="M7 12h10"/><path d="M10 19h4"/></symbol>
|
||||||
|
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></symbol>
|
||||||
|
<symbol id="icon-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="m10 14 11-11"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||||
|
<symbol id="icon-package" viewBox="0 0 24 24"><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 8 9 5 9-5v8l-9 5-9-5Z"/><path d="M12 13v8"/></symbol>
|
||||||
|
<symbol id="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></symbol>
|
||||||
|
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5 8.5 8.5 0 1 0 20.5 14.2Z"/></symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="__LANG__" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark light"><meta name="pa-rs-version" content="__VERSION__"><meta name="pa-rs-asset" content="__ASSET_ID__"><base href="__BASE_PATH__"><title>PostfixAdmin-RS</title><link rel="stylesheet" href="assets/app.__ASSET_ID__.css"></head><body><div id="app"><div class="boot"><svg><use href="assets/icons.__ASSET_ID__.svg#icon-mail"></use></svg><strong>PostfixAdmin-RS</strong><span>Loading…</span></div></div><div id="toast" class="toast" hidden></div><dialog id="modal"></dialog><script src="assets/app.__ASSET_ID__.js" defer></script></body></html>
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (selector, root = document) => root.querySelector(selector);
|
||||||
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||||
|
const app = $('#app');
|
||||||
|
const modal = $('#modal');
|
||||||
|
const toast = $('#toast');
|
||||||
|
|
||||||
|
const S = { me: null, features: {}, csrf: '', page: 'overview', 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 || '';
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
for (const child of children.flat()) {
|
||||||
|
element.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell() {
|
||||||
|
const role = S.me.role.replace('_', ' ');
|
||||||
|
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="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>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<aside class="sidebar"><nav class="nav" id="nav"></nav><div class="sidebar-foot">Rust ${esc(window.PA_RS?.version || '')}</div></aside>
|
||||||
|
<main class="content" id="content"></main>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
$('#logoutBtn').onclick = logout;
|
||||||
|
$('#languageSelect').onchange = event => { setLanguage(event.target.value); };
|
||||||
|
const updateThemeButton = () => {
|
||||||
|
const current = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
updateThemeButton();
|
||||||
|
$('#themeBtn').onclick = () => {
|
||||||
|
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
localStorage.setItem('pa_theme', next);
|
||||||
|
updateThemeButton();
|
||||||
|
};
|
||||||
|
$('#globalSearch').oninput = event => {
|
||||||
|
const query = event.target.value.toLowerCase();
|
||||||
|
$$('#content tbody tr').forEach(row => { row.hidden = !row.textContent.toLowerCase().includes(query); });
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const label = h('label', { class: `field ${field.wide ? 'wide' : ''}` });
|
||||||
|
label.innerHTML = `<span>${esc(field.label)}</span>`;
|
||||||
|
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.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;
|
||||||
|
} else {
|
||||||
|
element = h('input', {
|
||||||
|
type: field.type || 'text',
|
||||||
|
name: field.name,
|
||||||
|
autocomplete: field.autocomplete || 'off',
|
||||||
|
});
|
||||||
|
element.value = values[field.name] ?? '';
|
||||||
|
if (field.min != null) element.min = field.min;
|
||||||
|
if (field.placeholder) element.placeholder = field.placeholder;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
label.append(element);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const submit = $('button[type="submit"]', form);
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
const outcome = await onSubmit(data);
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
submit.disabled = false;
|
||||||
|
showToast(error.message, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.append(form);
|
||||||
|
translateDom(modal);
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
$$('[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); }
|
||||||
|
};
|
||||||
|
modal.append(wrapper);
|
||||||
|
translateDom(modal);
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmModal(title, message, options = {}) {
|
||||||
|
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>
|
||||||
|
<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>`;
|
||||||
|
let settled = false;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptModal(title, label, value = '', options = {}) {
|
||||||
|
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>`;
|
||||||
|
const input = $('input[name="value"]', form);
|
||||||
|
input.value = value || '';
|
||||||
|
let settled = false;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try { await api('/auth/logout', { method: 'POST' }); }
|
||||||
|
finally {
|
||||||
|
S.me = null;
|
||||||
|
S.csrf = '';
|
||||||
|
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']] },
|
||||||
|
], { 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.' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
], { kind: 'user' }, async data => {
|
||||||
|
await api('/auth/recovery/reset', { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
return { refresh: false, message: 'Hasło zostało zmienione.' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginView() {
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</section></div>`;
|
||||||
|
|
||||||
|
translateDom(app);
|
||||||
|
$('#loginLanguage').onchange = event => { setLanguage(event.target.value); };
|
||||||
|
$('#recoverBtn').onclick = recoveryRequestModal;
|
||||||
|
$('#resetBtn').onclick = recoveryResetModal;
|
||||||
|
$('#loginForm').onsubmit = async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.target);
|
||||||
|
try {
|
||||||
|
const result = await api('/auth/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(form)) });
|
||||||
|
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 }) });
|
||||||
|
S.csrf = mfa.csrf;
|
||||||
|
} else {
|
||||||
|
S.csrf = result.csrf;
|
||||||
|
}
|
||||||
|
await bootAuthed();
|
||||||
|
} catch (error) {
|
||||||
|
$('#loginErr').textContent = translateText(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootAuthed() {
|
||||||
|
S.me = await api('/auth/me');
|
||||||
|
S.csrf = S.me.csrf;
|
||||||
|
const runtime = await api('/system/features');
|
||||||
|
S.features = runtime.features;
|
||||||
|
S.runtime = runtime;
|
||||||
|
shell();
|
||||||
|
go(S.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.UI = {
|
||||||
|
S, api, esc, icon, showToast, go, pageHead, table, formModal, confirmModal, promptModal, textModal, closeModal, loginView, bootAuthed,
|
||||||
|
t: translateText, translateDom, initLanguage, setLanguage,
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
(()=>{'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()})();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
00-core.js
|
||||||
|
10-pages.js
|
||||||
|
90-boot.js
|
||||||
Reference in new issue
Block a user