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
+781 -181

No files matched your search

+2
View File
@@ -4,3 +4,5 @@
*.log
.DS_Store
.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
Stan: **0.0.1-dev**. Celem jest replacement runtime PHP z zachowaniem istniejacej bazy PostfixAdmin i SQL map uzywanych przez Postfix/Dovecot.
Stan: **0.0.2**. Celem jest replacement runtime PHP z zachowaniem istniejacej bazy PostfixAdmin i SQL map uzywanych przez Postfix/Dovecot.
| Obszar | Stan | Uwagi |
|---|---|---|
@@ -66,7 +66,7 @@ Aktywne elementy istotne dla tego wdrozenia (MySQL/MariaDB, `php_crypt:MD5`, ali
## Audit PostfixAdmin 4.0.1
Pelniejszy audyt funkcji z dostarczonego PHP 4.0.1 jest w [PORT-PARITY.md](PORT-PARITY.md). 0.0.1-dev nadal **nie jest 100% parity**; dokument rozdziela funkcje runtime juz przeportowane od pozostalych narzedzi instalacyjnych, API, CLI i legacy hookow/UI.
Pelniejszy audyt funkcji z dostarczonego PHP 4.0.1 jest w [PORT-PARITY.md](PORT-PARITY.md). 0.0.2 nadal **nie jest 100% parity**; dokument rozdziela funkcje runtime juz przeportowane od pozostalych narzedzi instalacyjnych, API, CLI i legacy hookow/UI.
## Rspamd DKIM
Generated
+1 -1
View File
@@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postfixadmin-rs"
version = "0.0.1-dev"
version = "0.0.2"
dependencies = [
"argon2",
"async-native-tls",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "postfixadmin-rs"
version = "0.0.1-dev"
version = "0.0.2"
edition = "2024"
rust-version = "1.99.1"
default-run = "postfixadmin-rs"
+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`.
The goal of 0.0.1-dev is runtime replacement for normal mail administration, not binary/API compatibility with PHP.
The goal of 0.0.2 is runtime replacement for normal mail administration, not binary/API compatibility with PHP.
## Runtime features already ported
@@ -35,7 +35,7 @@ The goal of 0.0.1-dev is runtime replacement for normal mail administration, not
## Not yet 1:1 with PHP PostfixAdmin
These are real parity gaps; 0.0.1-dev must not be described as 100% complete while they remain.
These are real parity gaps; 0.0.2 must not be described as 100% complete while they remain.
| Area | Gap / intended Rust replacement |
|---|---|
+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.
Aktualna linia developerska: **0.0.1-dev**. Projekt jest przypiety do **Rust 1.99.1**, edition 2024. Frontend jest osadzony w pojedynczym binarnym pliku; bundlowanie assetow, sprite SVG i workflow developerski sa wzorowane na przekazanym RustBox. Stary wyglad PostfixAdmin nie jest kopiowany.
Aktualna linia developerska: **0.0.2**. Projekt jest przypiety do **Rust 1.99.1**, edition 2024. Frontend jest osadzony w pojedynczym binarnym pliku; bundlowanie assetow, sprite SVG i workflow developerski sa wzorowane na przekazanym RustBox. Stary wyglad PostfixAdmin nie jest kopiowany.
## Development bez produkcyjnej bazy
@@ -147,7 +147,7 @@ Gate wykonuje walidacje statyczna, `cargo fmt --check`, testy, Clippy z `-D warn
4. Nie zmieniaj `password.scheme`, dopoki Dovecot/Postfix SASL nie obsluguje tego samego formatu.
5. Reverse proxy przelacz dopiero po testach regresyjnych; PHP/FPM usun na koncu.
## Status 0.0.1-dev
## Status 0.0.2
To nadal wersja developerska, nie deklaruje jeszcze 100% parity PostfixAdmin 4.x. Pozostale luki to przede wszystkim webowy setup/upgrade schematu, XML-RPC, SMS provider, update checker oraz generic IMAP/Courier do tworzenia folderow. Dynamiczne hooki PHP (`*_struct_hook`, `language_hook`) nie beda uruchamiane jako PHP — wymagaja natywnego mechanizmu rozszerzen.
+9 -1
View File
@@ -1,4 +1,4 @@
PostfixAdmin-RS 0.0.1-dev
PostfixAdmin-RS 0.0.2
Validated in the current workspace:
- TOML parsing: PASS
@@ -67,3 +67,11 @@ Current mailbox-user iteration:
- dev ManageSieve SSH tunnel defaults to READ-ONLY and has separate write opt-in: PASS (shell/static)
- quota progress uses CSP-safe native <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.
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
@@ -124,7 +124,7 @@ flag columns must not be selected without the normalization helper.
## Debian package
Run `./scripts/build-deb.sh`. Expected output is `packages/postfixadmin-rs_0.0.1~dev_<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
+1 -1
View File
@@ -208,7 +208,7 @@ esac
exit 1
}
echo "PostfixAdmin RS 0.0.1-dev: http://127.0.0.1:8080"
echo "PostfixAdmin RS 0.0.2: http://127.0.0.1:8080"
echo "Config: $POSTFIXADMIN_RS_CONFIG"
if [ "$DEV_DB_MODE" = "mock" ]; then
echo "DB: SQLite mock ($DEV_DB)"
+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.
It uses Rspamd's supported `selector_map` and `path_map` mechanism. DKIM is always per-domain: every domain maps to its own selector and private-key path. `default_selector` is only the default selector name used for new mappings; it is not a shared key.
@@ -37,7 +37,7 @@ If `selector_map` or `path_map` already exists outside the managed block, Postfi
Do not run the web service as root. The service account needs write access only to the DKIM key directory, the two map files, and (if automatic bootstrap is desired) `dkim_signing.conf`.
Rspamd needs read access to private keys.
A typical layout is the dedicated `postfixadmin` service user whose files inherit the `_rspamd` group from a setgid DKIM directory. Keep private keys at mode `0640`.
In 0.0.2 the packaged permission helper creates a dedicated `postfixadmin-rspamd` group shared only by the `postfixadmin` service account and the detected Rspamd runtime user. The DKIM directory is setgid so generated keys inherit that group. Keep private keys at mode `0640`.
Reload is deliberately an argv-only configured command. If automatic reload is wanted, use a tightly scoped sudoers rule that permits only `systemctl reload rspamd`, then configure:
@@ -83,3 +83,18 @@ Rspamd obserwuje lokalne pliki map i odswieza je po zmianach, wiec po jednorazow
When `[dkim].user_read_only = true`, a mailbox user can open DKIM and see the complete public DNS record for their own domain. The API may return records from both the PostfixAdmin DKIM tables and Rspamd, but it never exposes private-key bytes or private-key paths to mailbox users.
In SSH development mode the application cannot directly read `/var/lib/rspamd/dkim` on the remote mail server. `dev.sh` therefore creates a temporary public-only cache under `.dev/rspamd-remote-public`: it reads the remote maps/config and derives the public key with OpenSSL on the remote host. Only the final public TXT record is copied back; private key bytes never leave the mail server. Disable this development helper with `POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC=false`.
## Host permissions for 0.0.2
`postfixadmin-rs` never needs to run as root. For the standard paths, install the package and run once:
```bash
sudo /usr/lib/postfixadmin-rs/setup-rspamd-dkim-permissions
sudo systemctl restart postfixadmin-rs rspamd
```
The helper creates a dedicated `postfixadmin-rspamd` group, adds the PostfixAdmin-RS and Rspamd runtime users to it, prepares `/var/lib/rspamd/dkim` as a setgid shared directory, keeps private keys at `0640`, and grants the service write access only to `dkim_signing.conf` rather than all of `/etc/rspamd/local.d`.
You can override detection with `POSTFIXADMIN_RS_RSPAMD_USER`, `POSTFIXADMIN_RS_RSPAMD_KEY_DIR`, and `POSTFIXADMIN_RS_RSPAMD_SIGNING_CONFIG` when the host uses non-standard names or paths.
Filesystem failures now include the operation and exact path in the API error instead of the generic `I/O operation failed` message.
+57 -25
View File
@@ -1,38 +1,70 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
import zipfile
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent
EXCLUDED_DIRS = {'.git', '.dev', 'target', 'dist', 'packages', '__pycache__'}
EXCLUDED_NAMES = {'.env'}
def run_git_command(args, repo_path: Path) -> bytes:
result = subprocess.run(
["git", *args],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout
def main() -> int:
if len(sys.argv) != 2:
print(f'usage: {Path(sys.argv[0]).name} OUTPUT.zip', file=sys.stderr)
return 2
output = Path(sys.argv[1]).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
prefix = f'PostfixAdmin-RS-{project_version()}'
with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for path in sorted(ROOT.rglob('*')):
if not path.is_file() or path.resolve() == output:
def get_files_to_archive(repo_path: Path) -> list[str]:
output = run_git_command(
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
repo_path,
)
files = output.decode("utf-8", errors="surrogateescape").split("\0")
return [f for f in files if f]
def make_zip(repo_path: Path, output_zip: Path) -> None:
files = get_files_to_archive(repo_path)
output_zip = output_zip.resolve()
if output_zip.exists():
output_zip.unlink()
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for rel_path in files:
abs_path = repo_path / rel_path
if not abs_path.exists():
continue
rel = path.relative_to(ROOT)
if any(part in EXCLUDED_DIRS for part in rel.parts) or rel.name in EXCLUDED_NAMES:
if abs_path.resolve() == output_zip:
continue
archive.write(path, f'{prefix}/{rel.as_posix()}')
return 0
zf.write(abs_path, arcname=rel_path)
print(f"Created: {output_zip}")
print(f"Added files: {len(files)}")
def project_version() -> str:
import tomllib
with (ROOT / 'Cargo.toml').open('rb') as handle:
return tomllib.load(handle)['package']['version']
def main():
repo_path = Path.cwd()
if len(sys.argv) > 1:
output_zip = Path(sys.argv[1])
else:
output_zip = repo_path / f"{repo_path.name}.zip"
try:
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
except subprocess.CalledProcessError:
print("Error: this directory is not a Git repository.", file=sys.stderr)
sys.exit(1)
make_zip(repo_path, output_zip)
if __name__ == '__main__':
raise SystemExit(main())
if __name__ == "__main__":
main()
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
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)"
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 0640 config.example.toml "$PKG/etc/postfixadmin-rs/config.toml"
install -m 0644 packaging/postfixadmin-rs.default "$PKG/etc/default/postfixadmin-rs"
install -m 0644 packaging/postfixadmin-rs.service "$PKG/lib/systemd/system/postfixadmin-rs.service"
install -m 0644 README.md SECURITY.md COMPATIBILITY.md PORT-PARITY.md "$PKG/usr/share/doc/postfixadmin-rs/"
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
Package: postfixadmin-rs
@@ -68,7 +70,7 @@ Section: admin
Priority: optional
Architecture: $ARCH
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
PostfixAdmin-RS is a Rust administration panel compatible with PostfixAdmin
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')
if package.get('rust-version') != '1.99.1':
fail('Cargo.toml rust-version must be 1.99.1')
if package.get('version') != '0.0.1-dev':
fail('Cargo.toml version must be 0.0.1-dev during the development phase')
if package.get('version') != '0.0.2':
fail('Cargo.toml version must be 0.0.2 for this release')
if toolchain.get('toolchain', {}).get('channel') != '1.99.1':
fail('rust-toolchain.toml must pin 1.99.1')
@@ -132,6 +132,18 @@ for marker in ['/rspamd-dkim/generate', 'selector_map', 'path_map', 'RspamdDkimC
if marker not in project_text + '\n' + (ROOT / 'src/config.rs').read_text(encoding='utf-8'):
fail(f'missing Rspamd DKIM integration marker: {marker}')
release_002_markers = {
'src/routes/vacation.rs': ['sync_vacation_alias', 'vacation_address', 'validate_dates'],
'src/routes/aliases.rs': ['technical', 'internal_vacation', 'can_edit_description'],
'src/web/js/10-pages.js': ['vacationMailboxPicker', 'Aliasy techniczne', 'local_part'],
'packaging/postfixadmin-rs-rspamd-setup': ['postfixadmin-rspamd', 'systemctl restart postfixadmin-rs rspamd'],
}
for relative, markers in release_002_markers.items():
text_value = (ROOT / relative).read_text(encoding='utf-8')
for marker in markers:
if marker not in text_value:
fail(f'{relative}: missing 0.0.2 regression marker: {marker}')
node = shutil.which('node')
if node:
for path in sorted((ROOT / 'src/web/js').glob('*.js')):
@@ -139,7 +151,7 @@ if node:
if result.returncode:
fail(f'{path.relative_to(ROOT)}: node --check failed: {result.stderr.strip()}')
for script in [ROOT / 'dev.sh', ROOT / 'scripts/build-release.sh', ROOT / 'scripts/package-release.sh', ROOT / 'scripts/build-deb.sh']:
for script in [ROOT / 'dev.sh', ROOT / 'scripts/build-release.sh', ROOT / 'scripts/package-release.sh', ROOT / 'scripts/build-deb.sh', ROOT / 'packaging/postfixadmin-rs-rspamd-setup']:
result = subprocess.run(['bash', '-n', str(script)], capture_output=True, text=True)
if result.returncode:
fail(f'{script.relative_to(ROOT)}: bash -n failed: {result.stderr.strip()}')
+1
View File
@@ -116,6 +116,7 @@ pub struct AliasSummary {
pub description: String,
pub active: bool,
pub is_mailbox: bool,
pub technical: bool,
pub status: Vec<String>,
}
+54 -3
View File
@@ -85,8 +85,22 @@ pub async fn list(
let mut result = Vec::with_capacity(rows.len());
for row in &rows {
let address = row_string(row, "address");
let gotos = split_goto(&row_string(row, "goto"));
let raw_gotos = split_goto(&row_string(row, "goto"));
let internal_vacation = vacation_destination(&state, &address);
let gotos: Vec<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 local = address.split('@').next().unwrap_or("");
let technical = state
.config
.defaults
.default_aliases
.keys()
.any(|name| name.eq_ignore_ascii_case(local));
result.push(AliasSummary {
address,
goto: gotos,
@@ -94,6 +108,7 @@ pub async fn list(
description: row_string(row, "description"),
active: row_bool(row, "active"),
is_mailbox: row_bool(row, "is_mailbox"),
technical,
status,
});
}
@@ -229,7 +244,10 @@ pub async fn update(
let goto = build_goto(&state, &address, &input).await?;
let has_description = state.db.has_column(Table::Alias, "description").await?;
let sql = if has_description {
// Mailbox users may only change forwarding, store-and-forward and active state.
// Preserve description even if a crafted request tries to modify it.
let can_edit_description = has_description && session.kind != AccountKind::User;
let sql = if can_edit_description {
state.db.q(&format!(
"UPDATE {table} SET goto=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?"
))
@@ -239,7 +257,7 @@ pub async fn update(
))
};
let mut query = sqlx::query(&sql).bind(goto.join(","));
if has_description {
if can_edit_description {
query = query.bind(&input.description);
}
query = query.bind(input.active).bind(&address);
@@ -727,8 +745,17 @@ fn split_goto(value: &str) -> Vec<String> {
.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>> {
let mut output = Vec::new();
let internal_vacation = vacation_destination(state, address);
for raw in &input.goto {
let value = validation::alias_destination_checked(
&state.config,
@@ -736,6 +763,11 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR
state.config.security.allow_unsafe_alias_destinations,
)
.await?;
// The vacation transport is an internal PostfixAdmin detail. Never let
// alias editing expose, duplicate or remove it directly.
if value.eq_ignore_ascii_case(&internal_vacation) {
continue;
}
if state.config.features.emailcheck_localaliasonly
&& let Some((_, domain)) = value.rsplit_once('@')
{
@@ -780,6 +812,25 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR
"At least one destination is required",
));
}
if state.config.features.vacation && state.db.has_table(Table::Vacation).await {
let vacation = state.db.table(Table::Vacation);
let sql = state.db.q(&format!(
"SELECT 1 FROM {vacation} WHERE email=? AND active=? LIMIT 1"
));
if sqlx::query(&sql)
.bind(address)
.bind(true)
.fetch_optional(state.db.pool())
.await?
.is_some()
&& !output
.iter()
.any(|value| value.eq_ignore_ascii_case(&internal_vacation))
{
output.push(internal_vacation);
}
}
Ok(output)
}
+128 -45
View File
@@ -28,6 +28,13 @@ use crate::{
const BEGIN_MARKER: &str = "# BEGIN PostfixAdmin-RS managed DKIM maps";
const END_MARKER: &str = "# END PostfixAdmin-RS managed DKIM maps";
fn rspamd_io(action: &str, path: &Path, error: std::io::Error) -> AppError {
AppError::config(format!(
"Rspamd DKIM {action} {} failed: {error}",
path.display()
))
}
fn default_true() -> bool {
true
}
@@ -97,7 +104,13 @@ pub(crate) async fn read_only_dns_records(
let legacy = match fs::read_to_string(&cfg.signing_config).await {
Ok(text) => parse_legacy_domain_blocks(&text, &cfg.default_selector),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
Err(error) => {
return Err(rspamd_io(
"read signing config",
Path::new(&cfg.signing_config),
error,
));
}
};
let mut result = Vec::new();
for requested in domains {
@@ -142,7 +155,7 @@ async fn dns_record_from_private_key(
Ok(metadata) if metadata.is_file() => {}
Ok(_) => return Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("stat private key", private_path, error)),
}
let path = private_path.to_string_lossy().into_owned();
let output = match timeout(
@@ -186,7 +199,13 @@ pub async fn list(
.filter(|domain| session.can_manage_domain(domain))
.collect(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
Err(error) => {
return Err(rspamd_io(
"read signing config",
Path::new(&cfg.signing_config),
error,
));
}
};
let mut entries = Vec::new();
for domain in domains {
@@ -205,13 +224,18 @@ pub async fn list(
let private_path = raw_path
.replace("$domain", &domain)
.replace("$selector", &selector);
let private_exists = fs::metadata(&private_path)
.await
.map(|m| m.is_file())
.unwrap_or(false);
let dns_record = read_dns_sidecar(Path::new(&private_path))
.await
.unwrap_or_default();
let private_exists = match fs::metadata(&private_path).await {
Ok(metadata) => metadata.is_file(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => {
return Err(rspamd_io(
"stat private key",
Path::new(&private_path),
error,
));
}
};
let dns_record = read_dns_sidecar(Path::new(&private_path)).await?;
entries.push(RspamdDkimEntry {
domain,
selector,
@@ -298,7 +322,10 @@ pub async fn generate(
let parent = final_path
.parent()
.ok_or_else(|| AppError::config("Invalid Rspamd DKIM key path"))?;
fs::create_dir_all(parent).await?;
fs::create_dir_all(parent)
.await
.map_err(|error| rspamd_io("create key directory", parent, error))?;
set_mode(parent, 0o2750).await?;
let tmp_path = parent.join(format!(
".{}.private.tmp-{}",
input.selector,
@@ -324,10 +351,14 @@ pub async fn generate(
return Err(AppError::conflict("Private key file already exists"));
}
if had_private {
fs::rename(&final_path, &backup_private).await?;
fs::rename(&final_path, &backup_private)
.await
.map_err(|error| rspamd_io("backup private key", &final_path, error))?;
}
if had_dns {
fs::rename(&dns_path, &backup_dns).await?;
fs::rename(&dns_path, &backup_dns)
.await
.map_err(|error| rspamd_io("backup DNS sidecar", &dns_path, error))?;
}
if let Err(error) = fs::rename(&tmp_path, &final_path).await {
if had_private {
@@ -336,7 +367,7 @@ pub async fn generate(
if had_dns {
let _ = fs::rename(&backup_dns, &dns_path).await;
}
return Err(error.into());
return Err(rspamd_io("activate generated private key", &final_path, error));
}
if let Err(error) = atomic_write(
&dns_path,
@@ -547,7 +578,7 @@ pub async fn import_legacy_domain_blocks(
if error.kind() == std::io::ErrorKind::NotFound {
AppError::not_found("Rspamd dkim_signing.conf does not exist")
} else {
error.into()
rspamd_io("read signing config for legacy import", config_path, error)
}
})?;
let candidates = parse_legacy_domain_blocks(&text, &cfg.default_selector);
@@ -622,7 +653,9 @@ pub async fn import_legacy_domain_blocks(
next.push_str(&managed_block(cfg));
next.push('\n');
let metadata = fs::metadata(config_path).await?;
let metadata = fs::metadata(config_path)
.await
.map_err(|error| rspamd_io("stat signing config", config_path, error))?;
let config_mode = metadata.permissions().mode() & 0o777;
let backup_path = Path::new(&cfg.key_dir).join("dkim_signing.conf.pre-postfixadmin-rs");
if fs::metadata(&backup_path).await.is_err() {
@@ -747,10 +780,10 @@ fn dns_sidecar(private_path: &Path) -> PathBuf {
async fn read_dns_sidecar(private_path: &Path) -> AppResult<String> {
let path = dns_sidecar(private_path);
match fs::read_to_string(path).await {
match fs::read_to_string(&path).await {
Ok(value) => Ok(value.trim().to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error.into()),
Err(error) => Err(rspamd_io("read DNS sidecar", &path, error)),
}
}
@@ -875,7 +908,13 @@ async fn reject_legacy_domain(cfg: &RspamdDkimConfig, domain: &str) -> AppResult
let text = match fs::read_to_string(&cfg.signing_config).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
Err(error) => {
return Err(rspamd_io(
"read signing config",
Path::new(&cfg.signing_config),
error,
));
}
};
if parse_legacy_domain_blocks(&text, &cfg.default_selector)
.iter()
@@ -892,7 +931,7 @@ async fn read_map(path: &Path) -> AppResult<BTreeMap<String, String>> {
let text = match fs::read_to_string(path).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("read map", path, error)),
};
let mut map = BTreeMap::new();
for line in text.lines() {
@@ -933,22 +972,33 @@ async fn atomic_write(path: &Path, bytes: &[u8], mode: u32) -> AppResult<()> {
let parent = path
.parent()
.ok_or_else(|| AppError::config(format!("Invalid path: {}", path.display())))?;
fs::create_dir_all(parent).await?;
fs::create_dir_all(parent)
.await
.map_err(|error| rspamd_io("create parent directory", parent, error))?;
let name = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("managed");
let tmp = parent.join(format!(".{name}.tmp-{}", std::process::id()));
fs::write(&tmp, bytes).await?;
fs::write(&tmp, bytes)
.await
.map_err(|error| rspamd_io("write temporary file", &tmp, error))?;
set_mode(&tmp, mode).await?;
fs::rename(&tmp, path).await?;
fs::rename(&tmp, path)
.await
.map_err(|error| rspamd_io("replace managed file", path, error))?;
Ok(())
}
async fn set_mode(path: &Path, mode: u32) -> AppResult<()> {
let mut permissions = fs::metadata(path).await?.permissions();
let mut permissions = fs::metadata(path)
.await
.map_err(|error| rspamd_io("stat path for chmod", path, error))?
.permissions();
permissions.set_mode(mode);
fs::set_permissions(path, permissions).await?;
fs::set_permissions(path, permissions)
.await
.map_err(|error| rspamd_io("chmod", path, error))?;
Ok(())
}
@@ -981,10 +1031,11 @@ fn strip_managed_block(text: &str) -> String {
}
async fn managed_config_present(cfg: &RspamdDkimConfig) -> AppResult<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)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
Err(error) => Err(rspamd_io("read signing config", path, error)),
}
}
@@ -1001,10 +1052,17 @@ async fn write_signing_config(
.write(true)
.truncate(true)
.open(path)
.await?;
file.write_all(bytes).await?;
file.flush().await?;
file.sync_all().await?;
.await
.map_err(|error| rspamd_io("open signing config for writing", path, error))?;
file.write_all(bytes)
.await
.map_err(|error| rspamd_io("write signing config", path, error))?;
file.flush()
.await
.map_err(|error| rspamd_io("flush signing config", path, error))?;
file.sync_all()
.await
.map_err(|error| rspamd_io("sync signing config", path, error))?;
Ok(())
}
@@ -1013,12 +1071,12 @@ async fn ensure_managed_config(cfg: &RspamdDkimConfig) -> AppResult<()> {
let old = match fs::read_to_string(path).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("read signing config", path, error)),
};
let (config_mode, existed) = match fs::metadata(path).await {
Ok(metadata) => (metadata.permissions().mode() & 0o777, true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (0o644, false),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("stat signing config", path, error)),
};
let clean = strip_managed_block(&old);
if clean.lines().any(|line| {
@@ -1080,7 +1138,9 @@ async fn generate_key(
let fake = format!(
"-----BEGIN PRIVATE KEY-----\nPOSTFIXADMIN-RS-DEV-MOCK-{domain}-{selector}\n-----END PRIVATE KEY-----\n"
);
fs::write(private_path, fake).await?;
fs::write(private_path, fake)
.await
.map_err(|error| rspamd_io("write development mock private key", private_path, error))?;
return Ok(format!(
"{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p=DEV-MOCK-NOT-FOR-PRODUCTION\""
));
@@ -1108,7 +1168,13 @@ async fn generate_key(
.output(),
)
.await
.map_err(|_| AppError::config("rspamadm dkim_keygen timed out"))??;
.map_err(|_| AppError::config("rspamadm dkim_keygen timed out"))?
.map_err(|error| {
AppError::config(format!(
"Failed to execute {} dkim_keygen: {error}",
cfg.rspamadm
))
})?;
if !output.status.success() {
return Err(AppError::config(format!(
"rspamadm dkim_keygen failed: {}",
@@ -1117,9 +1183,13 @@ async fn generate_key(
}
let dns = String::from_utf8(output.stdout)
.map_err(|_| AppError::config("rspamadm returned non-UTF8 DKIM record"))?;
let metadata = fs::metadata(private_path)
.await
.map_err(|_| AppError::config("rspamadm did not create the private key file"))?;
let metadata = match fs::metadata(private_path).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(AppError::config("rspamadm did not create the private key file"));
}
Err(error) => return Err(rspamd_io("stat generated private key", private_path, error)),
};
if metadata.len() == 0 {
return Err(AppError::config("rspamadm created an empty private key"));
}
@@ -1140,7 +1210,13 @@ async fn config_test(cfg: &RspamdDkimConfig) -> AppResult<String> {
.output(),
)
.await
.map_err(|_| AppError::config("rspamadm configtest timed out"))??;
.map_err(|_| AppError::config("rspamadm configtest timed out"))?
.map_err(|error| {
AppError::config(format!(
"Failed to execute {} configtest: {error}",
cfg.rspamadm
))
})?;
if !output.status.success() {
return Err(AppError::config(format!(
"rspamadm configtest failed: {}",
@@ -1170,7 +1246,8 @@ async fn reload_rspamd(cfg: &RspamdDkimConfig) -> AppResult<String> {
.output(),
)
.await
.map_err(|_| AppError::config("Rspamd reload command timed out"))??;
.map_err(|_| AppError::config("Rspamd reload command timed out"))?
.map_err(|error| AppError::config(format!("Failed to execute Rspamd reload command: {error}")))?;
if !output.status.success() {
return Err(AppError::config(format!(
"Rspamd reload failed: {}",
@@ -1192,7 +1269,11 @@ async fn require_existing_key_under_dir(cfg: &RspamdDkimConfig, input: &str) ->
"Private key must be inside rspamd_dkim.key_dir",
));
}
if !fs::metadata(&key).await?.is_file() {
if !fs::metadata(&key)
.await
.map_err(|error| rspamd_io("stat private key", &key, error))?
.is_file()
{
return Err(AppError::bad_request("Private key path is not a file"));
}
Ok(key)
@@ -1202,12 +1283,12 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu
let key_dir = match fs::canonicalize(&cfg.key_dir).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("canonicalize key directory", Path::new(&cfg.key_dir), error)),
};
let key = match fs::canonicalize(path).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("canonicalize private key", path, error)),
};
if !key.starts_with(&key_dir) {
return Err(AppError::forbidden(
@@ -1215,11 +1296,13 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu
));
}
let sidecar = dns_sidecar(&key);
fs::remove_file(&key).await?;
fs::remove_file(&key)
.await
.map_err(|error| rspamd_io("delete private key", &key, error))?;
if let Err(error) = fs::remove_file(&sidecar).await
&& error.kind() != std::io::ErrorKind::NotFound
{
return Err(error.into());
return Err(rspamd_io("delete DNS sidecar", &sidecar, error));
}
if let Some(parent) = key.parent() {
let _ = fs::remove_dir(parent).await;
+207 -12
View File
@@ -12,6 +12,7 @@ use axum::{
http::HeaderMap,
};
use axum_extra::extract::cookie::CookieJar;
use chrono::NaiveDate;
use std::sync::Arc;
pub async fn get(
@@ -29,13 +30,16 @@ pub async fn get(
let af = state.db.cast_text("activefrom");
let au = state.db.cast_text("activeuntil");
let active = state.db.flag("active", "active");
let q=state.db.q(&format!("SELECT email,subject,body,interval_time,{af} AS activefrom,{au} AS activeuntil,{active} FROM {t} WHERE email=?"));
let q = state.db.q(&format!(
"SELECT email,subject,body,interval_time,{af} AS activefrom,{au} AS activeuntil,{active} FROM {t} WHERE email=?"
));
let row = sqlx::query(&q)
.bind(&u)
.fetch_optional(state.db.pool())
.await?;
Ok(Json(row.as_ref().map(map)))
}
pub async fn set(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
@@ -51,10 +55,38 @@ pub async fn set(
"Vacation email does not match mailbox",
));
}
if !state.db.has_table(Table::Vacation).await {
return Err(AppError::config(
"Vacation is enabled but the vacation table does not exist",
));
}
if input.subject.len() > 255 || input.body.len() > 65535 {
return Err(AppError::bad_request("Vacation message is too long"));
}
let (_, domain) = u.rsplit_once('@').unwrap();
if input.interval_time < 0 {
return Err(AppError::bad_request(
"Vacation reply interval cannot be negative",
));
}
validate_dates(&input.active_from, &input.active_until)?;
let (_, domain) = u.rsplit_once('@').expect("validated email");
let mailbox = state.db.table(Table::Mailbox);
let mailbox_check = state
.db
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
if sqlx::query(&mailbox_check)
.bind(&u)
.fetch_optional(state.db.pool())
.await?
.is_none()
{
return Err(AppError::not_found("Mailbox not found"));
}
let alias_has_description = state.db.has_column(Table::Alias, "description").await?;
let vacation_domain = validation::domain(&state.config.defaults.vacation_domain)?;
let vacation_address = vacation_address(&u, &vacation_domain);
let t = state.db.table(Table::Vacation);
let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?"));
let exists = sqlx::query(&check)
@@ -62,10 +94,16 @@ pub async fn set(
.fetch_optional(state.db.pool())
.await?
.is_some();
let mut tx = state.db.pool().begin().await?;
let q = if exists {
state.db.q(&format!("UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?"))
state.db.q(&format!(
"UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?"
))
} else {
state.db.q(&format!("INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,?,?,'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
state.db.q(&format!(
"INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,?,?,'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"
))
};
if exists {
sqlx::query(&q)
@@ -76,7 +114,7 @@ pub async fn set(
.bind(input.interval_time)
.bind(input.active)
.bind(&u)
.execute(state.db.pool())
.execute(&mut *tx)
.await?;
} else {
sqlx::query(&q)
@@ -88,9 +126,22 @@ pub async fn set(
.bind(domain)
.bind(input.interval_time)
.bind(input.active)
.execute(state.db.pool())
.execute(&mut *tx)
.await?;
}
sync_vacation_alias(
&state,
&mut tx,
&u,
domain,
&vacation_address,
input.active,
alias_has_description,
)
.await?;
tx.commit().await?;
state
.db
.log(
@@ -101,8 +152,13 @@ pub async fn set(
&u,
)
.await;
Ok(Json(serde_json::json!({"ok":true})))
Ok(Json(serde_json::json!({
"ok": true,
"active": input.active,
"email": u
})))
}
pub async fn remove(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
@@ -112,16 +168,135 @@ pub async fn remove(
let session = routes::mutation_session(&state, &jar, &headers)?;
let u = validation::email(&path)?;
authorize(&state, &session, &u, true)?;
let (_, domain) = u.rsplit_once('@').expect("validated email");
let vacation_domain = validation::domain(&state.config.defaults.vacation_domain)?;
let vacation_address = vacation_address(&u, &vacation_domain);
let alias_has_description = state.db.has_column(Table::Alias, "description").await?;
let has_notifications = state.db.has_table(Table::VacationNotification).await;
let mut tx = state.db.pool().begin().await?;
let t = state.db.table(Table::Vacation);
let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?"));
sqlx::query(&q).bind(&u).execute(state.db.pool()).await?;
if state.db.has_table(Table::VacationNotification).await {
sqlx::query(&q).bind(&u).execute(&mut *tx).await?;
if has_notifications {
let n = state.db.table(Table::VacationNotification);
let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?"));
let _ = sqlx::query(&q).bind(&u).execute(state.db.pool()).await;
let q = state
.db
.q(&format!("DELETE FROM {n} WHERE on_vacation=?"));
sqlx::query(&q).bind(&u).execute(&mut *tx).await?;
}
sync_vacation_alias(
&state,
&mut tx,
&u,
domain,
&vacation_address,
false,
alias_has_description,
)
.await?;
tx.commit().await?;
state
.db
.log(
&state.config,
&session.username,
domain,
"delete_vacation",
&u,
)
.await;
Ok(Json(serde_json::json!({"ok":true})))
}
async fn sync_vacation_alias(
state: &AppState,
tx: &mut sqlx::Transaction<'_, sqlx::Any>,
email: &str,
domain: &str,
vacation_address: &str,
enabled: bool,
has_description: bool,
) -> AppResult<()> {
let alias = state.db.table(Table::Alias);
let select = state
.db
.q(&format!("SELECT goto FROM {alias} WHERE address=? LIMIT 1"));
let row = sqlx::query(&select)
.bind(email)
.fetch_optional(&mut **tx)
.await?;
if let Some(row) = row {
let current = row_string(&row, "goto");
let mut destinations: Vec<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<()> {
if !state.config.features.vacation {
return Err(AppError::forbidden("Vacation is disabled"));
@@ -139,10 +314,11 @@ fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -
"Vacation control is disabled for domain administrators",
));
}
let (_, d) = u.rsplit_once('@').unwrap();
let (_, d) = u.rsplit_once('@').expect("validated email");
s.require_domain(d)
}
}
fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
VacationSummary {
email: row_string(r, "email"),
@@ -154,3 +330,22 @@ fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
active: row_bool(r, "active"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_postfixadmin_vacation_transport_address() {
assert_eq!(
vacation_address("user@example.org", "autoreply.example.org"),
"user#example.org@autoreply.example.org"
);
}
#[test]
fn rejects_reversed_vacation_dates() {
assert!(validate_dates("2026-11-10", "2026-11-09").is_err());
assert!(validate_dates("2026-11-09", "2026-11-10").is_ok());
}
}
+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)}
.compact-select{width:auto;min-width:150px;max-width:240px}
@@ -6,7 +6,7 @@
.notice{margin:10px 0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);font-size:11px;line-height:1.5}.notice.warning{border-color:var(--warn);color:var(--warn)}
.language-select{min-width:82px;max-width:96px;height:28px;min-height:28px;padding:2px 24px 2px 8px;font-size:10px}.login-footer{display:flex;align-items:center;justify-content:space-between;gap:10px}.login-language{display:flex;align-items:center;gap:6px;font:10px var(--sans);color:var(--muted)}.login-language span{white-space:nowrap}.login-language select{min-width:88px;height:26px;min-height:26px}
/* 0.0.1-dev domain-focused administration */
/* 0.0.2 domain-focused administration */
.theme-toggle{width:34px;min-width:34px;height:30px;min-height:30px;padding:0;border-color:var(--line);background:var(--panel2);color:var(--text)}
.theme-toggle:hover{background:var(--hover);border-color:var(--accent)}
.theme-toggle .icon{width:18px;height:18px;display:block;flex:0 0 auto}
@@ -29,7 +29,7 @@
@media(max-width:1100px){.workspace-grid{grid-template-columns:1fr}.domain-cards{grid-template-columns:repeat(2,minmax(150px,1fr))}}
@media(max-width:650px){.list-toolbar{align-items:stretch;flex-direction:column}.domain-picker label{align-items:stretch;flex-direction:column}.domain-picker select{min-width:0;max-width:none;width:100%}.list-summary{justify-content:space-between}.backend-tabs{width:100%}.backend-tab{flex:1}.backend-info{align-items:flex-start;flex-direction:column}}
/* 0.0.1-dev feedback, quota levels and cached icon fixes */
/* 0.0.2 feedback, quota levels and cached icon fixes */
.topbar{position:relative}.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;z-index:10;background:transparent}.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1s ease-in-out infinite}.route-progress[hidden]{display:none}@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
.api-busy .domain-picker .domain-loading{display:inline-flex}.domain-loading{display:none;align-items:center;color:var(--accent)}.domain-loading .icon{width:14px;height:14px;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.theme-toggle{outline:1px solid color-mix(in srgb,var(--text) 18%,transparent)}.theme-toggle .icon{color:var(--accent2);stroke-width:2.2}.theme-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
@@ -37,7 +37,7 @@
.actions-cell .btn+.btn{margin-left:9px}.actions-cell .btn.danger{border-color:color-mix(in srgb,var(--danger) 36%,var(--line2))}
.input-action{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.input-action .btn{white-space:nowrap}
/* 0.0.1-dev mailbox-user workspace */
/* 0.0.2 mailbox-user workspace */
.user-overview-cards .mailbox-address{font-size:15px!important;font-family:var(--mono);overflow-wrap:anywhere}
.user-overview-cards .quota-card .progress-cell{min-width:0;margin-top:8px}
.user-account-grid .panel{min-width:0}.account-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}
@@ -52,3 +52,4 @@
.sieve-editor-panel{display:flex;flex-direction:column;min-width:0}.sieve-editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;border-bottom:1px solid var(--line)}.sieve-editor-head>div:first-child{display:flex;align-items:center;gap:8px}.sieve-editor{flex:1;width:100%;min-height:420px;resize:vertical;border:0;border-radius:0;background:var(--field);padding:14px;font:12px/1.55 var(--mono);color:var(--text);outline:none}.sieve-editor:focus{box-shadow:inset 0 0 0 1px var(--accent)}.sieve-empty{min-height:420px;display:grid;place-items:center}.sieve-capabilities{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.compact-empty{padding:14px 8px}
@media(max-width:900px){.sieve-layout{grid-template-columns:1fr}.sieve-list-body{grid-template-columns:repeat(2,minmax(0,1fr))}.sieve-editor-head{align-items:flex-start;flex-direction:column}.account-facts{grid-template-columns:1fr}}
@media(max-width:560px){.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.sieve-list-body{grid-template-columns:1fr}}
.field-help{display:block;margin-top:5px;color:var(--subtle);font-size:10px;line-height:1.35}.vacation-mailbox-picker{display:grid;gap:10px}.vacation-mailbox-results{max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:var(--r)}.vacation-mailbox-result{display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--text);text-align:left}.vacation-mailbox-result:last-child{border-bottom:0}.vacation-mailbox-result:hover{background:var(--hover)}.alias-section+.alias-section{margin-top:16px}
+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}}
+19 -3
View File
@@ -297,8 +297,8 @@
"Brak celów aliasu.": "No alias targets.",
"Automatyczna odpowiedź dla Twojej skrzynki": "Automatic reply for your mailbox",
"Od": "From",
"Zapisz Vacation": "Save vacation",
"Vacation zapisany": "Vacation saved",
"Zapisz Vacation": "Save autoresponder",
"Vacation zapisany": "Autoresponder saved",
"ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules",
"Nowy skrypt": "New script",
"Skrypty": "Scripts",
@@ -341,7 +341,19 @@
"Rekord DNS": "DNS record",
"Rekordy DNS DKIM dla Twojej domeny — tylko do odczytu": "DKIM DNS records for your domain — read-only",
"Brak skonfigurowanego rekordu DKIM dla tej domeny.": "No DKIM record is configured for this domain.",
"Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node"
"Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node",
"Aliasy użytkowników": "User aliases",
"Aliasy techniczne": "Technical aliases",
"Wpisz tylko część przed znakiem @": "Enter only the part before @",
"Podaj wyłącznie adresy docelowe przekierowania.": "Enter forwarding destination addresses only.",
"Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.": "Enter an address or search for a mailbox by address or name.",
"Edytuj autoresponder": "Edit autoresponder",
"Brak pasujących skrzynek": "No matching mailboxes",
"Wybierz istniejącą skrzynkę z listy.": "Select an existing mailbox from the list.",
"Wybierz inną skrzynkę": "Choose another mailbox",
"Wybierz skrzynkę": "Choose mailbox",
"Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.": "Choose the mailbox for which you want to configure an autoresponder.",
"Skrzynka": "Mailbox"
},
"patterns": [
{
@@ -415,6 +427,10 @@
{
"match": "^Pominięto: (\\d+)$",
"replace": "Skipped: $1"
},
{
"match": "^Wpisz tylko nazwę przed @(.+)$",
"replace": "Enter only the name before @$1"
}
]
}
+2 -1
View File
@@ -173,7 +173,7 @@
</div>
</header>
<div class="body">
<aside class="sidebar"><nav class="nav" id="nav"></nav><div class="sidebar-foot">Rust ${esc(window.PA_RS?.version || '')}</div></aside>
<aside class="sidebar"><nav class="nav" id="nav"></nav></aside>
<main class="content" id="content"></main>
</div>
</div>`;
@@ -319,6 +319,7 @@
} else {
label.append(element);
}
if (field.help) label.append(h('small', { class: 'field-help' }, field.help));
body.append(label);
}
+159 -64
View File
@@ -307,9 +307,10 @@
function mailboxForm(row = {}, domainOverride = '') {
const domain = row.domain || domainOverride || S.cache.mailboxDomain || (S.me.domains || []).find(item => item !== 'ALL') || '';
const localPart = row.local_part || (row.username ? row.username.split('@')[0] : '');
const fields = [
{ name: 'username', label: 'Adres e-mail', required: true, readonly: !!row.username },
{ name: 'domain', label: 'Domena', required: true, readonly: !!row.username },
{ name: 'local_part', label: 'Adres e-mail', required: true, readonly: !!row.username, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' },
{ name: 'domain', label: 'Domena', required: true, readonly: true },
...(S.features.edit_mailbox_name ? [{ name: 'name', label: 'Nazwa wyświetlana' }] : []),
{ name: 'password', label: row.username ? 'Nowe hasło (puste = bez zmian)' : 'Hasło', type: 'password', autocomplete: 'new-password', required: !row.username, generate: !!S.features.generate_password },
{ name: 'quota', label: 'Quota [MB]', type: 'number', min: 0 },
@@ -320,13 +321,16 @@
{ name: 'send_welcome', label: 'Wiadomość powitalna', type: 'checkbox', help: 'Wyślij po utworzeniu' },
];
formModal(row.username ? 'Edytuj skrzynkę' : 'Nowa skrzynka', fields, {
username: row.username || '', domain, name: row.name || '', password: '',
local_part: localPart, domain, name: row.name || '', password: '',
quota: row.quota ? mbFromBytes(row.quota) : 10,
phone: row.phone || '', email_other: row.email_other || '', active: row.username ? row.active : true,
smtp_active: row.smtp_active ?? true, send_welcome: false,
}, async data => {
const body = {
username: data.username, domain: data.domain, name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null,
username: row.username || null,
local_part: row.username ? null : data.local_part,
domain: data.domain,
name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null,
quota: number(data.quota), phone: data.phone, email_other: data.email_other,
active: data.active, smtp_active: data.smtp_active, send_welcome: data.send_welcome,
};
@@ -334,7 +338,8 @@
method: row.username ? 'PUT' : 'POST', body: JSON.stringify(body),
});
if (!row.username && S.features.show_password && data.password) {
return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.username}\n${data.password}` } };
return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.local_part}@${data.domain}
${data.password}` } };
}
return { message: row.username ? 'Skrzynka zapisana' : 'Skrzynka utworzona' };
});
@@ -367,14 +372,29 @@
const inactiveAction = S.me.kind === 'admin' && hasInactive ? `<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 tools = S.me.kind === 'user' ? readonlyDomain(selected) : (domains.length > 1 ? domainPicker('aliasDomainSelect', domains, selected) : readonlyDomain(selected));
const aliasTable = table(
const indexedAliases = filteredAliases.map((row, index) => ({ row, index }));
const aliasTableFor = entries => table(
['Adres', 'Cel', 'Opis', 'Status', 'Mailbox', 'Aktywny', ''],
filteredAliases.map((row, index) => `<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)
? `<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 => {
event.target.disabled = true;
S.cache.aliasDomain = event.target.value;
@@ -416,6 +436,11 @@
async function aliasForm(row = {}, domainOverride = '') {
const domain = row.domain || domainOverride || S.cache.aliasDomain || (S.me.domains || []).find(item => item !== 'ALL') || '';
const userEdit = S.me.kind === 'user' && Boolean(row.address);
const localPart = row.address ? row.address.split('@')[0] : '';
const selfTarget = row.address || '';
const visibleTargets = (row.goto || []).filter(value => !selfTarget || value.toLowerCase() !== selfTarget.toLowerCase());
const keepsLocalDelivery = Boolean(row.is_mailbox && (row.goto || []).some(value => value.toLowerCase() === selfTarget.toLowerCase()));
let defaultGoto = '';
if (!row.address && S.me.kind === 'admin') {
try {
@@ -423,22 +448,39 @@
defaultGoto = preferences.find(pref => pref.key === 'alias_goto_default')?.value || '';
} catch {}
}
const fields = [
{ name: 'address', label: 'Adres aliasu', required: true, readonly: S.me.kind === 'user' || !!row.address },
{ name: 'domain', label: 'Domena', required: true, readonly: S.me.kind === 'user' || !!row.address },
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, required: true },
const fields = userEdit ? [
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, help: 'Podaj wyłącznie adresy docelowe przekierowania.' },
...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []),
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' },
] : [
{ name: 'local_part', label: 'Adres aliasu', required: true, readonly: !!row.address, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' },
{ name: 'domain', label: 'Domena', required: true, readonly: true },
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true },
{ name: 'description', label: 'Opis', wide: true },
{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' },
...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []),
...(!row.address ? [{ name: 'save_goto_default', label: 'Zapamiętaj cele', type: 'checkbox', help: 'Zapisz jako domyślny cel nowych aliasów dla tego administratora' }] : []),
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' },
];
formModal(row.address ? 'Edytuj alias' : 'Nowy alias', fields, {
address: row.address || '', domain, goto: row.address ? (row.goto || []).join('\n') : defaultGoto, description: row.description || '',
store_and_forward: row.is_mailbox, save_goto_default: false, active: row.address ? row.active : true,
local_part: localPart,
domain,
goto: row.address ? visibleTargets.join('\n') : defaultGoto,
description: row.description || '',
store_and_forward: keepsLocalDelivery,
save_goto_default: false,
active: row.address ? row.active : true,
}, async data => {
const body = {
address: data.address, domain: data.domain, goto: splitList(data.goto), description: data.description,
store_and_forward: data.store_and_forward, save_goto_default: !!data.save_goto_default, active: data.active,
address: row.address || null,
local_part: row.address ? null : data.local_part,
domain,
goto: splitList(data.goto),
description: userEdit ? (row.description || '') : data.description,
store_and_forward: row.is_mailbox ? Boolean(data.store_and_forward) : false,
save_goto_default: userEdit ? false : !!data.save_goto_default,
active: data.active,
};
await api(row.address ? `/aliases/${enc(row.address)}` : '/aliases', {
method: row.address ? 'PUT' : 'POST', body: JSON.stringify(body),
@@ -561,20 +603,87 @@
});
}
async function vacation() {
if (S.me.kind === 'user') {
const mailbox = S.me.username;
const existing = await api(`/vacation/${enc(mailbox)}`);
const row = existing || {
email: mailbox, subject: 'Out of office', body: '', interval_time: 0,
active_from: new Date().toISOString().slice(0, 10),
active_until: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), active: true,
function defaultVacation(mailbox) {
const localDate = date => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
const now = new Date();
return {
email: mailbox,
subject: 'Out of office',
body: '',
interval_time: 0,
active_from: localDate(now),
active_until: localDate(new Date(now.getTime() + 7 * 86400000)),
active: true,
};
content().innerHTML = pageHead('Vacation', 'Automatyczna odpowiedź dla Twojej skrzynki') +
}
const vacationDate = value => String(value || '').slice(0, 10);
function vacationMailboxPicker(mailboxes) {
return new Promise(resolve => {
const modal = document.querySelector('#modal');
modal.innerHTML = `<form id="vacationMailboxPickerForm">
<div class="modal-head"><h2>${esc(t('Vacation'))}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
<div class="modal-body modal-body-single"><div class="vacation-mailbox-picker">
<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>
<div class="muted">${esc(t('Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.'))}</div>
<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">
<label class="field wide"><span>Temat</span><input name="subject" required value="${esc(row.subject)}"></label>
${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>
@@ -584,6 +693,10 @@
<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);
@@ -597,57 +710,39 @@
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); }
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');
vacation();
await renderVacationEditor(mailbox, adminMode);
});
}
async function vacation() {
if (S.me.kind === 'user') {
await renderVacationEditor(S.me.username, false);
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.' });
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') + '<div class="empty">Wybierz skrzynkę ponownie z menu.</div>';
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;
}
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();
};
S.cache.vacationMailbox = mailbox;
await renderVacationEditor(mailbox, true);
}
function vacationForm(mailbox, row) {
formModal('Vacation', [
{ name: 'subject', label: 'Temat', wide: true },
{ name: 'body', label: 'Treść', type: 'textarea', wide: true },
{ name: 'active_from', label: 'Od', type: 'date' },
{ name: 'active_until', label: 'Do', type: 'date' },
{ name: 'interval_time', label: 'Interwał odpowiedzi [s]', type: 'number' },
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Autoresponder aktywny' },
], row, async data => {
await api(`/vacation/${enc(mailbox)}`, {
method: 'PUT', body: JSON.stringify({ ...data, email: mailbox, interval_time: number(data.interval_time) }),
});
});
}
function sieveQuote(value) {
return String(value ?? '').replaceAll('\\', '\\\\').replaceAll('"', '\\"').replace(/[\r\n]+/g, ' ');