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

No files matched your search

+1
View File
@@ -116,6 +116,7 @@ pub struct AliasSummary {
pub description: String,
pub active: bool,
pub is_mailbox: bool,
pub technical: bool,
pub status: Vec<String>,
}
+54 -3
View File
@@ -85,8 +85,22 @@ pub async fn list(
let mut result = Vec::with_capacity(rows.len());
for row in &rows {
let address = row_string(row, "address");
let gotos = split_goto(&row_string(row, "goto"));
let raw_gotos = split_goto(&row_string(row, "goto"));
let internal_vacation = vacation_destination(&state, &address);
let gotos: Vec<String> = raw_gotos
.into_iter()
.filter(|value| !value.eq_ignore_ascii_case(&internal_vacation))
.collect();
// The PostfixAdmin vacation transport is implementation detail, not a
// user-visible alias destination and must not affect delivery status.
let status = alias_status(&state, &session, &address, &gotos).await?;
let local = address.split('@').next().unwrap_or("");
let technical = state
.config
.defaults
.default_aliases
.keys()
.any(|name| name.eq_ignore_ascii_case(local));
result.push(AliasSummary {
address,
goto: gotos,
@@ -94,6 +108,7 @@ pub async fn list(
description: row_string(row, "description"),
active: row_bool(row, "active"),
is_mailbox: row_bool(row, "is_mailbox"),
technical,
status,
});
}
@@ -229,7 +244,10 @@ pub async fn update(
let goto = build_goto(&state, &address, &input).await?;
let has_description = state.db.has_column(Table::Alias, "description").await?;
let sql = if has_description {
// Mailbox users may only change forwarding, store-and-forward and active state.
// Preserve description even if a crafted request tries to modify it.
let can_edit_description = has_description && session.kind != AccountKind::User;
let sql = if can_edit_description {
state.db.q(&format!(
"UPDATE {table} SET goto=?,description=?,active=?,modified=CURRENT_TIMESTAMP WHERE address=?"
))
@@ -239,7 +257,7 @@ pub async fn update(
))
};
let mut query = sqlx::query(&sql).bind(goto.join(","));
if has_description {
if can_edit_description {
query = query.bind(&input.description);
}
query = query.bind(input.active).bind(&address);
@@ -727,8 +745,17 @@ fn split_goto(value: &str) -> Vec<String> {
.collect()
}
fn vacation_destination(state: &AppState, address: &str) -> String {
format!(
"{}@{}",
address.replace('@', "#"),
state.config.defaults.vacation_domain
)
}
async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppResult<Vec<String>> {
let mut output = Vec::new();
let internal_vacation = vacation_destination(state, address);
for raw in &input.goto {
let value = validation::alias_destination_checked(
&state.config,
@@ -736,6 +763,11 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR
state.config.security.allow_unsafe_alias_destinations,
)
.await?;
// The vacation transport is an internal PostfixAdmin detail. Never let
// alias editing expose, duplicate or remove it directly.
if value.eq_ignore_ascii_case(&internal_vacation) {
continue;
}
if state.config.features.emailcheck_localaliasonly
&& let Some((_, domain)) = value.rsplit_once('@')
{
@@ -780,6 +812,25 @@ async fn build_goto(state: &AppState, address: &str, input: &AliasInput) -> AppR
"At least one destination is required",
));
}
if state.config.features.vacation && state.db.has_table(Table::Vacation).await {
let vacation = state.db.table(Table::Vacation);
let sql = state.db.q(&format!(
"SELECT 1 FROM {vacation} WHERE email=? AND active=? LIMIT 1"
));
if sqlx::query(&sql)
.bind(address)
.bind(true)
.fetch_optional(state.db.pool())
.await?
.is_some()
&& !output
.iter()
.any(|value| value.eq_ignore_ascii_case(&internal_vacation))
{
output.push(internal_vacation);
}
}
Ok(output)
}
+128 -45
View File
@@ -28,6 +28,13 @@ use crate::{
const BEGIN_MARKER: &str = "# BEGIN PostfixAdmin-RS managed DKIM maps";
const END_MARKER: &str = "# END PostfixAdmin-RS managed DKIM maps";
fn rspamd_io(action: &str, path: &Path, error: std::io::Error) -> AppError {
AppError::config(format!(
"Rspamd DKIM {action} {} failed: {error}",
path.display()
))
}
fn default_true() -> bool {
true
}
@@ -97,7 +104,13 @@ pub(crate) async fn read_only_dns_records(
let legacy = match fs::read_to_string(&cfg.signing_config).await {
Ok(text) => parse_legacy_domain_blocks(&text, &cfg.default_selector),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
Err(error) => {
return Err(rspamd_io(
"read signing config",
Path::new(&cfg.signing_config),
error,
));
}
};
let mut result = Vec::new();
for requested in domains {
@@ -142,7 +155,7 @@ async fn dns_record_from_private_key(
Ok(metadata) if metadata.is_file() => {}
Ok(_) => return Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("stat private key", private_path, error)),
}
let path = private_path.to_string_lossy().into_owned();
let output = match timeout(
@@ -186,7 +199,13 @@ pub async fn list(
.filter(|domain| session.can_manage_domain(domain))
.collect(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
Err(error) => {
return Err(rspamd_io(
"read signing config",
Path::new(&cfg.signing_config),
error,
));
}
};
let mut entries = Vec::new();
for domain in domains {
@@ -205,13 +224,18 @@ pub async fn list(
let private_path = raw_path
.replace("$domain", &domain)
.replace("$selector", &selector);
let private_exists = fs::metadata(&private_path)
.await
.map(|m| m.is_file())
.unwrap_or(false);
let dns_record = read_dns_sidecar(Path::new(&private_path))
.await
.unwrap_or_default();
let private_exists = match fs::metadata(&private_path).await {
Ok(metadata) => metadata.is_file(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
Err(error) => {
return Err(rspamd_io(
"stat private key",
Path::new(&private_path),
error,
));
}
};
let dns_record = read_dns_sidecar(Path::new(&private_path)).await?;
entries.push(RspamdDkimEntry {
domain,
selector,
@@ -298,7 +322,10 @@ pub async fn generate(
let parent = final_path
.parent()
.ok_or_else(|| AppError::config("Invalid Rspamd DKIM key path"))?;
fs::create_dir_all(parent).await?;
fs::create_dir_all(parent)
.await
.map_err(|error| rspamd_io("create key directory", parent, error))?;
set_mode(parent, 0o2750).await?;
let tmp_path = parent.join(format!(
".{}.private.tmp-{}",
input.selector,
@@ -324,10 +351,14 @@ pub async fn generate(
return Err(AppError::conflict("Private key file already exists"));
}
if had_private {
fs::rename(&final_path, &backup_private).await?;
fs::rename(&final_path, &backup_private)
.await
.map_err(|error| rspamd_io("backup private key", &final_path, error))?;
}
if had_dns {
fs::rename(&dns_path, &backup_dns).await?;
fs::rename(&dns_path, &backup_dns)
.await
.map_err(|error| rspamd_io("backup DNS sidecar", &dns_path, error))?;
}
if let Err(error) = fs::rename(&tmp_path, &final_path).await {
if had_private {
@@ -336,7 +367,7 @@ pub async fn generate(
if had_dns {
let _ = fs::rename(&backup_dns, &dns_path).await;
}
return Err(error.into());
return Err(rspamd_io("activate generated private key", &final_path, error));
}
if let Err(error) = atomic_write(
&dns_path,
@@ -547,7 +578,7 @@ pub async fn import_legacy_domain_blocks(
if error.kind() == std::io::ErrorKind::NotFound {
AppError::not_found("Rspamd dkim_signing.conf does not exist")
} else {
error.into()
rspamd_io("read signing config for legacy import", config_path, error)
}
})?;
let candidates = parse_legacy_domain_blocks(&text, &cfg.default_selector);
@@ -622,7 +653,9 @@ pub async fn import_legacy_domain_blocks(
next.push_str(&managed_block(cfg));
next.push('\n');
let metadata = fs::metadata(config_path).await?;
let metadata = fs::metadata(config_path)
.await
.map_err(|error| rspamd_io("stat signing config", config_path, error))?;
let config_mode = metadata.permissions().mode() & 0o777;
let backup_path = Path::new(&cfg.key_dir).join("dkim_signing.conf.pre-postfixadmin-rs");
if fs::metadata(&backup_path).await.is_err() {
@@ -747,10 +780,10 @@ fn dns_sidecar(private_path: &Path) -> PathBuf {
async fn read_dns_sidecar(private_path: &Path) -> AppResult<String> {
let path = dns_sidecar(private_path);
match fs::read_to_string(path).await {
match fs::read_to_string(&path).await {
Ok(value) => Ok(value.trim().to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error.into()),
Err(error) => Err(rspamd_io("read DNS sidecar", &path, error)),
}
}
@@ -875,7 +908,13 @@ async fn reject_legacy_domain(cfg: &RspamdDkimConfig, domain: &str) -> AppResult
let text = match fs::read_to_string(&cfg.signing_config).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
Err(error) => {
return Err(rspamd_io(
"read signing config",
Path::new(&cfg.signing_config),
error,
));
}
};
if parse_legacy_domain_blocks(&text, &cfg.default_selector)
.iter()
@@ -892,7 +931,7 @@ async fn read_map(path: &Path) -> AppResult<BTreeMap<String, String>> {
let text = match fs::read_to_string(path).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("read map", path, error)),
};
let mut map = BTreeMap::new();
for line in text.lines() {
@@ -933,22 +972,33 @@ async fn atomic_write(path: &Path, bytes: &[u8], mode: u32) -> AppResult<()> {
let parent = path
.parent()
.ok_or_else(|| AppError::config(format!("Invalid path: {}", path.display())))?;
fs::create_dir_all(parent).await?;
fs::create_dir_all(parent)
.await
.map_err(|error| rspamd_io("create parent directory", parent, error))?;
let name = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("managed");
let tmp = parent.join(format!(".{name}.tmp-{}", std::process::id()));
fs::write(&tmp, bytes).await?;
fs::write(&tmp, bytes)
.await
.map_err(|error| rspamd_io("write temporary file", &tmp, error))?;
set_mode(&tmp, mode).await?;
fs::rename(&tmp, path).await?;
fs::rename(&tmp, path)
.await
.map_err(|error| rspamd_io("replace managed file", path, error))?;
Ok(())
}
async fn set_mode(path: &Path, mode: u32) -> AppResult<()> {
let mut permissions = fs::metadata(path).await?.permissions();
let mut permissions = fs::metadata(path)
.await
.map_err(|error| rspamd_io("stat path for chmod", path, error))?
.permissions();
permissions.set_mode(mode);
fs::set_permissions(path, permissions).await?;
fs::set_permissions(path, permissions)
.await
.map_err(|error| rspamd_io("chmod", path, error))?;
Ok(())
}
@@ -981,10 +1031,11 @@ fn strip_managed_block(text: &str) -> String {
}
async fn managed_config_present(cfg: &RspamdDkimConfig) -> AppResult<bool> {
match fs::read_to_string(&cfg.signing_config).await {
let path = Path::new(&cfg.signing_config);
match fs::read_to_string(path).await {
Ok(text) => Ok(text.contains(BEGIN_MARKER) && text.contains(END_MARKER)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
Err(error) => Err(rspamd_io("read signing config", path, error)),
}
}
@@ -1001,10 +1052,17 @@ async fn write_signing_config(
.write(true)
.truncate(true)
.open(path)
.await?;
file.write_all(bytes).await?;
file.flush().await?;
file.sync_all().await?;
.await
.map_err(|error| rspamd_io("open signing config for writing", path, error))?;
file.write_all(bytes)
.await
.map_err(|error| rspamd_io("write signing config", path, error))?;
file.flush()
.await
.map_err(|error| rspamd_io("flush signing config", path, error))?;
file.sync_all()
.await
.map_err(|error| rspamd_io("sync signing config", path, error))?;
Ok(())
}
@@ -1013,12 +1071,12 @@ async fn ensure_managed_config(cfg: &RspamdDkimConfig) -> AppResult<()> {
let old = match fs::read_to_string(path).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("read signing config", path, error)),
};
let (config_mode, existed) = match fs::metadata(path).await {
Ok(metadata) => (metadata.permissions().mode() & 0o777, true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (0o644, false),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("stat signing config", path, error)),
};
let clean = strip_managed_block(&old);
if clean.lines().any(|line| {
@@ -1080,7 +1138,9 @@ async fn generate_key(
let fake = format!(
"-----BEGIN PRIVATE KEY-----\nPOSTFIXADMIN-RS-DEV-MOCK-{domain}-{selector}\n-----END PRIVATE KEY-----\n"
);
fs::write(private_path, fake).await?;
fs::write(private_path, fake)
.await
.map_err(|error| rspamd_io("write development mock private key", private_path, error))?;
return Ok(format!(
"{selector}._domainkey.{domain}. IN TXT \"v=DKIM1; k=rsa; p=DEV-MOCK-NOT-FOR-PRODUCTION\""
));
@@ -1108,7 +1168,13 @@ async fn generate_key(
.output(),
)
.await
.map_err(|_| AppError::config("rspamadm dkim_keygen timed out"))??;
.map_err(|_| AppError::config("rspamadm dkim_keygen timed out"))?
.map_err(|error| {
AppError::config(format!(
"Failed to execute {} dkim_keygen: {error}",
cfg.rspamadm
))
})?;
if !output.status.success() {
return Err(AppError::config(format!(
"rspamadm dkim_keygen failed: {}",
@@ -1117,9 +1183,13 @@ async fn generate_key(
}
let dns = String::from_utf8(output.stdout)
.map_err(|_| AppError::config("rspamadm returned non-UTF8 DKIM record"))?;
let metadata = fs::metadata(private_path)
.await
.map_err(|_| AppError::config("rspamadm did not create the private key file"))?;
let metadata = match fs::metadata(private_path).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(AppError::config("rspamadm did not create the private key file"));
}
Err(error) => return Err(rspamd_io("stat generated private key", private_path, error)),
};
if metadata.len() == 0 {
return Err(AppError::config("rspamadm created an empty private key"));
}
@@ -1140,7 +1210,13 @@ async fn config_test(cfg: &RspamdDkimConfig) -> AppResult<String> {
.output(),
)
.await
.map_err(|_| AppError::config("rspamadm configtest timed out"))??;
.map_err(|_| AppError::config("rspamadm configtest timed out"))?
.map_err(|error| {
AppError::config(format!(
"Failed to execute {} configtest: {error}",
cfg.rspamadm
))
})?;
if !output.status.success() {
return Err(AppError::config(format!(
"rspamadm configtest failed: {}",
@@ -1170,7 +1246,8 @@ async fn reload_rspamd(cfg: &RspamdDkimConfig) -> AppResult<String> {
.output(),
)
.await
.map_err(|_| AppError::config("Rspamd reload command timed out"))??;
.map_err(|_| AppError::config("Rspamd reload command timed out"))?
.map_err(|error| AppError::config(format!("Failed to execute Rspamd reload command: {error}")))?;
if !output.status.success() {
return Err(AppError::config(format!(
"Rspamd reload failed: {}",
@@ -1192,7 +1269,11 @@ async fn require_existing_key_under_dir(cfg: &RspamdDkimConfig, input: &str) ->
"Private key must be inside rspamd_dkim.key_dir",
));
}
if !fs::metadata(&key).await?.is_file() {
if !fs::metadata(&key)
.await
.map_err(|error| rspamd_io("stat private key", &key, error))?
.is_file()
{
return Err(AppError::bad_request("Private key path is not a file"));
}
Ok(key)
@@ -1202,12 +1283,12 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu
let key_dir = match fs::canonicalize(&cfg.key_dir).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("canonicalize key directory", Path::new(&cfg.key_dir), error)),
};
let key = match fs::canonicalize(path).await {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
Err(error) => return Err(rspamd_io("canonicalize private key", path, error)),
};
if !key.starts_with(&key_dir) {
return Err(AppError::forbidden(
@@ -1215,11 +1296,13 @@ async fn remove_managed_key_file(cfg: &RspamdDkimConfig, path: &Path) -> AppResu
));
}
let sidecar = dns_sidecar(&key);
fs::remove_file(&key).await?;
fs::remove_file(&key)
.await
.map_err(|error| rspamd_io("delete private key", &key, error))?;
if let Err(error) = fs::remove_file(&sidecar).await
&& error.kind() != std::io::ErrorKind::NotFound
{
return Err(error.into());
return Err(rspamd_io("delete DNS sidecar", &sidecar, error));
}
if let Some(parent) = key.parent() {
let _ = fs::remove_dir(parent).await;
+207 -12
View File
@@ -12,6 +12,7 @@ use axum::{
http::HeaderMap,
};
use axum_extra::extract::cookie::CookieJar;
use chrono::NaiveDate;
use std::sync::Arc;
pub async fn get(
@@ -29,13 +30,16 @@ pub async fn get(
let af = state.db.cast_text("activefrom");
let au = state.db.cast_text("activeuntil");
let active = state.db.flag("active", "active");
let q=state.db.q(&format!("SELECT email,subject,body,interval_time,{af} AS activefrom,{au} AS activeuntil,{active} FROM {t} WHERE email=?"));
let q = state.db.q(&format!(
"SELECT email,subject,body,interval_time,{af} AS activefrom,{au} AS activeuntil,{active} FROM {t} WHERE email=?"
));
let row = sqlx::query(&q)
.bind(&u)
.fetch_optional(state.db.pool())
.await?;
Ok(Json(row.as_ref().map(map)))
}
pub async fn set(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
@@ -51,10 +55,38 @@ pub async fn set(
"Vacation email does not match mailbox",
));
}
if !state.db.has_table(Table::Vacation).await {
return Err(AppError::config(
"Vacation is enabled but the vacation table does not exist",
));
}
if input.subject.len() > 255 || input.body.len() > 65535 {
return Err(AppError::bad_request("Vacation message is too long"));
}
let (_, domain) = u.rsplit_once('@').unwrap();
if input.interval_time < 0 {
return Err(AppError::bad_request(
"Vacation reply interval cannot be negative",
));
}
validate_dates(&input.active_from, &input.active_until)?;
let (_, domain) = u.rsplit_once('@').expect("validated email");
let mailbox = state.db.table(Table::Mailbox);
let mailbox_check = state
.db
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
if sqlx::query(&mailbox_check)
.bind(&u)
.fetch_optional(state.db.pool())
.await?
.is_none()
{
return Err(AppError::not_found("Mailbox not found"));
}
let alias_has_description = state.db.has_column(Table::Alias, "description").await?;
let vacation_domain = validation::domain(&state.config.defaults.vacation_domain)?;
let vacation_address = vacation_address(&u, &vacation_domain);
let t = state.db.table(Table::Vacation);
let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?"));
let exists = sqlx::query(&check)
@@ -62,10 +94,16 @@ pub async fn set(
.fetch_optional(state.db.pool())
.await?
.is_some();
let mut tx = state.db.pool().begin().await?;
let q = if exists {
state.db.q(&format!("UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?"))
state.db.q(&format!(
"UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?"
))
} else {
state.db.q(&format!("INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,?,?,'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
state.db.q(&format!(
"INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,?,?,'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"
))
};
if exists {
sqlx::query(&q)
@@ -76,7 +114,7 @@ pub async fn set(
.bind(input.interval_time)
.bind(input.active)
.bind(&u)
.execute(state.db.pool())
.execute(&mut *tx)
.await?;
} else {
sqlx::query(&q)
@@ -88,9 +126,22 @@ pub async fn set(
.bind(domain)
.bind(input.interval_time)
.bind(input.active)
.execute(state.db.pool())
.execute(&mut *tx)
.await?;
}
sync_vacation_alias(
&state,
&mut tx,
&u,
domain,
&vacation_address,
input.active,
alias_has_description,
)
.await?;
tx.commit().await?;
state
.db
.log(
@@ -101,8 +152,13 @@ pub async fn set(
&u,
)
.await;
Ok(Json(serde_json::json!({"ok":true})))
Ok(Json(serde_json::json!({
"ok": true,
"active": input.active,
"email": u
})))
}
pub async fn remove(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
@@ -112,16 +168,135 @@ pub async fn remove(
let session = routes::mutation_session(&state, &jar, &headers)?;
let u = validation::email(&path)?;
authorize(&state, &session, &u, true)?;
let (_, domain) = u.rsplit_once('@').expect("validated email");
let vacation_domain = validation::domain(&state.config.defaults.vacation_domain)?;
let vacation_address = vacation_address(&u, &vacation_domain);
let alias_has_description = state.db.has_column(Table::Alias, "description").await?;
let has_notifications = state.db.has_table(Table::VacationNotification).await;
let mut tx = state.db.pool().begin().await?;
let t = state.db.table(Table::Vacation);
let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?"));
sqlx::query(&q).bind(&u).execute(state.db.pool()).await?;
if state.db.has_table(Table::VacationNotification).await {
sqlx::query(&q).bind(&u).execute(&mut *tx).await?;
if has_notifications {
let n = state.db.table(Table::VacationNotification);
let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?"));
let _ = sqlx::query(&q).bind(&u).execute(state.db.pool()).await;
let q = state
.db
.q(&format!("DELETE FROM {n} WHERE on_vacation=?"));
sqlx::query(&q).bind(&u).execute(&mut *tx).await?;
}
sync_vacation_alias(
&state,
&mut tx,
&u,
domain,
&vacation_address,
false,
alias_has_description,
)
.await?;
tx.commit().await?;
state
.db
.log(
&state.config,
&session.username,
domain,
"delete_vacation",
&u,
)
.await;
Ok(Json(serde_json::json!({"ok":true})))
}
async fn sync_vacation_alias(
state: &AppState,
tx: &mut sqlx::Transaction<'_, sqlx::Any>,
email: &str,
domain: &str,
vacation_address: &str,
enabled: bool,
has_description: bool,
) -> AppResult<()> {
let alias = state.db.table(Table::Alias);
let select = state
.db
.q(&format!("SELECT goto FROM {alias} WHERE address=? LIMIT 1"));
let row = sqlx::query(&select)
.bind(email)
.fetch_optional(&mut **tx)
.await?;
if let Some(row) = row {
let current = row_string(&row, "goto");
let mut destinations: Vec<String> = current
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| !value.eq_ignore_ascii_case(vacation_address))
.map(str::to_string)
.collect();
if destinations.is_empty() {
destinations.push(email.to_string());
}
if enabled {
destinations.push(vacation_address.to_string());
}
let update = state.db.q(&format!(
"UPDATE {alias} SET goto=?,modified=CURRENT_TIMESTAMP WHERE address=?"
));
sqlx::query(&update)
.bind(destinations.join(","))
.bind(email)
.execute(&mut **tx)
.await?;
return Ok(());
}
if !enabled {
return Ok(());
}
// A mailbox normally has a self-alias. Recreate it defensively if an older
// database is missing that row, then add the internal vacation transport.
let goto = format!("{email},{vacation_address}");
let insert = if has_description {
state.db.q(&format!(
"INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES (?,?,?,'',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"
))
} else {
state.db.q(&format!(
"INSERT INTO {alias} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"
))
};
sqlx::query(&insert)
.bind(email)
.bind(goto)
.bind(domain)
.bind(true)
.execute(&mut **tx)
.await?;
Ok(())
}
fn vacation_address(email: &str, vacation_domain: &str) -> String {
format!("{}@{}", email.replace('@', "#"), vacation_domain)
}
fn validate_dates(active_from: &str, active_until: &str) -> AppResult<()> {
let from = NaiveDate::parse_from_str(active_from, "%Y-%m-%d")
.map_err(|_| AppError::bad_request("Invalid vacation start date"))?;
let until = NaiveDate::parse_from_str(active_until, "%Y-%m-%d")
.map_err(|_| AppError::bad_request("Invalid vacation end date"))?;
if until < from {
return Err(AppError::bad_request(
"Vacation end date cannot be before start date",
));
}
Ok(())
}
fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -> AppResult<()> {
if !state.config.features.vacation {
return Err(AppError::forbidden("Vacation is disabled"));
@@ -139,10 +314,11 @@ fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -
"Vacation control is disabled for domain administrators",
));
}
let (_, d) = u.rsplit_once('@').unwrap();
let (_, d) = u.rsplit_once('@').expect("validated email");
s.require_domain(d)
}
}
fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
VacationSummary {
email: row_string(r, "email"),
@@ -154,3 +330,22 @@ fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
active: row_bool(r, "active"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_postfixadmin_vacation_transport_address() {
assert_eq!(
vacation_address("user@example.org", "autoreply.example.org"),
"user#example.org@autoreply.example.org"
);
}
#[test]
fn rejects_reversed_vacation_dates() {
assert!(validate_dates("2026-11-10", "2026-11-09").is_err());
assert!(validate_dates("2026-11-09", "2026-11-10").is_ok());
}
}
+5 -4
View File
@@ -1,4 +1,4 @@
.login-view{height:100%;display:grid;place-items:center;padding:24px}.login-panel{width:min(100%,370px);border:1px solid var(--line);background:var(--panel);border-radius:var(--r);box-shadow:0 18px 46px var(--shadow)}.login-brand{display:flex;gap:11px;align-items:center;padding:18px 20px;border-bottom:1px solid var(--line)}.login-brand svg{width:27px;height:27px;color:var(--accent)}.login-brand strong{display:block;font-size:16px}.login-brand small{color:var(--muted)}.login-form{display:grid;gap:13px;padding:20px}.field span{display:block;margin-bottom:5px;color:var(--muted);font-size:11px;font-weight:650}.login-footer{padding:0 20px 16px;color:var(--subtle);font:10px var(--mono);text-align:center}.shell{height:100%;display:grid;grid-template-rows:44px minmax(0,1fr)}.topbar{display:grid;grid-template-columns:230px 1fr auto;align-items:center;gap:12px;padding:0 10px;border-bottom:1px solid var(--line);background:var(--panel)}.brand{display:flex;align-items:center;gap:8px;font-weight:700}.brand svg{color:var(--accent)}.search{max-width:540px;position:relative}.search input{height:30px;min-height:30px;padding-left:30px}.search svg{position:absolute;left:9px;top:7px;color:var(--muted)}.top-actions{display:flex;align-items:center;gap:4px}.who{padding:0 8px;border-left:1px solid var(--line);line-height:1.2}.who strong{display:block;font-size:11px;max-width:190px;overflow:hidden;text-overflow:ellipsis}.who span{font-size:9px;color:var(--success)}.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)}.sidebar{min-height:0;padding:10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto}.nav{display:grid;gap:3px}.nav button{display:flex;align-items:center;gap:9px;width:100%;height:34px;padding:0 9px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);text-align:left}.nav button:hover{background:var(--hover);color:var(--text)}.nav button.active{background:var(--selected);color:var(--text);border-color:var(--line)}.nav button svg{width:16px;height:16px}.nav-section{margin:14px 8px 5px;color:var(--subtle);font-size:9px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.sidebar-foot{margin-top:18px;padding:10px 8px;border-top:1px solid var(--line);color:var(--subtle);font:9px var(--mono)}.content{min-width:0;min-height:0;overflow:auto;padding:20px}.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}.page-head h1{margin:0;font-size:20px;letter-spacing:-.3px}.page-head p{margin:3px 0 0;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap}.cards{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:10px}.card{padding:14px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}.card .k{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em}.card .v{margin-top:5px;font-size:25px;font-weight:720}.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden}.panel-head{display:flex;justify-content:space-between;align-items:center;min-height:40px;padding:0 11px;border-bottom:1px solid var(--line);font-weight:650}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;white-space:nowrap}th,td{height:38px;padding:0 10px;border-bottom:1px solid var(--line);text-align:left}th{position:sticky;top:0;background:var(--panel2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.04em}tbody tr:hover{background:var(--hover)}td.actions-cell{text-align:right}.mini{min-height:26px;height:26px;padding:2px 7px;font-size:11px}.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--line)}.modal-head h2{margin:0;font-size:15px}.modal-body{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:14px;overflow:auto}.modal-body .wide{grid-column:1/-1}.modal-foot{display:flex;justify-content:flex-end;gap:7px;padding:12px 14px;border-top:1px solid var(--line)}.codebox{padding:12px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);font:11px/1.5 var(--mono);white-space:pre-wrap;word-break:break-all}.toolbar{display:flex;gap:7px;align-items:center;margin-bottom:10px}.toolbar select{max-width:260px}.feature-grid{display:grid;grid-template-columns:repeat(3,minmax(180px,1fr));gap:8px}.feature{padding:10px;border:1px solid var(--line);background:var(--panel);border-radius:var(--r);display:flex;justify-content:space-between;gap:10px}
.login-view{height:100%;display:grid;place-items:center;padding:24px}.login-panel{width:min(100%,370px);border:1px solid var(--line);background:var(--panel);border-radius:var(--r);box-shadow:0 18px 46px var(--shadow)}.login-brand{display:flex;gap:11px;align-items:center;padding:18px 20px;border-bottom:1px solid var(--line)}.login-brand svg{width:27px;height:27px;color:var(--accent)}.login-brand strong{display:block;font-size:16px}.login-brand small{color:var(--muted)}.login-form{display:grid;gap:13px;padding:20px}.field span{display:block;margin-bottom:5px;color:var(--muted);font-size:11px;font-weight:650}.login-footer{padding:0 20px 16px;color:var(--subtle);font:10px var(--mono);text-align:center}.shell{height:100%;display:grid;grid-template-rows:44px minmax(0,1fr)}.topbar{display:grid;grid-template-columns:230px 1fr auto;align-items:center;gap:12px;padding:0 10px;border-bottom:1px solid var(--line);background:var(--panel)}.brand{display:flex;align-items:center;gap:8px;font-weight:700}.brand svg{color:var(--accent)}.search{max-width:540px;position:relative}.search input{height:30px;min-height:30px;padding-left:30px}.search svg{position:absolute;left:9px;top:7px;color:var(--muted)}.top-actions{display:flex;align-items:center;gap:4px}.who{padding:0 8px;border-left:1px solid var(--line);line-height:1.2}.who strong{display:block;font-size:11px;max-width:190px;overflow:hidden;text-overflow:ellipsis}.who span{font-size:9px;color:var(--success)}.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)}.sidebar{min-height:0;padding:10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto}.nav{display:grid;gap:3px}.nav button{display:flex;align-items:center;gap:9px;width:100%;height:34px;padding:0 9px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);text-align:left}.nav button:hover{background:var(--hover);color:var(--text)}.nav button.active{background:var(--selected);color:var(--text);border-color:var(--line)}.nav button svg{width:16px;height:16px}.nav-section{margin:14px 8px 5px;color:var(--subtle);font-size:9px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.content{min-width:0;min-height:0;overflow:auto;padding:20px}.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}.page-head h1{margin:0;font-size:20px;letter-spacing:-.3px}.page-head p{margin:3px 0 0;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap}.cards{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:10px}.card{padding:14px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}.card .k{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em}.card .v{margin-top:5px;font-size:25px;font-weight:720}.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden}.panel-head{display:flex;justify-content:space-between;align-items:center;min-height:40px;padding:0 11px;border-bottom:1px solid var(--line);font-weight:650}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;white-space:nowrap}th,td{height:38px;padding:0 10px;border-bottom:1px solid var(--line);text-align:left}th{position:sticky;top:0;background:var(--panel2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.04em}tbody tr:hover{background:var(--hover)}td.actions-cell{text-align:right}.mini{min-height:26px;height:26px;padding:2px 7px;font-size:11px}.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--line)}.modal-head h2{margin:0;font-size:15px}.modal-body{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:14px;overflow:auto}.modal-body .wide{grid-column:1/-1}.modal-foot{display:flex;justify-content:flex-end;gap:7px;padding:12px 14px;border-top:1px solid var(--line)}.codebox{padding:12px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);font:11px/1.5 var(--mono);white-space:pre-wrap;word-break:break-all}.toolbar{display:flex;gap:7px;align-items:center;margin-bottom:10px}.toolbar select{max-width:260px}.feature-grid{display:grid;grid-template-columns:repeat(3,minmax(180px,1fr));gap:8px}.feature{padding:10px;border:1px solid var(--line);background:var(--panel);border-radius:var(--r);display:flex;justify-content:space-between;gap:10px}
.spacer{height:12px}.panel-pad{padding:14px}.panel-margin{margin:10px}.small-value{font-size:17px!important}.section-title{margin:18px 2px 8px;color:var(--muted);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.stack-form{display:grid;gap:12px}.stack-form h3,.panel-pad h3{margin-top:0}.checkbox-row{display:flex!important;align-items:center;gap:8px}.checkbox-row input{width:auto;min-height:auto}.checkbox-row span{margin:0!important}.login-form .actions{justify-content:center}.login-form .btn.ghost{font-size:11px;color:var(--muted)}
.compact-select{width:auto;min-width:150px;max-width:240px}
@@ -6,7 +6,7 @@
.notice{margin:10px 0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);font-size:11px;line-height:1.5}.notice.warning{border-color:var(--warn);color:var(--warn)}
.language-select{min-width:82px;max-width:96px;height:28px;min-height:28px;padding:2px 24px 2px 8px;font-size:10px}.login-footer{display:flex;align-items:center;justify-content:space-between;gap:10px}.login-language{display:flex;align-items:center;gap:6px;font:10px var(--sans);color:var(--muted)}.login-language span{white-space:nowrap}.login-language select{min-width:88px;height:26px;min-height:26px}
/* 0.0.1-dev domain-focused administration */
/* 0.0.2 domain-focused administration */
.theme-toggle{width:34px;min-width:34px;height:30px;min-height:30px;padding:0;border-color:var(--line);background:var(--panel2);color:var(--text)}
.theme-toggle:hover{background:var(--hover);border-color:var(--accent)}
.theme-toggle .icon{width:18px;height:18px;display:block;flex:0 0 auto}
@@ -29,7 +29,7 @@
@media(max-width:1100px){.workspace-grid{grid-template-columns:1fr}.domain-cards{grid-template-columns:repeat(2,minmax(150px,1fr))}}
@media(max-width:650px){.list-toolbar{align-items:stretch;flex-direction:column}.domain-picker label{align-items:stretch;flex-direction:column}.domain-picker select{min-width:0;max-width:none;width:100%}.list-summary{justify-content:space-between}.backend-tabs{width:100%}.backend-tab{flex:1}.backend-info{align-items:flex-start;flex-direction:column}}
/* 0.0.1-dev feedback, quota levels and cached icon fixes */
/* 0.0.2 feedback, quota levels and cached icon fixes */
.topbar{position:relative}.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;z-index:10;background:transparent}.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1s ease-in-out infinite}.route-progress[hidden]{display:none}@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
.api-busy .domain-picker .domain-loading{display:inline-flex}.domain-loading{display:none;align-items:center;color:var(--accent)}.domain-loading .icon{width:14px;height:14px;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.theme-toggle{outline:1px solid color-mix(in srgb,var(--text) 18%,transparent)}.theme-toggle .icon{color:var(--accent2);stroke-width:2.2}.theme-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
@@ -37,7 +37,7 @@
.actions-cell .btn+.btn{margin-left:9px}.actions-cell .btn.danger{border-color:color-mix(in srgb,var(--danger) 36%,var(--line2))}
.input-action{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.input-action .btn{white-space:nowrap}
/* 0.0.1-dev mailbox-user workspace */
/* 0.0.2 mailbox-user workspace */
.user-overview-cards .mailbox-address{font-size:15px!important;font-family:var(--mono);overflow-wrap:anywhere}
.user-overview-cards .quota-card .progress-cell{min-width:0;margin-top:8px}
.user-account-grid .panel{min-width:0}.account-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}
@@ -52,3 +52,4 @@
.sieve-editor-panel{display:flex;flex-direction:column;min-width:0}.sieve-editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;border-bottom:1px solid var(--line)}.sieve-editor-head>div:first-child{display:flex;align-items:center;gap:8px}.sieve-editor{flex:1;width:100%;min-height:420px;resize:vertical;border:0;border-radius:0;background:var(--field);padding:14px;font:12px/1.55 var(--mono);color:var(--text);outline:none}.sieve-editor:focus{box-shadow:inset 0 0 0 1px var(--accent)}.sieve-empty{min-height:420px;display:grid;place-items:center}.sieve-capabilities{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.compact-empty{padding:14px 8px}
@media(max-width:900px){.sieve-layout{grid-template-columns:1fr}.sieve-list-body{grid-template-columns:repeat(2,minmax(0,1fr))}.sieve-editor-head{align-items:flex-start;flex-direction:column}.account-facts{grid-template-columns:1fr}}
@media(max-width:560px){.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.sieve-list-body{grid-template-columns:1fr}}
.field-help{display:block;margin-top:5px;color:var(--subtle);font-size:10px;line-height:1.35}.vacation-mailbox-picker{display:grid;gap:10px}.vacation-mailbox-results{max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:var(--r)}.vacation-mailbox-result{display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--text);text-align:left}.vacation-mailbox-result:last-child{border-bottom:0}.vacation-mailbox-result:hover{background:var(--hover)}.alias-section+.alias-section{margin-top:16px}
+1 -1
View File
@@ -1,2 +1,2 @@
@media(max-width:850px){.body{grid-template-columns:58px minmax(0,1fr)}.sidebar{padding:8px}.nav button{justify-content:center;padding:0}.nav button span,.nav-section,.sidebar-foot,.brand span,.search{display:none}.topbar{grid-template-columns:40px 1fr}.who strong{max-width:120px}.cards{grid-template-columns:repeat(2,1fr)}.feature-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.content{padding:12px}.cards{grid-template-columns:1fr 1fr}.page-head{display:block}.page-head .actions{margin-top:10px}.modal-body{grid-template-columns:1fr}.modal-body .wide{grid-column:auto}.who{display:none}.feature-grid{grid-template-columns:1fr}}
@media(max-width:850px){.body{grid-template-columns:58px minmax(0,1fr)}.sidebar{padding:8px}.nav button{justify-content:center;padding:0}.nav button span,.nav-section,.brand span,.search{display:none}.topbar{grid-template-columns:40px 1fr}.who strong{max-width:120px}.cards{grid-template-columns:repeat(2,1fr)}.feature-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.content{padding:12px}.cards{grid-template-columns:1fr 1fr}.page-head{display:block}.page-head .actions{margin-top:10px}.modal-body{grid-template-columns:1fr}.modal-body .wide{grid-column:auto}.who{display:none}.feature-grid{grid-template-columns:1fr}}
@media(max-width:860px){.two-col{grid-template-columns:1fr}}
+19 -3
View File
@@ -297,8 +297,8 @@
"Brak celów aliasu.": "No alias targets.",
"Automatyczna odpowiedź dla Twojej skrzynki": "Automatic reply for your mailbox",
"Od": "From",
"Zapisz Vacation": "Save vacation",
"Vacation zapisany": "Vacation saved",
"Zapisz Vacation": "Save autoresponder",
"Vacation zapisany": "Autoresponder saved",
"ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules",
"Nowy skrypt": "New script",
"Skrypty": "Scripts",
@@ -341,7 +341,19 @@
"Rekord DNS": "DNS record",
"Rekordy DNS DKIM dla Twojej domeny — tylko do odczytu": "DKIM DNS records for your domain — read-only",
"Brak skonfigurowanego rekordu DKIM dla tej domeny.": "No DKIM record is configured for this domain.",
"Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node"
"Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node",
"Aliasy użytkowników": "User aliases",
"Aliasy techniczne": "Technical aliases",
"Wpisz tylko część przed znakiem @": "Enter only the part before @",
"Podaj wyłącznie adresy docelowe przekierowania.": "Enter forwarding destination addresses only.",
"Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.": "Enter an address or search for a mailbox by address or name.",
"Edytuj autoresponder": "Edit autoresponder",
"Brak pasujących skrzynek": "No matching mailboxes",
"Wybierz istniejącą skrzynkę z listy.": "Select an existing mailbox from the list.",
"Wybierz inną skrzynkę": "Choose another mailbox",
"Wybierz skrzynkę": "Choose mailbox",
"Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.": "Choose the mailbox for which you want to configure an autoresponder.",
"Skrzynka": "Mailbox"
},
"patterns": [
{
@@ -415,6 +427,10 @@
{
"match": "^Pominięto: (\\d+)$",
"replace": "Skipped: $1"
},
{
"match": "^Wpisz tylko nazwę przed @(.+)$",
"replace": "Enter only the name before @$1"
}
]
}
+2 -1
View File
@@ -173,7 +173,7 @@
</div>
</header>
<div class="body">
<aside class="sidebar"><nav class="nav" id="nav"></nav><div class="sidebar-foot">Rust ${esc(window.PA_RS?.version || '')}</div></aside>
<aside class="sidebar"><nav class="nav" id="nav"></nav></aside>
<main class="content" id="content"></main>
</div>
</div>`;
@@ -319,6 +319,7 @@
} else {
label.append(element);
}
if (field.help) label.append(h('small', { class: 'field-help' }, field.help));
body.append(label);
}
+192 -97
View File
@@ -307,9 +307,10 @@
function mailboxForm(row = {}, domainOverride = '') {
const domain = row.domain || domainOverride || S.cache.mailboxDomain || (S.me.domains || []).find(item => item !== 'ALL') || '';
const localPart = row.local_part || (row.username ? row.username.split('@')[0] : '');
const fields = [
{ name: 'username', label: 'Adres e-mail', required: true, readonly: !!row.username },
{ name: 'domain', label: 'Domena', required: true, readonly: !!row.username },
{ name: 'local_part', label: 'Adres e-mail', required: true, readonly: !!row.username, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' },
{ name: 'domain', label: 'Domena', required: true, readonly: true },
...(S.features.edit_mailbox_name ? [{ name: 'name', label: 'Nazwa wyświetlana' }] : []),
{ name: 'password', label: row.username ? 'Nowe hasło (puste = bez zmian)' : 'Hasło', type: 'password', autocomplete: 'new-password', required: !row.username, generate: !!S.features.generate_password },
{ name: 'quota', label: 'Quota [MB]', type: 'number', min: 0 },
@@ -320,13 +321,16 @@
{ name: 'send_welcome', label: 'Wiadomość powitalna', type: 'checkbox', help: 'Wyślij po utworzeniu' },
];
formModal(row.username ? 'Edytuj skrzynkę' : 'Nowa skrzynka', fields, {
username: row.username || '', domain, name: row.name || '', password: '',
local_part: localPart, domain, name: row.name || '', password: '',
quota: row.quota ? mbFromBytes(row.quota) : 10,
phone: row.phone || '', email_other: row.email_other || '', active: row.username ? row.active : true,
smtp_active: row.smtp_active ?? true, send_welcome: false,
}, async data => {
const body = {
username: data.username, domain: data.domain, name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null,
username: row.username || null,
local_part: row.username ? null : data.local_part,
domain: data.domain,
name: S.features.edit_mailbox_name ? data.name : (row.name || ''), password: data.password || null,
quota: number(data.quota), phone: data.phone, email_other: data.email_other,
active: data.active, smtp_active: data.smtp_active, send_welcome: data.send_welcome,
};
@@ -334,7 +338,8 @@
method: row.username ? 'PUT' : 'POST', body: JSON.stringify(body),
});
if (!row.username && S.features.show_password && data.password) {
return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.username}\n${data.password}` } };
return { reveal: { title: 'Hasło nowej skrzynki', text: `${data.local_part}@${data.domain}
${data.password}` } };
}
return { message: row.username ? 'Skrzynka zapisana' : 'Skrzynka utworzona' };
});
@@ -367,14 +372,29 @@
const inactiveAction = S.me.kind === 'admin' && hasInactive ? `<button id="deleteInactive" class="btn danger">${icon('trash')}Usuń nieaktywne</button>` : '';
const add = S.me.kind === 'admin' ? `<button id="addAlias" class="btn primary">${icon('plus')}Nowy alias</button>${S.features.alias_domain ? `<button id="addAliasDomain" class="btn">${icon('folder-plus')}Alias domeny</button>` : ''}${inactiveAction}` : '';
const tools = S.me.kind === 'user' ? readonlyDomain(selected) : (domains.length > 1 ? domainPicker('aliasDomainSelect', domains, selected) : readonlyDomain(selected));
const aliasTable = table(
const indexedAliases = filteredAliases.map((row, index) => ({ row, index }));
const aliasTableFor = entries => table(
['Adres', 'Cel', 'Opis', 'Status', 'Mailbox', 'Aktywny', ''],
filteredAliases.map((row, index) => `<tr data-row="${index}"><td class="mono">${esc(row.address)}</td><td>${row.goto.map(esc).join('<br>')}</td><td>${esc(row.description)}</td><td>${aliasStatus(row.status)}</td><td>${yes(row.is_mailbox)}</td><td>${yes(row.active)}</td><td class="actions-cell">${action('Edytuj')}${!row.is_mailbox && S.me.kind === 'admin' ? action('Usuń', true) : ''}</td></tr>`)
entries.map(({ row, index }) => {
const canEditTechnical = !row.technical || S.me.role === 'global_admin' || S.features.special_alias_control;
const editAction = canEditTechnical ? action('Edytuj') : '';
const deleteAction = !row.is_mailbox && S.me.kind === 'admin' && canEditTechnical ? action('Usuń', true) : '';
return `<tr data-row="${index}"><td class="mono">${esc(row.address)}</td><td>${row.goto.map(esc).join('<br>')}</td><td>${esc(row.description)}</td><td>${aliasStatus(row.status)}</td><td>${yes(row.is_mailbox)}</td><td>${yes(row.active)}</td><td class="actions-cell">${editAction}${deleteAction}</td></tr>`;
})
);
let aliasSections;
if (S.me.kind === 'admin') {
const technical = indexedAliases.filter(item => item.row.technical);
const regular = indexedAliases.filter(item => !item.row.technical);
aliasSections = `<div class="alias-section"><div class="section-title">Aliasy użytkowników</div>${aliasTableFor(regular)}</div>` +
(technical.length ? `<div class="alias-section"><div class="section-title">Aliasy techniczne</div>${aliasTableFor(technical)}</div>` : '');
} else {
aliasSections = `<div class="section-title">${esc(selected || t('Aliasy'))}</div>${aliasTableFor(indexedAliases)}`;
}
const domainTable = filteredAliasDomains.length || (S.me.kind === 'admin' && S.features.alias_domain)
? `<div class="section-title">Aliasy domen</div>${table(['Alias domeny', 'Domena docelowa', 'Opis', 'Aktywny', ''], filteredAliasDomains.map((row, index) => `<tr data-ad-row="${index}"><td class="mono">${esc(row.alias_domain)}</td><td class="mono">${esc(row.target_domain)}</td><td>${esc(row.description)}</td><td>${yes(row.active)}</td><td class="actions-cell">${action('Edytuj AD')}${action('Usuń AD', true)}</td></tr>`))}`
: '';
content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `<div class="list-toolbar">${tools}<div class="list-summary"><span>${icon('forward')} ${filteredAliases.length}</span><span>${icon('folder-plus')} ${filteredAliasDomains.length}</span></div></div><div class="section-title">${esc(selected || t('Aliasy'))}</div>` + aliasTable + domainTable;
content().innerHTML = pageHead('Aliasy', 'Przekierowania, self-aliasy skrzynek i alias-domains', add) + `<div class="list-toolbar">${tools}<div class="list-summary"><span>${icon('forward')} ${filteredAliases.length}</span><span>${icon('folder-plus')} ${filteredAliasDomains.length}</span></div></div>` + aliasSections + domainTable;
document.querySelector('#aliasDomainSelect')?.addEventListener('change', async event => {
event.target.disabled = true;
S.cache.aliasDomain = event.target.value;
@@ -416,6 +436,11 @@
async function aliasForm(row = {}, domainOverride = '') {
const domain = row.domain || domainOverride || S.cache.aliasDomain || (S.me.domains || []).find(item => item !== 'ALL') || '';
const userEdit = S.me.kind === 'user' && Boolean(row.address);
const localPart = row.address ? row.address.split('@')[0] : '';
const selfTarget = row.address || '';
const visibleTargets = (row.goto || []).filter(value => !selfTarget || value.toLowerCase() !== selfTarget.toLowerCase());
const keepsLocalDelivery = Boolean(row.is_mailbox && (row.goto || []).some(value => value.toLowerCase() === selfTarget.toLowerCase()));
let defaultGoto = '';
if (!row.address && S.me.kind === 'admin') {
try {
@@ -423,22 +448,39 @@
defaultGoto = preferences.find(pref => pref.key === 'alias_goto_default')?.value || '';
} catch {}
}
const fields = [
{ name: 'address', label: 'Adres aliasu', required: true, readonly: S.me.kind === 'user' || !!row.address },
{ name: 'domain', label: 'Domena', required: true, readonly: S.me.kind === 'user' || !!row.address },
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, required: true },
const fields = userEdit ? [
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true, help: 'Podaj wyłącznie adresy docelowe przekierowania.' },
...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []),
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' },
] : [
{ name: 'local_part', label: 'Adres aliasu', required: true, readonly: !!row.address, help: domain ? `Wpisz tylko nazwę przed @${domain}` : 'Wpisz tylko część przed znakiem @' },
{ name: 'domain', label: 'Domena', required: true, readonly: true },
{ name: 'goto', label: 'Cele (jeden na linię)', type: 'textarea', wide: true },
{ name: 'description', label: 'Opis', wide: true },
{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' },
...(row.is_mailbox ? [{ name: 'store_and_forward', label: 'Store & forward', type: 'checkbox', help: 'Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie' }] : []),
...(!row.address ? [{ name: 'save_goto_default', label: 'Zapamiętaj cele', type: 'checkbox', help: 'Zapisz jako domyślny cel nowych aliasów dla tego administratora' }] : []),
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Alias aktywny' },
];
formModal(row.address ? 'Edytuj alias' : 'Nowy alias', fields, {
address: row.address || '', domain, goto: row.address ? (row.goto || []).join('\n') : defaultGoto, description: row.description || '',
store_and_forward: row.is_mailbox, save_goto_default: false, active: row.address ? row.active : true,
local_part: localPart,
domain,
goto: row.address ? visibleTargets.join('\n') : defaultGoto,
description: row.description || '',
store_and_forward: keepsLocalDelivery,
save_goto_default: false,
active: row.address ? row.active : true,
}, async data => {
const body = {
address: data.address, domain: data.domain, goto: splitList(data.goto), description: data.description,
store_and_forward: data.store_and_forward, save_goto_default: !!data.save_goto_default, active: data.active,
address: row.address || null,
local_part: row.address ? null : data.local_part,
domain,
goto: splitList(data.goto),
description: userEdit ? (row.description || '') : data.description,
store_and_forward: row.is_mailbox ? Boolean(data.store_and_forward) : false,
save_goto_default: userEdit ? false : !!data.save_goto_default,
active: data.active,
};
await api(row.address ? `/aliases/${enc(row.address)}` : '/aliases', {
method: row.address ? 'PUT' : 'POST', body: JSON.stringify(body),
@@ -561,92 +603,145 @@
});
}
async function vacation() {
if (S.me.kind === 'user') {
const mailbox = S.me.username;
const existing = await api(`/vacation/${enc(mailbox)}`);
const row = existing || {
email: mailbox, subject: 'Out of office', body: '', interval_time: 0,
active_from: new Date().toISOString().slice(0, 10),
active_until: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), active: true,
};
content().innerHTML = pageHead('Vacation', 'Automatyczna odpowiedź dla Twojej skrzynki') +
`<div class="panel panel-pad vacation-user-panel">
<form id="vacationUserForm" class="stack-form">
<div class="form-grid">
<label class="field wide"><span>Temat</span><input name="subject" required value="${esc(row.subject)}"></label>
<label class="field wide"><span>Treść</span><textarea name="body" required>${esc(row.body)}</textarea></label>
<label class="field"><span>Od</span><input name="active_from" type="date" required value="${esc(row.active_from)}"></label>
<label class="field"><span>Do</span><input name="active_until" type="date" required value="${esc(row.active_until)}"></label>
<label class="field"><span>Interwał odpowiedzi [s]</span><input name="interval_time" type="number" min="0" value="${number(row.interval_time)}"></label>
<label class="field checkbox-row"><input name="active" type="checkbox" ${row.active ? 'checked' : ''}><span>Autoresponder aktywny</span></label>
</div>
<div class="panel-actions"><button class="btn primary" type="submit">${icon('check-circle')}Zapisz Vacation</button>${existing ? `<button id="removeUserVacation" class="btn danger" type="button">${icon('trash')}Wyłącz/usuń</button>` : ''}</div>
</form>
</div>`;
document.querySelector('#vacationUserForm').onsubmit = async event => {
event.preventDefault();
const form = new FormData(event.target);
const submit = event.submitter || event.target.querySelector('button[type="submit"]');
if (submit) submit.disabled = true;
try {
await api(`/vacation/${enc(mailbox)}`, { method: 'PUT', body: JSON.stringify({
email: mailbox,
subject: form.get('subject'), body: form.get('body'),
active_from: form.get('active_from'), active_until: form.get('active_until'),
interval_time: number(form.get('interval_time')), active: form.get('active') === 'on',
}) });
showToast('Vacation zapisany');
await vacation();
} catch (error) { if (submit) submit.disabled = false; showToast(error.message, true); }
};
document.querySelector('#removeUserVacation')?.addEventListener('click', async () => {
if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return;
await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' });
showToast('Vacation usunięty');
vacation();
});
return;
}
const mailbox = await promptModal('Vacation', 'Skrzynka dla autorespondera', (S.me.domains || []).find(item => item !== 'ALL') ? `@${(S.me.domains || []).find(item => item !== 'ALL')}` : '', { help: 'Podaj pełny adres skrzynki, np. user@example.org.' });
if (!mailbox) {
content().innerHTML = pageHead('Vacation', 'Autoresponder') + '<div class="empty">Wybierz skrzynkę ponownie z menu.</div>';
return;
}
let row = await api(`/vacation/${enc(mailbox)}`);
row = row || {
email: mailbox, subject: 'Out of office', body: '', interval_time: 0,
active_from: new Date().toISOString().slice(0, 10),
active_until: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), active: true,
};
content().innerHTML = pageHead('Vacation', mailbox, `<button id="editVac" class="btn primary">Edytuj</button>${row.email ? '<button id="delVac" class="btn danger">Wyłącz/usuń</button>' : ''}`) +
`<div class="panel panel-pad"><h3>${esc(row.subject)}</h3><div class="codebox">${esc(row.body)}</div><p class="muted">${esc(row.active_from)} → ${esc(row.active_until)} · interval ${row.interval_time}s · ${row.active ? 'aktywny' : 'wyłączony'}</p></div>`;
document.querySelector('#editVac').onclick = () => vacationForm(mailbox, row);
const remove = document.querySelector('#delVac');
if (remove) remove.onclick = async () => {
if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return;
await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' });
showToast('Vacation usunięty');
vacation();
function defaultVacation(mailbox) {
const localDate = date => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
const now = new Date();
return {
email: mailbox,
subject: 'Out of office',
body: '',
interval_time: 0,
active_from: localDate(now),
active_until: localDate(new Date(now.getTime() + 7 * 86400000)),
active: true,
};
}
function vacationForm(mailbox, row) {
formModal('Vacation', [
{ name: 'subject', label: 'Temat', wide: true },
{ name: 'body', label: 'Treść', type: 'textarea', wide: true },
{ name: 'active_from', label: 'Od', type: 'date' },
{ name: 'active_until', label: 'Do', type: 'date' },
{ name: 'interval_time', label: 'Interwał odpowiedzi [s]', type: 'number' },
{ name: 'active', label: 'Aktywny', type: 'checkbox', help: 'Autoresponder aktywny' },
], row, async data => {
await api(`/vacation/${enc(mailbox)}`, {
method: 'PUT', body: JSON.stringify({ ...data, email: mailbox, interval_time: number(data.interval_time) }),
});
const vacationDate = value => String(value || '').slice(0, 10);
function vacationMailboxPicker(mailboxes) {
return new Promise(resolve => {
const modal = document.querySelector('#modal');
modal.innerHTML = `<form id="vacationMailboxPickerForm">
<div class="modal-head"><h2>${esc(t('Vacation'))}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
<div class="modal-body modal-body-single"><div class="vacation-mailbox-picker">
<label class="field"><span>${esc(t('Skrzynka dla autorespondera'))}</span><input id="vacationMailboxSearch" name="mailbox" type="text" inputmode="email" autocomplete="off" placeholder="user@example.org" required></label>
<div class="muted">${esc(t('Wpisz adres lub wyszukaj skrzynkę po adresie albo nazwie.'))}</div>
<div id="vacationMailboxResults" class="vacation-mailbox-results"></div>
<div id="vacationMailboxError" class="muted"></div>
</div></div>
<div class="modal-foot"><button class="btn" type="button" data-cancel>${esc(t('Anuluj'))}</button><button class="btn primary" type="submit">${esc(t('Edytuj autoresponder'))}</button></div>
</form>`;
const form = modal.querySelector('#vacationMailboxPickerForm');
const input = modal.querySelector('#vacationMailboxSearch');
const results = modal.querySelector('#vacationMailboxResults');
const error = modal.querySelector('#vacationMailboxError');
let filtered = [];
let settled = false;
const onCancel = event => { event.preventDefault(); finish(null); };
const finish = value => {
if (settled) return;
settled = true;
modal.removeEventListener('cancel', onCancel);
if (modal.open) modal.close();
modal.innerHTML = '';
resolve(value);
};
const render = () => {
const query = input.value.trim().toLowerCase();
filtered = mailboxes.filter(row => !query || row.username.toLowerCase().includes(query) || String(row.name || '').toLowerCase().includes(query)).slice(0, 40);
results.innerHTML = filtered.length ? filtered.map(row => `<button type="button" class="vacation-mailbox-result" data-mailbox="${esc(row.username)}"><span class="mono">${esc(row.username)}</span><span class="muted">${esc(row.name || '')}</span></button>`).join('') : `<div class="empty">${esc(t('Brak pasujących skrzynek'))}</div>`;
results.querySelectorAll('[data-mailbox]').forEach(button => {
button.onclick = () => finish(button.dataset.mailbox);
});
};
input.oninput = () => { error.textContent = ''; render(); };
form.onsubmit = event => {
event.preventDefault();
const value = input.value.trim().toLowerCase();
const exact = mailboxes.find(row => row.username.toLowerCase() === value);
if (exact) return finish(exact.username);
if (filtered.length === 1) return finish(filtered[0].username);
error.textContent = t('Wybierz istniejącą skrzynkę z listy.');
};
form.querySelectorAll('[data-cancel]').forEach(button => { button.onclick = () => finish(null); });
modal.addEventListener('cancel', onCancel, { once: true });
render();
modal.showModal();
input.focus();
});
}
async function renderVacationEditor(mailbox, adminMode) {
const existing = await api(`/vacation/${enc(mailbox)}`);
const row = existing || defaultVacation(mailbox);
row.active_from = vacationDate(row.active_from);
row.active_until = vacationDate(row.active_until);
const switchMailbox = adminMode ? `<button id="chooseVacationMailbox" class="btn">${icon('search')}Wybierz inną skrzynkę</button>` : '';
content().innerHTML = pageHead('Vacation', adminMode ? mailbox : 'Automatyczna odpowiedź dla Twojej skrzynki', switchMailbox) +
`<div class="panel panel-pad vacation-user-panel">
<form id="vacationUserForm" class="stack-form">
<div class="form-grid">
${adminMode ? `<label class="field wide"><span>Skrzynka</span><input value="${esc(mailbox)}" readonly></label>` : ''}
<label class="field wide"><span>Temat</span><input name="subject" required maxlength="255" value="${esc(row.subject)}"></label>
<label class="field wide"><span>Treść</span><textarea name="body" required>${esc(row.body)}</textarea></label>
<label class="field"><span>Od</span><input name="active_from" type="date" required value="${esc(row.active_from)}"></label>
<label class="field"><span>Do</span><input name="active_until" type="date" required value="${esc(row.active_until)}"></label>
<label class="field"><span>Interwał odpowiedzi [s]</span><input name="interval_time" type="number" min="0" value="${number(row.interval_time)}"></label>
<label class="field checkbox-row"><input name="active" type="checkbox" ${row.active ? 'checked' : ''}><span>Autoresponder aktywny</span></label>
</div>
<div class="panel-actions"><button class="btn primary" type="submit">${icon('check-circle')}Zapisz Vacation</button>${existing ? `<button id="removeUserVacation" class="btn danger" type="button">${icon('trash')}Wyłącz/usuń</button>` : ''}</div>
</form>
</div>`;
document.querySelector('#chooseVacationMailbox')?.addEventListener('click', () => {
S.cache.vacationMailbox = '';
vacation();
});
document.querySelector('#vacationUserForm').onsubmit = async event => {
event.preventDefault();
const form = new FormData(event.target);
const submit = event.submitter || event.target.querySelector('button[type="submit"]');
if (submit) submit.disabled = true;
try {
await api(`/vacation/${enc(mailbox)}`, { method: 'PUT', body: JSON.stringify({
email: mailbox,
subject: form.get('subject'), body: form.get('body'),
active_from: form.get('active_from'), active_until: form.get('active_until'),
interval_time: number(form.get('interval_time')), active: form.get('active') === 'on',
}) });
showToast('Vacation zapisany');
await renderVacationEditor(mailbox, adminMode);
} catch (error) {
if (submit) submit.disabled = false;
showToast(error.message, true);
}
};
document.querySelector('#removeUserVacation')?.addEventListener('click', async () => {
if (!await confirmModal('Usuń vacation', `Wyłączyć i usunąć autoresponder dla ${mailbox}?`)) return;
await api(`/vacation/${enc(mailbox)}`, { method: 'DELETE' });
showToast('Vacation usunięty');
await renderVacationEditor(mailbox, adminMode);
});
}
async function vacation() {
if (S.me.kind === 'user') {
await renderVacationEditor(S.me.username, false);
return;
}
const mailboxes = await api('/mailboxes');
let mailbox = S.cache.vacationMailbox || '';
if (mailbox && !mailboxes.some(row => row.username.toLowerCase() === mailbox.toLowerCase())) mailbox = '';
if (!mailbox) mailbox = await vacationMailboxPicker(mailboxes);
if (!mailbox) {
content().innerHTML = pageHead('Vacation', 'Autoresponder', `<button id="chooseVacationMailbox" class="btn primary">${icon('search')}Wybierz skrzynkę</button>`) + '<div class="empty">Wybierz skrzynkę, dla której chcesz skonfigurować autoresponder.</div>';
document.querySelector('#chooseVacationMailbox').onclick = () => vacation();
return;
}
S.cache.vacationMailbox = mailbox;
await renderVacationEditor(mailbox, true);
}
function sieveQuote(value) {