352 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|