v0.0.2
This commit is contained in:
1 parent
ba300b2448
commit
6e5aa8e4e4
26 files changed
+814
-214
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user