v0.0.3
This commit is contained in:
1 parent
6e5aa8e4e4
commit
cdca612a59
17 files changed
+191
-62
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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(|_| {
|
||||
|
||||
+46
-18
@@ -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(
|
||||
&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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user