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