first commit
This commit is contained in:
commit
2eb8aac027
91 files changed
+20139
No files matched your search
@@ -0,0 +1,333 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{AdminInput, AdminSummary},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<AdminSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
session.require_global()?;
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let da = state.db.table(Table::DomainAdmins);
|
||||
let phone = if state.db.has_column(Table::Admin, "phone").await? {
|
||||
"phone"
|
||||
} else {
|
||||
"'' AS phone"
|
||||
};
|
||||
let email_other = if state.db.has_column(Table::Admin, "email_other").await? {
|
||||
"email_other"
|
||||
} else {
|
||||
"'' AS email_other"
|
||||
};
|
||||
let totp = if state.db.has_column(Table::Admin, "totp_secret").await? {
|
||||
"totp_secret"
|
||||
} else {
|
||||
"NULL AS totp_secret"
|
||||
};
|
||||
let superadmin = state.db.flag("superadmin", "superadmin");
|
||||
let active = state.db.flag("active", "active");
|
||||
let sql = format!(
|
||||
"SELECT username,{superadmin},{active},{phone},{email_other},{totp} FROM {admin} ORDER BY username"
|
||||
);
|
||||
let rows = sqlx::query(&sql).fetch_all(state.db.pool()).await?;
|
||||
let mut result = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let username = row_string(&row, "username");
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT domain FROM {da} WHERE username=? AND active=? ORDER BY domain"
|
||||
));
|
||||
let domains = sqlx::query(&q)
|
||||
.bind(&username)
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| row_string(r, "domain"))
|
||||
.collect();
|
||||
result.push(AdminSummary {
|
||||
username,
|
||||
superadmin: row_bool(&row, "superadmin"),
|
||||
active: row_bool(&row, "active"),
|
||||
phone: row_string(&row, "phone"),
|
||||
email_other: row_string(&row, "email_other"),
|
||||
domains,
|
||||
totp_enabled: !row_string(&row, "totp_secret").is_empty(),
|
||||
});
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<AdminInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
input.username = validation::resolvable_email(&state.config, &input.username).await?;
|
||||
let password = input
|
||||
.password
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::bad_request("Password is required"))?;
|
||||
let hash = passwords::hash(&state.config, password, &input.username).await?;
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let check = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {admin} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&check)
|
||||
.bind(&input.username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("Administrator already exists"));
|
||||
}
|
||||
validate_domains(&state, &input.domains, input.superadmin).await?;
|
||||
let has_phone = state.db.has_column(Table::Admin, "phone").await?;
|
||||
let has_other = state.db.has_column(Table::Admin, "email_other").await?;
|
||||
let mut cols = vec![
|
||||
"username",
|
||||
"password",
|
||||
"superadmin",
|
||||
"created",
|
||||
"modified",
|
||||
"active",
|
||||
];
|
||||
let mut vals = vec!["?", "?", "?", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP", "?"];
|
||||
if has_phone {
|
||||
cols.insert(3, "phone");
|
||||
vals.insert(3, "?");
|
||||
}
|
||||
if has_other {
|
||||
let pos = if has_phone { 4 } else { 3 };
|
||||
cols.insert(pos, "email_other");
|
||||
vals.insert(pos, "?");
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {admin} ({}) VALUES ({})",
|
||||
cols.join(","),
|
||||
vals.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.username)
|
||||
.bind(hash)
|
||||
.bind(input.superadmin);
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_other {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
q.bind(input.active).execute(state.db.pool()).await?;
|
||||
sync_domains(&state, &input.username, &input.domains, input.superadmin).await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
"",
|
||||
"create_admin",
|
||||
&input.username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"username":input.username}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<AdminInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
input.username = validation::email(&input.username)?;
|
||||
if input.username != username {
|
||||
return Err(AppError::bad_request(
|
||||
"Renaming administrators is not supported",
|
||||
));
|
||||
}
|
||||
validate_domains(&state, &input.domains, input.superadmin).await?;
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let exists = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {admin} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&exists)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::not_found("Administrator not found"));
|
||||
}
|
||||
let has_phone = state.db.has_column(Table::Admin, "phone").await?;
|
||||
let has_other = state.db.has_column(Table::Admin, "email_other").await?;
|
||||
let new_hash = if let Some(password) = input.password.as_deref() {
|
||||
Some(passwords::hash(&state.config, password, &username).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut sets = vec![
|
||||
"superadmin=?".to_string(),
|
||||
"active=?".to_string(),
|
||||
"modified=CURRENT_TIMESTAMP".to_string(),
|
||||
];
|
||||
if has_phone {
|
||||
sets.push("phone=?".into());
|
||||
}
|
||||
if has_other {
|
||||
sets.push("email_other=?".into());
|
||||
}
|
||||
if new_hash.is_some() {
|
||||
sets.push("password=?".into());
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {admin} SET {} WHERE username=?",
|
||||
sets.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql).bind(input.superadmin).bind(input.active);
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_other {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
if let Some(hash) = new_hash {
|
||||
q = q.bind(hash);
|
||||
}
|
||||
q.bind(&username).execute(state.db.pool()).await?;
|
||||
sync_domains(&state, &username, &input.domains, input.superadmin).await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
"",
|
||||
"edit_admin",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
if username.eq_ignore_ascii_case(&session.username) {
|
||||
return Err(AppError::conflict(
|
||||
"You cannot delete the currently logged-in administrator",
|
||||
));
|
||||
}
|
||||
let da = state.db.table(Table::DomainAdmins);
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let sql = state.db.q(&format!("DELETE FROM {da} WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let sql = state.db.q(&format!("DELETE FROM {admin} WHERE username=?"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::not_found("Administrator not found"));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
"",
|
||||
"delete_admin",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
async fn validate_domains(state: &AppState, domains: &[String], superadmin: bool) -> AppResult<()> {
|
||||
if superadmin {
|
||||
return Ok(());
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
for raw in domains {
|
||||
if raw == "ALL" {
|
||||
return Err(AppError::bad_request(
|
||||
"ALL can only be assigned to a global administrator",
|
||||
));
|
||||
}
|
||||
let domain = validation::domain(raw)?;
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::bad_request(format!("Unknown domain: {raw}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_domains(
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
domains: &[String],
|
||||
superadmin: bool,
|
||||
) -> AppResult<()> {
|
||||
let table = state.db.table(Table::DomainAdmins);
|
||||
let sql = state.db.q(&format!("DELETE FROM {table} WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let assigned: Vec<String> = if superadmin {
|
||||
vec!["ALL".into()]
|
||||
} else {
|
||||
domains.iter().map(|d| d.to_ascii_lowercase()).collect()
|
||||
};
|
||||
for domain in assigned {
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {table} (username,domain,created,active) VALUES (?,?,CURRENT_TIMESTAMP,?)"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.bind(domain)
|
||||
.bind(true)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,465 @@
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{ConnectInfo, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::verify_totp,
|
||||
error::{AppError, AppResult},
|
||||
models::{AccountKind, Role},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginInput {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default = "admin_kind")]
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
fn admin_kind() -> AccountKind {
|
||||
AccountKind::Admin
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct MfaInput {
|
||||
pub challenge: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginResponse {
|
||||
authenticated: bool,
|
||||
mfa_required: bool,
|
||||
challenge: Option<String>,
|
||||
csrf: Option<String>,
|
||||
username: String,
|
||||
role: Option<Role>,
|
||||
kind: AccountKind,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct MeResponse {
|
||||
username: String,
|
||||
role: Role,
|
||||
kind: AccountKind,
|
||||
csrf: String,
|
||||
domains: Vec<String>,
|
||||
language: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<LoginInput>,
|
||||
) -> AppResult<Response> {
|
||||
let username = input.username.trim().to_ascii_lowercase();
|
||||
if username.is_empty() || input.password.is_empty() {
|
||||
return Err(AppError::unauthorized("Invalid credentials"));
|
||||
}
|
||||
if !state.auth.login_allowed(peer.ip(), &username) {
|
||||
return Err(AppError::too_many("Too many login attempts"));
|
||||
}
|
||||
|
||||
let record = state.db.login_record(input.kind, &username).await?;
|
||||
let valid = if let Some(ref record) = record {
|
||||
record.active
|
||||
&& passwords::verify(&state.config, &input.password, &record.password, &username).await
|
||||
} else {
|
||||
let _ = passwords::verify(
|
||||
&state.config,
|
||||
&input.password,
|
||||
"$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
false
|
||||
};
|
||||
if !valid {
|
||||
return Err(AppError::unauthorized("Invalid credentials"));
|
||||
}
|
||||
let record = record.expect("valid implies record");
|
||||
state.auth.clear_attempts(peer.ip(), &username);
|
||||
|
||||
let domains = if input.kind == AccountKind::Admin {
|
||||
state.db.allowed_domains(&username).await?
|
||||
} else {
|
||||
username
|
||||
.split_once('@')
|
||||
.map(|(_, d)| vec![d.to_string()])
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let role = match input.kind {
|
||||
AccountKind::User => Role::User,
|
||||
AccountKind::Admin if record.superadmin || domains.iter().any(|d| d == "ALL") => {
|
||||
Role::GlobalAdmin
|
||||
}
|
||||
AccountKind::Admin => Role::Admin,
|
||||
};
|
||||
let domains: Arc<[String]> = domains.into();
|
||||
|
||||
let mail_password = if input.kind == AccountKind::User && state.config.sieve.enabled {
|
||||
Some(Arc::<str>::from(input.password.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let needs_mfa = state.config.features.totp
|
||||
&& record.totp_secret.as_deref().is_some_and(|s| !s.is_empty())
|
||||
&& !state
|
||||
.db
|
||||
.totp_exception(&peer.ip().to_string(), &username)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if needs_mfa {
|
||||
let challenge = state.auth.create_pending(
|
||||
username.clone(),
|
||||
input.kind,
|
||||
role,
|
||||
domains,
|
||||
peer.ip(),
|
||||
mail_password.clone(),
|
||||
);
|
||||
return Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(LoginResponse {
|
||||
authenticated: false,
|
||||
mfa_required: true,
|
||||
challenge: Some(challenge),
|
||||
csrf: None,
|
||||
username,
|
||||
role: None,
|
||||
kind: input.kind,
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let (token, session) =
|
||||
state
|
||||
.auth
|
||||
.create_session(username.clone(), input.kind, role, domains, mail_password);
|
||||
let jar = add_session_cookie(&state, jar, token);
|
||||
Ok((
|
||||
jar,
|
||||
Json(LoginResponse {
|
||||
authenticated: true,
|
||||
mfa_required: false,
|
||||
challenge: None,
|
||||
csrf: Some(session.csrf),
|
||||
username,
|
||||
role: Some(role),
|
||||
kind: input.kind,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn mfa(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<MfaInput>,
|
||||
) -> AppResult<Response> {
|
||||
let pending = state
|
||||
.auth
|
||||
.take_pending(&input.challenge, peer.ip())
|
||||
.ok_or_else(|| AppError::unauthorized("MFA challenge expired"))?;
|
||||
let record = state
|
||||
.db
|
||||
.login_record(pending.kind, &pending.username)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::unauthorized("Account no longer exists"))?;
|
||||
let secret = record
|
||||
.totp_secret
|
||||
.ok_or_else(|| AppError::unauthorized("TOTP is not configured"))?;
|
||||
if !verify_totp(&secret, input.code.trim()) {
|
||||
return Err(AppError::unauthorized("Invalid TOTP code"));
|
||||
}
|
||||
let (token, session) = state.auth.create_session(
|
||||
pending.username.clone(),
|
||||
pending.kind,
|
||||
pending.role,
|
||||
pending.allowed_domains,
|
||||
pending.mail_password,
|
||||
);
|
||||
let jar = add_session_cookie(&state, jar, token);
|
||||
Ok((
|
||||
jar,
|
||||
Json(LoginResponse {
|
||||
authenticated: true,
|
||||
mfa_required: false,
|
||||
challenge: None,
|
||||
csrf: Some(session.csrf),
|
||||
username: pending.username,
|
||||
role: Some(pending.role),
|
||||
kind: pending.kind,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
|
||||
if let Some(cookie) = jar.get("pa_session") {
|
||||
state.auth.revoke(cookie.value());
|
||||
}
|
||||
let mut removal = Cookie::new("pa_session", "");
|
||||
removal.set_path(state.config.normalized_base_path());
|
||||
(jar.remove(removal), StatusCode::NO_CONTENT).into_response()
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Json<MeResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
Ok(Json(MeResponse {
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
kind: session.kind,
|
||||
csrf: session.csrf,
|
||||
domains: session.allowed_domains.to_vec(),
|
||||
language: state.config.default_language.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn add_session_cookie(state: &AppState, jar: CookieJar, token: String) -> CookieJar {
|
||||
let mut cookie = Cookie::new("pa_session", token);
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(SameSite::Strict);
|
||||
cookie.set_secure(state.config.security.cookie_secure);
|
||||
cookie.set_path(state.config.normalized_base_path());
|
||||
jar.add(cookie)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RecoveryRequestInput {
|
||||
pub username: String,
|
||||
#[serde(default = "user_kind")]
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
fn user_kind() -> AccountKind {
|
||||
AccountKind::User
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct RecoveryResetInput {
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
#[serde(default = "user_kind")]
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
|
||||
pub async fn recovery_request(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Json(input): Json<RecoveryRequestInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
routes::require_writable(&state)?;
|
||||
use std::time::{Duration, Instant};
|
||||
let started = Instant::now();
|
||||
let username = input.username.trim().to_ascii_lowercase();
|
||||
let enabled = match input.kind {
|
||||
AccountKind::User => {
|
||||
state.config.features.forgotten_user_password_reset
|
||||
&& state.config.hooks.mailbox_postpassword.is_empty()
|
||||
}
|
||||
AccountKind::Admin => state.config.features.forgotten_admin_password_reset,
|
||||
};
|
||||
if !enabled {
|
||||
return Err(AppError::forbidden("Password recovery is disabled"));
|
||||
}
|
||||
if !state
|
||||
.auth
|
||||
.login_allowed(peer.ip(), &format!("recovery:{username}"))
|
||||
{
|
||||
return Err(AppError::too_many("Too many recovery requests"));
|
||||
}
|
||||
let table_key = match input.kind {
|
||||
AccountKind::User => crate::db::Table::Mailbox,
|
||||
AccountKind::Admin => crate::db::Table::Admin,
|
||||
};
|
||||
let has_token = state
|
||||
.db
|
||||
.has_column(table_key, "token")
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
&& state
|
||||
.db
|
||||
.has_column(table_key, "token_validity")
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_email = state
|
||||
.db
|
||||
.has_column(table_key, "email_other")
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if has_token {
|
||||
let table = state.db.table(table_key);
|
||||
let email_expr = if has_email {
|
||||
"email_other"
|
||||
} else {
|
||||
"'' AS email_other"
|
||||
};
|
||||
let active_expr = state.db.flag("active", "active");
|
||||
let sql = state.db.q(&format!(
|
||||
"SELECT {active_expr},{email_expr} FROM {table} WHERE username=? LIMIT 1"
|
||||
));
|
||||
if let Some(row) = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
&& crate::db::row_bool(&row, "active")
|
||||
{
|
||||
let token = crate::auth::random_token(18);
|
||||
let token_hash = passwords::hash_internal_secret(&token)?;
|
||||
let expiry = match state.db.dialect() {
|
||||
crate::db::Dialect::MySql => "DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)",
|
||||
crate::db::Dialect::Postgres => "CURRENT_TIMESTAMP + INTERVAL '1 hour'",
|
||||
crate::db::Dialect::Sqlite => "datetime('now','+1 hour')",
|
||||
};
|
||||
let cooldown = match state.db.dialect() {
|
||||
crate::db::Dialect::MySql => "DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)",
|
||||
crate::db::Dialect::Postgres => "CURRENT_TIMESTAMP - INTERVAL '5 minutes'",
|
||||
crate::db::Dialect::Sqlite => "datetime('now','-5 minutes')",
|
||||
};
|
||||
let update = state.db.q(&format!("UPDATE {table} SET token=?,token_validity={expiry},modified=CURRENT_TIMESTAMP WHERE username=? AND active=? AND (token IS NULL OR token='' OR modified<={cooldown})"));
|
||||
if sqlx::query(&update)
|
||||
.bind(token_hash)
|
||||
.bind(&username)
|
||||
.bind(true)
|
||||
.execute(state.db.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
== 1
|
||||
{
|
||||
let to = crate::db::row_string(&row, "email_other");
|
||||
if !to.trim().is_empty() {
|
||||
let base = state.config.normalized_base_path();
|
||||
let body = format!(
|
||||
"Password recovery code for {username}: {token}\n\nThe code expires in one hour. Open {base} and use the password recovery form."
|
||||
);
|
||||
let from = recovery_sender(&state.config, &username);
|
||||
if let Err(error) = crate::mailer::send_text(
|
||||
&state.config,
|
||||
&from,
|
||||
&to,
|
||||
"Password recovery",
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error,%username,"password recovery mail failed");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(%username,"password recovery requested but no email_other is configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed < Duration::from_secs(2) {
|
||||
tokio::time::sleep(Duration::from_secs(2) - elapsed).await;
|
||||
}
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"message":"If the account is eligible, a recovery code has been sent."}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn recovery_reset(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Json(input): Json<RecoveryResetInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
routes::require_writable(&state)?;
|
||||
let username = input.username.trim().to_ascii_lowercase();
|
||||
if !state
|
||||
.auth
|
||||
.login_allowed(peer.ip(), &format!("reset:{username}"))
|
||||
{
|
||||
return Err(AppError::too_many("Too many reset attempts"));
|
||||
}
|
||||
let enabled = match input.kind {
|
||||
AccountKind::User => {
|
||||
state.config.features.forgotten_user_password_reset
|
||||
&& state.config.hooks.mailbox_postpassword.is_empty()
|
||||
}
|
||||
AccountKind::Admin => state.config.features.forgotten_admin_password_reset,
|
||||
};
|
||||
if !enabled {
|
||||
return Err(AppError::forbidden("Password recovery is disabled"));
|
||||
}
|
||||
let table_key = match input.kind {
|
||||
AccountKind::User => crate::db::Table::Mailbox,
|
||||
AccountKind::Admin => crate::db::Table::Admin,
|
||||
};
|
||||
if !state.db.has_column(table_key, "token").await?
|
||||
|| !state.db.has_column(table_key, "token_validity").await?
|
||||
{
|
||||
return Err(AppError::conflict(
|
||||
"Database schema does not support password recovery",
|
||||
));
|
||||
}
|
||||
let table = state.db.table(table_key);
|
||||
let now = match state.db.dialect() {
|
||||
crate::db::Dialect::Sqlite => "datetime('now')",
|
||||
_ => "CURRENT_TIMESTAMP",
|
||||
};
|
||||
let sql=state.db.q(&format!("SELECT token FROM {table} WHERE username=? AND active=? AND token IS NOT NULL AND token<>'' AND token_validity>{now} LIMIT 1"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(true)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::unauthorized("Invalid or expired recovery code"))?;
|
||||
if !passwords::verify(
|
||||
&state.config,
|
||||
&input.token,
|
||||
&crate::db::row_string(&row, "token"),
|
||||
&username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(AppError::unauthorized("Invalid or expired recovery code"));
|
||||
}
|
||||
let hash = passwords::hash(&state.config, &input.new_password, &username).await?;
|
||||
let sql=state.db.q(&format!("UPDATE {table} SET password=?,token='',token_validity='2000-01-01 00:00:00',modified=CURRENT_TIMESTAMP WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(hash)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
if input.kind == AccountKind::User {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||
}
|
||||
state
|
||||
.auth
|
||||
.clear_attempts(peer.ip(), &format!("reset:{username}"));
|
||||
let domain = username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&username,
|
||||
domain,
|
||||
"password_recovery",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn recovery_sender(config: &crate::config::AppConfig, username: &str) -> String {
|
||||
if !config.smtp.admin_email.trim().is_empty() {
|
||||
return config.smtp.admin_email.trim().to_owned();
|
||||
}
|
||||
let domain = username
|
||||
.rsplit_once('@')
|
||||
.map(|(_, d)| d)
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or("localhost");
|
||||
format!("postmaster@{domain}")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::{
|
||||
db::Dialect,
|
||||
error::{AppError, AppResult},
|
||||
routes,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
use tokio::process::Command;
|
||||
|
||||
pub async fn download(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Response> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
s.require_global()?;
|
||||
if !state.config.features.backup {
|
||||
return Err(AppError::forbidden("Backup is disabled"));
|
||||
}
|
||||
if state.db.dialect() == Dialect::Sqlite {
|
||||
let path = state
|
||||
.config
|
||||
.database
|
||||
.path
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.database.name);
|
||||
let bytes = tokio::fs::read(path).await?;
|
||||
return response(bytes, "postfixadmin.sqlite");
|
||||
}
|
||||
let mut cmd = if state.db.dialect() == Dialect::Postgres {
|
||||
let mut c = Command::new(&state.config.backup.pg_dump);
|
||||
c.arg("--no-owner")
|
||||
.arg("--no-privileges")
|
||||
.arg("--host")
|
||||
.arg(&state.config.database.host)
|
||||
.arg("--port")
|
||||
.arg(state.config.database.port.to_string())
|
||||
.arg("--username")
|
||||
.arg(&state.config.database.user)
|
||||
.arg(&state.config.database.name);
|
||||
c.env("PGPASSWORD", &state.config.database.password);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(&state.config.backup.mysql_dump);
|
||||
c.arg("--single-transaction")
|
||||
.arg("--skip-lock-tables")
|
||||
.arg("--host")
|
||||
.arg(&state.config.database.host)
|
||||
.arg("--port")
|
||||
.arg(state.config.database.port.to_string())
|
||||
.arg("--user")
|
||||
.arg(&state.config.database.user)
|
||||
.arg(&state.config.database.name);
|
||||
c.env("MYSQL_PWD", &state.config.database.password);
|
||||
c
|
||||
};
|
||||
let out = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !out.status.success() {
|
||||
tracing::error!(stderr=%String::from_utf8_lossy(&out.stderr),"database dump failed");
|
||||
return Err(AppError::config("Database dump command failed"));
|
||||
}
|
||||
response(out.stdout, "postfixadmin.sql")
|
||||
}
|
||||
fn response(bytes: Vec<u8>, name: &str) -> AppResult<Response> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&format!("attachment; filename=\"{name}\""))
|
||||
.map_err(|_| AppError::config("invalid backup filename"))?,
|
||||
);
|
||||
Ok((headers, Body::from(bytes)).into_response())
|
||||
}
|
||||
use axum::response::IntoResponse;
|
||||
@@ -0,0 +1,578 @@
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Serialize;
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_i64, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{DkimKeyInput, DkimKeySummary, DkimSigningInput},
|
||||
routes::{self, rspamd_dkim},
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct DkimDnsRecord {
|
||||
pub source: String,
|
||||
pub domain: String,
|
||||
pub selector: String,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub record: String,
|
||||
}
|
||||
|
||||
impl DkimDnsRecord {
|
||||
pub(crate) fn from_value(source: &str, domain: &str, selector: &str, value: &str) -> Self {
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
let name = format!("{selector}._domainkey.{domain}");
|
||||
let escaped = value.replace('"', "\\\"");
|
||||
Self {
|
||||
source: source.to_string(),
|
||||
domain: domain.to_string(),
|
||||
selector: selector.to_string(),
|
||||
name: name.clone(),
|
||||
value,
|
||||
record: format!("{name} IN TXT \"{escaped}\""),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_complete(source: &str, domain: &str, selector: &str, record: &str) -> Self {
|
||||
let name = format!("{selector}._domainkey.{domain}");
|
||||
Self {
|
||||
source: source.to_string(),
|
||||
domain: domain.to_string(),
|
||||
selector: selector.to_string(),
|
||||
name,
|
||||
value: extract_txt_value(record).unwrap_or_else(|| record.trim().to_string()),
|
||||
record: record.trim().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DkimDnsResponse {
|
||||
read_only: bool,
|
||||
records: Vec<DkimDnsRecord>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn dns_records(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<DkimDnsResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
if !session.role.is_admin() && !state.config.dkim.user_read_only {
|
||||
return Err(AppError::forbidden(
|
||||
"DKIM read-only view is disabled for mailbox users",
|
||||
));
|
||||
}
|
||||
|
||||
let domains: Vec<String> = session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|domain| domain.as_str() != "ALL")
|
||||
.map(|domain| domain.to_ascii_lowercase())
|
||||
.collect();
|
||||
let mut records = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if state.config.features.dkim && state.db.has_table(Table::Dkim).await {
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT domain_name,selector,public_key FROM {table} ORDER BY domain_name,selector"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
for row in rows {
|
||||
let domain = row_string(&row, "domain_name").to_ascii_lowercase();
|
||||
if !session.role.is_global() && !domains.iter().any(|item| item == &domain) {
|
||||
continue;
|
||||
}
|
||||
let selector = row_string(&row, "selector");
|
||||
let public_key = row_string(&row, "public_key");
|
||||
if let Some(value) = public_key_to_dns_value(&public_key) {
|
||||
records.push(DkimDnsRecord::from_value(
|
||||
"postfixadmin",
|
||||
&domain,
|
||||
&selector,
|
||||
&value,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state.config.rspamd_dkim.enabled {
|
||||
match rspamd_dkim::read_only_dns_records(&state.config.rspamd_dkim, &domains).await {
|
||||
Ok(mut values) => records.append(&mut values),
|
||||
Err(_) => warnings
|
||||
.push("Rspamd DKIM records are not readable on this application node".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
records.sort_by(|a, b| {
|
||||
(&a.domain, &a.selector, &a.source).cmp(&(&b.domain, &b.selector, &b.source))
|
||||
});
|
||||
records
|
||||
.dedup_by(|a, b| a.domain == b.domain && a.selector == b.selector && a.record == b.record);
|
||||
|
||||
Ok(Json(DkimDnsResponse {
|
||||
read_only: !session.role.is_admin() || state.config.database.read_only,
|
||||
records,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_keys(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<DkimKeySummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
dkim_role(&state, &session)?;
|
||||
if !state.db.has_table(Table::Dkim).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT id,domain_name,description,selector,private_key,public_key FROM {table} ORDER BY domain_name,selector"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.filter(|row| session.can_manage_domain(&row_string(row, "domain_name")))
|
||||
.map(|row| DkimKeySummary {
|
||||
id: row_i64(row, "id"),
|
||||
domain_name: row_string(row, "domain_name"),
|
||||
description: row_string(row, "description"),
|
||||
selector: row_string(row, "selector"),
|
||||
public_key: row_string(row, "public_key"),
|
||||
has_private_key: !row_string(row, "private_key").is_empty(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<DkimKeyInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
input.domain_name = validation::domain(&input.domain_name)?;
|
||||
session.require_domain(&input.domain_name)?;
|
||||
validate_selector(&input.selector)?;
|
||||
let (private, public) = match (input.private_key.take(), input.public_key.take()) {
|
||||
(Some(private), Some(public)) => (private, public),
|
||||
(None, None) => generate_keypair().await?,
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"Provide both private_key and public_key, or neither to auto-generate",
|
||||
));
|
||||
}
|
||||
};
|
||||
if private.len() > 32768 || public.len() > 32768 {
|
||||
return Err(AppError::bad_request("DKIM key is too large"));
|
||||
}
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let query = state.db.q(&format!(
|
||||
"INSERT INTO {table} (domain_name,description,selector,private_key,public_key,created,modified) VALUES (?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&input.domain_name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.selector)
|
||||
.bind(private)
|
||||
.bind(public)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&input.domain_name,
|
||||
"create_dkim_entry",
|
||||
&input.description,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn update_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<DkimKeyInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let get = state.db.q(&format!(
|
||||
"SELECT domain_name,private_key,public_key FROM {table} WHERE id=?"
|
||||
));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM key not found"))?;
|
||||
session.require_domain(&row_string(&row, "domain_name"))?;
|
||||
input.domain_name = validation::domain(&input.domain_name)?;
|
||||
session.require_domain(&input.domain_name)?;
|
||||
validate_selector(&input.selector)?;
|
||||
let private = input
|
||||
.private_key
|
||||
.take()
|
||||
.unwrap_or_else(|| row_string(&row, "private_key"));
|
||||
let public = input
|
||||
.public_key
|
||||
.take()
|
||||
.unwrap_or_else(|| row_string(&row, "public_key"));
|
||||
let query = state.db.q(&format!(
|
||||
"UPDATE {table} SET domain_name=?,description=?,selector=?,private_key=?,public_key=?,modified=CURRENT_TIMESTAMP WHERE id=?"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&input.domain_name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.selector)
|
||||
.bind(private)
|
||||
.bind(public)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let get = state.db.q(&format!(
|
||||
"SELECT domain_name,description FROM {table} WHERE id=?"
|
||||
));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM key not found"))?;
|
||||
let domain = row_string(&row, "domain_name");
|
||||
session.require_domain(&domain)?;
|
||||
let query = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||
sqlx::query(&query)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain,
|
||||
"delete_dkim_entry",
|
||||
&row_string(&row, "description"),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<serde_json::Value>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let signing = state.db.table(Table::DkimSigning);
|
||||
let keys = state.db.table(Table::Dkim);
|
||||
if !state.db.has_table(Table::DkimSigning).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT s.id,s.author,s.dkim_id,k.domain_name,k.selector,k.description FROM {signing} s LEFT JOIN {keys} k ON k.id=s.dkim_id ORDER BY k.domain_name,k.selector,s.author"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.filter_map(|row| {
|
||||
let author = row_string(row, "author");
|
||||
let domain = author
|
||||
.rsplit_once('@')
|
||||
.map(|(_, d)| d.to_string())
|
||||
.unwrap_or_else(|| author.clone());
|
||||
if !session.can_manage_domain(&domain) {
|
||||
return None;
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"id": row_i64(row,"id"),
|
||||
"author": author,
|
||||
"dkim_id": row_i64(row,"dkim_id"),
|
||||
"domain_name": row_string(row,"domain_name"),
|
||||
"selector": row_string(row,"selector"),
|
||||
"description": row_string(row,"description")
|
||||
}))
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DkimSigningInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let author = validate_author(&input.author)?;
|
||||
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||
session.require_domain(domain)?;
|
||||
require_key_scope(&state, &session, input.dkim_id).await?;
|
||||
let table = state.db.table(Table::DkimSigning);
|
||||
let query = state.db.q(&format!(
|
||||
"INSERT INTO {table} (author,dkim_id,created,modified) VALUES (?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&author)
|
||||
.bind(input.dkim_id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"create_dkim_signing_entry",
|
||||
&author,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn update_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DkimSigningInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::DkimSigning);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT author FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM signing record not found"))?;
|
||||
let old = row_string(&row, "author");
|
||||
let old_domain = old.rsplit_once('@').map(|(_, d)| d).unwrap_or(&old);
|
||||
session.require_domain(old_domain)?;
|
||||
let author = validate_author(&input.author)?;
|
||||
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||
session.require_domain(domain)?;
|
||||
require_key_scope(&state, &session, input.dkim_id).await?;
|
||||
let query = state.db.q(&format!(
|
||||
"UPDATE {table} SET author=?,dkim_id=?,modified=CURRENT_TIMESTAMP WHERE id=?"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&author)
|
||||
.bind(input.dkim_id)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::DkimSigning);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT author FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM signing record not found"))?;
|
||||
let author = row_string(&row, "author");
|
||||
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||
session.require_domain(domain)?;
|
||||
let query = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||
sqlx::query(&query)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn dkim_role(state: &AppState, session: &crate::auth::Session) -> AppResult<()> {
|
||||
if !state.config.features.dkim {
|
||||
return Err(AppError::forbidden("DKIM management is disabled"));
|
||||
}
|
||||
if state.config.features.dkim_all_admins {
|
||||
session.require_admin()
|
||||
} else {
|
||||
session.require_global()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_selector(selector: &str) -> AppResult<()> {
|
||||
if selector.is_empty()
|
||||
|| selector.len() > 63
|
||||
|| !selector
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
Err(AppError::bad_request("Invalid DKIM selector"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_author(value: &str) -> AppResult<String> {
|
||||
if value.contains('@') {
|
||||
validation::email(value)
|
||||
} else {
|
||||
validation::domain(value)
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_key_scope(
|
||||
state: &AppState,
|
||||
session: &crate::auth::Session,
|
||||
id: i64,
|
||||
) -> AppResult<()> {
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let query = state
|
||||
.db
|
||||
.q(&format!("SELECT domain_name FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&query)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::bad_request("Unknown DKIM key"))?;
|
||||
session.require_domain(&row_string(&row, "domain_name"))
|
||||
}
|
||||
|
||||
async fn generate_keypair() -> AppResult<(String, String)> {
|
||||
let output = Command::new("openssl")
|
||||
.args([
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"RSA",
|
||||
"-pkeyopt",
|
||||
"rsa_keygen_bits:2048",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"openssl key generation failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let private = String::from_utf8(output.stdout)
|
||||
.map_err(|_| AppError::config("openssl returned non-UTF8 private key"))?;
|
||||
let mut child = Command::new("openssl")
|
||||
.args(["pkey", "-pubout"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(private.as_bytes()).await?;
|
||||
}
|
||||
let output = child.wait_with_output().await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"openssl public key extraction failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let public = String::from_utf8(output.stdout)
|
||||
.map_err(|_| AppError::config("openssl returned non-UTF8 public key"))?;
|
||||
Ok((private.trim().into(), public.trim().into()))
|
||||
}
|
||||
|
||||
fn public_key_to_dns_value(public_key: &str) -> Option<String> {
|
||||
let value = public_key.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value.to_ascii_lowercase().contains("v=dkim1") && value.to_ascii_lowercase().contains("p=") {
|
||||
return Some(
|
||||
extract_txt_value(value).unwrap_or_else(|| value.trim_matches('"').to_string()),
|
||||
);
|
||||
}
|
||||
let encoded: String = value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.starts_with("-----BEGIN") && !line.starts_with("-----END"))
|
||||
.collect();
|
||||
if encoded.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("v=DKIM1; k=rsa; p={encoded}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_txt_value(record: &str) -> Option<String> {
|
||||
let trimmed = record.trim();
|
||||
let start = trimmed.find('"')? + 1;
|
||||
let end = trimmed.rfind('"')?;
|
||||
if end <= start {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed[start..end].replace("\\\"", "\""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pem_public_key_becomes_dns_value() {
|
||||
let pem = "-----BEGIN PUBLIC KEY-----\nABC123\nDEF456\n-----END PUBLIC KEY-----";
|
||||
assert_eq!(
|
||||
public_key_to_dns_value(pem).as_deref(),
|
||||
Some("v=DKIM1; k=rsa; p=ABC123DEF456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_record_is_complete() {
|
||||
let record =
|
||||
DkimDnsRecord::from_value("rspamd", "example.org", "mail", "v=DKIM1; k=rsa; p=ABC");
|
||||
assert_eq!(record.name, "mail._domainkey.example.org");
|
||||
assert_eq!(
|
||||
record.record,
|
||||
"mail._domainkey.example.org IN TXT \"v=DKIM1; k=rsa; p=ABC\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Serialize;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use trust_dns_resolver::TokioAsyncResolver;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_opt_bool, row_opt_i64, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
hooks,
|
||||
models::{DomainInput, DomainSummary},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<DomainSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
session.require_admin()?;
|
||||
let domain = state.db.table(Table::Domain);
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let has_dns = state.db.has_column(Table::Domain, "dns_active").await?;
|
||||
let has_dns_checked = state.db.has_column(Table::Domain, "dns_checked").await?;
|
||||
let expiry_expr = if has_expiry {
|
||||
"d.password_expiry"
|
||||
} else {
|
||||
"NULL AS password_expiry"
|
||||
};
|
||||
let backupmx_expr = state.db.flag("d.backupmx", "backupmx");
|
||||
let active_expr = state.db.flag("d.active", "active");
|
||||
let dns_expr = match (has_dns, has_dns_checked) {
|
||||
(true, true) => format!(
|
||||
"{}, {} AS dns_checked",
|
||||
state.db.flag("d.dns_active", "dns_active"),
|
||||
state.db.cast_text("d.dns_checked")
|
||||
),
|
||||
(true, false) => format!(
|
||||
"{}, NULL AS dns_checked",
|
||||
state.db.flag("d.dns_active", "dns_active")
|
||||
),
|
||||
(false, _) => "NULL AS dns_active, NULL AS dns_checked".into(),
|
||||
};
|
||||
let mut sql = format!(
|
||||
"SELECT d.domain,d.description,d.aliases,d.mailboxes,d.maxquota,d.quota,d.transport,{backupmx_expr},{active_expr},{expiry_expr},{dns_expr},\n (SELECT COUNT(*) FROM {alias} a WHERE a.domain=d.domain) AS alias_count,\n (SELECT COUNT(*) FROM {mailbox} m WHERE m.domain=d.domain) AS mailbox_count\n FROM {domain} d WHERE d.domain <> 'ALL'"
|
||||
);
|
||||
let mut query_domains = false;
|
||||
if !session.role.is_global() {
|
||||
if !session.allowed_domains.iter().any(|d| d.as_str() != "ALL") {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
sql.push_str(" AND d.domain IN (");
|
||||
for (i, _) in session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.enumerate()
|
||||
{
|
||||
if i > 0 {
|
||||
sql.push(',');
|
||||
}
|
||||
sql.push('?');
|
||||
}
|
||||
sql.push(')');
|
||||
query_domains = true;
|
||||
}
|
||||
sql.push_str(" ORDER BY d.domain");
|
||||
let sql = state.db.q(&sql);
|
||||
let mut query = sqlx::query(&sql);
|
||||
if query_domains {
|
||||
for d in session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
{
|
||||
query = query.bind(d.as_str());
|
||||
}
|
||||
}
|
||||
let rows = query.fetch_all(state.db.pool()).await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| DomainSummary {
|
||||
domain: row_string(r, "domain"),
|
||||
description: row_string(r, "description"),
|
||||
aliases: row_i64(r, "aliases"),
|
||||
mailboxes: row_i64(r, "mailboxes"),
|
||||
maxquota: row_i64(r, "maxquota"),
|
||||
quota: row_i64(r, "quota"),
|
||||
transport: row_string(r, "transport"),
|
||||
backupmx: row_bool(r, "backupmx"),
|
||||
active: row_bool(r, "active"),
|
||||
password_expiry: row_opt_i64(r, "password_expiry"),
|
||||
dns_active: row_opt_bool(r, "dns_active"),
|
||||
dns_checked: row_opt_string(r, "dns_checked"),
|
||||
alias_count: row_i64(r, "alias_count"),
|
||||
mailbox_count: row_i64(r, "mailbox_count"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<DomainInput>,
|
||||
) -> AppResult<Json<DomainSummary>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
input.domain = validation::resolvable_domain(&state.config, &input.domain).await?;
|
||||
if state.config.features.transport
|
||||
&& input.transport.as_deref().is_some_and(|v| {
|
||||
!state
|
||||
.config
|
||||
.defaults
|
||||
.transport_options
|
||||
.iter()
|
||||
.any(|x| x == v)
|
||||
})
|
||||
{
|
||||
return Err(AppError::bad_request("Unsupported transport"));
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
let exists_sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||
if sqlx::query(&exists_sql)
|
||||
.bind(&input.domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("Domain already exists"));
|
||||
}
|
||||
let aliases = input.aliases.unwrap_or(state.config.defaults.aliases);
|
||||
let mailboxes = input.mailboxes.unwrap_or(state.config.defaults.mailboxes);
|
||||
let maxquota = input.maxquota.unwrap_or(state.config.defaults.maxquota_mb);
|
||||
let quota = input.quota.unwrap_or(state.config.defaults.domain_quota_mb);
|
||||
let transport = if state.config.features.transport {
|
||||
input
|
||||
.transport
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.config.defaults.transport_default.clone())
|
||||
} else {
|
||||
state.config.defaults.transport_default.clone()
|
||||
};
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let sql = if has_expiry {
|
||||
state.db.q(&format!("INSERT INTO {table} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active,password_expiry) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?,?)"))
|
||||
} else {
|
||||
state.db.q(&format!("INSERT INTO {table} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.domain)
|
||||
.bind(&input.description)
|
||||
.bind(aliases)
|
||||
.bind(mailboxes)
|
||||
.bind(maxquota)
|
||||
.bind(quota)
|
||||
.bind(&transport)
|
||||
.bind(input.backupmx)
|
||||
.bind(input.active);
|
||||
if has_expiry {
|
||||
q = q.bind(input.password_expiry.unwrap_or(0));
|
||||
}
|
||||
q.execute(state.db.pool()).await?;
|
||||
|
||||
if input.default_aliases {
|
||||
let alias_table = state.db.table(Table::Alias);
|
||||
for (local, target) in &state.config.defaults.default_aliases {
|
||||
let address = format!("{local}@{}", input.domain);
|
||||
let goto = if target.contains('@') {
|
||||
target.clone()
|
||||
} else {
|
||||
format!("{target}@{}", input.domain)
|
||||
};
|
||||
let sql = state.db.q(&format!("INSERT INTO {alias_table} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"));
|
||||
sqlx::query(&sql)
|
||||
.bind(address)
|
||||
.bind(goto)
|
||||
.bind(&input.domain)
|
||||
.bind(true)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postcreation,
|
||||
&[("domain", &input.domain)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&input.domain,
|
||||
"create_domain",
|
||||
&input.domain,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(DomainSummary {
|
||||
domain: input.domain,
|
||||
description: input.description,
|
||||
aliases,
|
||||
mailboxes,
|
||||
maxquota,
|
||||
quota,
|
||||
transport,
|
||||
backupmx: input.backupmx,
|
||||
active: input.active,
|
||||
password_expiry: if has_expiry {
|
||||
Some(input.password_expiry.unwrap_or(0))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
dns_active: None,
|
||||
dns_checked: None,
|
||||
alias_count: if input.default_aliases {
|
||||
state.config.defaults.default_aliases.len() as i64
|
||||
} else {
|
||||
0
|
||||
},
|
||||
mailbox_count: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(domain_path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DomainInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let domain_name = validation::domain(&domain_path)?;
|
||||
if validation::domain(&input.domain)? != domain_name {
|
||||
return Err(AppError::bad_request(
|
||||
"Renaming domains is not supported; create a new domain instead",
|
||||
));
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
let exists = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||
if sqlx::query(&exists)
|
||||
.bind(&domain_name)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::not_found("Domain not found"));
|
||||
}
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let sql = if has_expiry {
|
||||
state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,password_expiry=?,modified=CURRENT_TIMESTAMP WHERE domain=?"))
|
||||
} else {
|
||||
state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,modified=CURRENT_TIMESTAMP WHERE domain=?"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.description)
|
||||
.bind(input.aliases.unwrap_or(state.config.defaults.aliases))
|
||||
.bind(input.mailboxes.unwrap_or(state.config.defaults.mailboxes))
|
||||
.bind(input.maxquota.unwrap_or(state.config.defaults.maxquota_mb))
|
||||
.bind(input.quota.unwrap_or(state.config.defaults.domain_quota_mb))
|
||||
.bind(if state.config.features.transport {
|
||||
input
|
||||
.transport
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.defaults.transport_default)
|
||||
} else {
|
||||
&state.config.defaults.transport_default
|
||||
})
|
||||
.bind(input.backupmx)
|
||||
.bind(input.active);
|
||||
if has_expiry {
|
||||
q = q.bind(input.password_expiry.unwrap_or(0));
|
||||
}
|
||||
q = q.bind(&domain_name);
|
||||
q.execute(state.db.pool()).await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postedit,
|
||||
&[("domain", &domain_name)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain_name,
|
||||
"edit_domain",
|
||||
&domain_name,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(domain_path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let domain_name = validation::domain(&domain_path)?;
|
||||
if state.config.features.alias_domain && state.db.has_table(Table::AliasDomain).await {
|
||||
let ad = state.db.table(Table::AliasDomain);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {ad} WHERE target_domain=? LIMIT 1"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(&domain_name)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict(
|
||||
"Domain is still the target of an alias domain",
|
||||
));
|
||||
}
|
||||
}
|
||||
let like = format!("%@{domain_name}");
|
||||
for (table, col, extra_like) in [
|
||||
(Table::DomainAdmins, "domain", false),
|
||||
(Table::Alias, "domain", false),
|
||||
(Table::Mailbox, "domain", false),
|
||||
(Table::AliasDomain, "alias_domain", false),
|
||||
(Table::Vacation, "domain", false),
|
||||
(Table::VacationNotification, "on_vacation", true),
|
||||
(Table::Quota, "username", true),
|
||||
(Table::Quota2, "username", true),
|
||||
(Table::Fetchmail, "mailbox", true),
|
||||
(Table::Log, "domain", false),
|
||||
] {
|
||||
if !state.db.has_table(table).await {
|
||||
continue;
|
||||
}
|
||||
let t = state.db.table(table);
|
||||
let sql = if extra_like {
|
||||
state
|
||||
.db
|
||||
.q(&format!("DELETE FROM {t} WHERE {col}=? OR {col} LIKE ?"))
|
||||
} else {
|
||||
state.db.q(&format!("DELETE FROM {t} WHERE {col}=?"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql).bind(&domain_name);
|
||||
if extra_like {
|
||||
q = q.bind(&like);
|
||||
}
|
||||
q.execute(state.db.pool()).await?;
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
let sql = state.db.q(&format!("DELETE FROM {table} WHERE domain=?"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(&domain_name)
|
||||
.execute(state.db.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::not_found("Domain not found"));
|
||||
}
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postdeletion,
|
||||
&[("domain", &domain_name)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain_name,
|
||||
"delete_domain",
|
||||
&domain_name,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DnsRefreshResult {
|
||||
domain: String,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
pub async fn dns_refresh(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(domain_path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<DnsRefreshResult>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let domain = validation::domain(&domain_path)?;
|
||||
session.require_domain(&domain)?;
|
||||
if state.config.features.domain_dns_status_check == 0 {
|
||||
return Err(AppError::bad_request("DNS status checks are disabled"));
|
||||
}
|
||||
if !state.db.has_column(Table::Domain, "dns_active").await?
|
||||
|| !state.db.has_column(Table::Domain, "dns_checked").await?
|
||||
{
|
||||
return Err(AppError::bad_request(
|
||||
"Database schema has no dns_active/dns_checked columns",
|
||||
));
|
||||
}
|
||||
let active = check_dns(&domain, state.config.features.domain_dns_status_check).await;
|
||||
let table = state.db.table(Table::Domain);
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {table} SET dns_active=?, dns_checked=CURRENT_TIMESTAMP WHERE domain=?"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(active)
|
||||
.bind(&domain)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(DnsRefreshResult { domain, active }))
|
||||
}
|
||||
|
||||
async fn check_dns(domain: &str, mode: u8) -> bool {
|
||||
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if mode == 2 {
|
||||
let Ok(mx) = resolver.mx_lookup(domain).await else {
|
||||
return false;
|
||||
};
|
||||
for record in mx.iter() {
|
||||
let target = record.exchange().to_utf8();
|
||||
if resolver
|
||||
.lookup_ip(target)
|
||||
.await
|
||||
.is_ok_and(|ips| ips.iter().next().is_some())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let Ok(ns) = resolver.ns_lookup(domain).await else {
|
||||
return false;
|
||||
};
|
||||
for name in ns.iter() {
|
||||
let target = name.to_utf8();
|
||||
let Ok(ips) = resolver.lookup_ip(target).await else {
|
||||
continue;
|
||||
};
|
||||
for ip in ips.iter() {
|
||||
if authoritative_soa(domain, ip).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn authoritative_soa(domain: &str, ip: IpAddr) -> bool {
|
||||
let id: u16 = rand::random();
|
||||
let mut packet = Vec::with_capacity(512);
|
||||
packet.extend_from_slice(&id.to_be_bytes());
|
||||
packet.extend_from_slice(&0u16.to_be_bytes());
|
||||
packet.extend_from_slice(&1u16.to_be_bytes());
|
||||
packet.extend_from_slice(&[0u8; 6]);
|
||||
for label in domain.trim_end_matches('.').split('.') {
|
||||
if label.is_empty() || label.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
packet.push(label.len() as u8);
|
||||
packet.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
packet.push(0);
|
||||
packet.extend_from_slice(&6u16.to_be_bytes());
|
||||
packet.extend_from_slice(&1u16.to_be_bytes());
|
||||
let bind = if ip.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" };
|
||||
let Ok(socket) = UdpSocket::bind(bind).await else {
|
||||
return false;
|
||||
};
|
||||
if socket.connect(SocketAddr::new(ip, 53)).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
if socket.send(&packet).await.ok() != Some(packet.len()) {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 4096];
|
||||
let Ok(Ok(size)) = timeout(Duration::from_secs(1), socket.recv(&mut buf)).await else {
|
||||
return false;
|
||||
};
|
||||
if size < 12 {
|
||||
return false;
|
||||
}
|
||||
let rid = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
let flags = u16::from_be_bytes([buf[2], buf[3]]);
|
||||
rid == id && flags & 0x8000 != 0 && flags & 0x0400 != 0 && flags & 0x000f == 0
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_opt_i64, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{FetchmailInput, FetchmailSummary},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FetchmailColumns {
|
||||
domain: bool,
|
||||
src_port: bool,
|
||||
usessl: bool,
|
||||
sslcertck: bool,
|
||||
sslcertpath: bool,
|
||||
sslfingerprint: bool,
|
||||
created: bool,
|
||||
modified: bool,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl FetchmailColumns {
|
||||
async fn detect(state: &AppState) -> AppResult<Self> {
|
||||
Ok(Self {
|
||||
domain: state.db.has_column(Table::Fetchmail, "domain").await?,
|
||||
src_port: state.db.has_column(Table::Fetchmail, "src_port").await?,
|
||||
usessl: state.db.has_column(Table::Fetchmail, "usessl").await?,
|
||||
sslcertck: state.db.has_column(Table::Fetchmail, "sslcertck").await?,
|
||||
sslcertpath: state.db.has_column(Table::Fetchmail, "sslcertpath").await?,
|
||||
sslfingerprint: state
|
||||
.db
|
||||
.has_column(Table::Fetchmail, "sslfingerprint")
|
||||
.await?,
|
||||
created: state.db.has_column(Table::Fetchmail, "created").await?,
|
||||
modified: state.db.has_column(Table::Fetchmail, "modified").await?,
|
||||
active: state.db.has_column(Table::Fetchmail, "active").await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<FetchmailSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
if !state.config.features.fetchmail {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let columns = FetchmailColumns::detect(&state).await?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let domain = if columns.domain {
|
||||
"domain"
|
||||
} else {
|
||||
"'' AS domain"
|
||||
};
|
||||
let port = if columns.src_port {
|
||||
"src_port"
|
||||
} else {
|
||||
"NULL AS src_port"
|
||||
};
|
||||
let fetchall = state.db.flag("fetchall", "fetchall");
|
||||
let keep = state.db.flag("keep", "keep");
|
||||
let usessl = if columns.usessl {
|
||||
state.db.flag("usessl", "usessl")
|
||||
} else {
|
||||
"0 AS usessl".into()
|
||||
};
|
||||
let sslcertck = if columns.sslcertck {
|
||||
state.db.flag("sslcertck", "sslcertck")
|
||||
} else {
|
||||
"0 AS sslcertck".into()
|
||||
};
|
||||
let sslcertpath = if columns.sslcertpath {
|
||||
"sslcertpath"
|
||||
} else {
|
||||
"'' AS sslcertpath"
|
||||
};
|
||||
let sslfingerprint = if columns.sslfingerprint {
|
||||
"sslfingerprint"
|
||||
} else {
|
||||
"'' AS sslfingerprint"
|
||||
};
|
||||
let active = if columns.active {
|
||||
state.db.flag("active", "active")
|
||||
} else {
|
||||
"1 AS active".into()
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id,{domain},mailbox,src_server,{port},src_auth,src_user,src_folder,poll_time,{fetchall},{keep},protocol,\
|
||||
{usessl},{sslcertck},{sslcertpath},{sslfingerprint},extra_options,returned_text,mda,{active} \
|
||||
FROM {table} ORDER BY mailbox,id"
|
||||
);
|
||||
let rows = sqlx::query(&sql).fetch_all(state.db.pool()).await?;
|
||||
let values = rows
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
let mailbox = row_string(row, "mailbox");
|
||||
if session.role.is_admin() {
|
||||
mailbox
|
||||
.rsplit_once('@')
|
||||
.is_some_and(|(_, domain)| session.can_manage_domain(domain))
|
||||
} else {
|
||||
mailbox == session.username
|
||||
}
|
||||
})
|
||||
.map(map)
|
||||
.collect();
|
||||
Ok(Json(values))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<FetchmailInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
require(&state, &session, &input.mailbox)?;
|
||||
let mailbox = validation::email(&input.mailbox)?;
|
||||
let (_, domain) = mailbox.rsplit_once('@').expect("validated email");
|
||||
assert_mailbox(&state, &mailbox).await?;
|
||||
validate(&state, &input, true)?;
|
||||
|
||||
let columns = FetchmailColumns::detect(&state).await?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let mut names = Vec::new();
|
||||
let mut values = Vec::new();
|
||||
if columns.domain {
|
||||
names.push("domain");
|
||||
values.push("?");
|
||||
}
|
||||
names.extend(["mailbox", "src_server"]);
|
||||
values.extend(["?", "?"]);
|
||||
if columns.src_port {
|
||||
names.push("src_port");
|
||||
values.push("?");
|
||||
}
|
||||
names.extend([
|
||||
"src_auth",
|
||||
"src_user",
|
||||
"src_password",
|
||||
"src_folder",
|
||||
"poll_time",
|
||||
"fetchall",
|
||||
"keep",
|
||||
"protocol",
|
||||
]);
|
||||
values.extend(["?", "?", "?", "?", "?", "?", "?", "?"]);
|
||||
if columns.usessl {
|
||||
names.push("usessl");
|
||||
values.push("?");
|
||||
}
|
||||
if columns.sslcertck {
|
||||
names.push("sslcertck");
|
||||
values.push("?");
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
names.push("sslcertpath");
|
||||
values.push("?");
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
names.push("sslfingerprint");
|
||||
values.push("?");
|
||||
}
|
||||
names.extend(["extra_options", "returned_text", "mda", "date"]);
|
||||
values.extend(["?", "?", "?", "CURRENT_TIMESTAMP"]);
|
||||
if columns.created {
|
||||
names.push("created");
|
||||
values.push("CURRENT_TIMESTAMP");
|
||||
}
|
||||
if columns.modified {
|
||||
names.push("modified");
|
||||
values.push("CURRENT_TIMESTAMP");
|
||||
}
|
||||
if columns.active {
|
||||
names.push("active");
|
||||
values.push("?");
|
||||
}
|
||||
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {table} ({}) VALUES ({})",
|
||||
names.join(","),
|
||||
values.join(",")
|
||||
));
|
||||
let mut query = sqlx::query(&sql);
|
||||
if columns.domain {
|
||||
query = query.bind(domain);
|
||||
}
|
||||
query = query.bind(&mailbox).bind(&input.src_server);
|
||||
if columns.src_port {
|
||||
query = query.bind(input.src_port.unwrap_or(0));
|
||||
}
|
||||
query = query
|
||||
.bind(input.src_auth.as_deref().unwrap_or("password"))
|
||||
.bind(&input.src_user)
|
||||
.bind(&input.src_password)
|
||||
.bind(&input.src_folder)
|
||||
.bind(input.poll_time)
|
||||
.bind(input.fetchall)
|
||||
.bind(input.keep)
|
||||
.bind(input.protocol.as_deref().unwrap_or("POP3"));
|
||||
if columns.usessl {
|
||||
query = query.bind(input.usessl);
|
||||
}
|
||||
if columns.sslcertck {
|
||||
query = query.bind(input.sslcertck);
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
query = query.bind(&input.sslcertpath);
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
query = query.bind(&input.sslfingerprint);
|
||||
}
|
||||
query = query.bind(&input.extra_options).bind("").bind(&input.mda);
|
||||
if columns.active {
|
||||
query = query.bind(input.active);
|
||||
}
|
||||
query.execute(state.db.pool()).await?;
|
||||
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"create_fetchmail",
|
||||
&mailbox,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<FetchmailInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT mailbox FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Fetchmail record not found"))?;
|
||||
let old = row_string(&row, "mailbox");
|
||||
require(&state, &session, &old)?;
|
||||
let mailbox = validation::email(&input.mailbox)?;
|
||||
require(&state, &session, &mailbox)?;
|
||||
assert_mailbox(&state, &mailbox).await?;
|
||||
validate(&state, &input, false)?;
|
||||
let (_, domain) = mailbox.rsplit_once('@').expect("validated email");
|
||||
|
||||
let columns = FetchmailColumns::detect(&state).await?;
|
||||
let mut sets = Vec::new();
|
||||
if columns.domain {
|
||||
sets.push("domain=?");
|
||||
}
|
||||
sets.extend(["mailbox=?", "src_server=?"]);
|
||||
if columns.src_port {
|
||||
sets.push("src_port=?");
|
||||
}
|
||||
sets.extend(["src_auth=?", "src_user=?"]);
|
||||
if !input.src_password.is_empty() {
|
||||
sets.push("src_password=?");
|
||||
}
|
||||
sets.extend([
|
||||
"src_folder=?",
|
||||
"poll_time=?",
|
||||
"fetchall=?",
|
||||
"keep=?",
|
||||
"protocol=?",
|
||||
]);
|
||||
if columns.usessl {
|
||||
sets.push("usessl=?");
|
||||
}
|
||||
if columns.sslcertck {
|
||||
sets.push("sslcertck=?");
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
sets.push("sslcertpath=?");
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
sets.push("sslfingerprint=?");
|
||||
}
|
||||
sets.extend(["extra_options=?", "mda=?"]);
|
||||
if columns.modified {
|
||||
sets.push("modified=CURRENT_TIMESTAMP");
|
||||
}
|
||||
if columns.active {
|
||||
sets.push("active=?");
|
||||
}
|
||||
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("UPDATE {table} SET {} WHERE id=?", sets.join(",")));
|
||||
let mut query = sqlx::query(&sql);
|
||||
if columns.domain {
|
||||
query = query.bind(domain);
|
||||
}
|
||||
query = query.bind(&mailbox).bind(&input.src_server);
|
||||
if columns.src_port {
|
||||
query = query.bind(input.src_port.unwrap_or(0));
|
||||
}
|
||||
query = query
|
||||
.bind(input.src_auth.as_deref().unwrap_or("password"))
|
||||
.bind(&input.src_user);
|
||||
if !input.src_password.is_empty() {
|
||||
query = query.bind(&input.src_password);
|
||||
}
|
||||
query = query
|
||||
.bind(&input.src_folder)
|
||||
.bind(input.poll_time)
|
||||
.bind(input.fetchall)
|
||||
.bind(input.keep)
|
||||
.bind(input.protocol.as_deref().unwrap_or("POP3"));
|
||||
if columns.usessl {
|
||||
query = query.bind(input.usessl);
|
||||
}
|
||||
if columns.sslcertck {
|
||||
query = query.bind(input.sslcertck);
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
query = query.bind(&input.sslcertpath);
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
query = query.bind(&input.sslfingerprint);
|
||||
}
|
||||
query = query.bind(&input.extra_options).bind(&input.mda);
|
||||
if columns.active {
|
||||
query = query.bind(input.active);
|
||||
}
|
||||
query.bind(id).execute(state.db.pool()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT mailbox FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Fetchmail record not found"))?;
|
||||
require(&state, &session, &row_string(&row, "mailbox"))?;
|
||||
let sql = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||
sqlx::query(&sql).bind(id).execute(state.db.pool()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
fn require(state: &AppState, session: &crate::auth::Session, mailbox: &str) -> AppResult<()> {
|
||||
if !state.config.features.fetchmail {
|
||||
return Err(AppError::forbidden("Fetchmail is disabled"));
|
||||
}
|
||||
let email = validation::email(mailbox)?;
|
||||
if session.role.is_admin() {
|
||||
let (_, domain) = email.rsplit_once('@').expect("validated email");
|
||||
session.require_domain(domain)
|
||||
} else if email == session.username {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Mailbox is outside this account"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(state: &AppState, input: &FetchmailInput, creating: bool) -> AppResult<()> {
|
||||
if input.poll_time < 1 {
|
||||
return Err(AppError::bad_request("poll_time must be positive"));
|
||||
}
|
||||
if input.src_server.trim().is_empty() || input.src_user.trim().is_empty() {
|
||||
return Err(AppError::bad_request("Source server and user are required"));
|
||||
}
|
||||
if creating && input.src_password.is_empty() {
|
||||
return Err(AppError::bad_request("Source password is required"));
|
||||
}
|
||||
if !state.config.features.fetchmail_extra_options
|
||||
&& (!input.extra_options.trim().is_empty() || !input.mda.trim().is_empty())
|
||||
{
|
||||
return Err(AppError::forbidden(
|
||||
"fetchmail extra options/MDA are disabled",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_mailbox(state: &AppState, username: &str) -> AppResult<()> {
|
||||
let table = state.db.table(Table::Mailbox);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
Err(AppError::bad_request("Target mailbox does not exist"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map(row: &sqlx::any::AnyRow) -> FetchmailSummary {
|
||||
FetchmailSummary {
|
||||
id: row_i64(row, "id"),
|
||||
domain: row_string(row, "domain"),
|
||||
mailbox: row_string(row, "mailbox"),
|
||||
src_server: row_string(row, "src_server"),
|
||||
src_port: row_opt_i64(row, "src_port"),
|
||||
src_auth: row_opt_string(row, "src_auth"),
|
||||
src_user: row_string(row, "src_user"),
|
||||
src_folder: row_string(row, "src_folder"),
|
||||
poll_time: row_i64(row, "poll_time"),
|
||||
fetchall: row_bool(row, "fetchall"),
|
||||
keep: row_bool(row, "keep"),
|
||||
protocol: row_opt_string(row, "protocol"),
|
||||
usessl: row_bool(row, "usessl"),
|
||||
sslcertck: row_bool(row, "sslcertck"),
|
||||
sslcertpath: row_string(row, "sslcertpath"),
|
||||
sslfingerprint: row_string(row, "sslfingerprint"),
|
||||
extra_options: row_string(row, "extra_options"),
|
||||
returned_text: row_string(row, "returned_text"),
|
||||
mda: row_string(row, "mda"),
|
||||
active: row_bool(row, "active"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::{
|
||||
db::{Table, row_string},
|
||||
error::AppResult,
|
||||
models::LogEntry,
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
#[derive(Deserialize)]
|
||||
pub struct Filter {
|
||||
pub domain: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
Query(f): Query<Filter>,
|
||||
) -> AppResult<Json<Vec<LogEntry>>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
s.require_admin()?;
|
||||
if !state.config.features.logging {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let t = state.db.table(Table::Log);
|
||||
let ts = state.db.cast_text("timestamp");
|
||||
let mut qsql = format!("SELECT {ts} AS timestamp,username,domain,action,data FROM {t}");
|
||||
let mut bind = None;
|
||||
if let Some(raw) = f.domain {
|
||||
let d = validation::domain(&raw)?;
|
||||
s.require_domain(&d)?;
|
||||
qsql.push_str(" WHERE domain=?");
|
||||
bind = Some(d);
|
||||
} else if !s.role.is_global() {
|
||||
let ds: Vec<_> = s
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.cloned()
|
||||
.collect();
|
||||
if ds.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
qsql.push_str(" WHERE domain IN (");
|
||||
qsql.push_str(&vec!["?"; ds.len()].join(","));
|
||||
qsql.push(')');
|
||||
let sql = state.db.q(&(qsql + " ORDER BY timestamp DESC"));
|
||||
let mut q = sqlx::query(&sql);
|
||||
for d in ds {
|
||||
q = q.bind(d);
|
||||
}
|
||||
let rows = q.fetch_all(state.db.pool()).await?;
|
||||
return Ok(Json(
|
||||
rows.iter()
|
||||
.take(f.limit.unwrap_or(200).min(1000))
|
||||
.map(map)
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
qsql.push_str(" ORDER BY timestamp DESC");
|
||||
let sql = state.db.q(&qsql);
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(b) = bind {
|
||||
q = q.bind(b);
|
||||
}
|
||||
let rows = q.fetch_all(state.db.pool()).await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.take(f.limit.unwrap_or(200).min(1000))
|
||||
.map(map)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
fn map(r: &sqlx::any::AnyRow) -> LogEntry {
|
||||
LogEntry {
|
||||
timestamp: row_string(r, "timestamp"),
|
||||
username: row_string(r, "username"),
|
||||
domain: row_string(r, "domain"),
|
||||
action: row_string(r, "action"),
|
||||
data: row_string(r, "data"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use axum::{Json, extract::State, http::HeaderMap};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_string},
|
||||
error::{AppError, AppResult},
|
||||
mailer,
|
||||
models::{BroadcastInput, SendMailInput},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
pub async fn send(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<SendMailInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
if !state.config.features.sendmail {
|
||||
return Err(AppError::forbidden("Send mail is disabled"));
|
||||
}
|
||||
if !session.role.is_global() && !state.config.features.sendmail_all_admins {
|
||||
return Err(AppError::forbidden(
|
||||
"Send mail is restricted to global administrators",
|
||||
));
|
||||
}
|
||||
|
||||
let to = validation::email(&input.to)?;
|
||||
if !session.role.is_global() {
|
||||
let (_, domain) = to.rsplit_once('@').expect("validated email has domain");
|
||||
session.require_domain(domain)?;
|
||||
}
|
||||
|
||||
mailer::send_text(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&to,
|
||||
&input.subject,
|
||||
&input.body,
|
||||
)
|
||||
.await?;
|
||||
let domain = to.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
state
|
||||
.db
|
||||
.log(&state.config, &session.username, domain, "send_mail", &to)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn broadcast(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<BroadcastInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
if !state.config.features.sendmail {
|
||||
return Err(AppError::forbidden("Broadcast mail is disabled"));
|
||||
}
|
||||
if !session.role.is_global() && !state.config.features.sendmail_all_admins {
|
||||
return Err(AppError::forbidden(
|
||||
"Broadcast is restricted to global administrators",
|
||||
));
|
||||
}
|
||||
if input.name.trim().is_empty()
|
||||
|| input.subject.trim().is_empty()
|
||||
|| input.message.trim().is_empty()
|
||||
{
|
||||
return Err(AppError::bad_request(
|
||||
"Sender name, subject and message are required",
|
||||
));
|
||||
}
|
||||
|
||||
let domains = selected_domains(&state, &session, &input.domains).await?;
|
||||
if domains.is_empty() {
|
||||
return Err(AppError::bad_request("Select at least one domain"));
|
||||
}
|
||||
|
||||
let mut recipients = BTreeSet::new();
|
||||
let mailbox_table = state.db.table(Table::Mailbox);
|
||||
let mailbox_sql = state.db.q(&format!(
|
||||
"SELECT username FROM {mailbox_table} WHERE domain=? AND active=? ORDER BY username"
|
||||
));
|
||||
|
||||
for domain in &domains {
|
||||
let rows = sqlx::query(&mailbox_sql)
|
||||
.bind(domain)
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
for row in rows {
|
||||
let address = row_string(&row, "username");
|
||||
if let Ok(address) = validation::email(&address) {
|
||||
recipients.insert(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input.mailboxes_only {
|
||||
let alias_table = state.db.table(Table::Alias);
|
||||
let alias_sql = state.db.q(&format!(
|
||||
"SELECT goto FROM {alias_table} WHERE domain=? AND active=? ORDER BY address"
|
||||
));
|
||||
for domain in &domains {
|
||||
let rows = sqlx::query(&alias_sql)
|
||||
.bind(domain)
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
for row in rows {
|
||||
for raw in row_string(&row, "goto").split(',') {
|
||||
if let Ok(address) = validation::email(raw.trim()) {
|
||||
recipients.insert(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut sent = 0usize;
|
||||
let mut failed = 0usize;
|
||||
for to in recipients {
|
||||
match mailer::send_text_named(
|
||||
&state.config,
|
||||
&session.username,
|
||||
Some(input.name.trim()),
|
||||
&to,
|
||||
&input.subject,
|
||||
&input.message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => sent += 1,
|
||||
Err(error) => {
|
||||
failed += 1;
|
||||
tracing::warn!(%error, %to, "broadcast delivery failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for domain in &domains {
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"broadcast_message",
|
||||
&input.name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": failed == 0,
|
||||
"sent": sent,
|
||||
"failed": failed
|
||||
})))
|
||||
}
|
||||
|
||||
async fn selected_domains(
|
||||
state: &AppState,
|
||||
session: &crate::auth::Session,
|
||||
requested: &[String],
|
||||
) -> AppResult<Vec<String>> {
|
||||
if !requested.is_empty() {
|
||||
let mut domains = Vec::with_capacity(requested.len());
|
||||
for raw in requested {
|
||||
let domain = validation::domain(raw)?;
|
||||
session.require_domain(&domain)?;
|
||||
if !domains.contains(&domain) {
|
||||
domains.push(domain);
|
||||
}
|
||||
}
|
||||
return Ok(domains);
|
||||
}
|
||||
|
||||
if !session.role.is_global() {
|
||||
return Ok(session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|domain| domain.as_str() != "ALL")
|
||||
.cloned()
|
||||
.collect());
|
||||
}
|
||||
|
||||
let table = state.db.table(Table::Domain);
|
||||
let sql = state.db.q(&format!(
|
||||
"SELECT domain FROM {table} WHERE domain<>? AND active=? ORDER BY domain"
|
||||
));
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind("ALL")
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(rows.iter().map(|row| row_string(row, "domain")).collect())
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_opt_bool, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
hooks, mailer,
|
||||
models::{AccountKind, MailboxInput, MailboxSummary},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Filter {
|
||||
pub domain: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
Query(filter): Query<Filter>,
|
||||
) -> AppResult<Json<Vec<MailboxSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let vacation = state.db.table(Table::Vacation);
|
||||
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Mailbox, "password_expiry")
|
||||
.await?;
|
||||
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||
let has_created = state.db.has_column(Table::Mailbox, "created").await?;
|
||||
let has_modified = state.db.has_column(Table::Mailbox, "modified").await?;
|
||||
let active = state.db.flag("m.active", "active");
|
||||
let smtp = if has_smtp {
|
||||
state.db.flag("m.smtp_active", "smtp_active")
|
||||
} else {
|
||||
"NULL AS smtp_active".into()
|
||||
};
|
||||
let expiry = if has_expiry {
|
||||
format!(
|
||||
"{} AS password_expiry",
|
||||
state.db.cast_text("m.password_expiry")
|
||||
)
|
||||
} else {
|
||||
"NULL AS password_expiry".into()
|
||||
};
|
||||
let phone = if has_phone { "m.phone" } else { "'' AS phone" };
|
||||
let email = if has_email {
|
||||
"m.email_other"
|
||||
} else {
|
||||
"'' AS email_other"
|
||||
};
|
||||
let created = if has_created {
|
||||
format!("{} AS created", state.db.cast_text("m.created"))
|
||||
} else {
|
||||
"NULL AS created".into()
|
||||
};
|
||||
let modified = if has_modified {
|
||||
format!("{} AS modified", state.db.cast_text("m.modified"))
|
||||
} else {
|
||||
"NULL AS modified".into()
|
||||
};
|
||||
let vacation_expr = if state.db.has_table(Table::Vacation).await {
|
||||
state.db.flag(
|
||||
"CASE WHEN v.email IS NULL THEN 0 ELSE v.active END",
|
||||
"vacation_active",
|
||||
)
|
||||
} else {
|
||||
"0 AS vacation_active".into()
|
||||
};
|
||||
let vacation_join = if state.db.has_table(Table::Vacation).await {
|
||||
format!("LEFT JOIN {vacation} v ON v.email=m.username")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut used_expr = "NULL AS used_quota_bytes".to_string();
|
||||
let mut used_join = String::new();
|
||||
if state.config.features.used_quotas {
|
||||
let qt = if state.config.features.new_quota_table {
|
||||
Table::Quota2
|
||||
} else {
|
||||
Table::Quota
|
||||
};
|
||||
if state.db.has_table(qt).await {
|
||||
let q = state.db.table(qt);
|
||||
if matches!(qt, Table::Quota2) {
|
||||
used_expr = "q.bytes AS used_quota_bytes".into();
|
||||
used_join = format!("LEFT JOIN {q} q ON q.username=m.username");
|
||||
} else {
|
||||
used_expr = "q.current AS used_quota_bytes".into();
|
||||
used_join =
|
||||
format!("LEFT JOIN {q} q ON q.username=m.username AND q.path='quota/storage'");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sql = format!(
|
||||
"SELECT m.username,m.name,m.maildir,m.quota,m.local_part,m.domain,{active},{smtp},{expiry},{phone},{email},{created},{modified},{used_expr},{vacation_expr} FROM {mailbox} m {vacation_join} {used_join} WHERE 1=1"
|
||||
);
|
||||
let mut binds = Vec::new();
|
||||
if session.kind == AccountKind::User {
|
||||
sql.push_str(" AND m.username=?");
|
||||
binds.push(session.username.clone());
|
||||
} else if let Some(raw) = filter.domain {
|
||||
let d = validation::domain(&raw)?;
|
||||
session.require_domain(&d)?;
|
||||
sql.push_str(" AND m.domain=?");
|
||||
binds.push(d);
|
||||
} else if !session.role.is_global() {
|
||||
let ds: Vec<_> = session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.cloned()
|
||||
.collect();
|
||||
if ds.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
sql.push_str(" AND m.domain IN (");
|
||||
sql.push_str(&vec!["?"; ds.len()].join(","));
|
||||
sql.push(')');
|
||||
binds.extend(ds);
|
||||
}
|
||||
sql.push_str(" ORDER BY m.username");
|
||||
let sql = state.db.q(&sql);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for b in binds {
|
||||
q = q.bind(b);
|
||||
}
|
||||
let rows = q.fetch_all(state.db.pool()).await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| MailboxSummary {
|
||||
username: row_string(r, "username"),
|
||||
name: row_string(r, "name"),
|
||||
maildir: row_string(r, "maildir"),
|
||||
quota: row_i64(r, "quota"),
|
||||
local_part: row_string(r, "local_part"),
|
||||
domain: row_string(r, "domain"),
|
||||
active: row_bool(r, "active"),
|
||||
smtp_active: row_opt_bool(r, "smtp_active"),
|
||||
password_expiry: row_opt_string(r, "password_expiry"),
|
||||
phone: row_string(r, "phone"),
|
||||
email_other: row_string(r, "email_other"),
|
||||
used_quota_bytes: crate::db::row_opt_i64(r, "used_quota_bytes"),
|
||||
vacation_active: row_bool(r, "vacation_active"),
|
||||
created: row_opt_string(r, "created"),
|
||||
modified: row_opt_string(r, "modified"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<MailboxInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
let domain = validation::resolvable_domain(&state.config, &input.domain).await?;
|
||||
session.require_domain(&domain)?;
|
||||
let local = if let Some(u) = input.username.as_deref() {
|
||||
let e = validation::resolvable_email(&state.config, u).await?;
|
||||
let (l, d) = e.rsplit_once('@').unwrap();
|
||||
if d != domain {
|
||||
return Err(AppError::bad_request(
|
||||
"Mailbox domain does not match domain",
|
||||
));
|
||||
}
|
||||
l.to_string()
|
||||
} else {
|
||||
validation::local_part(
|
||||
input
|
||||
.local_part
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::bad_request("local_part or username is required"))?,
|
||||
)?
|
||||
};
|
||||
let username = format!("{local}@{domain}");
|
||||
let password = input
|
||||
.password
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::bad_request("Password is required"))?;
|
||||
enforce_mailbox_limit(&state, &domain).await?;
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let check = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&check)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("Mailbox already exists"));
|
||||
}
|
||||
let check = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {alias} WHERE address=? LIMIT 1"));
|
||||
if sqlx::query(&check)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict(
|
||||
"An alias with this address already exists",
|
||||
));
|
||||
}
|
||||
let quota_mb = mailbox_quota(&state, &domain, input.quota, None).await?;
|
||||
let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier);
|
||||
let maildir = maildir(&state.config, &domain, &local, &username)?;
|
||||
let hash = passwords::hash(&state.config, password, &username).await?;
|
||||
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Mailbox, "password_expiry")
|
||||
.await?;
|
||||
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||
let expiry = if has_expiry {
|
||||
password_expiry_days(&state, &domain).await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if has_email && !input.email_other.trim().is_empty() {
|
||||
let _ = validation::email(&input.email_other)?;
|
||||
}
|
||||
let mut cols = vec![
|
||||
"username",
|
||||
"password",
|
||||
"name",
|
||||
"maildir",
|
||||
"quota",
|
||||
"local_part",
|
||||
"domain",
|
||||
"created",
|
||||
"modified",
|
||||
"active",
|
||||
];
|
||||
let mut vals = vec![
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"CURRENT_TIMESTAMP",
|
||||
"CURRENT_TIMESTAMP",
|
||||
"?",
|
||||
];
|
||||
if has_smtp {
|
||||
cols.push("smtp_active");
|
||||
vals.push("?");
|
||||
}
|
||||
if has_phone {
|
||||
cols.push("phone");
|
||||
vals.push("?");
|
||||
}
|
||||
if has_email {
|
||||
cols.push("email_other");
|
||||
vals.push("?");
|
||||
}
|
||||
if has_expiry {
|
||||
cols.push("password_expiry");
|
||||
vals.push(if expiry > 0 {
|
||||
"CURRENT_TIMESTAMP"
|
||||
} else {
|
||||
"'9999-12-31 23:59:59'"
|
||||
});
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {mailbox} ({}) VALUES ({})",
|
||||
cols.join(","),
|
||||
vals.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(hash)
|
||||
.bind(&input.name)
|
||||
.bind(&maildir)
|
||||
.bind(quota)
|
||||
.bind(&local)
|
||||
.bind(&domain)
|
||||
.bind(input.active);
|
||||
if has_smtp {
|
||||
q = q.bind(input.smtp_active.unwrap_or(true));
|
||||
}
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_email {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
q.execute(state.db.pool()).await?;
|
||||
if has_expiry {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||
}
|
||||
let has_desc = state.db.has_column(Table::Alias, "description").await?;
|
||||
let sql = if has_desc {
|
||||
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(&sql)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.bind(&domain)
|
||||
.bind(input.active)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let quota_text = quota.to_string();
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postcreation,
|
||||
&[
|
||||
("username", &username),
|
||||
("domain", &domain),
|
||||
("maildir", &maildir),
|
||||
("quota", "a_text),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let subdirs_created = match create_mailbox_subdirs(&state.config, &username).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, %username, "mailbox created but automatic subfolder creation failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
if input.send_welcome {
|
||||
let subject = "Welcome";
|
||||
if let Err(e) = mailer::send_text(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&username,
|
||||
subject,
|
||||
&state.config.defaults.welcome_text,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%e,"welcome mail failed after mailbox creation");
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain,
|
||||
"create_mailbox",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"username":username,"maildir":maildir,"quota_bytes":quota,"subdirs_created":subdirs_created}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<MailboxInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
let (_, domain) = username.rsplit_once('@').unwrap();
|
||||
session.require_domain(domain)?;
|
||||
if let Some(u) = input.username.as_deref()
|
||||
&& validation::email(u)? != username
|
||||
{
|
||||
return Err(AppError::bad_request("Renaming mailboxes is not supported"));
|
||||
}
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let exists = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&exists)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::not_found("Mailbox not found"));
|
||||
}
|
||||
let quota_mb = mailbox_quota(&state, domain, input.quota, Some(&username)).await?;
|
||||
let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier);
|
||||
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||
if has_email && !input.email_other.trim().is_empty() {
|
||||
let _ = validation::email(&input.email_other)?;
|
||||
}
|
||||
let mut sets = vec![
|
||||
"name=?",
|
||||
"quota=?",
|
||||
"active=?",
|
||||
"modified=CURRENT_TIMESTAMP",
|
||||
];
|
||||
let new_hash = if let Some(p) = input.password.as_deref() {
|
||||
Some(passwords::hash(&state.config, p, &username).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let password_changed = new_hash.is_some();
|
||||
if has_smtp {
|
||||
sets.push("smtp_active=?");
|
||||
}
|
||||
if has_phone {
|
||||
sets.push("phone=?");
|
||||
}
|
||||
if has_email {
|
||||
sets.push("email_other=?");
|
||||
}
|
||||
if new_hash.is_some() {
|
||||
sets.push("password=?");
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {mailbox} SET {} WHERE username=?",
|
||||
sets.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.name)
|
||||
.bind(quota)
|
||||
.bind(input.active);
|
||||
if has_smtp {
|
||||
q = q.bind(input.smtp_active.unwrap_or(true));
|
||||
}
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_email {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
if let Some(hash) = new_hash {
|
||||
q = q.bind(hash);
|
||||
}
|
||||
q.bind(&username).execute(state.db.pool()).await?;
|
||||
if password_changed {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||
}
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {alias} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=? AND goto=?"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(input.active)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let local = username.split('@').next().unwrap_or("");
|
||||
let md = maildir(&state.config, domain, local, &username)?;
|
||||
let quota_text = quota.to_string();
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postedit,
|
||||
&[
|
||||
("username", &username),
|
||||
("domain", domain),
|
||||
("maildir", &md),
|
||||
("quota", "a_text),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"edit_mailbox",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
let (_, domain) = username.rsplit_once('@').unwrap();
|
||||
session.require_domain(domain)?;
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT maildir FROM {mailbox} WHERE username=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Mailbox not found"))?;
|
||||
let maildir = row_string(&row, "maildir");
|
||||
for (table, col) in [
|
||||
(Table::Fetchmail, "mailbox"),
|
||||
(Table::Vacation, "email"),
|
||||
(Table::Quota, "username"),
|
||||
(Table::Quota2, "username"),
|
||||
(Table::MailboxAppPassword, "username"),
|
||||
(Table::TotpException, "username"),
|
||||
] {
|
||||
if state.db.has_table(table).await {
|
||||
let t = state.db.table(table);
|
||||
let sql = state.db.q(&format!("DELETE FROM {t} WHERE {col}=?"));
|
||||
let _ = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if state.db.has_table(Table::VacationNotification).await {
|
||||
let t = state.db.table(Table::VacationNotification);
|
||||
let sql = state.db.q(&format!(
|
||||
"DELETE FROM {t} WHERE on_vacation=? OR notified=?"
|
||||
));
|
||||
let _ = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let sql = state.db.q(&format!("DELETE FROM {alias} WHERE address=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("DELETE FROM {mailbox} WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postdeletion,
|
||||
&[
|
||||
("username", &username),
|
||||
("domain", domain),
|
||||
("maildir", &maildir),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"delete_mailbox",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn maildir(
|
||||
config: &crate::config::AppConfig,
|
||||
domain: &str,
|
||||
local: &str,
|
||||
username: &str,
|
||||
) -> AppResult<String> {
|
||||
let mut value = if !config.defaults.maildir_template.trim().is_empty() {
|
||||
config
|
||||
.defaults
|
||||
.maildir_template
|
||||
.replace("{domain}", domain)
|
||||
.replace("{local_part}", local)
|
||||
.replace("{username}", username)
|
||||
} else if config.defaults.domain_path {
|
||||
if config.defaults.domain_in_mailbox {
|
||||
format!("{domain}/{username}/")
|
||||
} else {
|
||||
format!("{domain}/{local}/")
|
||||
}
|
||||
} else {
|
||||
format!("{username}/")
|
||||
};
|
||||
value = value.trim().to_string();
|
||||
if value.is_empty() || value.starts_with('/') || value.contains("..") || value.contains('\0') {
|
||||
return Err(AppError::config("generated maildir path is unsafe"));
|
||||
}
|
||||
if !value.ends_with('/') {
|
||||
value.push('/');
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn create_mailbox_subdirs(
|
||||
config: &crate::config::AppConfig,
|
||||
username: &str,
|
||||
) -> AppResult<bool> {
|
||||
if config.defaults.create_mailbox_subdirs.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let program = config
|
||||
.password
|
||||
.doveadm
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::config("password.doveadm is empty"))?;
|
||||
for subdir in &config.defaults.create_mailbox_subdirs {
|
||||
let mailbox = format!(
|
||||
"{}{}",
|
||||
config.defaults.create_mailbox_subdirs_prefix,
|
||||
subdir.trim()
|
||||
);
|
||||
let output = Command::new(program)
|
||||
.args(["mailbox", "create", "-u", username, "-s", &mailbox])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"doveadm mailbox create failed for {mailbox}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
async fn enforce_mailbox_limit(state: &AppState, domain: &str) -> AppResult<()> {
|
||||
let d = state.db.table(Table::Domain);
|
||||
let m = state.db.table(Table::Mailbox);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT mailboxes FROM {d} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Domain not found"))?;
|
||||
let limit = row_i64(&row, "mailboxes");
|
||||
if limit < 0 {
|
||||
return Err(AppError::forbidden(
|
||||
"Mailboxes are disabled for this domain",
|
||||
));
|
||||
}
|
||||
if limit == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT COUNT(*) AS n FROM {m} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
if row_i64(&row, "n") >= limit {
|
||||
return Err(AppError::conflict("Domain mailbox limit reached"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn mailbox_quota(
|
||||
state: &AppState,
|
||||
domain: &str,
|
||||
requested: Option<i64>,
|
||||
exclude: Option<&str>,
|
||||
) -> AppResult<i64> {
|
||||
if !state.config.features.quota {
|
||||
return Ok(0);
|
||||
}
|
||||
let d = state.db.table(Table::Domain);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT maxquota,quota FROM {d} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Domain not found"))?;
|
||||
let max = row_i64(&row, "maxquota");
|
||||
let q = requested.unwrap_or(max.max(0));
|
||||
if q < 0 {
|
||||
return Err(AppError::bad_request("Quota cannot be negative"));
|
||||
}
|
||||
if max > 0 && q > max {
|
||||
return Err(AppError::bad_request(
|
||||
"Mailbox quota exceeds domain maxquota",
|
||||
));
|
||||
}
|
||||
if state.config.features.domain_quota {
|
||||
let domain_limit = row_i64(&row, "quota");
|
||||
if domain_limit > 0 {
|
||||
let m = state.db.table(Table::Mailbox);
|
||||
let (sql, bind_exclude) = if exclude.is_some() {
|
||||
(state.db.q(&format!("SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=? AND username<>?")),true)
|
||||
} else {
|
||||
(
|
||||
state.db.q(&format!(
|
||||
"SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=?"
|
||||
)),
|
||||
false,
|
||||
)
|
||||
};
|
||||
let mut query = sqlx::query(&sql).bind(domain);
|
||||
if bind_exclude {
|
||||
query = query.bind(exclude.unwrap());
|
||||
}
|
||||
let r = query.fetch_one(state.db.pool()).await?;
|
||||
let current = row_i64(&r, "total") / state.config.defaults.quota_multiplier.max(1);
|
||||
if current.saturating_add(q) > domain_limit {
|
||||
return Err(AppError::bad_request("Domain quota would be exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
async fn password_expiry_days(state: &AppState, domain: &str) -> AppResult<i64> {
|
||||
if !state.config.features.password_expiration {
|
||||
return Ok(0);
|
||||
}
|
||||
let d = state.db.table(Table::Domain);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT password_expiry FROM {d} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?;
|
||||
Ok(row
|
||||
.as_ref()
|
||||
.map(|r| row_i64(r, "password_expiry"))
|
||||
.unwrap_or(0))
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
http::HeaderMap,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
auth::{Session, verify_csrf},
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub mod admins;
|
||||
pub mod aliases;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod dkim;
|
||||
pub mod domains;
|
||||
pub mod fetchmail;
|
||||
pub mod logs;
|
||||
pub mod mail;
|
||||
pub mod mailboxes;
|
||||
pub mod preferences;
|
||||
pub mod rspamd_dkim;
|
||||
pub mod security;
|
||||
pub mod sieve;
|
||||
pub mod system;
|
||||
pub mod vacation;
|
||||
|
||||
pub fn router(state: &AppState) -> Router<Arc<AppState>> {
|
||||
let limit = state
|
||||
.config
|
||||
.security
|
||||
.request_body_limit_mb
|
||||
.saturating_mul(1024 * 1024);
|
||||
let router = Router::new()
|
||||
.route("/health", get(system::health))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/mfa", post(auth::mfa))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route("/auth/recovery/request", post(auth::recovery_request))
|
||||
.route("/auth/recovery/reset", post(auth::recovery_reset))
|
||||
.route("/overview", get(system::overview))
|
||||
.route("/admins", get(admins::list).post(admins::create))
|
||||
.route(
|
||||
"/admins/{username}",
|
||||
put(admins::update).delete(admins::remove),
|
||||
)
|
||||
.route("/domains", get(domains::list).post(domains::create))
|
||||
.route(
|
||||
"/domains/{domain}",
|
||||
put(domains::update).delete(domains::remove),
|
||||
)
|
||||
.route("/domains/{domain}/dns-refresh", post(domains::dns_refresh))
|
||||
.route("/mailboxes", get(mailboxes::list).post(mailboxes::create))
|
||||
.route(
|
||||
"/mailboxes/{username}",
|
||||
put(mailboxes::update).delete(mailboxes::remove),
|
||||
)
|
||||
.route("/aliases", get(aliases::list).post(aliases::create))
|
||||
.route(
|
||||
"/aliases/delete-inactive/{domain}",
|
||||
post(aliases::delete_inactive),
|
||||
)
|
||||
.route(
|
||||
"/aliases/{address}",
|
||||
put(aliases::update).delete(aliases::remove),
|
||||
)
|
||||
.route(
|
||||
"/alias-domains",
|
||||
get(aliases::list_domains).post(aliases::create_domain),
|
||||
)
|
||||
.route(
|
||||
"/alias-domains/{domain}",
|
||||
put(aliases::update_domain).delete(aliases::remove_domain),
|
||||
)
|
||||
.route("/fetchmail", get(fetchmail::list).post(fetchmail::create))
|
||||
.route(
|
||||
"/fetchmail/{id}",
|
||||
put(fetchmail::update).delete(fetchmail::remove),
|
||||
)
|
||||
.route(
|
||||
"/vacation/{username}",
|
||||
get(vacation::get)
|
||||
.put(vacation::set)
|
||||
.delete(vacation::remove),
|
||||
)
|
||||
.route("/logs", get(logs::list))
|
||||
.route("/dkim/records", get(dkim::dns_records))
|
||||
.route("/dkim/keys", get(dkim::list_keys).post(dkim::create_key))
|
||||
.route(
|
||||
"/dkim/keys/{id}",
|
||||
put(dkim::update_key).delete(dkim::remove_key),
|
||||
)
|
||||
.route(
|
||||
"/dkim/signing",
|
||||
get(dkim::list_signing).post(dkim::create_signing),
|
||||
)
|
||||
.route(
|
||||
"/dkim/signing/{id}",
|
||||
put(dkim::update_signing).delete(dkim::remove_signing),
|
||||
)
|
||||
.route("/rspamd-dkim", get(rspamd_dkim::list))
|
||||
.route("/rspamd-dkim/configure", post(rspamd_dkim::configure))
|
||||
.route("/rspamd-dkim/generate", post(rspamd_dkim::generate))
|
||||
.route("/rspamd-dkim/adopt", post(rspamd_dkim::adopt))
|
||||
.route(
|
||||
"/rspamd-dkim/import-legacy",
|
||||
post(rspamd_dkim::import_legacy_domain_blocks),
|
||||
)
|
||||
.route("/rspamd-dkim/test", get(rspamd_dkim::test_config))
|
||||
.route("/rspamd-dkim/reload", post(rspamd_dkim::reload))
|
||||
.route("/rspamd-dkim/{domain}", delete(rspamd_dkim::unmap))
|
||||
.route("/rspamd-dkim/{domain}/purge", post(rspamd_dkim::purge))
|
||||
.route("/security/password", post(security::change_password))
|
||||
.route("/security/totp/setup", post(security::totp_setup))
|
||||
.route("/security/totp/confirm", post(security::totp_confirm))
|
||||
.route("/security/totp", delete(security::totp_disable))
|
||||
.route(
|
||||
"/security/totp-exceptions",
|
||||
get(security::list_exceptions).post(security::add_exception),
|
||||
)
|
||||
.route(
|
||||
"/security/totp-exceptions/{id}",
|
||||
delete(security::remove_exception),
|
||||
)
|
||||
.route(
|
||||
"/security/app-passwords",
|
||||
get(security::list_app_passwords).post(security::add_app_password),
|
||||
)
|
||||
.route(
|
||||
"/security/app-passwords/{id}",
|
||||
delete(security::remove_app_password),
|
||||
)
|
||||
.route("/mail/send", post(mail::send))
|
||||
.route("/mail/broadcast", post(mail::broadcast))
|
||||
.route("/backup", get(backup::download))
|
||||
.route("/preferences", get(preferences::list))
|
||||
.route(
|
||||
"/preferences/{key}",
|
||||
put(preferences::set).delete(preferences::remove),
|
||||
)
|
||||
.route("/system/schema", get(system::schema))
|
||||
.route("/system/features", get(system::features));
|
||||
|
||||
let router = if state.config.sieve.enabled {
|
||||
router
|
||||
.route("/sieve/status", get(sieve::status))
|
||||
.route("/sieve/scripts", get(sieve::list_scripts))
|
||||
.route(
|
||||
"/sieve/scripts/{name}",
|
||||
get(sieve::get_script)
|
||||
.put(sieve::put_script)
|
||||
.delete(sieve::delete_script),
|
||||
)
|
||||
.route(
|
||||
"/sieve/scripts/{name}/activate",
|
||||
post(sieve::activate_script),
|
||||
)
|
||||
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
||||
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
||||
.route("/sieve/check", post(sieve::check_script))
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
router.layer(DefaultBodyLimit::max(limit))
|
||||
}
|
||||
|
||||
pub fn session(state: &AppState, jar: &CookieJar) -> AppResult<Session> {
|
||||
let token = jar
|
||||
.get("pa_session")
|
||||
.map(|c| c.value())
|
||||
.ok_or_else(|| AppError::unauthorized("Login required"))?;
|
||||
state
|
||||
.auth
|
||||
.get_session(token)
|
||||
.ok_or_else(|| AppError::unauthorized("Session expired"))
|
||||
}
|
||||
|
||||
pub fn require_writable(state: &AppState) -> AppResult<()> {
|
||||
if state.config.database.read_only {
|
||||
return Err(AppError::forbidden(
|
||||
"Database is read-only in this development session",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mutation_session(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
headers: &HeaderMap,
|
||||
) -> AppResult<Session> {
|
||||
let session = session(state, jar)?;
|
||||
verify_csrf(
|
||||
&session,
|
||||
headers.get("x-csrf-token").and_then(|v| v.to_str().ok()),
|
||||
)?;
|
||||
require_writable(state)?;
|
||||
Ok(session)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_string},
|
||||
error::{AppError, AppResult},
|
||||
routes,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Preference {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PreferenceInput {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<Preference>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
session.require_admin()?;
|
||||
if !state.db.has_table(Table::AdminPreferences).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let table = state.db.table(Table::AdminPreferences);
|
||||
let sql = state.db.q(&format!(
|
||||
"SELECT pref_key,pref_value FROM {table} WHERE username=? ORDER BY pref_key"
|
||||
));
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(&session.username)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|row| Preference {
|
||||
key: row_string(row, "pref_key"),
|
||||
value: row_string(row, "pref_value"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<PreferenceInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
validate_key(&key)?;
|
||||
if input.value.len() > 2048 {
|
||||
return Err(AppError::bad_request("Preference value is too long"));
|
||||
}
|
||||
if !state.db.has_table(Table::AdminPreferences).await {
|
||||
return Err(AppError::conflict(
|
||||
"admin_preferences table is not available",
|
||||
));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.set_admin_pref(&session.username, &key, &input.value)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
validate_key(&key)?;
|
||||
if state.db.has_table(Table::AdminPreferences).await {
|
||||
state.db.delete_admin_pref(&session.username, &key).await?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
fn validate_key(key: &str) -> AppResult<()> {
|
||||
if key.is_empty()
|
||||
|| key.len() > 64
|
||||
|| !key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||
{
|
||||
return Err(AppError::bad_request("Invalid preference key"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,420 @@
|
||||
use crate::{
|
||||
auth,
|
||||
db::{Table, row_i64, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
hooks,
|
||||
models::{
|
||||
AccountKind, AppPasswordInput, AppPasswordSummary, PasswordChangeInput, TotpExceptionInput,
|
||||
TotpExceptionSummary,
|
||||
},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use rand::RngCore;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TotpConfirm {
|
||||
pub secret: String,
|
||||
pub code: String,
|
||||
pub current_password: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct TotpDisable {
|
||||
pub current_password: String,
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<PasswordChangeInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let (table, col) = account_table(&s);
|
||||
let t = state.db.table(table);
|
||||
let q = state
|
||||
.db
|
||||
.q(&format!("SELECT password FROM {t} WHERE {col}=?"));
|
||||
let r = sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||
if !passwords::verify(
|
||||
&state.config,
|
||||
&input.current_password,
|
||||
&row_string(&r, "password"),
|
||||
&s.username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(AppError::forbidden("Current password is incorrect"));
|
||||
}
|
||||
let hash = passwords::hash(&state.config, &input.new_password, &s.username).await?;
|
||||
let q = state.db.q(&format!(
|
||||
"UPDATE {t} SET password=?,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(hash)
|
||||
.bind(&s.username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
if s.kind == AccountKind::User {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &s.username).await?;
|
||||
}
|
||||
let domain = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
if s.kind == AccountKind::User {
|
||||
let hook_stdin = format!("{}\0{}\0", input.current_password, input.new_password);
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postpassword,
|
||||
&[("username", &s.username), ("domain", domain)],
|
||||
Some(hook_stdin.as_bytes()),
|
||||
)
|
||||
.await?;
|
||||
if let Some(cookie) = jar.get("pa_session") {
|
||||
state
|
||||
.auth
|
||||
.update_mail_password(cookie.value(), input.new_password.clone());
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&s.username,
|
||||
domain,
|
||||
"edit_password",
|
||||
&s.username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn totp_setup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if !state.config.features.totp {
|
||||
return Err(AppError::forbidden("TOTP is disabled"));
|
||||
}
|
||||
let (table, _) = account_table(&s);
|
||||
if !state.db.has_column(table, "totp_secret").await? {
|
||||
return Err(AppError::conflict("Database schema has no TOTP field"));
|
||||
}
|
||||
let mut bytes = [0u8; 20];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let secret = BASE32_NOPAD.encode(&bytes);
|
||||
let label = urlencoding::encode(&s.username);
|
||||
let issuer = urlencoding::encode("PostfixAdmin-RS");
|
||||
let uri = format!(
|
||||
"otpauth://totp/{issuer}:{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30"
|
||||
);
|
||||
Ok(Json(serde_json::json!({"secret":secret,"otpauth_uri":uri})))
|
||||
}
|
||||
|
||||
pub async fn totp_confirm(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TotpConfirm>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if !state.config.features.totp {
|
||||
return Err(AppError::forbidden("TOTP is disabled"));
|
||||
}
|
||||
verify_current(&state, &s, &input.current_password).await?;
|
||||
if !auth::verify_totp(&input.secret, &input.code) {
|
||||
return Err(AppError::bad_request("Invalid TOTP code"));
|
||||
}
|
||||
let (table, col) = account_table(&s);
|
||||
let t = state.db.table(table);
|
||||
if !state.db.has_column(table, "totp_secret").await? {
|
||||
return Err(AppError::conflict("Database schema has no TOTP field"));
|
||||
}
|
||||
let q = state.db.q(&format!(
|
||||
"UPDATE {t} SET totp_secret=?,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(input.secret.trim().to_ascii_uppercase())
|
||||
.bind(&s.username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let d = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_totp_change,
|
||||
&[("username", &s.username), ("domain", d)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn totp_disable(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TotpDisable>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
verify_current(&state, &s, &input.current_password).await?;
|
||||
let (table, col) = account_table(&s);
|
||||
let t = state.db.table(table);
|
||||
if state.db.has_column(table, "totp_secret").await? {
|
||||
let q = state.db.q(&format!(
|
||||
"UPDATE {t} SET totp_secret=NULL,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_exceptions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<TotpExceptionSummary>>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
if !state.config.features.totp || !state.db.has_table(Table::TotpException).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let t = state.db.table(Table::TotpException);
|
||||
let rows = if s.role.is_global() {
|
||||
sqlx::query(&format!(
|
||||
"SELECT id,ip,username,description FROM {t} ORDER BY id"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
} else {
|
||||
let q=state.db.q(&format!("SELECT id,ip,username,description FROM {t} WHERE username=? OR username IS NULL ORDER BY id"));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
};
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| TotpExceptionSummary {
|
||||
id: row_i64(r, "id"),
|
||||
ip: row_string(r, "ip"),
|
||||
username: row_opt_string(r, "username"),
|
||||
description: row_string(r, "description"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn add_exception(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TotpExceptionInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if !state.config.features.totp {
|
||||
return Err(AppError::forbidden("TOTP is disabled"));
|
||||
}
|
||||
let ip: std::net::IpAddr = input
|
||||
.ip
|
||||
.parse()
|
||||
.map_err(|_| AppError::bad_request("Invalid IP address"))?;
|
||||
let username = if s.role.is_global() {
|
||||
input.username.as_deref().unwrap_or(&s.username).to_string()
|
||||
} else {
|
||||
s.username.clone()
|
||||
};
|
||||
let t = state.db.table(Table::TotpException);
|
||||
let q = state.db.q(&format!(
|
||||
"INSERT INTO {t} (ip,username,description) VALUES (?,?,?)"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(ip.to_string())
|
||||
.bind(&username)
|
||||
.bind(&input.description)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let ip_text = ip.to_string();
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_totp_exception_add,
|
||||
&[("username", &username), ("ip", &ip_text)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_exception(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let t = state.db.table(Table::TotpException);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT username,ip FROM {t} WHERE id=?"));
|
||||
let r = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("TOTP exception not found"))?;
|
||||
let owner = row_opt_string(&r, "username");
|
||||
if !s.role.is_global() && owner.as_deref() != Some(&s.username) {
|
||||
return Err(AppError::forbidden(
|
||||
"TOTP exception is outside this account",
|
||||
));
|
||||
}
|
||||
let q = state.db.q(&format!("DELETE FROM {t} WHERE id=?"));
|
||||
sqlx::query(&q).bind(id).execute(state.db.pool()).await?;
|
||||
let ip_text = row_string(&r, "ip");
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_totp_exception_delete,
|
||||
&[
|
||||
("username", owner.as_deref().unwrap_or("")),
|
||||
("ip", &ip_text),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_app_passwords(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<AppPasswordSummary>>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
if !state.config.features.app_passwords || !state.db.has_table(Table::MailboxAppPassword).await
|
||||
{
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let t = state.db.table(Table::MailboxAppPassword);
|
||||
let rows = if s.role.is_global() {
|
||||
sqlx::query(&format!(
|
||||
"SELECT id,username,description FROM {t} ORDER BY username,id"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
} else {
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT id,username,description FROM {t} WHERE username=? ORDER BY id"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
};
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| AppPasswordSummary {
|
||||
id: row_i64(r, "id"),
|
||||
username: row_string(r, "username"),
|
||||
description: row_string(r, "description"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn add_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<AppPasswordInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if s.kind != AccountKind::User {
|
||||
return Err(AppError::forbidden(
|
||||
"Application passwords are created by mailbox users",
|
||||
));
|
||||
}
|
||||
if !state.config.features.app_passwords {
|
||||
return Err(AppError::forbidden("Application passwords are disabled"));
|
||||
}
|
||||
if input.description.trim().is_empty() {
|
||||
return Err(AppError::bad_request("Description is required"));
|
||||
}
|
||||
verify_current(&state, &s, &input.current_password).await?;
|
||||
let hash = passwords::hash(&state.config, &input.password, &s.username).await?;
|
||||
let t = state.db.table(Table::MailboxAppPassword);
|
||||
let q = state.db.q(&format!(
|
||||
"INSERT INTO {t} (username,description,password_hash) VALUES (?,?,?)"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.bind(&input.description)
|
||||
.bind(&hash)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_app_password,
|
||||
&[
|
||||
("username", &s.username),
|
||||
("description", &input.description),
|
||||
],
|
||||
Some(hash.as_bytes()),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let t = state.db.table(Table::MailboxAppPassword);
|
||||
let q = if s.role.is_global() {
|
||||
state.db.q(&format!("DELETE FROM {t} WHERE id=?"))
|
||||
} else {
|
||||
state
|
||||
.db
|
||||
.q(&format!("DELETE FROM {t} WHERE id=? AND username=?"))
|
||||
};
|
||||
let mut x = sqlx::query(&q).bind(id);
|
||||
if !s.role.is_global() {
|
||||
x = x.bind(&s.username);
|
||||
}
|
||||
if x.execute(state.db.pool()).await?.rows_affected() != 1 {
|
||||
return Err(AppError::not_found("Application password not found"));
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn account_table(s: &crate::auth::Session) -> (Table, &'static str) {
|
||||
match s.kind {
|
||||
AccountKind::Admin => (Table::Admin, "username"),
|
||||
AccountKind::User => (Table::Mailbox, "username"),
|
||||
}
|
||||
}
|
||||
async fn verify_current(state: &AppState, s: &crate::auth::Session, p: &str) -> AppResult<()> {
|
||||
let (t, c) = account_table(s);
|
||||
let table = state.db.table(t);
|
||||
let q = state
|
||||
.db
|
||||
.q(&format!("SELECT password FROM {table} WHERE {c}=?"));
|
||||
let r = sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||
if passwords::verify(&state.config, p, &row_string(&r, "password"), &s.username).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Current password is incorrect"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::verify_csrf,
|
||||
error::{AppError, AppResult},
|
||||
models::AccountKind,
|
||||
routes,
|
||||
sieve::{ManageSieveClient, SieveCredentials, SieveError, SieveScript},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusResponse {
|
||||
connected: bool,
|
||||
port: u16,
|
||||
tls_mode: String,
|
||||
implementation: Option<String>,
|
||||
extensions: Vec<String>,
|
||||
sasl: Vec<String>,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScriptsResponse {
|
||||
scripts: Vec<SieveScript>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScriptResponse {
|
||||
name: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ScriptBody {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenameBody {
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ValidationResponse {
|
||||
valid: bool,
|
||||
warning: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionResponse {
|
||||
ok: bool,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
fn credentials(state: &AppState, session: &crate::auth::Session) -> AppResult<SieveCredentials> {
|
||||
if !state.config.sieve.enabled {
|
||||
return Err(AppError::forbidden("ManageSieve is disabled"));
|
||||
}
|
||||
if session.kind != AccountKind::User {
|
||||
return Err(AppError::forbidden(
|
||||
"ManageSieve is available to mailbox users only",
|
||||
));
|
||||
}
|
||||
let password = session.mail_password().ok_or_else(|| {
|
||||
AppError::unauthorized("ManageSieve credentials are not available; sign in again")
|
||||
})?;
|
||||
Ok(SieveCredentials {
|
||||
host: state.config.sieve.host.clone(),
|
||||
port: state.config.sieve.port,
|
||||
tls_mode: state.config.sieve.tls_mode.clone(),
|
||||
allow_insecure_auth: state.config.sieve.allow_insecure_auth,
|
||||
username: session.username.clone(),
|
||||
password: password.to_string(),
|
||||
timeout: Duration::from_secs(state.config.sieve.timeout_secs),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_error(error: SieveError) -> AppError {
|
||||
match error {
|
||||
SieveError::Protocol(message) | SieveError::ServerRejected(message) => {
|
||||
AppError::bad_request(message)
|
||||
}
|
||||
SieveError::ScriptTooLarge => AppError::bad_request("Sieve script exceeds the 1 MiB limit"),
|
||||
SieveError::InsecureAuthenticationBlocked => AppError::forbidden(
|
||||
"ManageSieve plaintext authentication is disabled; use TLS/STARTTLS or explicitly opt in",
|
||||
),
|
||||
SieveError::AuthenticationFailed(message) => {
|
||||
AppError::unauthorized(format!("ManageSieve authentication failed: {message}"))
|
||||
}
|
||||
SieveError::ConnectionFailed(message) | SieveError::TlsFailed(message) => {
|
||||
AppError::bad_gateway(message)
|
||||
}
|
||||
SieveError::Timeout => AppError::bad_gateway("ManageSieve request timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(state: &AppState, session: &crate::auth::Session) -> AppResult<ManageSieveClient> {
|
||||
ManageSieveClient::connect(&credentials(state, session)?)
|
||||
.await
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
fn mutation_session(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
headers: &HeaderMap,
|
||||
) -> AppResult<crate::auth::Session> {
|
||||
let session = routes::session(state, jar)?;
|
||||
verify_csrf(
|
||||
&session,
|
||||
headers
|
||||
.get("x-csrf-token")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
)?;
|
||||
if state.config.sieve.read_only {
|
||||
return Err(AppError::forbidden("ManageSieve is read-only"));
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<StatusResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let capabilities = client.capabilities().clone();
|
||||
client.logout().await;
|
||||
Ok(Json(StatusResponse {
|
||||
connected: true,
|
||||
port: state.config.sieve.port,
|
||||
tls_mode: state.config.sieve.tls_mode.clone(),
|
||||
implementation: capabilities.implementation,
|
||||
extensions: capabilities.extensions,
|
||||
sasl: capabilities.sasl,
|
||||
read_only: state.config.sieve.read_only,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_scripts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<ScriptsResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let scripts = client.list_scripts().await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ScriptsResponse { scripts }))
|
||||
}
|
||||
|
||||
pub async fn get_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<ScriptResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let content = client.get_script(&name).await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ScriptResponse { name, content }))
|
||||
}
|
||||
|
||||
pub async fn check_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
Json(body): Json<ScriptBody>,
|
||||
) -> AppResult<Json<ValidationResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let result = client.check_script(&body.content).await;
|
||||
client.logout().await;
|
||||
let response = match result {
|
||||
Ok(warning) => ValidationResponse {
|
||||
valid: true,
|
||||
warning,
|
||||
error: None,
|
||||
},
|
||||
Err(SieveError::ServerRejected(message)) => ValidationResponse {
|
||||
valid: false,
|
||||
warning: None,
|
||||
error: Some(message),
|
||||
},
|
||||
Err(error) => return Err(map_error(error)),
|
||||
};
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn put_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ScriptBody>,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let warning = client
|
||||
.put_script(&name, &body.content)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse { ok: true, warning }))
|
||||
}
|
||||
|
||||
pub async fn activate_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client
|
||||
.activate_script(Some(&name))
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn deactivate_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client.activate_script(None).await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client.delete_script(&name).await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn rename_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<RenameBody>,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client
|
||||
.rename_script(&name, &body.new_name)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use crate::{
|
||||
db::{Table, row_i64},
|
||||
error::AppResult,
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
#[derive(Serialize)]
|
||||
pub struct Health {
|
||||
status: &'static str,
|
||||
version: &'static str,
|
||||
}
|
||||
pub async fn health(State(state): State<Arc<AppState>>) -> AppResult<Json<Health>> {
|
||||
state.db.ping().await?;
|
||||
Ok(Json(Health {
|
||||
status: "ok",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
pub async fn overview(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
let domains = count(&state, Table::Domain, "domain<>'ALL'").await?;
|
||||
let mailboxes = count_scoped(&state, Table::Mailbox, "domain", &s).await?;
|
||||
let aliases = count_scoped(&state, Table::Alias, "domain", &s).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"domains":if s.role.is_global(){domains}else{s.allowed_domains.iter().filter(|d|d.as_str()!="ALL").count() as i64},"mailboxes":mailboxes,"aliases":aliases,"role":s.role,"username":s.username,"features":&state.config.features}),
|
||||
))
|
||||
}
|
||||
pub async fn schema(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
s.require_global()?;
|
||||
let warnings = state.db.check_schema(&state.config).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":warnings.is_empty(),"warnings":warnings,"database_backend":state.config.database.backend}),
|
||||
))
|
||||
}
|
||||
pub async fn features(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let _ = routes::session(&state, &jar)?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"features":&state.config.features,"defaults":{"page_size":state.config.defaults.page_size,"quota_multiplier":state.config.defaults.quota_multiplier,"quota_level_med_pct":state.config.defaults.quota_level_med_pct,"quota_level_high_pct":state.config.defaults.quota_level_high_pct,"motd_user":state.config.defaults.motd_user,"motd_admin":state.config.defaults.motd_admin,"motd_superadmin":state.config.defaults.motd_superadmin},"password":{"configured":state.config.password.scheme,"supported":passwords::supported_schemes()},"development_mock":state.config.database.dev_mock,"database_read_only":state.config.database.read_only,"dkim":{"default_backend":state.config.dkim.default_backend,"user_read_only":state.config.dkim.user_read_only},"rspamd_dkim":{"enabled":state.config.rspamd_dkim.enabled,"default_selector":state.config.rspamd_dkim.default_selector,"development_mock":state.config.rspamd_dkim.dev_mock},"sieve":{"enabled":state.config.sieve.enabled,"port":state.config.sieve.port,"tls_mode":state.config.sieve.tls_mode,"read_only":state.config.sieve.read_only},"version":env!("CARGO_PKG_VERSION")}),
|
||||
))
|
||||
}
|
||||
async fn count(state: &AppState, table: Table, where_sql: &str) -> AppResult<i64> {
|
||||
let t = state.db.table(table);
|
||||
let r = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {t} WHERE {where_sql}"))
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
Ok(row_i64(&r, "n"))
|
||||
}
|
||||
async fn count_scoped(
|
||||
state: &AppState,
|
||||
table: Table,
|
||||
col: &str,
|
||||
s: &crate::auth::Session,
|
||||
) -> AppResult<i64> {
|
||||
let t = state.db.table(table);
|
||||
if s.role.is_global() {
|
||||
let r = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {t}"))
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
return Ok(row_i64(&r, "n"));
|
||||
}
|
||||
if !s.role.is_admin() {
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT COUNT(*) AS n FROM {t} WHERE {}=?",
|
||||
if matches!(table, Table::Mailbox) {
|
||||
"username"
|
||||
} else {
|
||||
"address"
|
||||
}
|
||||
));
|
||||
let r = sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
return Ok(row_i64(&r, "n"));
|
||||
}
|
||||
let ds: Vec<_> = s
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.collect();
|
||||
if ds.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT COUNT(*) AS n FROM {t} WHERE {col} IN ({})",
|
||||
vec!["?"; ds.len()].join(",")
|
||||
));
|
||||
let mut x = sqlx::query(&q);
|
||||
for d in ds {
|
||||
x = x.bind(d.as_str());
|
||||
}
|
||||
let r = x.fetch_one(state.db.pool()).await?;
|
||||
Ok(row_i64(&r, "n"))
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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 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 input.subject.len() > 255 || input.body.len() > 65535 {
|
||||
return Err(AppError::bad_request("Vacation message is too long"));
|
||||
}
|
||||
let (_, domain) = u.rsplit_once('@').unwrap();
|
||||
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 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(state.db.pool())
|
||||
.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(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"edit_vacation",
|
||||
&u,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
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 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 {
|
||||
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;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
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('@').unwrap();
|
||||
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"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user