diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c390e2..411de5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.0.3 +- Alias status is shown as a simple `Alias` badge; catch-all aliases follow PostfixAdmin semantics (`*` in the form, `@domain` in the database), including edit/delete compatibility and catch-all domain forwarding to `@target-domain`. + +- Fixed MySQL/MariaDB `TEXT`/`LONGTEXT` decoding through `sqlx::Any`; alias destinations from PostfixAdmin (`alias.goto`) now render from the real database value instead of falling back to an empty string. +- Alias destination listing now normalizes `goto` through the database dialect so targets render consistently on MySQL/MariaDB, PostgreSQL and SQLite. +- Static release validation no longer hard-codes the application or Rust version; it validates semantic versioning and Cargo/toolchain consistency instead. + +- Domain workspace now separates user aliases from technical/default aliases instead of mixing them in one table. +- Alias tables keep the PostfixAdmin-RS data model (address, destination, description, delivery status, active state and actions) while using a clearer full-width layout inspired by classic PostfixAdmin. +- Mailbox self-alias rows are hidden from the domain alias sections so mailbox-managed routing does not clutter the alias view. +- Technical alias edit/delete controls follow the existing `special_alias_control` and global-admin permissions. +- Release version bumped to 0.0.3. + ## 0.0.2 - Mailbox and alias creation in a selected domain now accepts only the local part; the domain is read-only. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 8015cf3..02423e2 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Zgodnosc z PostfixAdmin -Stan: **0.0.2**. Celem jest replacement runtime PHP z zachowaniem istniejacej bazy PostfixAdmin i SQL map uzywanych przez Postfix/Dovecot. +Stan: **0.0.3**. 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.2 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.3 nadal **nie jest 100% parity**; dokument rozdziela funkcje runtime juz przeportowane od pozostalych narzedzi instalacyjnych, API, CLI i legacy hookow/UI. ## Rspamd DKIM diff --git a/Cargo.lock b/Cargo.lock index 9ed7e45..bd3f240 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "postfixadmin-rs" -version = "0.0.2" +version = "0.0.3" dependencies = [ "argon2", "async-native-tls", diff --git a/Cargo.toml b/Cargo.toml index 0e38c6d..d0ee10d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postfixadmin-rs" -version = "0.0.2" +version = "0.0.3" edition = "2024" rust-version = "1.99.1" default-run = "postfixadmin-rs" diff --git a/PORT-PARITY.md b/PORT-PARITY.md index ea2b8b6..8bfe1db 100644 --- a/PORT-PARITY.md +++ b/PORT-PARITY.md @@ -1,7 +1,7 @@ -# PostfixAdmin 4.0.1 -> PostfixAdmin-RS 0.0.2 parity audit +# PostfixAdmin 4.0.1 -> PostfixAdmin-RS 0.0.3 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.2 is runtime replacement for normal mail administration, not binary/API compatibility with PHP. +The goal of 0.0.3 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.2 is runtime replacement for normal mail administration, not bin ## Not yet 1:1 with PHP PostfixAdmin -These are real parity gaps; 0.0.2 must not be described as 100% complete while they remain. +These are real parity gaps; 0.0.3 must not be described as 100% complete while they remain. | Area | Gap / intended Rust replacement | |---|---| diff --git a/README.md b/README.md index 74d8eaa..680efc0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rustowy panel administracyjny dla Postfix/Dovecot, rozwijany jako zamiennik runtime PHP PostfixAdmin i zgodny z jego istniejaca baza danych. -Aktualna linia developerska: **0.0.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. +Aktualna linia developerska: **0.0.3**. Projekt jest przypiety do **Rust 1.99.1**, edition 2024. Frontend jest osadzony w pojedynczym binarnym pliku; bundlowanie assetow, sprite SVG i workflow developerski sa wzorowane na przekazanym RustBox. Stary wyglad PostfixAdmin nie jest kopiowany. ## 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.2 +## Status 0.0.3 To nadal wersja developerska, nie deklaruje jeszcze 100% parity PostfixAdmin 4.x. Pozostale luki to przede wszystkim webowy setup/upgrade schematu, XML-RPC, SMS provider, update checker oraz generic IMAP/Courier do tworzenia folderow. Dynamiczne hooki PHP (`*_struct_hook`, `language_hook`) nie beda uruchamiane jako PHP — wymagaja natywnego mechanizmu rozszerzen. diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 2f58b38..5cdda7b 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,4 +1,4 @@ -PostfixAdmin-RS 0.0.2 +PostfixAdmin-RS 0.0.3 Validated in the current workspace: - TOML parsing: PASS @@ -35,9 +35,9 @@ Reason: Rust/Cargo toolchain is not installed in the execution environment. Readiness conclusion: - READY FOR DEVELOPMENT TESTING with the SQLite mock. - READY FOR READ-ONLY production-schema testing through the guarded SSH tunnel. -- NOT YET PRODUCTION-READY until the Rust 1.99.1 compiler/test/clippy gate and the regression checklist in TESTING.md pass. +- NOT YET PRODUCTION-READY until the Rust compiler/test/clippy gate declared by Cargo.toml and the regression checklist in TESTING.md pass. -Developer gate on a machine with Rust 1.99.1: +Developer gate on a machine with the Rust toolchain declared by Cargo.toml/rust-toolchain.toml: POSTFIXADMIN_RS_DEV_RESET=1 bash dev.sh Read-only production DB test through SSH: @@ -66,12 +66,25 @@ Current mailbox-user iteration: - ManageSieve login credential held only in in-memory mailbox session when enabled: PASS (static) - dev ManageSieve SSH tunnel defaults to READ-ONLY and has separate write opt-in: PASS (shell/static) - quota progress uses CSP-safe native , 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 compiler gate: NOT RUN; the required toolchain is absent in this execution environment. -0.0.2 requested-change static checks: +Compatibility regression checks: - mailbox/alias local-part-only create UX with read-only selected domain: PASS (static) - admin Vacation mailbox picker/editor: PASS (JS syntax/static) - Vacation alias transport synchronization: PASS (source review + unit-test markers; Rust test gate not runnable here) - mailbox-user alias field restriction in UI and backend: PASS (static) - technical alias separation: PASS (static) - Rspamd contextual filesystem errors + packaged permission helper: PASS (static + helper smoke test with isolated paths) + +Domain alias UI regression checks: +- domain workspace separates user aliases from technical/default aliases: PASS (static) +- mailbox self-aliases are excluded from the domain alias sections: PASS (static) +- alias tables retain PostfixAdmin-RS fields (address, destination, description, status, active, actions): PASS (static) +- technical alias actions honor special_alias_control/global-admin permissions: PASS (static) +- domain mailbox/alias sections use the full content width for readability: PASS (static) + +Current corrections: +- version-agnostic release validation (semantic package version + Cargo/toolchain consistency): PASS +- MySQL/MariaDB TEXT/LONGTEXT fallback decoding in row_string(): PASS (static) +- alias `goto` reads the real PostfixAdmin database value and uses the Blob fallback when required: PASS (static) +- frontend accepts alias destinations as array or comma-separated text and renders an explicit dash only when empty: PASS (static) diff --git a/TESTING.md b/TESTING.md index 0fb650a..d06b820 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,4 +1,4 @@ -# PostfixAdmin-RS 0.0.2 - test checklist +# PostfixAdmin-RS 0.0.3 - 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.2_.deb` plus `.sha256`. Install on a disposable Debian-compatible host first and configure `/etc/postfixadmin-rs/config.toml` before enabling the service. Verify `getent passwd postfixadmin`, `systemctl cat postfixadmin-rs` (User/Group must be `postfixadmin`), and permissions `root:postfixadmin 0640` on the config file. +Run `./scripts/build-deb.sh`. Expected output is `packages/postfixadmin-rs__.deb` plus `.sha256`, where `` is read from `Cargo.toml`. Install on a disposable Debian-compatible host first and configure `/etc/postfixadmin-rs/config.toml` before enabling the service. Verify `getent passwd postfixadmin`, `systemctl cat postfixadmin-rs` (User/Group must be `postfixadmin`), and permissions `root:postfixadmin 0640` on the config file. ## Local Rust quality gate diff --git a/dev.sh b/dev.sh index fc431e2..f73ba32 100755 --- a/dev.sh +++ b/dev.sh @@ -208,7 +208,7 @@ esac exit 1 } -echo "PostfixAdmin RS 0.0.2: http://127.0.0.1:8080" +echo "PostfixAdmin RS 0.0.3: http://127.0.0.1:8080" echo "Config: $POSTFIXADMIN_RS_CONFIG" if [ "$DEV_DB_MODE" = "mock" ]; then echo "DB: SQLite mock ($DEV_DB)" diff --git a/packages/postfixadmin-rs_0.0.2_amd64.deb b/packages/postfixadmin-rs_0.0.2_amd64.deb new file mode 100644 index 0000000..98a41b7 Binary files /dev/null and b/packages/postfixadmin-rs_0.0.2_amd64.deb differ diff --git a/packages/postfixadmin-rs_0.0.2_amd64.deb.sha256 b/packages/postfixadmin-rs_0.0.2_amd64.deb.sha256 new file mode 100644 index 0000000..02a9b47 --- /dev/null +++ b/packages/postfixadmin-rs_0.0.2_amd64.deb.sha256 @@ -0,0 +1 @@ +af32c1dda4c764d477ac8783e70e10fe2d2b84d205fd8e51af884df64b34f855 /packages/postfixadmin-rs_0.0.2_amd64.deb diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 713c883..cb70113 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -31,12 +31,17 @@ toolchain = load_toml(ROOT / 'rust-toolchain.toml') package = cargo.get('package', {}) if package.get('edition') != '2024': fail('Cargo.toml must use edition 2024') -if package.get('rust-version') != '1.99.1': - fail('Cargo.toml rust-version must be 1.99.1') -if package.get('version') != '0.0.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') +package_version = package.get('version') +rust_version = package.get('rust-version') +toolchain_channel = toolchain.get('toolchain', {}).get('channel') +if not isinstance(package_version, str) or not re.fullmatch(r'\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', package_version): + fail('Cargo.toml package.version must be a valid semantic version') +if not isinstance(rust_version, str) or not rust_version.strip(): + fail('Cargo.toml rust-version must be set') +if not isinstance(toolchain_channel, str) or not toolchain_channel.strip(): + fail('rust-toolchain.toml toolchain.channel must be set') +elif isinstance(rust_version, str) and toolchain_channel != rust_version: + fail(f'Rust version mismatch: Cargo.toml requires {rust_version}, rust-toolchain.toml pins {toolchain_channel}') for path in ROOT.glob('config*.toml'): load_toml(path) @@ -132,17 +137,38 @@ 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 = { +compatibility_regression_markers = { 'src/routes/vacation.rs': ['sync_vacation_alias', 'vacation_address', 'validate_dates'], 'src/routes/aliases.rs': ['technical', 'internal_vacation', 'can_edit_description'], 'src/web/js/10-pages.js': ['vacationMailboxPicker', 'Aliasy techniczne', 'local_part'], 'packaging/postfixadmin-rs-rspamd-setup': ['postfixadmin-rspamd', 'systemctl restart postfixadmin-rs rspamd'], } -for relative, markers in release_002_markers.items(): +for relative, markers in compatibility_regression_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}') + fail(f'{relative}: missing compatibility regression marker: {marker}') + +domain_ui_regression_markers = { + 'src/web/js/10-pages.js': [ + 'aliasRows.filter(item => !item.is_mailbox)', + 'regularAliases = indexedAliases.filter(({ item }) => !item.technical)', + 'technicalAliases = indexedAliases.filter(({ item }) => item.technical)', + 'Aliasy użytkowników', + 'Aliasy techniczne', + "['Adres', 'Cel', 'Opis', 'Status', 'Aktywny', '']", + 'aliasDestinations', + 'aliasTargetsCell', + ], + 'src/web/css/20-app.css': ['.workspace-grid{display:grid;grid-template-columns:1fr;'], + 'src/routes/aliases.rs': ['a.goto AS goto_value', 'row_string(row, "goto_value")'], + 'src/db.rs': ['row.try_get::, _>(name)', 'String::from_utf8(value)'], +} +for relative, markers in domain_ui_regression_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 domain UI regression marker: {marker}') node = shutil.which('node') if node: diff --git a/src/db.rs b/src/db.rs index ef037ee..071b9c7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -761,7 +761,16 @@ impl Db { } pub fn row_string(row: &AnyRow, name: &str) -> String { - row.try_get::(name).unwrap_or_default() + if let Ok(value) = row.try_get::(name) { + return value; + } + // sqlx::Any maps MySQL/MariaDB TEXT/LONGTEXT to its generic Blob kind. + // Decode the bytes as UTF-8 as a fallback so PostfixAdmin text columns + // (notably alias.goto) do not silently become empty strings. + row.try_get::, _>(name) + .ok() + .and_then(|value| String::from_utf8(value).ok()) + .unwrap_or_default() } pub fn row_opt_string(row: &AnyRow, name: &str) -> Option { row.try_get::, _>(name).unwrap_or_else(|_| { diff --git a/src/routes/aliases.rs b/src/routes/aliases.rs index 974a595..07b90cb 100644 --- a/src/routes/aliases.rs +++ b/src/routes/aliases.rs @@ -42,7 +42,7 @@ pub async fn list( "is_mailbox", ); let mut sql = format!( - "SELECT a.address,a.goto,a.domain,{active},{description},\ + "SELECT a.address,a.goto AS goto_value,a.domain,{active},{description},\ {is_mailbox} \ FROM {alias} a LEFT JOIN {mailbox} m ON m.username=a.address WHERE 1=1" ); @@ -85,7 +85,7 @@ pub async fn list( let mut result = Vec::with_capacity(rows.len()); for row in &rows { let address = row_string(row, "address"); - let raw_gotos = split_goto(&row_string(row, "goto")); + let raw_gotos = split_goto(&row_string(row, "goto_value")); let internal_vacation = vacation_destination(&state, &address); let gotos: Vec = raw_gotos .into_iter() @@ -129,8 +129,10 @@ pub async fn create( let local = match input.address.as_deref() { Some(address) => { - let email = validation::resolvable_email(&state.config, address).await?; - let (local, address_domain) = email.rsplit_once('@').expect("validated email"); + let alias_address = validation::alias_address(address)?; + let (local, address_domain) = alias_address + .rsplit_once('@') + .expect("validated alias address"); if address_domain != domain { return Err(AppError::bad_request( "Address domain does not match domain", @@ -138,14 +140,26 @@ pub async fn create( } local.to_string() } - None => validation::local_part( - input + None => { + let raw = input .local_part .as_deref() - .ok_or_else(|| AppError::bad_request("local_part or address is required"))?, - )?, + .ok_or_else(|| AppError::bad_request("local_part or address is required"))? + .trim(); + if raw == "*" { + String::new() + } else { + validation::local_part(raw)? + } + } + }; + // PostfixAdmin stores a catch-all entered as `*` in canonical Postfix form + // `@domain`, not `*@domain`. Keep the database representation compatible. + let address = if local.is_empty() { + format!("@{domain}") + } else { + format!("{local}@{domain}") }; - let address = format!("{local}@{domain}"); protect_special_alias(&state, &session, &local)?; enforce_alias_limit(&state, &domain).await?; @@ -213,7 +227,7 @@ pub async fn update( Json(input): Json, ) -> AppResult> { let session = routes::mutation_session(&state, &jar, &headers)?; - let address = validation::email(&path_address)?; + let address = validation::alias_address(&path_address)?; let (_, domain) = address.rsplit_once('@').expect("validated email"); if session.kind == AccountKind::User { if !state.config.features.edit_alias || !address.eq_ignore_ascii_case(&session.username) { @@ -237,7 +251,7 @@ pub async fn update( return Err(AppError::not_found("Alias not found")); } if let Some(new_address) = input.address.as_deref() - && validation::email(new_address)? != address + && validation::alias_address(new_address)? != address { return Err(AppError::bad_request("Renaming aliases is not supported")); } @@ -282,7 +296,7 @@ pub async fn remove( headers: HeaderMap, ) -> AppResult> { let session = routes::mutation_session(&state, &jar, &headers)?; - let address = validation::email(&path_address)?; + let address = validation::alias_address(&path_address)?; let (local, domain) = address.rsplit_once('@').expect("validated email"); if session.kind == AccountKind::User { return Err(AppError::forbidden( @@ -757,12 +771,26 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR 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, - raw, - state.config.security.allow_unsafe_alias_destinations, - ) - .await?; + let raw = raw.trim(); + // PostfixAdmin also permits a catch-all to forward the whole domain to + // another domain using `@target.example`. Keep that compatibility only + // for catch-all source aliases; regular aliases still require a normal + // address or another explicitly allowed destination. + let value = if address.starts_with('@') && raw.starts_with('@') { + let target_domain = validation::resolvable_domain( + &state.config, + raw.trim_start_matches('@'), + ) + .await?; + format!("@{target_domain}") + } else { + validation::alias_destination_checked( + &state.config, + raw, + 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) { diff --git a/src/validation.rs b/src/validation.rs index 307a314..b8dd9e4 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -71,6 +71,19 @@ pub fn email(value: &str) -> AppResult { Ok(format!("{local}@{host}")) } +/// Validate an alias source address. Catch-all aliases use PostfixAdmin's +/// canonical database form `@domain`; the UI may also submit `*@domain`. +pub fn alias_address(value: &str) -> AppResult { + let value = value.trim(); + if let Some(host) = value.strip_prefix("*@").or_else(|| value.strip_prefix('@')) { + if host.is_empty() || host.contains('@') { + return Err(AppError::bad_request("Invalid catch-all alias address")); + } + return Ok(format!("@{}", domain(host)?)); + } + email(value) +} + pub async fn resolvable_email(config: &AppConfig, value: &str) -> AppResult { let email = email(value)?; let (_, host) = email.rsplit_once('@').expect("validated email has domain"); diff --git a/src/web/css/20-app.css b/src/web/css/20-app.css index c9064fa..249d1cb 100644 --- a/src/web/css/20-app.css +++ b/src/web/css/20-app.css @@ -23,7 +23,7 @@ .domain-picker label{display:flex;align-items:center;gap:8px}.domain-picker label>span{color:var(--muted);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.domain-picker select{min-width:230px} .list-summary{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:11px}.list-summary span{display:flex;align-items:center;gap:5px}.list-summary .icon{width:14px;height:14px} .domain-cards .card{min-height:92px}.metric-card{display:grid;grid-template-columns:30px minmax(0,1fr);gap:10px;align-items:start}.metric-card>.icon{width:24px;height:24px;color:var(--accent);margin-top:2px}.metric-card .progress-cell{margin-top:7px;min-width:0} -.workspace-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}.workspace-grid .section-title{margin-top:0}.workspace-grid .panel{min-width:0} +.workspace-grid{display:grid;grid-template-columns:1fr;gap:18px;align-items:start}.workspace-grid .section-title{margin-top:0}.workspace-grid .panel{min-width:0} .backend-tabs{display:flex;gap:7px;margin:0 0 12px;padding:4px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);width:max-content;max-width:100%}.backend-tab{display:flex;align-items:center;gap:7px;min-height:32px;padding:5px 10px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);font-weight:650}.backend-tab:hover{color:var(--text);background:var(--hover)}.backend-tab.active{border-color:var(--line2);background:var(--panel);color:var(--text)}.backend-tab .icon{width:15px;height:15px} .backend-info{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:10px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);color:var(--muted);font-size:11px}.backend-info>div{display:flex;align-items:center;gap:7px;color:var(--text)}.backend-info .icon{width:16px;height:16px;color:var(--accent)} @media(max-width:1100px){.workspace-grid{grid-template-columns:1fr}.domain-cards{grid-template-columns:repeat(2,minmax(150px,1fr))}} diff --git a/src/web/js/10-pages.js b/src/web/js/10-pages.js index 18e03c0..82aa6fb 100644 --- a/src/web/js/10-pages.js +++ b/src/web/js/10-pages.js @@ -41,13 +41,9 @@ if (limitBytes <= 0) return progress(0, 0, { icon: 'archive', label: '∞' }); return `${icon('archive')}${limitMb} MB`; }; - const aliasStatusLabels = { - undeliverable: 'niedostarczalny', external: 'zewnetrzny', vacation: 'vacation', - disabled: 'wylaczony', expired: 'haslo wygaslo', popimap: 'POP/IMAP', - }; - const aliasStatus = values => (values || []).length - ? values.map(value => `${esc(value.startsWith('custom:') ? value.slice(7) : (aliasStatusLabels[value] || value))}`).join(' ') - : '—'; + // The alias list is already split by purpose. Keep Status intentionally + // simple and unambiguous instead of exposing delivery diagnostics here. + const aliasStatus = () => 'Alias'; function bind(rows, handlers, scope = document) { rows.forEach((row, index) => { @@ -163,6 +159,22 @@ }); } + function aliasDestinations(row) { + if (Array.isArray(row?.goto)) return row.goto.filter(Boolean); + if (typeof row?.goto === 'string') return row.goto.split(',').map(value => value.trim()).filter(Boolean); + return []; + } + + function aliasDisplayAddress(value) { + const address = String(value || ''); + return address.startsWith('@') ? `*${address}` : address; + } + + function aliasTargetsCell(row) { + const targets = aliasDestinations(row); + return targets.length ? targets.map(esc).join('
') : '—'; + } + async function domainWorkspace() { const domain = S.cache.domainWorkspace; if (!domain) { await window.UI.go('domains'); return; } @@ -173,8 +185,20 @@ const row = domainsRows.find(item => item.domain === domain); if (!row) throw new Error('Domain not found'); const mailboxes = mailboxRows; - const aliases = aliasRows; + const aliases = aliasRows.filter(item => !item.is_mailbox); + const indexedAliases = aliases.map((item, index) => ({ item, index })); + const regularAliases = indexedAliases.filter(({ item }) => !item.technical); + const technicalAliases = indexedAliases.filter(({ item }) => item.technical); const aliasDomains = aliasDomainRows.filter(item => item.alias_domain === domain || item.target_domain === domain); + const workspaceAliasTable = entries => table( + ['Adres', 'Cel', 'Opis', 'Status', 'Aktywny', ''], + entries.map(({ item, index }) => { + const canEditTechnical = !item.technical || S.me.role === 'global_admin' || S.features.special_alias_control; + const editAction = canEditTechnical ? action('Edytuj') : ''; + const deleteAction = S.me.kind === 'admin' && canEditTechnical ? action('Usuń', true) : ''; + return `${esc(aliasDisplayAddress(item.address))}${aliasTargetsCell(item)}${esc(item.description)}${aliasStatus(item.status)}${yes(item.active)}${editAction}${deleteAction}`; + }) + ); const back = ``; const actions = `${back}${S.me.kind === 'admin' ? `` : ''}${S.me.role === 'global_admin' ? `` : ''}`; const quotaKnown = mailboxes.filter(item => item.used_quota_bytes != null); @@ -189,7 +213,8 @@
Skrzynki
${table(['Adres', 'Nazwa', 'Quota', 'SMTP', 'Vacation', ''], mailboxes.map((item, index) => `${esc(item.username)}${esc(item.name)}${mailboxQuota(item, row.maxquota)}${item.smtp_active == null ? '—' : yes(item.smtp_active)}${yes(item.vacation_active)}${action('Edytuj')}${action('Usuń', true)}`))}
-
Aliasy
${table(['Adres', 'Cel', 'Status', ''], aliases.map((item, index) => `${esc(item.address)}${item.goto.map(esc).join('
')}${aliasStatus(item.status)}${action('Edytuj')}${!item.is_mailbox ? action('Usuń', true) : ''}`))}
+
Aliasy użytkowników
${workspaceAliasTable(regularAliases)}
+ ${technicalAliases.length ? `
Aliasy techniczne
${workspaceAliasTable(technicalAliases)}
` : ''}
${aliasDomains.length ? `
Aliasy domen
${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny'], aliasDomains.map(item => `${esc(item.alias_domain)}${esc(item.target_domain)}${esc(item.description)}${yes(item.active)}`))}` : ''}`; document.querySelector('#domainBack').onclick = () => window.UI.go('domains'); @@ -209,7 +234,7 @@ const scope = `[data-ws-alias="${index}"]`; document.querySelector(`${scope} [data-act="Edytuj"]`)?.addEventListener('click', () => aliasForm(item, domain)); document.querySelector(`${scope} [data-act="Usuń"]`)?.addEventListener('click', async () => { - if (!await confirmModal('Usuń alias', `Usunąć alias ${item.address}?`)) return; + if (!await confirmModal('Usuń alias', `Usunąć alias ${aliasDisplayAddress(item.address)}?`)) return; await api(`/aliases/${enc(item.address)}`, { method: 'DELETE' }); domainWorkspace(); }); @@ -379,7 +404,7 @@ ${data.password}` } }; const canEditTechnical = !row.technical || S.me.role === 'global_admin' || S.features.special_alias_control; const editAction = canEditTechnical ? action('Edytuj') : ''; const deleteAction = !row.is_mailbox && S.me.kind === 'admin' && canEditTechnical ? action('Usuń', true) : ''; - return `${esc(row.address)}${row.goto.map(esc).join('
')}${esc(row.description)}${aliasStatus(row.status)}${yes(row.is_mailbox)}${yes(row.active)}${editAction}${deleteAction}`; + return `${esc(aliasDisplayAddress(row.address))}${aliasTargetsCell(row)}${esc(row.description)}${aliasStatus(row.status)}${yes(row.is_mailbox)}${yes(row.active)}${editAction}${deleteAction}`; }) ); let aliasSections; @@ -414,7 +439,7 @@ ${data.password}` } }; bind(filteredAliases, { Edytuj: row => aliasForm(row), Usuń: async row => { - if (await confirmModal('Usuń alias', `Usunąć alias ${row.address}?`)) { + if (await confirmModal('Usuń alias', `Usunąć alias ${aliasDisplayAddress(row.address)}?`)) { await api(`/aliases/${enc(row.address)}`, { method: 'DELETE' }); showToast('Alias usunięty'); aliases(); @@ -437,10 +462,11 @@ ${data.password}` } }; 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 localPart = row.address ? (row.address.startsWith('@') ? '*' : 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())); + const rowTargets = aliasDestinations(row); + const visibleTargets = rowTargets.filter(value => !selfTarget || value.toLowerCase() !== selfTarget.toLowerCase()); + const keepsLocalDelivery = Boolean(row.is_mailbox && rowTargets.some(value => value.toLowerCase() === selfTarget.toLowerCase())); let defaultGoto = ''; if (!row.address && S.me.kind === 'admin') { try { @@ -454,7 +480,7 @@ ${data.password}` } }; ...(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: 'local_part', label: 'Adres aliasu', required: true, readonly: !!row.address, help: domain ? `Wpisz nazwę przed @${domain} albo * dla catch-all` : 'Wpisz część przed @ albo * dla catch-all' }, { 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 },