This commit is contained in:
Mateusz Gruszczyński committed 2026-11-03 20:05:17 +01:00
1 parent ba300b2448
commit 6e5aa8e4e4
26 files changed
+814 -214

No files matched your search

+2
View File
@@ -4,3 +4,5 @@
*.log *.log
.DS_Store .DS_Store
.dev .dev
__pycache__
*.zip
+13
View File
@@ -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.
+2 -2
View File
@@ -1,6 +1,6 @@
# Zgodnosc z PostfixAdmin # 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 | | Obszar | Stan | Uwagi |
|---|---|---| |---|---|---|
@@ -66,7 +66,7 @@ Aktywne elementy istotne dla tego wdrozenia (MySQL/MariaDB, `php_crypt:MD5`, ali
## Audit PostfixAdmin 4.0.1 ## 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 ## Rspamd DKIM
Generated
+1 -1
View File
@@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "postfixadmin-rs" name = "postfixadmin-rs"
version = "0.0.1-dev" version = "0.0.2"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-native-tls", "async-native-tls",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "postfixadmin-rs" name = "postfixadmin-rs"
version = "0.0.1-dev" version = "0.0.2"
edition = "2024" edition = "2024"
rust-version = "1.99.1" rust-version = "1.99.1"
default-run = "postfixadmin-rs" default-run = "postfixadmin-rs"
+3 -3
View File
@@ -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`. 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 ## 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 ## 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 | | Area | Gap / intended Rust replacement |
|---|---| |---|---|
+2 -2
View File
@@ -2,7 +2,7 @@
Rustowy panel administracyjny dla Postfix/Dovecot, rozwijany jako zamiennik runtime PHP PostfixAdmin i zgodny z jego istniejaca baza danych. 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 ## 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. 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. 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. 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.
+9 -1
View File
@@ -1,4 +1,4 @@
PostfixAdmin-RS 0.0.1-dev PostfixAdmin-RS 0.0.2
Validated in the current workspace: Validated in the current workspace:
- TOML parsing: PASS - 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) - 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) - 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. - 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)
+2 -2
View File
@@ -1,4 +1,4 @@
# PostfixAdmin-RS 0.0.1-dev - test checklist # PostfixAdmin-RS 0.0.2 - test checklist
## 1. Static / compiler gate ## 1. Static / compiler gate
@@ -124,7 +124,7 @@ flag columns must not be selected without the normalization helper.
## Debian package ## 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. Run `./scripts/build-deb.sh`. Expected output is `packages/postfixadmin-rs_0.0.2_<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 ## Local Rust quality gate
+1 -1
View File
@@ -208,7 +208,7 @@ esac
exit 1 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" echo "Config: $POSTFIXADMIN_RS_CONFIG"
if [ "$DEV_DB_MODE" = "mock" ]; then if [ "$DEV_DB_MODE" = "mock" ]; then
echo "DB: SQLite mock ($DEV_DB)" echo "DB: SQLite mock ($DEV_DB)"
+17 -2
View File
@@ -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. 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. 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`. 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. 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: 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. 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`. 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.
+57 -25
View File
@@ -1,38 +1,70 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations import os
import sys import sys
import zipfile import zipfile
import subprocess
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent
EXCLUDED_DIRS = {'.git', '.dev', 'target', 'dist', 'packages', '__pycache__'} def run_git_command(args, repo_path: Path) -> bytes:
EXCLUDED_NAMES = {'.env'} result = subprocess.run(
["git", *args],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout
def main() -> int: def get_files_to_archive(repo_path: Path) -> list[str]:
if len(sys.argv) != 2: output = run_git_command(
print(f'usage: {Path(sys.argv[0]).name} OUTPUT.zip', file=sys.stderr) ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
return 2 repo_path,
output = Path(sys.argv[1]).resolve() )
output.parent.mkdir(parents=True, exist_ok=True) files = output.decode("utf-8", errors="surrogateescape").split("\0")
prefix = f'PostfixAdmin-RS-{project_version()}' return [f for f in files if f]
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 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 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 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: def main():
import tomllib repo_path = Path.cwd()
with (ROOT / 'Cargo.toml').open('rb') as handle:
return tomllib.load(handle)['package']['version'] 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__': if __name__ == "__main__":
raise SystemExit(main()) main()
Binary file not shown.
@@ -1 +0,0 @@
efb760202f137507083cff11b0c6198ea498891876c70845ca63c64b9375acbd /packages/postfixadmin-rs_0.0.1~dev_amd64.deb
+74
View File
@@ -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 <<MSG
Rspamd DKIM permissions prepared.
Rspamd user: $RSPAMD_USER
Shared group: $SHARED_GROUP
Key directory: $KEY_DIR
Signing config: $SIGNING_CONFIG
Restart both services so supplementary groups are reloaded:
systemctl restart postfixadmin-rs rspamd
MSG
+6 -4
View File
@@ -50,16 +50,18 @@ docker run --rm \
cargo build --release cargo build --release
VERSION="$(sed -n "s/^version[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" Cargo.toml | head -n1)" VERSION="$(sed -n "s/^version[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" Cargo.toml | head -n1)"
DEB_VERSION="${VERSION/-dev/~dev}" #DEB_VERSION="${VERSION/-dev/~dev}"
DEB_VERSION="${VERSION}"
ARCH="$(dpkg --print-architecture)" ARCH="$(dpkg --print-architecture)"
PKG=/work/pkg 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 -d "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/usr/lib/postfixadmin-rs" "$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 0755 target/release/postfixadmin-rs "$PKG/usr/bin/postfixadmin-rs"
install -m 0640 config.example.toml "$PKG/etc/postfixadmin-rs/config.toml" 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.default "$PKG/etc/default/postfixadmin-rs"
install -m 0644 packaging/postfixadmin-rs.service "$PKG/lib/systemd/system/postfixadmin-rs.service" 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/" install -m 0755 packaging/postfixadmin-rs-rspamd-setup "$PKG/usr/lib/postfixadmin-rs/setup-rspamd-dkim-permissions"
install -m 0644 README.md CHANGELOG.md SECURITY.md COMPATIBILITY.md PORT-PARITY.md docs/RSPAMD_DKIM.md "$PKG/usr/share/doc/postfixadmin-rs/"
cat > "$PKG/DEBIAN/control" <<CONTROL cat > "$PKG/DEBIAN/control" <<CONTROL
Package: postfixadmin-rs Package: postfixadmin-rs
@@ -68,7 +70,7 @@ Section: admin
Priority: optional Priority: optional
Architecture: $ARCH Architecture: $ARCH
Maintainer: local build <root@localhost> Maintainer: local build <root@localhost>
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 Description: Rust web administration panel for Postfix virtual mail data
PostfixAdmin-RS is a Rust administration panel compatible with PostfixAdmin PostfixAdmin-RS is a Rust administration panel compatible with PostfixAdmin
database layouts. This is a development package built locally. database layouts. This is a development package built locally.
+15 -3
View File
@@ -33,8 +33,8 @@ if package.get('edition') != '2024':
fail('Cargo.toml must use edition 2024') fail('Cargo.toml must use edition 2024')
if package.get('rust-version') != '1.99.1': if package.get('rust-version') != '1.99.1':
fail('Cargo.toml rust-version must be 1.99.1') fail('Cargo.toml rust-version must be 1.99.1')
if package.get('version') != '0.0.1-dev': if package.get('version') != '0.0.2':
fail('Cargo.toml version must be 0.0.1-dev during the development phase') fail('Cargo.toml version must be 0.0.2 for this release')
if toolchain.get('toolchain', {}).get('channel') != '1.99.1': if toolchain.get('toolchain', {}).get('channel') != '1.99.1':
fail('rust-toolchain.toml must pin 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'): if marker not in project_text + '\n' + (ROOT / 'src/config.rs').read_text(encoding='utf-8'):
fail(f'missing Rspamd DKIM integration marker: {marker}') 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') node = shutil.which('node')
if node: if node:
for path in sorted((ROOT / 'src/web/js').glob('*.js')): for path in sorted((ROOT / 'src/web/js').glob('*.js')):
@@ -139,7 +151,7 @@ if node:
if result.returncode: if result.returncode:
fail(f'{path.relative_to(ROOT)}: node --check failed: {result.stderr.strip()}') 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) result = subprocess.run(['bash', '-n', str(script)], capture_output=True, text=True)
if result.returncode: if result.returncode:
fail(f'{script.relative_to(ROOT)}: bash -n failed: {result.stderr.strip()}') fail(f'{script.relative_to(ROOT)}: bash -n failed: {result.stderr.strip()}')
+1
View File
@@ -116,6 +116,7 @@ pub struct AliasSummary {
pub description: String, pub description: String,
pub active: bool, pub active: bool,
pub is_mailbox: bool, pub is_mailbox: bool,
pub technical: bool,
pub status: Vec<String>, pub status: Vec<String>,
} }
+54 -3
View File
@@ -85,8 +85,22 @@ pub async fn list(
let mut result = Vec::with_capacity(rows.len()); let mut result = Vec::with_capacity(rows.len());
for row in &rows { for row in &rows {
let address = row_string(row, "address"); 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<String> = 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 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 { result.push(AliasSummary {
address, address,
goto: gotos, goto: gotos,
@@ -94,6 +108,7 @@ pub async fn list(
description: row_string(row, "description"), description: row_string(row, "description"),
active: row_bool(row, "active"), active: row_bool(row, "active"),
is_mailbox: row_bool(row, "is_mailbox"), is_mailbox: row_bool(row, "is_mailbox"),
technical,
status, status,
}); });
} }
@@ -229,7 +244,10 @@ pub async fn update(
let goto = build_goto(&state, &address, &input).await?; let goto = build_goto(&state, &address, &input).await?;
let has_description = state.db.has_column(Table::Alias, "description").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!( state.db.q(&format!(
"UPDATE {table} SET goto=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?" "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(",")); 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.description);
} }
query = query.bind(input.active).bind(&address); query = query.bind(input.active).bind(&address);
@@ -727,8 +745,17 @@ fn split_goto(value: &str) -> Vec<String> {
.collect() .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<Vec<String>> { async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppResult<Vec<String>> {
let mut output = Vec::new(); let mut output = Vec::new();
let internal_vacation = vacation_destination(state, address);
for raw in &input.goto { for raw in &input.goto {
let value = validation::alias_destination_checked( let value = validation::alias_destination_checked(
&state.config, &state.config,
@@ -736,6 +763,11 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR
state.config.security.allow_unsafe_alias_destinations, state.config.security.allow_unsafe_alias_destinations,
) )
.await?; .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 if state.config.features.emailcheck_localaliasonly
&& let Some((_, domain)) = value.rsplit_once('@') && 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", "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) Ok(output)
} }
+128 -45
View File
@@ -28,6 +28,13 @@ use crate::{
const BEGIN_MARKER: &str = "# BEGIN PostfixAdmin-RS managed DKIM maps"; const BEGIN_MARKER: &str = "# BEGIN PostfixAdmin-RS managed DKIM maps";
const END_MARKER: &str = "# END 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 { fn default_true() -> bool {
true true
} }
@@ -97,7 +104,13 @@ pub(crate) async fn read_only_dns_records(
let legacy = match fs::read_to_string(&cfg.signing_config).await { let legacy = match fs::read_to_string(&cfg.signing_config).await {
Ok(text) => parse_legacy_domain_blocks(&text, &cfg.default_selector), Ok(text) => parse_legacy_domain_blocks(&text, &cfg.default_selector),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), 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(); let mut result = Vec::new();
for requested in domains { for requested in domains {
@@ -142,7 +155,7 @@ async fn dns_record_from_private_key(
Ok(metadata) if metadata.is_file() => {} Ok(metadata) if metadata.is_file() => {}
Ok(_) => return Ok(None), Ok(_) => return Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 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 path = private_path.to_string_lossy().into_owned();
let output = match timeout( let output = match timeout(
@@ -186,7 +199,13 @@ pub async fn list(
.filter(|domain| session.can_manage_domain(domain)) .filter(|domain| session.can_manage_domain(domain))
.collect(), .collect(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), 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(); let mut entries = Vec::new();
for domain in domains { for domain in domains {
@@ -205,13 +224,18 @@ pub async fn list(
let private_path = raw_path let private_path = raw_path
.replace("$domain", &domain) .replace("$domain", &domain)
.replace("$selector", &selector); .replace("$selector", &selector);
let private_exists = fs::metadata(&private_path) let private_exists = match fs::metadata(&private_path).await {
.await Ok(metadata) => metadata.is_file(),
.map(|m| m.is_file()) Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
.unwrap_or(false); Err(error) => {
let dns_record = read_dns_sidecar(Path::new(&private_path)) return Err(rspamd_io(
.await "stat private key",
.unwrap_or_default(); Path::new(&private_path),
error,
));
}
};
let dns_record = read_dns_sidecar(Path::new(&private_path)).await?;
entries.push(RspamdDkimEntry { entries.push(RspamdDkimEntry {
domain, domain,
selector, selector,
@@ -298,7 +322,10 @@ pub async fn generate(
let parent = final_path let parent = final_path
.parent() .parent()
.ok_or_else(|| AppError::config("Invalid Rspamd DKIM key path"))?; .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!( let tmp_path = parent.join(format!(
".{}.private.tmp-{}", ".{}.private.tmp-{}",
input.selector, input.selector,
@@ -324,10 +351,14 @@ pub async fn generate(
return Err(AppError::conflict("Private key file already exists")); return Err(AppError::conflict("Private key file already exists"));
} }
if had_private { 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 { 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 let Err(error) = fs::rename(&tmp_path, &final_path).await {
if had_private { if had_private {
@@ -336,7 +367,7 @@ pub async fn generate(
if had_dns { if had_dns {
let _ = fs::rename(&backup_dns, &dns_path).await; 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( if let Err(error) = atomic_write(
&dns_path, &dns_path,
@@ -547,7 +578,7 @@ pub async fn import_legacy_domain_blocks(
if error.kind() == std::io::ErrorKind::NotFound { if error.kind() == std::io::ErrorKind::NotFound {
AppError::not_found("Rspamd dkim_signing.conf does not exist") AppError::not_found("Rspamd dkim_signing.conf does not exist")
} else { } else {
error.into() rspamd_io("read signing config for legacy import", config_path, error)
} }
})?; })?;
let candidates = parse_legacy_domain_blocks(&text, &cfg.default_selector); 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_str(&managed_block(cfg));
next.push('\n'); 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 config_mode = metadata.permissions().mode() & 0o777;
let backup_path = Path::new(&cfg.key_dir).join("dkim_signing.conf.pre-postfixadmin-rs"); let backup_path = Path::new(&cfg.key_dir).join("dkim_signing.conf.pre-postfixadmin-rs");
if fs::metadata(&backup_path).await.is_err() { 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<String> { async fn read_dns_sidecar(private_path: &Path) -> AppResult<String> {
let path = dns_sidecar(private_path); 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()), Ok(value) => Ok(value.trim().to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), 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 { let text = match fs::read_to_string(&cfg.signing_config).await {
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), 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) if parse_legacy_domain_blocks(&text, &cfg.default_selector)
.iter() .iter()
@@ -892,7 +931,7 @@ async fn read_map(path: &Path) -> AppResult<BTreeMap<String, String>> {
let text = match fs::read_to_string(path).await { let text = match fs::read_to_string(path).await {
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), 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(); let mut map = BTreeMap::new();
for line in text.lines() { for line in text.lines() {
@@ -933,22 +972,33 @@ async fn atomic_write(path: &Path, bytes: &[u8], mode: u32) -> AppResult<()> {
let parent = path let parent = path
.parent() .parent()
.ok_or_else(|| AppError::config(format!("Invalid path: {}", path.display())))?; .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 let name = path
.file_name() .file_name()
.and_then(|v| v.to_str()) .and_then(|v| v.to_str())
.unwrap_or("managed"); .unwrap_or("managed");
let tmp = parent.join(format!(".{name}.tmp-{}", std::process::id())); 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?; 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(()) Ok(())
} }
async fn set_mode(path: &Path, mode: u32) -> AppResult<()> { 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); 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(()) Ok(())
} }
@@ -981,10 +1031,11 @@ fn strip_managed_block(text: &str) -> String {
} }
async fn managed_config_present(cfg: &RspamdDkimConfig) -> AppResult<bool> { async fn managed_config_present(cfg: &RspamdDkimConfig) -> AppResult<bool> {
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)), Ok(text) => Ok(text.contains(BEGIN_MARKER) && text.contains(END_MARKER)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), 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) .write(true)
.truncate(true) .truncate(true)
.open(path) .open(path)
.await?; .await
file.write_all(bytes).await?; .map_err(|error| rspamd_io("open signing config for writing", path, error))?;
file.flush().await?; file.write_all(bytes)
file.sync_all().await?; .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(()) Ok(())
} }
@@ -1013,12 +1071,12 @@ async fn ensure_managed_config(cfg: &RspamdDkimConfig) -> AppResult<()> {
let old = match fs::read_to_string(path).await { let old = match fs::read_to_string(path).await {
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), 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 { let (config_mode, existed) = match fs::metadata(path).await {
Ok(metadata) => (metadata.permissions().mode() & 0o777, true), Ok(metadata) => (metadata.permissions().mode() & 0o777, true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (0o644, false), 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); let clean = strip_managed_block(&old);
if clean.lines().any(|line| { if clean.lines().any(|line| {
@@ -1080,7 +1138,9 @@ async fn generate_key(
let fake = format!( let fake = format!(
"-----BEGIN PRIVATE KEY-----\nPOSTFIXADMIN-RS-DEV-MOCK-{domain}-{selector}\n-----END PRIVATE KEY-----\n" "-----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!( return Ok(format!(
"{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p=DEV-MOCK-NOT-FOR-PRODUCTION\"" "{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p=DEV-MOCK-NOT-FOR-PRODUCTION\""
)); ));
@@ -1108,7 +1168,13 @@ async fn generate_key(
.output(), .output(),
) )
.await .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() { if !output.status.success() {
return Err(AppError::config(format!( return Err(AppError::config(format!(
"rspamadm dkim_keygen failed: {}", "rspamadm dkim_keygen failed: {}",
@@ -1117,9 +1183,13 @@ async fn generate_key(
} }
let dns = String::from_utf8(output.stdout) let dns = String::from_utf8(output.stdout)
.map_err(|_| AppError::config("rspamadm returned non-UTF8 DKIM record"))?; .map_err(|_| AppError::config("rspamadm returned non-UTF8 DKIM record"))?;
let metadata = fs::metadata(private_path) let metadata = match fs::metadata(private_path).await {
.await Ok(metadata) => metadata,
.map_err(|_| AppError::config("rspamadm did not create the private key file"))?; 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 { if metadata.len() == 0 {
return Err(AppError::config("rspamadm created an empty private key")); return Err(AppError::config("rspamadm created an empty private key"));
} }
@@ -1140,7 +1210,13 @@ async fn config_test(cfg: &RspamdDkimConfig) -> AppResult<String> {
.output(), .output(),
) )
.await .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() { if !output.status.success() {
return Err(AppError::config(format!( return Err(AppError::config(format!(
"rspamadm configtest failed: {}", "rspamadm configtest failed: {}",
@@ -1170,7 +1246,8 @@ async fn reload_rspamd(cfg: &RspamdDkimConfig) -> AppResult<String> {
.output(), .output(),
) )
.await .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() { if !output.status.success() {
return Err(AppError::config(format!( return Err(AppError::config(format!(
"Rspamd reload failed: {}", "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", "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")); return Err(AppError::bad_request("Private key path is not a file"));
} }
Ok(key) 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 { let key_dir = match fs::canonicalize(&cfg.key_dir).await {
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), 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 { let key = match fs::canonicalize(path).await {
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), 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) { if !key.starts_with(&key_dir) {
return Err(AppError::forbidden( return Err(AppError::forbidden(
@@ -1215,11 +1296,13 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu
)); ));
} }
let sidecar = dns_sidecar(&key); 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 if let Err(error) = fs::remove_file(&sidecar).await
&& error.kind() != std::io::ErrorKind::NotFound && 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() { if let Some(parent) = key.parent() {
let _ = fs::remove_dir(parent).await; let _ = fs::remove_dir(parent).await;
+207 -12
View File
@@ -12,6 +12,7 @@ use axum::{
http::HeaderMap, http::HeaderMap,
}; };
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use chrono::NaiveDate;
use std::sync::Arc; use std::sync::Arc;
pub async fn get( pub async fn get(
@@ -29,13 +30,16 @@ pub async fn get(
let af = state.db.cast_text("activefrom"); let af = state.db.cast_text("activefrom");
let au = state.db.cast_text("activeuntil"); let au = state.db.cast_text("activeuntil");
let active = state.db.flag("active", "active"); 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) let row = sqlx::query(&q)
.bind(&u) .bind(&u)
.fetch_optional(state.db.pool()) .fetch_optional(state.db.pool())
.await?; .await?;
Ok(Json(row.as_ref().map(map))) Ok(Json(row.as_ref().map(map)))
} }
pub async fn set( pub async fn set(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(path): Path<String>, Path(path): Path<String>,
@@ -51,10 +55,38 @@ pub async fn set(
"Vacation email does not match mailbox", "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 { if input.subject.len() > 255 || input.body.len() > 65535 {
return Err(AppError::bad_request("Vacation message is too long")); 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 t = state.db.table(Table::Vacation);
let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?")); let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?"));
let exists = sqlx::query(&check) let exists = sqlx::query(&check)
@@ -62,10 +94,16 @@ pub async fn set(
.fetch_optional(state.db.pool()) .fetch_optional(state.db.pool())
.await? .await?
.is_some(); .is_some();
let mut tx = state.db.pool().begin().await?;
let q = if exists { 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 { } 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 { if exists {
sqlx::query(&q) sqlx::query(&q)
@@ -76,7 +114,7 @@ pub async fn set(
.bind(input.interval_time) .bind(input.interval_time)
.bind(input.active) .bind(input.active)
.bind(&u) .bind(&u)
.execute(state.db.pool()) .execute(&mut *tx)
.await?; .await?;
} else { } else {
sqlx::query(&q) sqlx::query(&q)
@@ -88,9 +126,22 @@ pub async fn set(
.bind(domain) .bind(domain)
.bind(input.interval_time) .bind(input.interval_time)
.bind(input.active) .bind(input.active)
.execute(state.db.pool()) .execute(&mut *tx)
.await?; .await?;
} }
sync_vacation_alias(
&state,
&mut tx,
&u,
domain,
&vacation_address,
input.active,
alias_has_description,
)
.await?;
tx.commit().await?;
state state
.db .db
.log( .log(
@@ -101,8 +152,13 @@ pub async fn set(
&u, &u,
) )
.await; .await;
Ok(Json(serde_json::json!({"ok":true}))) Ok(Json(serde_json::json!({
"ok": true,
"active": input.active,
"email": u
})))
} }
pub async fn remove( pub async fn remove(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(path): Path<String>, Path(path): Path<String>,
@@ -112,16 +168,135 @@ pub async fn remove(
let session = routes::mutation_session(&state, &jar, &headers)?; let session = routes::mutation_session(&state, &jar, &headers)?;
let u = validation::email(&path)?; let u = validation::email(&path)?;
authorize(&state, &session, &u, true)?; 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 t = state.db.table(Table::Vacation);
let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?")); let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?"));
sqlx::query(&q).bind(&u).execute(state.db.pool()).await?; sqlx::query(&q).bind(&u).execute(&mut *tx).await?;
if state.db.has_table(Table::VacationNotification).await { if has_notifications {
let n = state.db.table(Table::VacationNotification); let n = state.db.table(Table::VacationNotification);
let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?")); let q = state
let _ = sqlx::query(&q).bind(&u).execute(state.db.pool()).await; .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}))) 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<String> = 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<()> { fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -> AppResult<()> {
if !state.config.features.vacation { if !state.config.features.vacation {
return Err(AppError::forbidden("Vacation is disabled")); 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", "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) s.require_domain(d)
} }
} }
fn map(r: &sqlx::any::AnyRow) -> VacationSummary { fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
VacationSummary { VacationSummary {
email: row_string(r, "email"), email: row_string(r, "email"),
@@ -154,3 +330,22 @@ fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
active: row_bool(r, "active"), 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());
}
}
+5 -4
View File
@@ -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)} .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} .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)} .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} .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{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:hover{background:var(--hover);border-color:var(--accent)}
.theme-toggle .icon{width:18px;height:18px;display:block;flex:0 0 auto} .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: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}} @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%)}} .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)}} .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} .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))} .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} .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 .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-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} .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} .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: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}} @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}
+1 -1
View File
@@ -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}} @media(max-width:860px){.two-col{grid-template-columns:1fr}}
+19 -3
View File
@@ -297,8 +297,8 @@
"Brak celów aliasu.": "No alias targets.", "Brak celów aliasu.": "No alias targets.",
"Automatyczna odpowiedź dla Twojej skrzynki": "Automatic reply for your mailbox", "Automatyczna odpowiedź dla Twojej skrzynki": "Automatic reply for your mailbox",
"Od": "From", "Od": "From",
"Zapisz Vacation": "Save vacation", "Zapisz Vacation": "Save autoresponder",
"Vacation zapisany": "Vacation saved", "Vacation zapisany": "Autoresponder saved",
"ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules", "ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules",
"Nowy skrypt": "New script", "Nowy skrypt": "New script",
"Skrypty": "Scripts", "Skrypty": "Scripts",
@@ -341,7 +341,19 @@
"Rekord DNS": "DNS record", "Rekord DNS": "DNS record",
"Rekordy DNS DKIM dla Twojej domeny — tylko do odczytu": "DKIM DNS records for your domain — read-only", "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.", "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": [ "patterns": [
{ {
@@ -415,6 +427,10 @@
{ {
"match": "^Pominięto: (\\d+)$", "match": "^Pominięto: (\\d+)$",
"replace": "Skipped: $1" "replace": "Skipped: $1"
},
{
"match": "^Wpisz tylko nazwę przed @(.+)$",
"replace": "Enter only the name before @$1"
} }
] ]
} }
+2 -1
View File
@@ -173,7 +173,7 @@
</div> </div>
</header> </header>
<div class="body"> <div class="body">
<aside class="sidebar"><nav class="nav" id="nav"></nav><div class="sidebar-foot">Rust ${esc(window.PA_RS?.version || '')}</div></aside> <aside class="sidebar"><nav class="nav" id="nav"></nav></aside>
<main class="content" id="content"></main> <main class="content" id="content"></main>
</div> </div>
</div>`; </div>`;
@@ -319,6 +319,7 @@
} else { } else {
label.append(element); label.append(element);
} }
if (field.help) label.append(h('small', { class: 'field-help' }, field.help));
body.append(label); body.append(label);
} }
+192 -97
View File
@@ -307,9 +307,10 @@
function mailboxForm(row = {}, domainOverride = '') { function mailboxForm(row = {}, domainOverride = '') {
const domain = row.domain || domainOverride || S.cache.mailboxDomain || (S.me.domains || []).find(item => item !== 'ALL') || ''; 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 = [ const fields = [
{ name: 'username', label: 'Adres e-mail', 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: !!row.username }, { name: 'domain', label: 'Domena', required: true, readonly: true },
...(S.features.edit_mailbox_name ? [{ name: 'name', label: 'Nazwa wyświetlana' }] : []), ...(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: 'password', label: row.username ? 'Nowe hasło (puste = bez zmian)' : 'Hasło', type: 'password', autocomplete: 'new-password', required: !row.username, generate: !!S.features.generate_password },
{ name: 'quota', label: 'Quota [MB]', type: 'number', min: 0 }, { name: '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' }, { name: 'send_welcome', label: 'Wiadomość powitalna', type: 'checkbox', help: 'Wyślij po utworzeniu' },
]; ];
formModal(row.username ? 'Edytuj skrzynkę' : 'Nowa skrzynka', fields, { 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, quota: row.quota ? mbFromBytes(row.quota) : 10,
phone: row.phone || '', email_other: row.email_other || '', active: row.username ? row.active : true, phone: row.phone || '', email_other: row.email_other || '', active: row.username ? row.active : true,
smtp_active: row.smtp_active ?? true, send_welcome: false, smtp_active: row.smtp_active ?? true, send_welcome: false,
}, async data => { }, async data => {
const body = { 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, 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, 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), method: row.username ? 'PUT' : 'POST', body: JSON.stringify(body),
}); });
if (!row.username && S.features.show_password && data.password) { 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' }; return { message: row.username ? 'Skrzynka zapisana' : 'Skrzynka utworzona' };
}); });
@@ -367,14 +372,29 @@
const inactiveAction = S.me.kind === 'admin' && hasInactive ? `<button id="deleteInactive" class="btn danger">${icon('trash')}Usuń nieaktywne</button>` : ''; const inactiveAction = S.me.kind === 'admin' && hasInactive ? `<button id="deleteInactive" class="btn danger">${icon('trash')}Usuń nieaktywne</button>` : '';
const add = S.me.kind === 'admin' ? `<button id="addAlias" class="btn primary">${icon('plus')}Nowy alias</button>${S.features.alias_domain ? `<button id="addAliasDomain" class="btn">${icon('folder-plus')}Alias domeny</button>` : ''}${inactiveAction}` : ''; const add = S.me.kind === 'admin' ? `<button id="addAlias" class="btn primary">${icon('plus')}Nowy alias</button>${S.features.alias_domain ? `<button id="addAliasDomain" class="btn">${icon('folder-plus')}Alias domeny</button>` : ''}${inactiveAction}` : '';
const tools = S.me.kind === 'user' ? readonlyDomain(selected) : (domains.length > 1 ? domainPicker('aliasDomainSelect', domains, selected) : readonlyDomain(selected)); 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', ''], ['Adres', 'Cel', 'Opis', 'Status', 'Mailbox', 'Aktywny', ''],
filteredAliases.map((row, index) => `<tr data-row="${index}"><td class="mono">${esc(row.address)}</td><td>${row.goto.map(esc).join('<br>')}</td><td>${esc(row.description)}</td><td>${aliasStatus(row.status)}</td><td>${yes(row.is_mailbox)}</td><td>${yes(row.active)}</td><td class="actions-cell">${action('Edytuj')}${!row.is_mailbox && S.me.kind === 'admin' ? action('Usuń', true) : ''}</td></tr>`) 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 `<tr data-row="${index}"><td class="mono">${esc(row.address)}</td><td>${row.goto.map(esc).join('<br>')}</td><td>${esc(row.description)}</td><td>${aliasStatus(row.status)}</td><td>${yes(row.is_mailbox)}</td><td>${yes(row.active)}</td><td class="actions-cell">${editAction}${deleteAction}</td></tr>`;
})
); );
let aliasSections;
if (S.me.kind === 'admin') {
const technical = indexedAliases.filter(item => item.row.technical);
const regular = indexedAliases.filter(item => !item.row.technical);
aliasSections = `<div class="alias-section"><div class="section-title">Aliasy użytkowników</div>${aliasTableFor(regular)}</div>` +
(technical.length ? `<div class="alias-section"><div class="section-title">Aliasy techniczne</div>${aliasTableFor(technical)}</div>` : '');
} else {
aliasSections = `<div class="section-title">${esc(selected || t('Aliasy'))}</div>${aliasTableFor(indexedAliases)}`;
}
const domainTable = filteredAliasDomains.length || (S.me.kind === 'admin' && S.features.alias_domain) const domainTable = filteredAliasDomains.length || (S.me.kind === 'admin' && S.features.alias_domain)
? `<div class="section-title">Aliasy domen</div>${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny', ''], filteredAliasDomains.map((row, index) => `<tr data-ad-row="${index}"><td class="mono">${esc(row.alias_domain)}</td><td class="mono">${esc(row.target_domain)}</td><td>${esc(row.description)}</td><td>${yes(row.active)}</td><td class="actions-cell">${action('Edytuj AD')}${action('Usuń AD', true)}</td></tr>`))}` ? `<div class="section-title">Aliasy domen</div>${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny', ''], filteredAliasDomains.map((row, index) => `<tr data-ad-row="${index}"><td class="mono">${esc(row.alias_domain)}</td><td class="mono">${esc(row.target_domain)}</td><td>${esc(row.description)}</td><td>${yes(row.active)}</td><td class="actions-cell">${action('Edytuj AD')}${action('Usuń AD', true)}</td></tr>`))}`
: ''; : '';
content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `<div class="list-toolbar">${tools}<div class="list-summary"><span>${icon('forward')} ${filteredAliases.length}</span><span>${icon('folder-plus')} ${filteredAliasDomains.length}</span></div></div><div class="section-title">${esc(selected || t('Aliasy'))}</div>` + aliasTable + domainTable; content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `<div class="list-toolbar">${tools}<div class="list-summary"><span>${icon('forward')} ${filteredAliases.length}</span><span>${icon('folder-plus')} ${filteredAliasDomains.length}</span></div></div>` + aliasSections + domainTable;
document.querySelector('#aliasDomainSelect')?.addEventListener('change', async event => { document.querySelector('#aliasDomainSelect')?.addEventListener('change', async event => {
event.target.disabled = true; event.target.disabled = true;
S.cache.aliasDomain = event.target.value; S.cache.aliasDomain = event.target.value;
@@ -416,6 +436,11 @@
async function aliasForm(row = {}, domainOverride = '') { async function aliasForm(row = {}, domainOverride = '') {
const domain = row.domain || domainOverride || S.cache.aliasDomain || (S.me.domains || []).find(item => item !== 'ALL') || ''; 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 = ''; let defaultGoto = '';
if (!row.address && S.me.kind === 'admin') { if (!row.address && S.me.kind === 'admin') {
try { try {
@@ -423,22 +448,39 @@
defaultGoto = preferences.find(pref => pref.key === 'alias_goto_default')?.value || ''; defaultGoto = preferences.find(pref => pref.key === 'alias_goto_default')?.value || '';
} catch {} } catch {}
} }
const fields = [
{ name: 'address', label: 'Adres aliasu', required: true, readonly: S.me.kind === 'user' || !!row.address }, const fields = userEdit ? [
{ name: 'domain', label: 'Domena', required: true, readonly: S.me.kind === 'user' || !!row.address }, { name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, help: 'Podaj wyłącznie adresy docelowe przekierowania.' },
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, required: true }, ...(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: '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' }] : []), ...(!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' }, { name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' },
]; ];
formModal(row.address ? 'Edytuj alias' : 'Nowy alias', fields, { formModal(row.address ? 'Edytuj alias' : 'Nowy alias', fields, {
address: row.address || '', domain, goto: row.address ? (row.goto || []).join('\n') : defaultGoto, description: row.description || '', local_part: localPart,
store_and_forward: row.is_mailbox, save_goto_default: false, active: row.address ? row.active : true, 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 => { }, async data => {
const body = { const body = {
address: data.address, domain: data.domain, goto: splitList(data.goto), description: data.description, address: row.address || null,
store_and_forward: data.store_and_forward, save_goto_default: !!data.save_goto_default, active: data.active, 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', { await api(row.address ? `/aliases/${enc(row.address)}` : '/aliases', {
method: row.address ? 'PUT' : 'POST', body: JSON.stringify(body), method: row.address ? 'PUT' : 'POST', body: JSON.stringify(body),
@@ -561,92 +603,145 @@
}); });
} }
async function vacation() { function defaultVacation(mailbox) {
if (S.me.kind === 'user') { const localDate = date => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
const mailbox = S.me.username; const now = new Date();
const existing = await api(`/vacation/${enc(mailbox)}`); return {
const row = existing || { email: mailbox,
email: mailbox, subject: 'Out of office', body: '', interval_time: 0, subject: 'Out of office',
active_from: new Date().toISOString().slice(0, 10), body: '',
active_until: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), active: true, interval_time: 0,
}; active_from: localDate(now),
content().innerHTML = pageHead('Vacation', 'Automatyczna odpowiedź dla Twojej skrzynki') + active_until: localDate(new Date(now.getTime() + 7 * 86400000)),
`<div class="panel panel-pad vacation-user-panel"> active: true,
<form id="vacationUserForm" class="stack-form">
<div class="form-grid">
<label class="field wide"><span>Temat</span><input name="subject" required value="${esc(row.subject)}"></label>
<label class="field wide"><span>Treść</span><textarea name="body" required>${esc(row.body)}</textarea></label>
<label class="field"><span>Od</span><input name="active_from" type="date" required value="${esc(row.active_from)}"></label>
<label class="field"><span>Do</span><input name="active_until" type="date" required value="${esc(row.active_until)}"></label>
<label class="field"><span>Interwał odpowiedzi [s]</span><input name="interval_time" type="number" min="0" value="${number(row.interval_time)}"></label>
<label class="field checkbox-row"><input name="active" type="checkbox" ${row.active ? 'checked' : ''}><span>Autoresponder aktywny</span></label>
</div>
<div class="panel-actions"><button class="btn primary" type="submit">${icon('check-circle')}Zapisz Vacation</button>${existing ? `<button id="removeUserVacation" class="btn danger" type="button">${icon('trash')}Wyłącz/usuń</button>` : ''}</div>
</form>
</div>`;
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') + '<div class="empty">Wybierz skrzynkę ponownie z menu.</div>';
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, `<button id="editVac" class="btn primary">Edytuj</button>${row.email ? '<button id="delVac" class="btn danger">Wyłącz/usuń</button>' : ''}`) +
`<div class="panel panel-pad"><h3>${esc(row.subject)}</h3><div class="codebox">${esc(row.body)}</div><p class="muted">${esc(row.active_from)} → ${esc(row.active_until)} · interval ${row.interval_time}s · ${row.active ? 'aktywny' : 'wyłączony'}</p></div>`;
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 vacationForm(mailbox, row) { const vacationDate = value => String(value || '').slice(0, 10);
formModal('Vacation', [
{ name: 'subject', label: 'Temat', wide: true }, function vacationMailboxPicker(mailboxes) {
{ name: 'body', label: 'Treść', type: 'textarea', wide: true }, return new Promise(resolve => {
{ name: 'active_from', label: 'Od', type: 'date' }, const modal = document.querySelector('#modal');
{ name: 'active_until', label: 'Do', type: 'date' }, modal.innerHTML = `<form id="vacationMailboxPickerForm">
{ name: 'interval_time', label: 'Interwał odpowiedzi [s]', type: 'number' }, <div class="modal-head"><h2>${esc(t('Vacation'))}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Autoresponder aktywny' }, <div class="modal-body modal-body-single"><div class="vacation-mailbox-picker">
], row, async data => { <label class="field"><span>${esc(t('Skrzynka dla autorespondera'))}</span><input id="vacationMailboxSearch" name="mailbox" type="text" inputmode="email" autocomplete="off" placeholder="user@example.org" required></label>
await api(`/vacation/${enc(mailbox)}`, { <div class="muted">${esc(t('Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.'))}</div>
method: 'PUT', body: JSON.stringify({ ...data, email: mailbox, interval_time: number(data.interval_time) }), <div id="vacationMailboxResults" class="vacation-mailbox-results"></div>
}); <div id="vacationMailboxError" class="muted"></div>
</div></div>
<div class="modal-foot"><button class="btn" type="button" data-cancel>${esc(t('Anuluj'))}</button><button class="btn primary" type="submit">${esc(t('Edytuj autoresponder'))}</button></div>
</form>`;
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 => `<button type="button" class="vacation-mailbox-result" data-mailbox="${esc(row.username)}"><span class="mono">${esc(row.username)}</span><span class="muted">${esc(row.name || '')}</span></button>`).join('') : `<div class="empty">${esc(t('Brak pasujących skrzynek'))}</div>`;
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 ? `<button id="chooseVacationMailbox" class="btn">${icon('search')}Wybierz inną skrzynkę</button>` : '';
content().innerHTML = pageHead('Vacation', adminMode ? mailbox : 'Automatyczna odpowiedź dla Twojej skrzynki', switchMailbox) +
`<div class="panel panel-pad vacation-user-panel">
<form id="vacationUserForm" class="stack-form">
<div class="form-grid">
${adminMode ? `<label class="field wide"><span>Skrzynka</span><input value="${esc(mailbox)}" readonly></label>` : ''}
<label class="field wide"><span>Temat</span><input name="subject" required maxlength="255" value="${esc(row.subject)}"></label>
<label class="field wide"><span>Treść</span><textarea name="body" required>${esc(row.body)}</textarea></label>
<label class="field"><span>Od</span><input name="active_from" type="date" required value="${esc(row.active_from)}"></label>
<label class="field"><span>Do</span><input name="active_until" type="date" required value="${esc(row.active_until)}"></label>
<label class="field"><span>Interwał odpowiedzi [s]</span><input name="interval_time" type="number" min="0" value="${number(row.interval_time)}"></label>
<label class="field checkbox-row"><input name="active" type="checkbox" ${row.active ? 'checked' : ''}><span>Autoresponder aktywny</span></label>
</div>
<div class="panel-actions"><button class="btn primary" type="submit">${icon('check-circle')}Zapisz Vacation</button>${existing ? `<button id="removeUserVacation" class="btn danger" type="button">${icon('trash')}Wyłącz/usuń</button>` : ''}</div>
</form>
</div>`;
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', `<button id="chooseVacationMailbox" class="btn primary">${icon('search')}Wybierz skrzynkę</button>`) + '<div class="empty">Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.</div>';
document.querySelector('#chooseVacationMailbox').onclick = () => vacation();
return;
}
S.cache.vacationMailbox = mailbox;
await renderVacationEditor(mailbox, true);
}
function sieveQuote(value) { function sieveQuote(value) {