This commit is contained in:
Mateusz Gruszczyński committed 2026-11-03 23:19:05 +01:00
1 parent 6e5aa8e4e4
commit cdca612a59
17 files changed
+187 -58

No files matched your search

+13
View File
@@ -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.
+2 -2
View File
@@ -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
Generated
+1 -1
View File
@@ -1542,7 +1542,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postfixadmin-rs"
version = "0.0.2"
version = "0.0.3"
dependencies = [
"argon2",
"async-native-tls",
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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 |
|---|---|
+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.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.
+18 -5
View File
@@ -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 <progress>, no inline style mutations: PASS (static)
- Rust 1.99.1 compiler gate: NOT RUN; toolchain absent and download attempt failed because the execution environment cannot resolve external hosts.
- Rust 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)
+2 -2
View File
@@ -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_<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_<version>_<arch>.deb` plus `.sha256`, where `<version>` 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
+1 -1
View File
@@ -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)"
Binary file not shown.
@@ -0,0 +1 @@
af32c1dda4c764d477ac8783e70e10fe2d2b84d205fd8e51af884df64b34f855 /packages/postfixadmin-rs_0.0.2_amd64.deb
+35 -9
View File
@@ -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::<Vec<u8>, _>(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:
+10 -1
View File
@@ -761,7 +761,16 @@ impl Db {
}
pub fn row_string(row: &AnyRow, name: &str) -> String {
row.try_get::<String, _>(name).unwrap_or_default()
if let Ok(value) = row.try_get::<String, _>(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::<Vec<u8>, _>(name)
.ok()
.and_then(|value| String::from_utf8(value).ok())
.unwrap_or_default()
}
pub fn row_opt_string(row: &AnyRow, name: &str) -> Option<String> {
row.try_get::<Option<String>, _>(name).unwrap_or_else(|_| {
+42 -14
View File
@@ -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<String> = 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<AliasInput>,
) -> AppResult<Json<serde_json::Value>> {
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<Json<serde_json::Value>> {
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(
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?;
.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) {
+13
View File
@@ -71,6 +71,19 @@ pub fn email(value: &str) -> AppResult<String> {
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<String> {
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<String> {
let email = email(value)?;
let (_, host) = email.rsplit_once('@').expect("validated email has domain");
+1 -1
View File
@@ -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))}}
+42 -16
View File
@@ -41,13 +41,9 @@
if (limitBytes <= 0) return progress(0, 0, { icon: 'archive', label: '∞' });
return `<span class="metric-inline">${icon('archive')}${limitMb} MB</span>`;
};
const aliasStatusLabels = {
undeliverable: 'niedostarczalny', external: 'zewnetrzny', vacation: 'vacation',
disabled: 'wylaczony', expired: 'haslo wygaslo', popimap: 'POP/IMAP',
};
const aliasStatus = values => (values || []).length
? values.map(value => `<span class="badge ${value === 'popimap' ? 'ok' : ''}">${esc(value.startsWith('custom:') ? value.slice(7) : (aliasStatusLabels[value] || value))}</span>`).join(' ')
: '—';
// The alias list is already split by purpose. Keep Status intentionally
// simple and unambiguous instead of exposing delivery diagnostics here.
const aliasStatus = () => '<span class="badge">Alias</span>';
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('<br>') : '<span class="muted">—</span>';
}
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 `<tr data-ws-alias="${index}"><td class="mono">${esc(aliasDisplayAddress(item.address))}</td><td>${aliasTargetsCell(item)}</td><td>${esc(item.description)}</td><td>${aliasStatus(item.status)}</td><td>${yes(item.active)}</td><td class="actions-cell">${editAction}${deleteAction}</td></tr>`;
})
);
const back = `<button id="domainBack" class="btn">${icon('arrow-left')}Domeny</button>`;
const actions = `${back}${S.me.kind === 'admin' ? `<button id="domainAddMailbox" class="btn primary">${icon('plus')}Nowa skrzynka</button><button id="domainAddAlias" class="btn">${icon('forward')}Nowy alias</button>` : ''}${S.me.role === 'global_admin' ? `<button id="domainEdit" class="btn">${icon('square-pen')}Edytuj domenę</button>` : ''}`;
const quotaKnown = mailboxes.filter(item => item.used_quota_bytes != null);
@@ -189,7 +213,8 @@
</div><div class="spacer"></div>
<div class="workspace-grid">
<section><div class="section-title">Skrzynki</div>${table(['Adres', 'Nazwa', 'Quota', 'SMTP', 'Vacation', ''], mailboxes.map((item, index) => `<tr data-ws-mailbox="${index}"><td class="mono">${esc(item.username)}</td><td>${esc(item.name)}</td><td>${mailboxQuota(item, row.maxquota)}</td><td>${item.smtp_active == null ? '—' : yes(item.smtp_active)}</td><td>${yes(item.vacation_active)}</td><td class="actions-cell">${action('Edytuj')}${action('Usuń', true)}</td></tr>`))}</section>
<section><div class="section-title">Aliasy</div>${table(['Adres', 'Cel', 'Status', ''], aliases.map((item, index) => `<tr data-ws-alias="${index}"><td class="mono">${esc(item.address)}</td><td>${item.goto.map(esc).join('<br>')}</td><td>${aliasStatus(item.status)}</td><td class="actions-cell">${action('Edytuj')}${!item.is_mailbox ? action('Usuń', true) : ''}</td></tr>`))}</section>
<section><div class="section-title">Aliasy użytkowników</div>${workspaceAliasTable(regularAliases)}</section>
${technicalAliases.length ? `<section><div class="section-title">Aliasy techniczne</div>${workspaceAliasTable(technicalAliases)}</section>` : ''}
</div>
${aliasDomains.length ? `<div class="section-title">Aliasy domen</div>${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny'], aliasDomains.map(item => `<tr><td class="mono">${esc(item.alias_domain)}</td><td class="mono">${esc(item.target_domain)}</td><td>${esc(item.description)}</td><td>${yes(item.active)}</td></tr>`))}` : ''}`;
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 `<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>`;
return `<tr data-row="${index}"><td class="mono">${esc(aliasDisplayAddress(row.address))}</td><td>${aliasTargetsCell(row)}</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;
@@ -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 },