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>, Path(path): Path, jar: CookieJar, ) -> AppResult>> { 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>, Path(path): Path, jar: CookieJar, headers: HeaderMap, Json(input): Json, ) -> AppResult> { 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>, Path(path): Path, jar: CookieJar, headers: HeaderMap, ) -> AppResult> { 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 = 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()); } }