diff --git a/.gitignore b/.gitignore index 68e8877..e746f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ *.log .DS_Store .dev +__pycache__ +*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c390e2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 0.0.2 + +- Mailbox and alias creation in a selected domain now accepts only the local part; the domain is read-only. +- Admin Vacation UI now searches/selects an existing mailbox and uses the same editor as a mailbox user. +- Vacation save/delete now synchronizes the PostfixAdmin autoreply transport address in `alias.goto`, so the mail transport actually receives the vacation copy. +- Vacation transport destinations are hidden from alias UI and do not affect alias delivery-status badges. +- Mailbox users can edit only alias destinations, Store & Forward, and Active; the backend also preserves protected fields on crafted requests. +- Default technical aliases (`abuse`, `hostmaster`, `postmaster`, `webmaster`, according to config keys) are shown separately from user aliases. +- Rspamd DKIM filesystem failures now report the operation and exact path instead of a generic I/O error. +- Debian package includes a one-time Rspamd DKIM permission helper using a dedicated shared group; private keys stay non-world-readable. +- Release version bumped to 0.0.2. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index aa539bb..8015cf3 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,6 +1,6 @@ # 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. +Stan: **0.0.2**. Celem jest replacement runtime PHP z zachowaniem istniejacej bazy PostfixAdmin i SQL map uzywanych przez Postfix/Dovecot. | Obszar | Stan | Uwagi | |---|---|---| @@ -66,7 +66,7 @@ Aktywne elementy istotne dla tego wdrozenia (MySQL/MariaDB, `php_crypt:MD5`, ali ## 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. +Pelniejszy audyt funkcji z dostarczonego PHP 4.0.1 jest w [PORT-PARITY.md](PORT-PARITY.md). 0.0.2 nadal **nie jest 100% parity**; dokument rozdziela funkcje runtime juz przeportowane od pozostalych narzedzi instalacyjnych, API, CLI i legacy hookow/UI. ## Rspamd DKIM diff --git a/Cargo.lock b/Cargo.lock index b1cc57e..9ed7e45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "postfixadmin-rs" -version = "0.0.1-dev" +version = "0.0.2" dependencies = [ "argon2", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index 873516b..0e38c6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postfixadmin-rs" -version = "0.0.1-dev" +version = "0.0.2" edition = "2024" rust-version = "1.99.1" default-run = "postfixadmin-rs" diff --git a/PORT-PARITY.md b/PORT-PARITY.md index 9bfb135..ea2b8b6 100644 --- a/PORT-PARITY.md +++ b/PORT-PARITY.md @@ -1,7 +1,7 @@ -# PostfixAdmin 4.0.1 -> PostfixAdmin-RS 0.0.1-dev parity audit +# PostfixAdmin 4.0.1 -> PostfixAdmin-RS 0.0.2 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. +The goal of 0.0.2 is runtime replacement for normal mail administration, not binary/API compatibility with PHP. ## Runtime features already ported @@ -35,7 +35,7 @@ The goal of 0.0.1-dev is runtime replacement for normal mail administration, not ## 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. +These are real parity gaps; 0.0.2 must not be described as 100% complete while they remain. | Area | Gap / intended Rust replacement | |---|---| diff --git a/README.md b/README.md index a264563..74d8eaa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. +Aktualna linia developerska: **0.0.2**. 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 @@ -147,7 +147,7 @@ Gate wykonuje walidacje statyczna, `cargo fmt --check`, testy, Clippy z `-D warn 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 +## Status 0.0.2 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. diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index b017152..2f58b38 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,4 +1,4 @@ -PostfixAdmin-RS 0.0.1-dev +PostfixAdmin-RS 0.0.2 Validated in the current workspace: - TOML parsing: PASS @@ -67,3 +67,11 @@ Current mailbox-user iteration: - dev ManageSieve SSH tunnel defaults to READ-ONLY and has separate write opt-in: PASS (shell/static) - quota progress uses CSP-safe native , no inline style mutations: PASS (static) - Rust 1.99.1 compiler gate: NOT RUN; toolchain absent and download attempt failed because the execution environment cannot resolve external hosts. + +0.0.2 requested-change static checks: +- mailbox/alias local-part-only create UX with read-only selected domain: PASS (static) +- admin Vacation mailbox picker/editor: PASS (JS syntax/static) +- Vacation alias transport synchronization: PASS (source review + unit-test markers; Rust test gate not runnable here) +- mailbox-user alias field restriction in UI and backend: PASS (static) +- technical alias separation: PASS (static) +- Rspamd contextual filesystem errors + packaged permission helper: PASS (static + helper smoke test with isolated paths) diff --git a/TESTING.md b/TESTING.md index b6c918c..0fb650a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,4 +1,4 @@ -# PostfixAdmin-RS 0.0.1-dev - test checklist +# PostfixAdmin-RS 0.0.2 - test checklist ## 1. Static / compiler gate @@ -124,7 +124,7 @@ flag columns must not be selected without the normalization helper. ## Debian package -Run `./scripts/build-deb.sh`. Expected output is `packages/postfixadmin-rs_0.0.1~dev_.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. +Run `./scripts/build-deb.sh`. Expected output is `packages/postfixadmin-rs_0.0.2_.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 diff --git a/dev.sh b/dev.sh index 6de53c0..fc431e2 100755 --- a/dev.sh +++ b/dev.sh @@ -208,7 +208,7 @@ esac exit 1 } -echo "PostfixAdmin RS 0.0.1-dev: http://127.0.0.1:8080" +echo "PostfixAdmin RS 0.0.2: http://127.0.0.1:8080" echo "Config: $POSTFIXADMIN_RS_CONFIG" if [ "$DEV_DB_MODE" = "mock" ]; then echo "DB: SQLite mock ($DEV_DB)" diff --git a/docs/RSPAMD_DKIM.md b/docs/RSPAMD_DKIM.md index b934bf6..d33679a 100644 --- a/docs/RSPAMD_DKIM.md +++ b/docs/RSPAMD_DKIM.md @@ -1,4 +1,4 @@ -# Rspamd DKIM integration (0.0.1-dev) +# Rspamd DKIM integration (0.0.2) 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. @@ -37,7 +37,7 @@ If `selector_map` or `path_map` already exists outside the managed block, Postfi 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`. +In 0.0.2 the packaged permission helper creates a dedicated `postfixadmin-rspamd` group shared only by the `postfixadmin` service account and the detected Rspamd runtime user. The DKIM directory is setgid so generated keys inherit that group. 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: @@ -83,3 +83,18 @@ Rspamd obserwuje lokalne pliki map i odswieza je po zmianach, wiec po jednorazow 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`. + +## Host permissions for 0.0.2 + +`postfixadmin-rs` never needs to run as root. For the standard paths, install the package and run once: + +```bash +sudo /usr/lib/postfixadmin-rs/setup-rspamd-dkim-permissions +sudo systemctl restart postfixadmin-rs rspamd +``` + +The helper creates a dedicated `postfixadmin-rspamd` group, adds the PostfixAdmin-RS and Rspamd runtime users to it, prepares `/var/lib/rspamd/dkim` as a setgid shared directory, keeps private keys at `0640`, and grants the service write access only to `dkim_signing.conf` rather than all of `/etc/rspamd/local.d`. + +You can override detection with `POSTFIXADMIN_RS_RSPAMD_USER`, `POSTFIXADMIN_RS_RSPAMD_KEY_DIR`, and `POSTFIXADMIN_RS_RSPAMD_SIGNING_CONFIG` when the host uses non-standard names or paths. + +Filesystem failures now include the operation and exact path in the API error instead of the generic `I/O operation failed` message. diff --git a/make_zip.py b/make_zip.py index 0c83a43..a8a050b 100755 --- a/make_zip.py +++ b/make_zip.py @@ -1,38 +1,70 @@ #!/usr/bin/env python3 -from __future__ import annotations - +import os import sys import zipfile +import subprocess from pathlib import Path -ROOT = Path(__file__).resolve().parent -EXCLUDED_DIRS = {'.git', '.dev', 'target', 'dist', 'packages', '__pycache__'} -EXCLUDED_NAMES = {'.env'} + +def run_git_command(args, repo_path: Path) -> bytes: + result = subprocess.run( + ["git", *args], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return result.stdout -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: +def get_files_to_archive(repo_path: Path) -> list[str]: + output = run_git_command( + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + repo_path, + ) + files = output.decode("utf-8", errors="surrogateescape").split("\0") + return [f for f in files if f] + + +def make_zip(repo_path: Path, output_zip: Path) -> None: + files = get_files_to_archive(repo_path) + + output_zip = output_zip.resolve() + if output_zip.exists(): + output_zip.unlink() + + with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for rel_path in files: + abs_path = repo_path / rel_path + + if not abs_path.exists(): continue - rel = path.relative_to(ROOT) - if any(part in EXCLUDED_DIRS for part in rel.parts) or rel.name in EXCLUDED_NAMES: + + if abs_path.resolve() == output_zip: continue - archive.write(path, f'{prefix}/{rel.as_posix()}') - return 0 + + zf.write(abs_path, arcname=rel_path) + + print(f"Created: {output_zip}") + print(f"Added files: {len(files)}") -def project_version() -> str: - import tomllib - with (ROOT / 'Cargo.toml').open('rb') as handle: - return tomllib.load(handle)['package']['version'] +def main(): + repo_path = Path.cwd() + + if len(sys.argv) > 1: + output_zip = Path(sys.argv[1]) + else: + output_zip = repo_path / f"{repo_path.name}.zip" + + try: + run_git_command(["rev-parse", "--show-toplevel"], repo_path) + except subprocess.CalledProcessError: + print("Error: this directory is not a Git repository.", file=sys.stderr) + sys.exit(1) + + make_zip(repo_path, output_zip) -if __name__ == '__main__': - raise SystemExit(main()) +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/packages/postfixadmin-rs_0.0.1~dev_amd64.deb b/packages/postfixadmin-rs_0.0.1~dev_amd64.deb deleted file mode 100644 index 83eb938..0000000 Binary files a/packages/postfixadmin-rs_0.0.1~dev_amd64.deb and /dev/null differ diff --git a/packages/postfixadmin-rs_0.0.1~dev_amd64.deb.sha256 b/packages/postfixadmin-rs_0.0.1~dev_amd64.deb.sha256 deleted file mode 100644 index 28cc535..0000000 --- a/packages/postfixadmin-rs_0.0.1~dev_amd64.deb.sha256 +++ /dev/null @@ -1 +0,0 @@ -efb760202f137507083cff11b0c6198ea498891876c70845ca63c64b9375acbd /packages/postfixadmin-rs_0.0.1~dev_amd64.deb diff --git a/packaging/postfixadmin-rs-rspamd-setup b/packaging/postfixadmin-rs-rspamd-setup new file mode 100755 index 0000000..71ce3e9 --- /dev/null +++ b/packaging/postfixadmin-rs-rspamd-setup @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu + +# One-time host permission setup for the file-based Rspamd DKIM integration. +# The web service stays unprivileged; both daemons share only this dedicated group. + +KEY_DIR="${POSTFIXADMIN_RS_RSPAMD_KEY_DIR:-/var/lib/rspamd/dkim}" +SIGNING_CONFIG="${POSTFIXADMIN_RS_RSPAMD_SIGNING_CONFIG:-/etc/rspamd/local.d/dkim_signing.conf}" +SHARED_GROUP="${POSTFIXADMIN_RS_RSPAMD_GROUP:-postfixadmin-rspamd}" + +if [ "$(id -u)" -ne 0 ]; then + echo "error: run this helper as root" >&2 + exit 1 +fi + +if ! getent passwd postfixadmin >/dev/null 2>&1; then + echo "error: postfixadmin service user does not exist; install postfixadmin-rs first" >&2 + exit 1 +fi + +RSPAMD_USER="${POSTFIXADMIN_RS_RSPAMD_USER:-}" +if [ -z "$RSPAMD_USER" ]; then + for candidate in _rspamd rspamd; do + if getent passwd "$candidate" >/dev/null 2>&1; then + RSPAMD_USER="$candidate" + break + fi + done +fi +if [ -z "$RSPAMD_USER" ] || ! getent passwd "$RSPAMD_USER" >/dev/null 2>&1; then + echo "error: cannot determine Rspamd runtime user; set POSTFIXADMIN_RS_RSPAMD_USER" >&2 + exit 1 +fi + +if ! getent group "$SHARED_GROUP" >/dev/null 2>&1; then + addgroup --system "$SHARED_GROUP" >/dev/null +fi + +usermod -a -G "$SHARED_GROUP" postfixadmin +usermod -a -G "$SHARED_GROUP" "$RSPAMD_USER" + +# The root DKIM directory is writable by the shared group. Domain directories +# created by PostfixAdmin-RS inherit the group through setgid. +install -d -o root -g "$SHARED_GROUP" -m 2770 "$KEY_DIR" + +# Normalize already-managed domain directories and files without making private +# keys world-readable. The service owns subdirectories so it can rotate keys. +find "$KEY_DIR" -mindepth 1 -type d -exec chown postfixadmin:"$SHARED_GROUP" {} \; -exec chmod 2750 {} \; +find "$KEY_DIR" -type f -exec chgrp "$SHARED_GROUP" {} \; +find "$KEY_DIR" -type f -name '*.private' -exec chmod 0640 {} \; +find "$KEY_DIR" -type f ! -name '*.private' -exec chmod 0640 {} \; + +# PostfixAdmin-RS edits this single Rspamd config file directly. Creating it here +# avoids granting write access to the whole /etc/rspamd/local.d directory. +SIGNING_DIR="$(dirname "$SIGNING_CONFIG")" +if [ ! -d "$SIGNING_DIR" ]; then + install -d -o root -g root -m 0755 "$SIGNING_DIR" +fi +if [ ! -e "$SIGNING_CONFIG" ]; then + : > "$SIGNING_CONFIG" +fi +chown root:"$SHARED_GROUP" "$SIGNING_CONFIG" +chmod 0660 "$SIGNING_CONFIG" + +cat < "$PKG/DEBIAN/control" < -Depends: adduser, libc6 (>= 2.36), libgcc-s1, libssl3, ca-certificates +Depends: adduser, passwd, 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. diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 4aa83dc..713c883 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -33,8 +33,8 @@ 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 package.get('version') != '0.0.2': + fail('Cargo.toml version must be 0.0.2 for this release') if toolchain.get('toolchain', {}).get('channel') != '1.99.1': fail('rust-toolchain.toml must pin 1.99.1') @@ -132,6 +132,18 @@ for marker in ['/rspamd-dkim/generate', 'selector_map', 'path_map', 'RspamdDkimC if marker not in project_text + '\n' + (ROOT / 'src/config.rs').read_text(encoding='utf-8'): fail(f'missing Rspamd DKIM integration marker: {marker}') +release_002_markers = { + 'src/routes/vacation.rs': ['sync_vacation_alias', 'vacation_address', 'validate_dates'], + 'src/routes/aliases.rs': ['technical', 'internal_vacation', 'can_edit_description'], + 'src/web/js/10-pages.js': ['vacationMailboxPicker', 'Aliasy techniczne', 'local_part'], + 'packaging/postfixadmin-rs-rspamd-setup': ['postfixadmin-rspamd', 'systemctl restart postfixadmin-rs rspamd'], +} +for relative, markers in release_002_markers.items(): + text_value = (ROOT / relative).read_text(encoding='utf-8') + for marker in markers: + if marker not in text_value: + fail(f'{relative}: missing 0.0.2 regression marker: {marker}') + node = shutil.which('node') if node: for path in sorted((ROOT / 'src/web/js').glob('*.js')): @@ -139,7 +151,7 @@ if node: 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']: +for script in [ROOT / 'dev.sh', ROOT / 'scripts/build-release.sh', ROOT / 'scripts/package-release.sh', ROOT / 'scripts/build-deb.sh', ROOT / 'packaging/postfixadmin-rs-rspamd-setup']: 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()}') diff --git a/src/models.rs b/src/models.rs index 0dc6dbf..1e2d248 100644 --- a/src/models.rs +++ b/src/models.rs @@ -116,6 +116,7 @@ pub struct AliasSummary { pub description: String, pub active: bool, pub is_mailbox: bool, + pub technical: bool, pub status: Vec, } diff --git a/src/routes/aliases.rs b/src/routes/aliases.rs index 185bb35..974a595 100644 --- a/src/routes/aliases.rs +++ b/src/routes/aliases.rs @@ -85,8 +85,22 @@ pub async fn list( let mut result = Vec::with_capacity(rows.len()); for row in &rows { let address = row_string(row, "address"); - let gotos = split_goto(&row_string(row, "goto")); + let raw_gotos = split_goto(&row_string(row, "goto")); + let internal_vacation = vacation_destination(&state, &address); + let gotos: Vec = raw_gotos + .into_iter() + .filter(|value| !value.eq_ignore_ascii_case(&internal_vacation)) + .collect(); + // The PostfixAdmin vacation transport is implementation detail, not a + // user-visible alias destination and must not affect delivery status. let status = alias_status(&state, &session, &address, &gotos).await?; + let local = address.split('@').next().unwrap_or(""); + let technical = state + .config + .defaults + .default_aliases + .keys() + .any(|name| name.eq_ignore_ascii_case(local)); result.push(AliasSummary { address, goto: gotos, @@ -94,6 +108,7 @@ pub async fn list( description: row_string(row, "description"), active: row_bool(row, "active"), is_mailbox: row_bool(row, "is_mailbox"), + technical, status, }); } @@ -229,7 +244,10 @@ pub async fn update( let goto = build_goto(&state, &address, &input).await?; let has_description = state.db.has_column(Table::Alias, "description").await?; - let sql = if has_description { + // Mailbox users may only change forwarding, store-and-forward and active state. + // Preserve description even if a crafted request tries to modify it. + let can_edit_description = has_description && session.kind != AccountKind::User; + let sql = if can_edit_description { state.db.q(&format!( "UPDATE {table} SET goto=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?" )) @@ -239,7 +257,7 @@ pub async fn update( )) }; let mut query = sqlx::query(&sql).bind(goto.join(",")); - if has_description { + if can_edit_description { query = query.bind(&input.description); } query = query.bind(input.active).bind(&address); @@ -727,8 +745,17 @@ fn split_goto(value: &str) -> Vec { .collect() } +fn vacation_destination(state: &AppState, address: &str) -> String { + format!( + "{}@{}", + address.replace('@', "#"), + state.config.defaults.vacation_domain + ) +} + async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppResult> { let mut output = Vec::new(); + let internal_vacation = vacation_destination(state, address); for raw in &input.goto { let value = validation::alias_destination_checked( &state.config, @@ -736,6 +763,11 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR state.config.security.allow_unsafe_alias_destinations, ) .await?; + // The vacation transport is an internal PostfixAdmin detail. Never let + // alias editing expose, duplicate or remove it directly. + if value.eq_ignore_ascii_case(&internal_vacation) { + continue; + } if state.config.features.emailcheck_localaliasonly && let Some((_, domain)) = value.rsplit_once('@') { @@ -780,6 +812,25 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR "At least one destination is required", )); } + + if state.config.features.vacation && state.db.has_table(Table::Vacation).await { + let vacation = state.db.table(Table::Vacation); + let sql = state.db.q(&format!( + "SELECT 1 FROM {vacation} WHERE email=? AND active=? LIMIT 1" + )); + if sqlx::query(&sql) + .bind(address) + .bind(true) + .fetch_optional(state.db.pool()) + .await? + .is_some() + && !output + .iter() + .any(|value| value.eq_ignore_ascii_case(&internal_vacation)) + { + output.push(internal_vacation); + } + } Ok(output) } diff --git a/src/routes/rspamd_dkim.rs b/src/routes/rspamd_dkim.rs index 043cf9a..9587dce 100644 --- a/src/routes/rspamd_dkim.rs +++ b/src/routes/rspamd_dkim.rs @@ -28,6 +28,13 @@ use crate::{ const BEGIN_MARKER: &str = "# BEGIN PostfixAdmin-RS managed DKIM maps"; const END_MARKER: &str = "# END PostfixAdmin-RS managed DKIM maps"; +fn rspamd_io(action: &str, path: &Path, error: std::io::Error) -> AppError { + AppError::config(format!( + "Rspamd DKIM {action} {} failed: {error}", + path.display() + )) +} + fn default_true() -> bool { true } @@ -97,7 +104,13 @@ pub(crate) async fn read_only_dns_records( let legacy = match fs::read_to_string(&cfg.signing_config).await { Ok(text) => parse_legacy_domain_blocks(&text, &cfg.default_selector), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(error) => return Err(error.into()), + Err(error) => { + return Err(rspamd_io( + "read signing config", + Path::new(&cfg.signing_config), + error, + )); + } }; let mut result = Vec::new(); for requested in domains { @@ -142,7 +155,7 @@ async fn dns_record_from_private_key( Ok(metadata) if metadata.is_file() => {} Ok(_) => return Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), + Err(error) => return Err(rspamd_io("stat private key", private_path, error)), } let path = private_path.to_string_lossy().into_owned(); let output = match timeout( @@ -186,7 +199,13 @@ pub async fn list( .filter(|domain| session.can_manage_domain(domain)) .collect(), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(error) => return Err(error.into()), + Err(error) => { + return Err(rspamd_io( + "read signing config", + Path::new(&cfg.signing_config), + error, + )); + } }; let mut entries = Vec::new(); for domain in domains { @@ -205,13 +224,18 @@ pub async fn list( let private_path = raw_path .replace("$domain", &domain) .replace("$selector", &selector); - let private_exists = fs::metadata(&private_path) - .await - .map(|m| m.is_file()) - .unwrap_or(false); - let dns_record = read_dns_sidecar(Path::new(&private_path)) - .await - .unwrap_or_default(); + let private_exists = match fs::metadata(&private_path).await { + Ok(metadata) => metadata.is_file(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(rspamd_io( + "stat private key", + Path::new(&private_path), + error, + )); + } + }; + let dns_record = read_dns_sidecar(Path::new(&private_path)).await?; entries.push(RspamdDkimEntry { domain, selector, @@ -298,7 +322,10 @@ pub async fn generate( let parent = final_path .parent() .ok_or_else(|| AppError::config("Invalid Rspamd DKIM key path"))?; - fs::create_dir_all(parent).await?; + fs::create_dir_all(parent) + .await + .map_err(|error| rspamd_io("create key directory", parent, error))?; + set_mode(parent, 0o2750).await?; let tmp_path = parent.join(format!( ".{}.private.tmp-{}", input.selector, @@ -324,10 +351,14 @@ pub async fn generate( return Err(AppError::conflict("Private key file already exists")); } if had_private { - fs::rename(&final_path, &backup_private).await?; + fs::rename(&final_path, &backup_private) + .await + .map_err(|error| rspamd_io("backup private key", &final_path, error))?; } if had_dns { - fs::rename(&dns_path, &backup_dns).await?; + fs::rename(&dns_path, &backup_dns) + .await + .map_err(|error| rspamd_io("backup DNS sidecar", &dns_path, error))?; } if let Err(error) = fs::rename(&tmp_path, &final_path).await { if had_private { @@ -336,7 +367,7 @@ pub async fn generate( if had_dns { let _ = fs::rename(&backup_dns, &dns_path).await; } - return Err(error.into()); + return Err(rspamd_io("activate generated private key", &final_path, error)); } if let Err(error) = atomic_write( &dns_path, @@ -547,7 +578,7 @@ pub async fn import_legacy_domain_blocks( if error.kind() == std::io::ErrorKind::NotFound { AppError::not_found("Rspamd dkim_signing.conf does not exist") } else { - error.into() + rspamd_io("read signing config for legacy import", config_path, error) } })?; let candidates = parse_legacy_domain_blocks(&text, &cfg.default_selector); @@ -622,7 +653,9 @@ pub async fn import_legacy_domain_blocks( next.push_str(&managed_block(cfg)); next.push('\n'); - let metadata = fs::metadata(config_path).await?; + let metadata = fs::metadata(config_path) + .await + .map_err(|error| rspamd_io("stat signing config", config_path, error))?; let config_mode = metadata.permissions().mode() & 0o777; let backup_path = Path::new(&cfg.key_dir).join("dkim_signing.conf.pre-postfixadmin-rs"); if fs::metadata(&backup_path).await.is_err() { @@ -747,10 +780,10 @@ fn dns_sidecar(private_path: &Path) -> PathBuf { async fn read_dns_sidecar(private_path: &Path) -> AppResult { let path = dns_sidecar(private_path); - match fs::read_to_string(path).await { + match fs::read_to_string(&path).await { Ok(value) => Ok(value.trim().to_string()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(error) => Err(error.into()), + Err(error) => Err(rspamd_io("read DNS sidecar", &path, error)), } } @@ -875,7 +908,13 @@ async fn reject_legacy_domain(cfg: &RspamdDkimConfig, domain: &str) -> AppResult let text = match fs::read_to_string(&cfg.signing_config).await { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(error.into()), + Err(error) => { + return Err(rspamd_io( + "read signing config", + Path::new(&cfg.signing_config), + error, + )); + } }; if parse_legacy_domain_blocks(&text, &cfg.default_selector) .iter() @@ -892,7 +931,7 @@ async fn read_map(path: &Path) -> AppResult> { let text = match fs::read_to_string(path).await { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), - Err(error) => return Err(error.into()), + Err(error) => return Err(rspamd_io("read map", path, error)), }; let mut map = BTreeMap::new(); for line in text.lines() { @@ -933,22 +972,33 @@ async fn atomic_write(path: &Path, bytes: &[u8], mode: u32) -> AppResult<()> { let parent = path .parent() .ok_or_else(|| AppError::config(format!("Invalid path: {}", path.display())))?; - fs::create_dir_all(parent).await?; + fs::create_dir_all(parent) + .await + .map_err(|error| rspamd_io("create parent directory", parent, error))?; let name = path .file_name() .and_then(|v| v.to_str()) .unwrap_or("managed"); let tmp = parent.join(format!(".{name}.tmp-{}", std::process::id())); - fs::write(&tmp, bytes).await?; + fs::write(&tmp, bytes) + .await + .map_err(|error| rspamd_io("write temporary file", &tmp, error))?; set_mode(&tmp, mode).await?; - fs::rename(&tmp, path).await?; + fs::rename(&tmp, path) + .await + .map_err(|error| rspamd_io("replace managed file", path, error))?; Ok(()) } async fn set_mode(path: &Path, mode: u32) -> AppResult<()> { - let mut permissions = fs::metadata(path).await?.permissions(); + let mut permissions = fs::metadata(path) + .await + .map_err(|error| rspamd_io("stat path for chmod", path, error))? + .permissions(); permissions.set_mode(mode); - fs::set_permissions(path, permissions).await?; + fs::set_permissions(path, permissions) + .await + .map_err(|error| rspamd_io("chmod", path, error))?; Ok(()) } @@ -981,10 +1031,11 @@ fn strip_managed_block(text: &str) -> String { } async fn managed_config_present(cfg: &RspamdDkimConfig) -> AppResult { - match fs::read_to_string(&cfg.signing_config).await { + let path = Path::new(&cfg.signing_config); + match fs::read_to_string(path).await { Ok(text) => Ok(text.contains(BEGIN_MARKER) && text.contains(END_MARKER)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error.into()), + Err(error) => Err(rspamd_io("read signing config", path, error)), } } @@ -1001,10 +1052,17 @@ async fn write_signing_config( .write(true) .truncate(true) .open(path) - .await?; - file.write_all(bytes).await?; - file.flush().await?; - file.sync_all().await?; + .await + .map_err(|error| rspamd_io("open signing config for writing", path, error))?; + file.write_all(bytes) + .await + .map_err(|error| rspamd_io("write signing config", path, error))?; + file.flush() + .await + .map_err(|error| rspamd_io("flush signing config", path, error))?; + file.sync_all() + .await + .map_err(|error| rspamd_io("sync signing config", path, error))?; Ok(()) } @@ -1013,12 +1071,12 @@ async fn ensure_managed_config(cfg: &RspamdDkimConfig) -> AppResult<()> { let old = match fs::read_to_string(path).await { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(error.into()), + Err(error) => return Err(rspamd_io("read signing config", path, error)), }; let (config_mode, existed) = match fs::metadata(path).await { Ok(metadata) => (metadata.permissions().mode() & 0o777, true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => (0o644, false), - Err(error) => return Err(error.into()), + Err(error) => return Err(rspamd_io("stat signing config", path, error)), }; let clean = strip_managed_block(&old); if clean.lines().any(|line| { @@ -1080,7 +1138,9 @@ async fn generate_key( let fake = format!( "-----BEGIN PRIVATE KEY-----\nPOSTFIXADMIN-RS-DEV-MOCK-{domain}-{selector}\n-----END PRIVATE KEY-----\n" ); - fs::write(private_path, fake).await?; + fs::write(private_path, fake) + .await + .map_err(|error| rspamd_io("write development mock private key", private_path, error))?; return Ok(format!( "{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p=DEV-MOCK-NOT-FOR-PRODUCTION\"" )); @@ -1108,7 +1168,13 @@ async fn generate_key( .output(), ) .await - .map_err(|_| AppError::config("rspamadm dkim_keygen timed out"))??; + .map_err(|_| AppError::config("rspamadm dkim_keygen timed out"))? + .map_err(|error| { + AppError::config(format!( + "Failed to execute {} dkim_keygen: {error}", + cfg.rspamadm + )) + })?; if !output.status.success() { return Err(AppError::config(format!( "rspamadm dkim_keygen failed: {}", @@ -1117,9 +1183,13 @@ async fn generate_key( } let dns = String::from_utf8(output.stdout) .map_err(|_| AppError::config("rspamadm returned non-UTF8 DKIM record"))?; - let metadata = fs::metadata(private_path) - .await - .map_err(|_| AppError::config("rspamadm did not create the private key file"))?; + let metadata = match fs::metadata(private_path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(AppError::config("rspamadm did not create the private key file")); + } + Err(error) => return Err(rspamd_io("stat generated private key", private_path, error)), + }; if metadata.len() == 0 { return Err(AppError::config("rspamadm created an empty private key")); } @@ -1140,7 +1210,13 @@ async fn config_test(cfg: &RspamdDkimConfig) -> AppResult { .output(), ) .await - .map_err(|_| AppError::config("rspamadm configtest timed out"))??; + .map_err(|_| AppError::config("rspamadm configtest timed out"))? + .map_err(|error| { + AppError::config(format!( + "Failed to execute {} configtest: {error}", + cfg.rspamadm + )) + })?; if !output.status.success() { return Err(AppError::config(format!( "rspamadm configtest failed: {}", @@ -1170,7 +1246,8 @@ async fn reload_rspamd(cfg: &RspamdDkimConfig) -> AppResult { .output(), ) .await - .map_err(|_| AppError::config("Rspamd reload command timed out"))??; + .map_err(|_| AppError::config("Rspamd reload command timed out"))? + .map_err(|error| AppError::config(format!("Failed to execute Rspamd reload command: {error}")))?; if !output.status.success() { return Err(AppError::config(format!( "Rspamd reload failed: {}", @@ -1192,7 +1269,11 @@ async fn require_existing_key_under_dir(cfg: &RspamdDkimConfig, input: &str) -> "Private key must be inside rspamd_dkim.key_dir", )); } - if !fs::metadata(&key).await?.is_file() { + if !fs::metadata(&key) + .await + .map_err(|error| rspamd_io("stat private key", &key, error))? + .is_file() + { return Err(AppError::bad_request("Private key path is not a file")); } Ok(key) @@ -1202,12 +1283,12 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu let key_dir = match fs::canonicalize(&cfg.key_dir).await { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(error.into()), + Err(error) => return Err(rspamd_io("canonicalize key directory", Path::new(&cfg.key_dir), error)), }; let key = match fs::canonicalize(path).await { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(error.into()), + Err(error) => return Err(rspamd_io("canonicalize private key", path, error)), }; if !key.starts_with(&key_dir) { return Err(AppError::forbidden( @@ -1215,11 +1296,13 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu )); } let sidecar = dns_sidecar(&key); - fs::remove_file(&key).await?; + fs::remove_file(&key) + .await + .map_err(|error| rspamd_io("delete private key", &key, error))?; if let Err(error) = fs::remove_file(&sidecar).await && error.kind() != std::io::ErrorKind::NotFound { - return Err(error.into()); + return Err(rspamd_io("delete DNS sidecar", &sidecar, error)); } if let Some(parent) = key.parent() { let _ = fs::remove_dir(parent).await; diff --git a/src/routes/vacation.rs b/src/routes/vacation.rs index 210e6a0..fef339d 100644 --- a/src/routes/vacation.rs +++ b/src/routes/vacation.rs @@ -12,6 +12,7 @@ use axum::{ http::HeaderMap, }; use axum_extra::extract::cookie::CookieJar; +use chrono::NaiveDate; use std::sync::Arc; pub async fn get( @@ -29,13 +30,16 @@ pub async fn get( 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 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>, Path(path): Path, @@ -51,10 +55,38 @@ pub async fn set( "Vacation email does not match mailbox", )); } + if !state.db.has_table(Table::Vacation).await { + return Err(AppError::config( + "Vacation is enabled but the vacation table does not exist", + )); + } 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(); + if input.interval_time < 0 { + return Err(AppError::bad_request( + "Vacation reply interval cannot be negative", + )); + } + validate_dates(&input.active_from, &input.active_until)?; + + let (_, domain) = u.rsplit_once('@').expect("validated email"); + let mailbox = state.db.table(Table::Mailbox); + let mailbox_check = state + .db + .q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1")); + if sqlx::query(&mailbox_check) + .bind(&u) + .fetch_optional(state.db.pool()) + .await? + .is_none() + { + return Err(AppError::not_found("Mailbox not found")); + } + + let alias_has_description = state.db.has_column(Table::Alias, "description").await?; + let vacation_domain = validation::domain(&state.config.defaults.vacation_domain)?; + let vacation_address = vacation_address(&u, &vacation_domain); let t = state.db.table(Table::Vacation); let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?")); let exists = sqlx::query(&check) @@ -62,10 +94,16 @@ pub async fn set( .fetch_optional(state.db.pool()) .await? .is_some(); + + let mut tx = state.db.pool().begin().await?; let q = if exists { - state.db.q(&format!("UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?")) + 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,?)")) + 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) @@ -76,7 +114,7 @@ pub async fn set( .bind(input.interval_time) .bind(input.active) .bind(&u) - .execute(state.db.pool()) + .execute(&mut *tx) .await?; } else { sqlx::query(&q) @@ -88,9 +126,22 @@ pub async fn set( .bind(domain) .bind(input.interval_time) .bind(input.active) - .execute(state.db.pool()) + .execute(&mut *tx) .await?; } + + sync_vacation_alias( + &state, + &mut tx, + &u, + domain, + &vacation_address, + input.active, + alias_has_description, + ) + .await?; + tx.commit().await?; + state .db .log( @@ -101,8 +152,13 @@ pub async fn set( &u, ) .await; - Ok(Json(serde_json::json!({"ok":true}))) + Ok(Json(serde_json::json!({ + "ok": true, + "active": input.active, + "email": u + }))) } + pub async fn remove( State(state): State>, Path(path): Path, @@ -112,16 +168,135 @@ pub async fn remove( let session = routes::mutation_session(&state, &jar, &headers)?; let u = validation::email(&path)?; authorize(&state, &session, &u, true)?; + let (_, domain) = u.rsplit_once('@').expect("validated email"); + let vacation_domain = validation::domain(&state.config.defaults.vacation_domain)?; + let vacation_address = vacation_address(&u, &vacation_domain); + let alias_has_description = state.db.has_column(Table::Alias, "description").await?; + let has_notifications = state.db.has_table(Table::VacationNotification).await; + + let mut tx = state.db.pool().begin().await?; let t = state.db.table(Table::Vacation); let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?")); - sqlx::query(&q).bind(&u).execute(state.db.pool()).await?; - if state.db.has_table(Table::VacationNotification).await { + sqlx::query(&q).bind(&u).execute(&mut *tx).await?; + if has_notifications { let n = state.db.table(Table::VacationNotification); - let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?")); - let _ = sqlx::query(&q).bind(&u).execute(state.db.pool()).await; + let q = state + .db + .q(&format!("DELETE FROM {n} WHERE on_vacation=?")); + sqlx::query(&q).bind(&u).execute(&mut *tx).await?; } + sync_vacation_alias( + &state, + &mut tx, + &u, + domain, + &vacation_address, + false, + alias_has_description, + ) + .await?; + tx.commit().await?; + + state + .db + .log( + &state.config, + &session.username, + domain, + "delete_vacation", + &u, + ) + .await; Ok(Json(serde_json::json!({"ok":true}))) } + +async fn sync_vacation_alias( + state: &AppState, + tx: &mut sqlx::Transaction<'_, sqlx::Any>, + email: &str, + domain: &str, + vacation_address: &str, + enabled: bool, + has_description: bool, +) -> AppResult<()> { + let alias = state.db.table(Table::Alias); + let select = state + .db + .q(&format!("SELECT goto FROM {alias} WHERE address=? LIMIT 1")); + let row = sqlx::query(&select) + .bind(email) + .fetch_optional(&mut **tx) + .await?; + + if let Some(row) = row { + let current = row_string(&row, "goto"); + let mut destinations: Vec = current + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter(|value| !value.eq_ignore_ascii_case(vacation_address)) + .map(str::to_string) + .collect(); + if destinations.is_empty() { + destinations.push(email.to_string()); + } + if enabled { + destinations.push(vacation_address.to_string()); + } + let update = state.db.q(&format!( + "UPDATE {alias} SET goto=?,modified=CURRENT_TIMESTAMP WHERE address=?" + )); + sqlx::query(&update) + .bind(destinations.join(",")) + .bind(email) + .execute(&mut **tx) + .await?; + return Ok(()); + } + + if !enabled { + return Ok(()); + } + + // A mailbox normally has a self-alias. Recreate it defensively if an older + // database is missing that row, then add the internal vacation transport. + let goto = format!("{email},{vacation_address}"); + let insert = if has_description { + 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(&insert) + .bind(email) + .bind(goto) + .bind(domain) + .bind(true) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn vacation_address(email: &str, vacation_domain: &str) -> String { + format!("{}@{}", email.replace('@', "#"), vacation_domain) +} + +fn validate_dates(active_from: &str, active_until: &str) -> AppResult<()> { + let from = NaiveDate::parse_from_str(active_from, "%Y-%m-%d") + .map_err(|_| AppError::bad_request("Invalid vacation start date"))?; + let until = NaiveDate::parse_from_str(active_until, "%Y-%m-%d") + .map_err(|_| AppError::bad_request("Invalid vacation end date"))?; + if until < from { + return Err(AppError::bad_request( + "Vacation end date cannot be before start date", + )); + } + Ok(()) +} + 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")); @@ -139,10 +314,11 @@ fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) - "Vacation control is disabled for domain administrators", )); } - let (_, d) = u.rsplit_once('@').unwrap(); + let (_, d) = u.rsplit_once('@').expect("validated email"); s.require_domain(d) } } + fn map(r: &sqlx::any::AnyRow) -> VacationSummary { VacationSummary { email: row_string(r, "email"), @@ -154,3 +330,22 @@ fn map(r: &sqlx::any::AnyRow) -> VacationSummary { active: row_bool(r, "active"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_postfixadmin_vacation_transport_address() { + assert_eq!( + vacation_address("user@example.org", "autoreply.example.org"), + "user#example.org@autoreply.example.org" + ); + } + + #[test] + fn rejects_reversed_vacation_dates() { + assert!(validate_dates("2026-11-10", "2026-11-09").is_err()); + assert!(validate_dates("2026-11-09", "2026-11-10").is_ok()); + } +} diff --git a/src/web/css/20-app.css b/src/web/css/20-app.css index 607c89c..c9064fa 100644 --- a/src/web/css/20-app.css +++ b/src/web/css/20-app.css @@ -1,4 +1,4 @@ -.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} +.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}.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} @@ -6,7 +6,7 @@ .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 */ +/* 0.0.2 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} @@ -29,7 +29,7 @@ @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 */ +/* 0.0.2 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} @@ -37,7 +37,7 @@ .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 */ +/* 0.0.2 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} @@ -52,3 +52,4 @@ .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}} +.field-help{display:block;margin-top:5px;color:var(--subtle);font-size:10px;line-height:1.35}.vacation-mailbox-picker{display:grid;gap:10px}.vacation-mailbox-results{max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:var(--r)}.vacation-mailbox-result{display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--text);text-align:left}.vacation-mailbox-result:last-child{border-bottom:0}.vacation-mailbox-result:hover{background:var(--hover)}.alias-section+.alias-section{margin-top:16px} diff --git a/src/web/css/30-responsive.css b/src/web/css/30-responsive.css index 5ac5964..522d864 100644 --- a/src/web/css/30-responsive.css +++ b/src/web/css/30-responsive.css @@ -1,2 +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:850px){.body{grid-template-columns:58px minmax(0,1fr)}.sidebar{padding:8px}.nav button{justify-content:center;padding:0}.nav button span,.nav-section,.brand span,.search{display:none}.topbar{grid-template-columns:40px 1fr}.who strong{max-width:120px}.cards{grid-template-columns:repeat(2,1fr)}.feature-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.content{padding:12px}.cards{grid-template-columns:1fr 1fr}.page-head{display:block}.page-head .actions{margin-top:10px}.modal-body{grid-template-columns:1fr}.modal-body .wide{grid-column:auto}.who{display:none}.feature-grid{grid-template-columns:1fr}} @media(max-width:860px){.two-col{grid-template-columns:1fr}} diff --git a/src/web/i18n/en.json b/src/web/i18n/en.json index 825c3b6..1c2f12e 100644 --- a/src/web/i18n/en.json +++ b/src/web/i18n/en.json @@ -297,8 +297,8 @@ "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", + "Zapisz Vacation": "Save autoresponder", + "Vacation zapisany": "Autoresponder saved", "ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules", "Nowy skrypt": "New script", "Skrypty": "Scripts", @@ -341,7 +341,19 @@ "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" + "Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node", + "Aliasy użytkowników": "User aliases", + "Aliasy techniczne": "Technical aliases", + "Wpisz tylko część przed znakiem @": "Enter only the part before @", + "Podaj wyłącznie adresy docelowe przekierowania.": "Enter forwarding destination addresses only.", + "Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.": "Enter an address or search for a mailbox by address or name.", + "Edytuj autoresponder": "Edit autoresponder", + "Brak pasujących skrzynek": "No matching mailboxes", + "Wybierz istniejącą skrzynkę z listy.": "Select an existing mailbox from the list.", + "Wybierz inną skrzynkę": "Choose another mailbox", + "Wybierz skrzynkę": "Choose mailbox", + "Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.": "Choose the mailbox for which you want to configure an autoresponder.", + "Skrzynka": "Mailbox" }, "patterns": [ { @@ -415,6 +427,10 @@ { "match": "^Pominięto: (\\d+)$", "replace": "Skipped: $1" + }, + { + "match": "^Wpisz tylko nazwę przed @(.+)$", + "replace": "Enter only the name before @$1" } ] } diff --git a/src/web/js/00-core.js b/src/web/js/00-core.js index 666a0f5..299d399 100644 --- a/src/web/js/00-core.js +++ b/src/web/js/00-core.js @@ -173,7 +173,7 @@
- +
`; @@ -319,6 +319,7 @@ } else { label.append(element); } + if (field.help) label.append(h('small', { class: 'field-help' }, field.help)); body.append(label); } diff --git a/src/web/js/10-pages.js b/src/web/js/10-pages.js index af16a25..18e03c0 100644 --- a/src/web/js/10-pages.js +++ b/src/web/js/10-pages.js @@ -307,9 +307,10 @@ function mailboxForm(row = {}, domainOverride = '') { const domain = row.domain || domainOverride || S.cache.mailboxDomain || (S.me.domains || []).find(item => item !== 'ALL') || ''; + const localPart = row.local_part || (row.username ? row.username.split('@')[0] : ''); const fields = [ - { name: 'username', label: 'Adres e-mail', required: true, readonly: !!row.username }, - { name: 'domain', label: 'Domena', required: true, readonly: !!row.username }, + { name: 'local_part', label: 'Adres e-mail', required: true, readonly: !!row.username, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' }, + { name: 'domain', label: 'Domena', required: true, readonly: true }, ...(S.features.edit_mailbox_name ? [{ name: 'name', label: 'Nazwa wyświetlana' }] : []), { name: 'password', label: row.username ? 'Nowe hasło (puste = bez zmian)' : 'Hasło', type: 'password', autocomplete: 'new-password', required: !row.username, generate: !!S.features.generate_password }, { name: 'quota', label: 'Quota [MB]', type: 'number', min: 0 }, @@ -320,13 +321,16 @@ { name: 'send_welcome', label: 'Wiadomość powitalna', type: 'checkbox', help: 'Wyślij po utworzeniu' }, ]; formModal(row.username ? 'Edytuj skrzynkę' : 'Nowa skrzynka', fields, { - username: row.username || '', domain, name: row.name || '', password: '', + local_part: localPart, domain, name: row.name || '', password: '', quota: row.quota ? mbFromBytes(row.quota) : 10, phone: row.phone || '', email_other: row.email_other || '', active: row.username ? row.active : true, smtp_active: row.smtp_active ?? true, send_welcome: false, }, async data => { const body = { - username: data.username, domain: data.domain, name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null, + username: row.username || null, + local_part: row.username ? null : data.local_part, + domain: data.domain, + name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null, quota: number(data.quota), phone: data.phone, email_other: data.email_other, active: data.active, smtp_active: data.smtp_active, send_welcome: data.send_welcome, }; @@ -334,7 +338,8 @@ method: row.username ? 'PUT' : 'POST', body: JSON.stringify(body), }); if (!row.username && S.features.show_password && data.password) { - return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.username}\n${data.password}` } }; + return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.local_part}@${data.domain} +${data.password}` } }; } return { message: row.username ? 'Skrzynka zapisana' : 'Skrzynka utworzona' }; }); @@ -367,14 +372,29 @@ const inactiveAction = S.me.kind === 'admin' && hasInactive ? `` : ''; const add = S.me.kind === 'admin' ? `${S.features.alias_domain ? `` : ''}${inactiveAction}` : ''; const tools = S.me.kind === 'user' ? readonlyDomain(selected) : (domains.length > 1 ? domainPicker('aliasDomainSelect', domains, selected) : readonlyDomain(selected)); - const aliasTable = table( + const indexedAliases = filteredAliases.map((row, index) => ({ row, index })); + const aliasTableFor = entries => table( ['Adres', 'Cel', 'Opis', 'Status', 'Mailbox', 'Aktywny', ''], - filteredAliases.map((row, index) => `${esc(row.address)}${row.goto.map(esc).join('
')}${esc(row.description)}${aliasStatus(row.status)}${yes(row.is_mailbox)}${yes(row.active)}${action('Edytuj')}${!row.is_mailbox && S.me.kind === 'admin' ? action('Usuń', true) : ''}`) + entries.map(({ row, index }) => { + const canEditTechnical = !row.technical || S.me.role === 'global_admin' || S.features.special_alias_control; + const editAction = canEditTechnical ? action('Edytuj') : ''; + const deleteAction = !row.is_mailbox && S.me.kind === 'admin' && canEditTechnical ? action('Usuń', true) : ''; + return `${esc(row.address)}${row.goto.map(esc).join('
')}${esc(row.description)}${aliasStatus(row.status)}${yes(row.is_mailbox)}${yes(row.active)}${editAction}${deleteAction}`; + }) ); + let aliasSections; + if (S.me.kind === 'admin') { + const technical = indexedAliases.filter(item => item.row.technical); + const regular = indexedAliases.filter(item => !item.row.technical); + aliasSections = `
Aliasy użytkowników
${aliasTableFor(regular)}
` + + (technical.length ? `
Aliasy techniczne
${aliasTableFor(technical)}
` : ''); + } else { + aliasSections = `
${esc(selected || t('Aliasy'))}
${aliasTableFor(indexedAliases)}`; + } const domainTable = filteredAliasDomains.length || (S.me.kind === 'admin' && S.features.alias_domain) ? `
Aliasy domen
${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny', ''], filteredAliasDomains.map((row, index) => `${esc(row.alias_domain)}${esc(row.target_domain)}${esc(row.description)}${yes(row.active)}${action('Edytuj AD')}${action('Usuń AD', true)}`))}` : ''; - content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `
${tools}
${icon('forward')} ${filteredAliases.length}${icon('folder-plus')} ${filteredAliasDomains.length}
${esc(selected || t('Aliasy'))}
` + aliasTable + domainTable; + content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `
${tools}
${icon('forward')} ${filteredAliases.length}${icon('folder-plus')} ${filteredAliasDomains.length}
` + aliasSections + domainTable; document.querySelector('#aliasDomainSelect')?.addEventListener('change', async event => { event.target.disabled = true; S.cache.aliasDomain = event.target.value; @@ -416,6 +436,11 @@ async function aliasForm(row = {}, domainOverride = '') { const domain = row.domain || domainOverride || S.cache.aliasDomain || (S.me.domains || []).find(item => item !== 'ALL') || ''; + const userEdit = S.me.kind === 'user' && Boolean(row.address); + const localPart = row.address ? row.address.split('@')[0] : ''; + const selfTarget = row.address || ''; + const visibleTargets = (row.goto || []).filter(value => !selfTarget || value.toLowerCase() !== selfTarget.toLowerCase()); + const keepsLocalDelivery = Boolean(row.is_mailbox && (row.goto || []).some(value => value.toLowerCase() === selfTarget.toLowerCase())); let defaultGoto = ''; if (!row.address && S.me.kind === 'admin') { try { @@ -423,22 +448,39 @@ defaultGoto = preferences.find(pref => pref.key === 'alias_goto_default')?.value || ''; } catch {} } - const fields = [ - { name: 'address', label: 'Adres aliasu', required: true, readonly: S.me.kind === 'user' || !!row.address }, - { name: 'domain', label: 'Domena', required: true, readonly: S.me.kind === 'user' || !!row.address }, - { name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, required: true }, + + const fields = userEdit ? [ + { name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, help: 'Podaj wyłącznie adresy docelowe przekierowania.' }, + ...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []), + { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' }, + ] : [ + { name: 'local_part', label: 'Adres aliasu', required: true, readonly: !!row.address, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' }, + { name: 'domain', label: 'Domena', required: true, readonly: true }, + { name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true }, { name: 'description', label: 'Opis', wide: true }, - { name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }, + ...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []), ...(!row.address ? [{ name: 'save_goto_default', label: 'Zapamiętaj cele', type: 'checkbox', help: 'Zapisz jako domyślny cel nowych aliasów dla tego administratora' }] : []), { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' }, ]; + formModal(row.address ? 'Edytuj alias' : 'Nowy alias', fields, { - address: row.address || '', domain, goto: row.address ? (row.goto || []).join('\n') : defaultGoto, description: row.description || '', - store_and_forward: row.is_mailbox, save_goto_default: false, active: row.address ? row.active : true, + local_part: localPart, + domain, + goto: row.address ? visibleTargets.join('\n') : defaultGoto, + description: row.description || '', + store_and_forward: keepsLocalDelivery, + save_goto_default: false, + active: row.address ? row.active : true, }, async data => { const body = { - address: data.address, domain: data.domain, goto: splitList(data.goto), description: data.description, - store_and_forward: data.store_and_forward, save_goto_default: !!data.save_goto_default, active: data.active, + address: row.address || null, + local_part: row.address ? null : data.local_part, + domain, + goto: splitList(data.goto), + description: userEdit ? (row.description || '') : data.description, + store_and_forward: row.is_mailbox ? Boolean(data.store_and_forward) : false, + save_goto_default: userEdit ? false : !!data.save_goto_default, + active: data.active, }; await api(row.address ? `/aliases/${enc(row.address)}` : '/aliases', { method: row.address ? 'PUT' : 'POST', body: JSON.stringify(body), @@ -561,92 +603,145 @@ }); } - async function vacation() { - if (S.me.kind === 'user') { - const mailbox = S.me.username; - const existing = await api(`/vacation/${enc(mailbox)}`); - const row = existing || { - email: mailbox, subject: 'Out of office', body: '', interval_time: 0, - active_from: new Date().toISOString().slice(0, 10), - active_until: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), active: true, - }; - content().innerHTML = pageHead('Vacation', 'Automatyczna odpowiedź dla Twojej skrzynki') + - `
-
-
- - - - - - -
-
${existing ? `` : ''}
-
-
`; - document.querySelector('#vacationUserForm').onsubmit = async event => { - event.preventDefault(); - const form = new FormData(event.target); - const submit = event.submitter || event.target.querySelector('button[type="submit"]'); - if (submit) submit.disabled = true; - try { - await api(`/vacation/${enc(mailbox)}`, { method: 'PUT', body: JSON.stringify({ - email: mailbox, - subject: form.get('subject'), body: form.get('body'), - active_from: form.get('active_from'), active_until: form.get('active_until'), - interval_time: number(form.get('interval_time')), active: form.get('active') === 'on', - }) }); - showToast('Vacation zapisany'); - await vacation(); - } catch (error) { if (submit) submit.disabled = false; showToast(error.message, true); } - }; - document.querySelector('#removeUserVacation')?.addEventListener('click', async () => { - if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return; - await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' }); - showToast('Vacation usunięty'); - vacation(); - }); - return; - } - - const mailbox = await promptModal('Vacation', 'Skrzynka dla autorespondera', (S.me.domains || []).find(item => item !== 'ALL') ? `@${(S.me.domains || []).find(item => item !== 'ALL')}` : '', { help: 'Podaj pełny adres skrzynki, np. user@example.org.' }); - if (!mailbox) { - content().innerHTML = pageHead('Vacation', 'Autoresponder') + '
Wybierz skrzynkę ponownie z menu.
'; - return; - } - let row = await api(`/vacation/${enc(mailbox)}`); - row = row || { - email: mailbox, subject: 'Out of office', body: '', interval_time: 0, - active_from: new Date().toISOString().slice(0, 10), - active_until: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), active: true, - }; - content().innerHTML = pageHead('Vacation', mailbox, `${row.email ? '' : ''}`) + - `

${esc(row.subject)}

${esc(row.body)}

${esc(row.active_from)} → ${esc(row.active_until)} · interval ${row.interval_time}s · ${row.active ? 'aktywny' : 'wyłączony'}

`; - document.querySelector('#editVac').onclick = () => vacationForm(mailbox, row); - const remove = document.querySelector('#delVac'); - if (remove) remove.onclick = async () => { - if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return; - await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' }); - showToast('Vacation usunięty'); - vacation(); + function defaultVacation(mailbox) { + const localDate = date => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 10); + const now = new Date(); + return { + email: mailbox, + subject: 'Out of office', + body: '', + interval_time: 0, + active_from: localDate(now), + active_until: localDate(new Date(now.getTime() + 7 * 86400000)), + active: true, }; } - function vacationForm(mailbox, row) { - formModal('Vacation', [ - { name: 'subject', label: 'Temat', wide: true }, - { name: 'body', label: 'Treść', type: 'textarea', wide: true }, - { name: 'active_from', label: 'Od', type: 'date' }, - { name: 'active_until', label: 'Do', type: 'date' }, - { name: 'interval_time', label: 'Interwał odpowiedzi [s]', type: 'number' }, - { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Autoresponder aktywny' }, - ], row, async data => { - await api(`/vacation/${enc(mailbox)}`, { - method: 'PUT', body: JSON.stringify({ ...data, email: mailbox, interval_time: number(data.interval_time) }), - }); + const vacationDate = value => String(value || '').slice(0, 10); + + function vacationMailboxPicker(mailboxes) { + return new Promise(resolve => { + const modal = document.querySelector('#modal'); + modal.innerHTML = `
+ + + +
`; + const form = modal.querySelector('#vacationMailboxPickerForm'); + const input = modal.querySelector('#vacationMailboxSearch'); + const results = modal.querySelector('#vacationMailboxResults'); + const error = modal.querySelector('#vacationMailboxError'); + let filtered = []; + let settled = false; + const onCancel = event => { event.preventDefault(); finish(null); }; + const finish = value => { + if (settled) return; + settled = true; + modal.removeEventListener('cancel', onCancel); + if (modal.open) modal.close(); + modal.innerHTML = ''; + resolve(value); + }; + const render = () => { + const query = input.value.trim().toLowerCase(); + filtered = mailboxes.filter(row => !query || row.username.toLowerCase().includes(query) || String(row.name || '').toLowerCase().includes(query)).slice(0, 40); + results.innerHTML = filtered.length ? filtered.map(row => ``).join('') : `
${esc(t('Brak pasujących skrzynek'))}
`; + results.querySelectorAll('[data-mailbox]').forEach(button => { + button.onclick = () => finish(button.dataset.mailbox); + }); + }; + input.oninput = () => { error.textContent = ''; render(); }; + form.onsubmit = event => { + event.preventDefault(); + const value = input.value.trim().toLowerCase(); + const exact = mailboxes.find(row => row.username.toLowerCase() === value); + if (exact) return finish(exact.username); + if (filtered.length === 1) return finish(filtered[0].username); + error.textContent = t('Wybierz istniejącą skrzynkę z listy.'); + }; + form.querySelectorAll('[data-cancel]').forEach(button => { button.onclick = () => finish(null); }); + modal.addEventListener('cancel', onCancel, { once: true }); + render(); + modal.showModal(); + input.focus(); }); } + async function renderVacationEditor(mailbox, adminMode) { + const existing = await api(`/vacation/${enc(mailbox)}`); + const row = existing || defaultVacation(mailbox); + row.active_from = vacationDate(row.active_from); + row.active_until = vacationDate(row.active_until); + const switchMailbox = adminMode ? `` : ''; + content().innerHTML = pageHead('Vacation', adminMode ? mailbox : 'Automatyczna odpowiedź dla Twojej skrzynki', switchMailbox) + + `
+
+
+ ${adminMode ? `` : ''} + + + + + + +
+
${existing ? `` : ''}
+
+
`; + document.querySelector('#chooseVacationMailbox')?.addEventListener('click', () => { + S.cache.vacationMailbox = ''; + vacation(); + }); + document.querySelector('#vacationUserForm').onsubmit = async event => { + event.preventDefault(); + const form = new FormData(event.target); + const submit = event.submitter || event.target.querySelector('button[type="submit"]'); + if (submit) submit.disabled = true; + try { + await api(`/vacation/${enc(mailbox)}`, { method: 'PUT', body: JSON.stringify({ + email: mailbox, + subject: form.get('subject'), body: form.get('body'), + active_from: form.get('active_from'), active_until: form.get('active_until'), + interval_time: number(form.get('interval_time')), active: form.get('active') === 'on', + }) }); + showToast('Vacation zapisany'); + await renderVacationEditor(mailbox, adminMode); + } catch (error) { + if (submit) submit.disabled = false; + showToast(error.message, true); + } + }; + document.querySelector('#removeUserVacation')?.addEventListener('click', async () => { + if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return; + await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' }); + showToast('Vacation usunięty'); + await renderVacationEditor(mailbox, adminMode); + }); + } + + async function vacation() { + if (S.me.kind === 'user') { + await renderVacationEditor(S.me.username, false); + return; + } + + const mailboxes = await api('/mailboxes'); + let mailbox = S.cache.vacationMailbox || ''; + if (mailbox && !mailboxes.some(row => row.username.toLowerCase() === mailbox.toLowerCase())) mailbox = ''; + if (!mailbox) mailbox = await vacationMailboxPicker(mailboxes); + if (!mailbox) { + content().innerHTML = pageHead('Vacation', 'Autoresponder', ``) + '
Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.
'; + document.querySelector('#chooseVacationMailbox').onclick = () => vacation(); + return; + } + S.cache.vacationMailbox = mailbox; + await renderVacationEditor(mailbox, true); + } function sieveQuote(value) {