Files
PostfixAdminRS/src/routes/vacation.rs
T
2026-11-03 20:05:17 +01:00

352 lines
11 KiB
Rust

use crate::{
db::{Table, row_bool, row_i64, row_string},
error::{AppError, AppResult},
models::{AccountKind, VacationInput, VacationSummary},
routes,
state::AppState,
validation,
};
use axum::{
Json,
extract::{Path, State},
http::HeaderMap,
};
use axum_extra::extract::cookie::CookieJar;
use chrono::NaiveDate;
use std::sync::Arc;
pub async fn get(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
jar: CookieJar,
) -> AppResult<Json<Option<VacationSummary>>> {
let session = routes::session(&state, &jar)?;
let u = validation::email(&path)?;
authorize(&state, &session, &u, false)?;
if !state.config.features.vacation || !state.db.has_table(Table::Vacation).await {
return Ok(Json(None));
}
let t = state.db.table(Table::Vacation);
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 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>,
jar: CookieJar,
headers: HeaderMap,
Json(input): Json<VacationInput>,
) -> AppResult<Json<serde_json::Value>> {
let session = routes::mutation_session(&state, &jar, &headers)?;
let u = validation::email(&path)?;
authorize(&state, &session, &u, true)?;
if validation::email(&input.email)? != u {
return Err(AppError::bad_request(
"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"));
}
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)
.bind(&u)
.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=?"
))
} 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,?)"
))
};
if exists {
sqlx::query(&q)
.bind(&input.subject)
.bind(&input.body)
.bind(&input.active_from)
.bind(&input.active_until)
.bind(input.interval_time)
.bind(input.active)
.bind(&u)
.execute(&mut *tx)
.await?;
} else {
sqlx::query(&q)
.bind(&u)
.bind(&input.subject)
.bind(&input.body)
.bind(&input.active_from)
.bind(&input.active_until)
.bind(domain)
.bind(input.interval_time)
.bind(input.active)
.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(
&state.config,
&session.username,
domain,
"edit_vacation",
&u,
)
.await;
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>,
jar: CookieJar,
headers: HeaderMap,
) -> AppResult<Json<serde_json::Value>> {
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(&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=?"));
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"));
}
if s.kind == AccountKind::User {
if !state.config.features.vacation_control || s.username != u {
return Err(AppError::forbidden(
"Vacation control is disabled for this user",
));
}
Ok(())
} else {
if write && !state.config.features.vacation_control_admin && !s.role.is_global() {
return Err(AppError::forbidden(
"Vacation control is disabled for domain administrators",
));
}
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"),
subject: row_string(r, "subject"),
body: row_string(r, "body"),
interval_time: row_i64(r, "interval_time"),
active_from: row_string(r, "activefrom"),
active_until: row_string(r, "activeuntil"),
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());
}
}