first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-03 16:52:28 +01:00
commit 2eb8aac027
91 files changed
+20139

No files matched your search

+333
View File
@@ -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
+465
View File
@@ -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}")
}
+85
View File
@@ -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;
+578
View File
@@ -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\""
);
}
}
+523
View File
@@ -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
}
+448
View File
@@ -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"),
}
}
+88
View File
@@ -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"),
}
}
+203
View File
@@ -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())
}
+751
View File
@@ -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", &quota_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", &quota_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))
}
+207
View File
@@ -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)
}
+106
View File
@@ -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
+420
View File
@@ -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"))
}
}
+283
View File
@@ -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,
}))
}
+108
View File
@@ -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"))
}
+156
View File
@@ -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"),
}
}