first commit
This commit is contained in:
commit
2eb8aac027
91 files changed
+20139
No files matched your search
+273
@@ -0,0 +1,273 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::IpAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use dashmap::DashMap;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use sha1::Sha1;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
error::{AppError, AppResult},
|
||||
models::{AccountKind, Role},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
pub username: String,
|
||||
pub kind: AccountKind,
|
||||
pub role: Role,
|
||||
pub allowed_domains: Arc<[String]>,
|
||||
pub csrf: String,
|
||||
mail_password: Option<Arc<str>>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn can_manage_domain(&self, domain: &str) -> bool {
|
||||
self.role.is_global()
|
||||
|| self
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.any(|d| d == domain || d == "ALL")
|
||||
}
|
||||
|
||||
pub fn require_admin(&self) -> AppResult<()> {
|
||||
if self.role.is_admin() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Administrator role required"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_global(&self) -> AppResult<()> {
|
||||
if self.role.is_global() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Global administrator role required"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_domain(&self, domain: &str) -> AppResult<()> {
|
||||
self.require_admin()?;
|
||||
if self.can_manage_domain(domain) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden(
|
||||
"Domain is outside this administrator's scope",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mail_password(&self) -> Option<&str> {
|
||||
self.mail_password.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PendingLogin {
|
||||
pub username: String,
|
||||
pub kind: AccountKind,
|
||||
pub role: Role,
|
||||
pub allowed_domains: Arc<[String]>,
|
||||
pub ip: IpAddr,
|
||||
pub mail_password: Option<Arc<str>>,
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthStore {
|
||||
sessions: Arc<DashMap<String, Session>>,
|
||||
pending: Arc<DashMap<String, PendingLogin>>,
|
||||
attempts: Arc<DashMap<String, VecDeque<Instant>>>,
|
||||
timeout: Duration,
|
||||
login_window: Duration,
|
||||
max_attempts: usize,
|
||||
}
|
||||
|
||||
impl AuthStore {
|
||||
pub fn new(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
pending: Arc::new(DashMap::new()),
|
||||
attempts: Arc::new(DashMap::new()),
|
||||
timeout: Duration::from_secs(
|
||||
config.security.session_timeout_minutes.saturating_mul(60),
|
||||
),
|
||||
login_window: Duration::from_secs(config.security.login_window_seconds),
|
||||
max_attempts: config.security.login_attempts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
&self,
|
||||
username: String,
|
||||
kind: AccountKind,
|
||||
role: Role,
|
||||
allowed_domains: Arc<[String]>,
|
||||
mail_password: Option<Arc<str>>,
|
||||
) -> (String, Session) {
|
||||
let token = random_token(32);
|
||||
let session = Session {
|
||||
username,
|
||||
kind,
|
||||
role,
|
||||
allowed_domains,
|
||||
csrf: random_token(32),
|
||||
mail_password,
|
||||
expires_at: Instant::now() + self.timeout,
|
||||
};
|
||||
self.sessions.insert(token.clone(), session.clone());
|
||||
(token, session)
|
||||
}
|
||||
|
||||
pub fn get_session(&self, token: &str) -> Option<Session> {
|
||||
let item = self.sessions.get(token)?;
|
||||
if Instant::now() >= item.expires_at {
|
||||
drop(item);
|
||||
self.sessions.remove(token);
|
||||
return None;
|
||||
}
|
||||
Some(item.clone())
|
||||
}
|
||||
|
||||
pub fn revoke(&self, token: &str) {
|
||||
self.sessions.remove(token);
|
||||
}
|
||||
|
||||
pub fn update_mail_password(&self, token: &str, password: String) {
|
||||
if let Some(mut session) = self.sessions.get_mut(token)
|
||||
&& session.kind == AccountKind::User
|
||||
{
|
||||
session.mail_password = Some(Arc::<str>::from(password));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_pending(
|
||||
&self,
|
||||
username: String,
|
||||
kind: AccountKind,
|
||||
role: Role,
|
||||
allowed_domains: Arc<[String]>,
|
||||
ip: IpAddr,
|
||||
mail_password: Option<Arc<str>>,
|
||||
) -> String {
|
||||
let token = random_token(32);
|
||||
self.pending.insert(
|
||||
token.clone(),
|
||||
PendingLogin {
|
||||
username,
|
||||
kind,
|
||||
role,
|
||||
allowed_domains,
|
||||
ip,
|
||||
mail_password,
|
||||
expires_at: Instant::now() + Duration::from_secs(300),
|
||||
},
|
||||
);
|
||||
token
|
||||
}
|
||||
|
||||
pub fn take_pending(&self, token: &str, ip: IpAddr) -> Option<PendingLogin> {
|
||||
let (_, pending) = self.pending.remove(token)?;
|
||||
if pending.ip != ip || Instant::now() >= pending.expires_at {
|
||||
return None;
|
||||
}
|
||||
Some(pending)
|
||||
}
|
||||
|
||||
pub fn login_allowed(&self, ip: IpAddr, username: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let key = format!("{ip}|{}", username.to_ascii_lowercase());
|
||||
let mut entry = self.attempts.entry(key).or_default();
|
||||
while entry
|
||||
.front()
|
||||
.is_some_and(|t| now.duration_since(*t) > self.login_window)
|
||||
{
|
||||
entry.pop_front();
|
||||
}
|
||||
if entry.len() >= self.max_attempts {
|
||||
return false;
|
||||
}
|
||||
entry.push_back(now);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear_attempts(&self, ip: IpAddr, username: &str) {
|
||||
self.attempts
|
||||
.remove(&format!("{ip}|{}", username.to_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_csrf(session: &Session, provided: Option<&str>) -> AppResult<()> {
|
||||
let Some(provided) = provided else {
|
||||
return Err(AppError::forbidden("Missing CSRF token"));
|
||||
};
|
||||
let a = session.csrf.as_bytes();
|
||||
let b = provided.as_bytes();
|
||||
if a.len() == b.len() && bool::from(a.ct_eq(b)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Invalid CSRF token"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_totp(secret: &str, code: &str) -> bool {
|
||||
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
let normalized = secret.trim().replace([' ', '-'], "").to_ascii_uppercase();
|
||||
let Ok(key) = BASE32_NOPAD.decode(normalized.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let now = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(v) => v.as_secs(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
let target = code.parse::<u32>().unwrap_or(u32::MAX);
|
||||
for offset in [-1i64, 0, 1] {
|
||||
let counter = (now / 30) as i64 + offset;
|
||||
if counter < 0 {
|
||||
continue;
|
||||
}
|
||||
if totp_code(&key, counter as u64) == target {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn totp_code(key: &[u8], counter: u64) -> u32 {
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(key).expect("HMAC accepts arbitrary key size");
|
||||
mac.update(&counter.to_be_bytes());
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let offset = (digest[19] & 0x0f) as usize;
|
||||
let binary = ((u32::from(digest[offset]) & 0x7f) << 24)
|
||||
| (u32::from(digest[offset + 1]) << 16)
|
||||
| (u32::from(digest[offset + 2]) << 8)
|
||||
| u32::from(digest[offset + 3]);
|
||||
binary % 1_000_000
|
||||
}
|
||||
|
||||
pub fn random_token(bytes: usize) -> String {
|
||||
let mut buf = vec![0u8; bytes];
|
||||
rand::rng().fill_bytes(&mut buf);
|
||||
URL_SAFE_NO_PAD.encode(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rfc6238_sha1_vector_internal() {
|
||||
let key = b"12345678901234567890";
|
||||
assert_eq!(totp_code(key, 59 / 30), 287082);
|
||||
}
|
||||
}
|
||||
+1101
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,800 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
use sqlx::{
|
||||
AnyPool, Row,
|
||||
any::{AnyPoolOptions, AnyRow},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{AppConfig, TableNames},
|
||||
error::AppResult,
|
||||
models::{AccountKind, LoginRecord},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Dialect {
|
||||
MySql,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Table {
|
||||
Admin,
|
||||
Alias,
|
||||
AliasDomain,
|
||||
Config,
|
||||
Domain,
|
||||
DomainAdmins,
|
||||
Fetchmail,
|
||||
Log,
|
||||
Mailbox,
|
||||
Vacation,
|
||||
VacationNotification,
|
||||
Quota,
|
||||
Quota2,
|
||||
Dkim,
|
||||
DkimSigning,
|
||||
TotpException,
|
||||
MailboxAppPassword,
|
||||
AdminPreferences,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pool: AnyPool,
|
||||
dialect: Dialect,
|
||||
prefix: Arc<str>,
|
||||
tables: Arc<TableNames>,
|
||||
column_cache: Arc<DashMap<String, bool>>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn connect(config: &AppConfig) -> AppResult<Self> {
|
||||
sqlx::any::install_default_drivers();
|
||||
let dialect = match config.database.backend.as_str() {
|
||||
"pgsql" | "postgres" => Dialect::Postgres,
|
||||
"sqlite" => Dialect::Sqlite,
|
||||
_ => Dialect::MySql,
|
||||
};
|
||||
let url = config.database_url()?;
|
||||
let pool = AnyPoolOptions::new()
|
||||
.max_connections(config.database.max_connections)
|
||||
.connect(&url)
|
||||
.await?;
|
||||
Ok(Self {
|
||||
pool,
|
||||
dialect,
|
||||
prefix: Arc::from(config.database.prefix.clone()),
|
||||
tables: Arc::new(config.database.tables.clone()),
|
||||
column_cache: Arc::new(DashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &AnyPool {
|
||||
&self.pool
|
||||
}
|
||||
pub fn dialect(&self) -> Dialect {
|
||||
self.dialect
|
||||
}
|
||||
|
||||
pub fn table(&self, key: Table) -> String {
|
||||
let raw = match key {
|
||||
Table::Admin => &self.tables.admin,
|
||||
Table::Alias => &self.tables.alias,
|
||||
Table::AliasDomain => &self.tables.alias_domain,
|
||||
Table::Config => &self.tables.config,
|
||||
Table::Domain => &self.tables.domain,
|
||||
Table::DomainAdmins => &self.tables.domain_admins,
|
||||
Table::Fetchmail => &self.tables.fetchmail,
|
||||
Table::Log => &self.tables.log,
|
||||
Table::Mailbox => &self.tables.mailbox,
|
||||
Table::Vacation => &self.tables.vacation,
|
||||
Table::VacationNotification => &self.tables.vacation_notification,
|
||||
Table::Quota => &self.tables.quota,
|
||||
Table::Quota2 => &self.tables.quota2,
|
||||
Table::Dkim => &self.tables.dkim,
|
||||
Table::DkimSigning => &self.tables.dkim_signing,
|
||||
Table::TotpException => &self.tables.totp_exception_address,
|
||||
Table::MailboxAppPassword => &self.tables.mailbox_app_password,
|
||||
Table::AdminPreferences => &self.tables.admin_preferences,
|
||||
};
|
||||
self.quote_ident(&format!("{}{}", self.prefix, raw))
|
||||
}
|
||||
|
||||
pub fn quote_ident(&self, ident: &str) -> String {
|
||||
match self.dialect {
|
||||
Dialect::MySql => format!("`{ident}`"),
|
||||
Dialect::Postgres | Dialect::Sqlite => format!("\"{ident}\""),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn q(&self, template: &str) -> String {
|
||||
if self.dialect != Dialect::Postgres {
|
||||
return template.to_string();
|
||||
}
|
||||
let mut n = 0usize;
|
||||
let mut out = String::with_capacity(template.len() + 16);
|
||||
for ch in template.chars() {
|
||||
if ch == '?' {
|
||||
n += 1;
|
||||
out.push('$');
|
||||
out.push_str(&n.to_string());
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn cast_text(&self, expr: &str) -> String {
|
||||
match self.dialect {
|
||||
Dialect::MySql => format!("CAST({expr} AS CHAR)"),
|
||||
Dialect::Postgres | Dialect::Sqlite => format!("CAST({expr} AS TEXT)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize integer-like flag columns before they enter sqlx::Any.
|
||||
///
|
||||
/// MySQL represents PostfixAdmin boolean flags as TINYINT(1). sqlx::Any
|
||||
/// intentionally supports only a common subset of backend type metadata and
|
||||
/// cannot map MySQL's TINY type directly. Casting at the SQL boundary keeps
|
||||
/// the API portable across MySQL/MariaDB, PostgreSQL and SQLite.
|
||||
pub fn cast_i64(&self, expr: &str) -> String {
|
||||
match self.dialect {
|
||||
Dialect::MySql => format!("CAST({expr} AS SIGNED)"),
|
||||
Dialect::Postgres => format!("CAST({expr} AS BIGINT)"),
|
||||
Dialect::Sqlite => format!("CAST({expr} AS INTEGER)"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flag(&self, expr: &str, alias: &str) -> String {
|
||||
format!("{} AS {alias}", self.cast_i64(expr))
|
||||
}
|
||||
|
||||
pub async fn has_column(&self, table: Table, column: &str) -> AppResult<bool> {
|
||||
let physical = match table {
|
||||
Table::Admin => &self.tables.admin,
|
||||
Table::Alias => &self.tables.alias,
|
||||
Table::AliasDomain => &self.tables.alias_domain,
|
||||
Table::Config => &self.tables.config,
|
||||
Table::Domain => &self.tables.domain,
|
||||
Table::DomainAdmins => &self.tables.domain_admins,
|
||||
Table::Fetchmail => &self.tables.fetchmail,
|
||||
Table::Log => &self.tables.log,
|
||||
Table::Mailbox => &self.tables.mailbox,
|
||||
Table::Vacation => &self.tables.vacation,
|
||||
Table::VacationNotification => &self.tables.vacation_notification,
|
||||
Table::Quota => &self.tables.quota,
|
||||
Table::Quota2 => &self.tables.quota2,
|
||||
Table::Dkim => &self.tables.dkim,
|
||||
Table::DkimSigning => &self.tables.dkim_signing,
|
||||
Table::TotpException => &self.tables.totp_exception_address,
|
||||
Table::MailboxAppPassword => &self.tables.mailbox_app_password,
|
||||
Table::AdminPreferences => &self.tables.admin_preferences,
|
||||
};
|
||||
let table_name = format!("{}{}", self.prefix, physical);
|
||||
let cache_key = format!("{table_name}.{column}");
|
||||
if let Some(value) = self.column_cache.get(&cache_key) {
|
||||
return Ok(*value);
|
||||
}
|
||||
let exists = match self.dialect {
|
||||
Dialect::MySql => {
|
||||
let sql = self.q("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1");
|
||||
sqlx::query(&sql)
|
||||
.bind(&table_name)
|
||||
.bind(column)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.is_some()
|
||||
}
|
||||
Dialect::Postgres => {
|
||||
let sql = self.q("SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ? LIMIT 1");
|
||||
sqlx::query(&sql)
|
||||
.bind(&table_name)
|
||||
.bind(column)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.is_some()
|
||||
}
|
||||
Dialect::Sqlite => {
|
||||
let pragma = format!("PRAGMA table_info({})", self.quote_ident(&table_name));
|
||||
let rows = sqlx::query(&pragma).fetch_all(&self.pool).await?;
|
||||
rows.iter().any(|r| row_string(r, "name") == column)
|
||||
}
|
||||
};
|
||||
self.column_cache.insert(cache_key, exists);
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
pub async fn has_table(&self, table: Table) -> bool {
|
||||
let name = self.table(table);
|
||||
sqlx::query(&format!("SELECT 1 FROM {name} LIMIT 1"))
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> AppResult<()> {
|
||||
sqlx::query("SELECT 1").execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates an isolated SQLite schema used only by `dev.sh`.
|
||||
/// It intentionally mirrors a superset of PostfixAdmin columns so UI/API
|
||||
/// development does not require a copy of the production mail database.
|
||||
pub async fn initialize_dev_mock(&self, config: &AppConfig) -> AppResult<()> {
|
||||
if !config.database.dev_mock || self.dialect != Dialect::Sqlite {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let admin = self.table(Table::Admin);
|
||||
let alias = self.table(Table::Alias);
|
||||
let alias_domain = self.table(Table::AliasDomain);
|
||||
let config_table = self.table(Table::Config);
|
||||
let domain = self.table(Table::Domain);
|
||||
let domain_admins = self.table(Table::DomainAdmins);
|
||||
let fetchmail = self.table(Table::Fetchmail);
|
||||
let log = self.table(Table::Log);
|
||||
let mailbox = self.table(Table::Mailbox);
|
||||
let vacation = self.table(Table::Vacation);
|
||||
let vacation_notification = self.table(Table::VacationNotification);
|
||||
let quota = self.table(Table::Quota);
|
||||
let quota2 = self.table(Table::Quota2);
|
||||
let dkim = self.table(Table::Dkim);
|
||||
let dkim_signing = self.table(Table::DkimSigning);
|
||||
let totp_exception = self.table(Table::TotpException);
|
||||
let app_password = self.table(Table::MailboxAppPassword);
|
||||
let admin_preferences = self.table(Table::AdminPreferences);
|
||||
|
||||
let statements = vec![
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {admin} (
|
||||
username TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL,
|
||||
superadmin INTEGER NOT NULL DEFAULT 0,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email_other TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
token_validity TEXT NOT NULL DEFAULT '2000-01-01 00:00:00',
|
||||
totp_secret TEXT
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {domain} (
|
||||
domain TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
aliases INTEGER NOT NULL DEFAULT 10,
|
||||
mailboxes INTEGER NOT NULL DEFAULT 10,
|
||||
maxquota INTEGER NOT NULL DEFAULT 10,
|
||||
quota INTEGER NOT NULL DEFAULT 2048,
|
||||
transport TEXT NOT NULL DEFAULT 'virtual',
|
||||
backupmx INTEGER NOT NULL DEFAULT 0,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
password_expiry INTEGER NOT NULL DEFAULT 0,
|
||||
dns_active INTEGER,
|
||||
dns_checked TEXT
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {domain_admins} (
|
||||
username TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (username, domain)
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {mailbox} (
|
||||
username TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
maildir TEXT NOT NULL DEFAULT '',
|
||||
quota INTEGER NOT NULL DEFAULT 0,
|
||||
local_part TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT NOT NULL,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
smtp_active INTEGER NOT NULL DEFAULT 1,
|
||||
password_expiry TEXT NOT NULL DEFAULT '9999-12-31 23:59:59',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
email_other TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
token_validity TEXT NOT NULL DEFAULT '2000-01-01 00:00:00',
|
||||
totp_secret TEXT
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {alias} (
|
||||
address TEXT PRIMARY KEY,
|
||||
goto TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {alias_domain} (
|
||||
alias_domain TEXT PRIMARY KEY,
|
||||
target_domain TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {fetchmail} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL DEFAULT '',
|
||||
mailbox TEXT NOT NULL,
|
||||
src_server TEXT NOT NULL,
|
||||
src_port INTEGER,
|
||||
src_auth TEXT,
|
||||
src_user TEXT NOT NULL,
|
||||
src_password TEXT NOT NULL,
|
||||
src_folder TEXT NOT NULL DEFAULT '',
|
||||
poll_time INTEGER NOT NULL DEFAULT 10,
|
||||
fetchall INTEGER NOT NULL DEFAULT 0,
|
||||
keep INTEGER NOT NULL DEFAULT 0,
|
||||
protocol TEXT NOT NULL DEFAULT 'POP3',
|
||||
usessl INTEGER NOT NULL DEFAULT 0,
|
||||
sslcertck INTEGER NOT NULL DEFAULT 0,
|
||||
sslcertpath TEXT NOT NULL DEFAULT '',
|
||||
sslfingerprint TEXT NOT NULL DEFAULT '',
|
||||
extra_options TEXT NOT NULL DEFAULT '',
|
||||
returned_text TEXT NOT NULL DEFAULT '',
|
||||
mda TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {vacation} (
|
||||
email TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
activefrom TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
activeuntil TEXT NOT NULL DEFAULT '9999-12-31 23:59:59',
|
||||
cache TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT NOT NULL DEFAULT '',
|
||||
interval_time INTEGER NOT NULL DEFAULT 0,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {vacation_notification} (
|
||||
on_vacation TEXT NOT NULL,
|
||||
notified TEXT NOT NULL,
|
||||
notified_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (on_vacation, notified)
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {log} (
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT ''
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {quota} (
|
||||
username TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
current INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (username, path)
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {quota2} (
|
||||
username TEXT PRIMARY KEY,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
messages INTEGER NOT NULL DEFAULT 0
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {dkim} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
selector TEXT NOT NULL DEFAULT 'default',
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {dkim_signing} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author TEXT NOT NULL,
|
||||
dkim_id INTEGER NOT NULL,
|
||||
created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {totp_exception} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
username TEXT,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {app_password} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {admin_preferences} (
|
||||
username TEXT NOT NULL,
|
||||
pref_key TEXT NOT NULL,
|
||||
pref_value TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (username, pref_key)
|
||||
)"#
|
||||
),
|
||||
format!(
|
||||
r#"CREATE TABLE IF NOT EXISTS {config_table} (
|
||||
name TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)"#
|
||||
),
|
||||
];
|
||||
|
||||
for statement in statements {
|
||||
sqlx::query(&statement).execute(&self.pool).await?;
|
||||
}
|
||||
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {domain} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,active,password_expiry,dns_active,dns_checked) VALUES ('example.test','Development mock domain',100,100,1024,10240,'virtual',0,1,90,1,CURRENT_TIMESTAMP)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {admin} (username,password,superadmin,active,email_other) VALUES ('admin@example.test','{{PLAIN}}admin123',1,1,'admin@example.test')"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {domain_admins} (username,domain,active) VALUES ('admin@example.test','ALL',1)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {admin} (username,password,superadmin,active,email_other) VALUES ('domain-admin@example.test','{{PLAIN}}domain123',0,1,'domain-admin@example.test')"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {domain_admins} (username,domain,active) VALUES ('domain-admin@example.test','example.test',1)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {mailbox} (username,password,name,maildir,quota,local_part,domain,active,smtp_active,email_other) VALUES ('user@example.test','{{PLAIN}}user123','Development User','example.test/user/',104857600,'user','example.test',1,1,'user@example.test')"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {alias} (address,goto,domain,description,active) VALUES ('user@example.test','user@example.test','example.test','Mailbox self alias',1)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {alias} (address,goto,domain,description,active) VALUES ('support@example.test','user@example.test','example.test','Development alias',1)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {alias} (address,goto,domain,description,active) VALUES ('old-forward@example.test','user@example.test','example.test','Inactive alias for bulk-delete testing',0)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {alias_domain} (alias_domain,target_domain,description,active) VALUES ('alias.example.test','example.test','Development alias domain',1)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {admin_preferences} (username,pref_key,pref_value) VALUES ('admin@example.test','alias_goto_default','user@example.test')"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
sqlx::query(&format!(
|
||||
"INSERT OR IGNORE INTO {quota2} (username,bytes,messages) VALUES ('user@example.test',12582912,42)"
|
||||
))
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("development PostfixAdmin mock database is ready");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_schema(&self, config: &AppConfig) -> AppResult<Vec<String>> {
|
||||
let required = [
|
||||
Table::Admin,
|
||||
Table::Domain,
|
||||
Table::DomainAdmins,
|
||||
Table::Alias,
|
||||
Table::Mailbox,
|
||||
];
|
||||
let mut warnings = Vec::new();
|
||||
for table in required {
|
||||
if !self.has_table(table).await {
|
||||
warnings.push(format!("missing required table {}", self.table(table)));
|
||||
}
|
||||
}
|
||||
let optional = [
|
||||
(config.features.logging, Table::Log, "logging"),
|
||||
(
|
||||
config.features.alias_domain,
|
||||
Table::AliasDomain,
|
||||
"alias_domain",
|
||||
),
|
||||
(config.features.fetchmail, Table::Fetchmail, "fetchmail"),
|
||||
(config.features.vacation, Table::Vacation, "vacation"),
|
||||
(
|
||||
config.features.vacation,
|
||||
Table::VacationNotification,
|
||||
"vacation_notification",
|
||||
),
|
||||
(config.features.dkim, Table::Dkim, "dkim"),
|
||||
(config.features.dkim, Table::DkimSigning, "dkim_signing"),
|
||||
(
|
||||
config.features.totp,
|
||||
Table::TotpException,
|
||||
"totp_exception_address",
|
||||
),
|
||||
(
|
||||
config.features.app_passwords,
|
||||
Table::MailboxAppPassword,
|
||||
"mailbox_app_password",
|
||||
),
|
||||
];
|
||||
for (enabled, table, feature) in optional {
|
||||
if enabled && !self.has_table(table).await {
|
||||
warnings.push(format!(
|
||||
"feature {feature} is enabled but table {} is missing",
|
||||
self.table(table)
|
||||
));
|
||||
}
|
||||
}
|
||||
if config.features.totp {
|
||||
for (table, label) in [(Table::Admin, "admin"), (Table::Mailbox, "mailbox")] {
|
||||
if self.has_table(table).await && !self.has_column(table, "totp_secret").await? {
|
||||
warnings.push(format!("TOTP enabled but {label}.totp_secret is missing"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.features.smtp_active_flag
|
||||
&& self.has_table(Table::Mailbox).await
|
||||
&& !self.has_column(Table::Mailbox, "smtp_active").await?
|
||||
{
|
||||
warnings.push("smtp_active_flag enabled but mailbox.smtp_active is missing".into());
|
||||
}
|
||||
if config.features.password_expiration {
|
||||
if self.has_table(Table::Mailbox).await
|
||||
&& !self.has_column(Table::Mailbox, "password_expiry").await?
|
||||
{
|
||||
warnings.push(
|
||||
"password_expiration enabled but mailbox.password_expiry is missing".into(),
|
||||
);
|
||||
}
|
||||
if self.has_table(Table::Domain).await
|
||||
&& !self.has_column(Table::Domain, "password_expiry").await?
|
||||
{
|
||||
warnings.push(
|
||||
"password_expiration enabled but domain.password_expiry is missing".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (enabled, table, label) in [
|
||||
(
|
||||
config.features.forgotten_user_password_reset,
|
||||
Table::Mailbox,
|
||||
"user password recovery",
|
||||
),
|
||||
(
|
||||
config.features.forgotten_admin_password_reset,
|
||||
Table::Admin,
|
||||
"admin password recovery",
|
||||
),
|
||||
] {
|
||||
if enabled && self.has_table(table).await {
|
||||
for column in ["token", "token_validity", "email_other"] {
|
||||
if !self.has_column(table, column).await? {
|
||||
warnings.push(format!(
|
||||
"{label} enabled but {}.{column} is missing",
|
||||
self.table(table)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
pub async fn login_record(
|
||||
&self,
|
||||
kind: AccountKind,
|
||||
username: &str,
|
||||
) -> AppResult<Option<LoginRecord>> {
|
||||
let table_key = match kind {
|
||||
AccountKind::Admin => Table::Admin,
|
||||
AccountKind::User => Table::Mailbox,
|
||||
};
|
||||
let table = self.table(table_key);
|
||||
let active_expr = self.flag("active", "active");
|
||||
let super_expr = if kind == AccountKind::Admin
|
||||
&& self
|
||||
.has_column(Table::Admin, "superadmin")
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.flag("superadmin", "superadmin")
|
||||
} else {
|
||||
"0 AS superadmin".to_string()
|
||||
};
|
||||
let totp_expr = if self
|
||||
.has_column(table_key, "totp_secret")
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"totp_secret"
|
||||
} else {
|
||||
"NULL AS totp_secret"
|
||||
};
|
||||
let sql = self.q(&format!("SELECT password, {active_expr}, {super_expr}, {totp_expr} FROM {table} WHERE username = ? LIMIT 1"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| LoginRecord {
|
||||
password: row_string(&row, "password"),
|
||||
active: row_bool(&row, "active"),
|
||||
superadmin: row_bool(&row, "superadmin"),
|
||||
totp_secret: row_opt_string(&row, "totp_secret"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn allowed_domains(&self, username: &str) -> AppResult<Vec<String>> {
|
||||
let table = self.table(Table::DomainAdmins);
|
||||
let sql = self.q(&format!(
|
||||
"SELECT domain FROM {table} WHERE username = ? AND active = ? ORDER BY domain"
|
||||
));
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.bind(true)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|r| row_string(r, "domain")).collect())
|
||||
}
|
||||
|
||||
pub async fn totp_exception(&self, ip: &str, username: &str) -> AppResult<bool> {
|
||||
let table = self.table(Table::TotpException);
|
||||
let domain = username.split_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
let sql = self.q(&format!(
|
||||
"SELECT id FROM {table} WHERE ip = ? AND (username = ? OR username = ? OR username IS NULL) LIMIT 1"
|
||||
));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(ip)
|
||||
.bind(username)
|
||||
.bind(domain)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
pub async fn log(
|
||||
&self,
|
||||
config: &AppConfig,
|
||||
username: &str,
|
||||
domain: &str,
|
||||
action: &str,
|
||||
data: &str,
|
||||
) {
|
||||
if !config.features.logging {
|
||||
return;
|
||||
}
|
||||
let table = self.table(Table::Log);
|
||||
let sql = self.q(&format!("INSERT INTO {table} (timestamp, username, domain, action, data) VALUES (CURRENT_TIMESTAMP, ?, ?, ?, ?)"));
|
||||
if let Err(error) = sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.bind(domain)
|
||||
.bind(action)
|
||||
.bind(data)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error, %username, %domain, %action, "failed to write PostfixAdmin log");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_admin_pref(&self, username: &str, key: &str, value: &str) -> AppResult<()> {
|
||||
self.delete_admin_pref(username, key).await?;
|
||||
let table = self.table(Table::AdminPreferences);
|
||||
let sql = self.q(&format!(
|
||||
"INSERT INTO {table} (username,pref_key,pref_value) VALUES (?,?,?)"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_admin_pref(&self, username: &str, key: &str) -> AppResult<()> {
|
||||
let table = self.table(Table::AdminPreferences);
|
||||
let sql = self.q(&format!(
|
||||
"DELETE FROM {table} WHERE username=? AND pref_key=?"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row_string(row: &AnyRow, name: &str) -> String {
|
||||
row.try_get::<String, _>(name).unwrap_or_default()
|
||||
}
|
||||
pub fn row_opt_string(row: &AnyRow, name: &str) -> Option<String> {
|
||||
row.try_get::<Option<String>, _>(name).unwrap_or_else(|_| {
|
||||
let s = row_string(row, name);
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
})
|
||||
}
|
||||
pub fn row_i64(row: &AnyRow, name: &str) -> i64 {
|
||||
row.try_get::<i64, _>(name)
|
||||
.or_else(|_| row.try_get::<i32, _>(name).map(i64::from))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn row_opt_i64(row: &AnyRow, name: &str) -> Option<i64> {
|
||||
if let Ok(v) = row.try_get::<Option<i64>, _>(name) {
|
||||
return v;
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<i32>, _>(name) {
|
||||
return v.map(i64::from);
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn row_bool(row: &AnyRow, name: &str) -> bool {
|
||||
row.try_get::<bool, _>(name)
|
||||
.or_else(|_| row.try_get::<i64, _>(name).map(|v| v != 0))
|
||||
.or_else(|_| row.try_get::<i32, _>(name).map(|v| v != 0))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
pub fn row_opt_bool(row: &AnyRow, name: &str) -> Option<bool> {
|
||||
if let Ok(v) = row.try_get::<Option<bool>, _>(name) {
|
||||
return v;
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Option<i64>, _>(name) {
|
||||
return v.map(|x| x != 0);
|
||||
}
|
||||
None
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("{message}")]
|
||||
Http {
|
||||
status: StatusCode,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
},
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("mail error: {0}")]
|
||||
Mail(String),
|
||||
#[error("configuration error: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody<'a> {
|
||||
error: ErrorEnvelope<'a>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct ErrorEnvelope<'a> {
|
||||
code: &'a str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
code: "bad_request",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
code: "unauthorized",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
code: "forbidden",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "not_found",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "conflict",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn too_many(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::TOO_MANY_REQUESTS,
|
||||
code: "rate_limited",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn bad_gateway(message: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
code: "upstream_error",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn config(message: impl Into<String>) -> Self {
|
||||
Self::Config(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match self {
|
||||
AppError::Http {
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
} => (status, code, message),
|
||||
AppError::Database(error) => {
|
||||
tracing::error!(%error, "database request failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"database_error",
|
||||
"Database operation failed".into(),
|
||||
)
|
||||
}
|
||||
AppError::Io(error) => {
|
||||
tracing::error!(%error, "I/O request failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"io_error",
|
||||
"I/O operation failed".into(),
|
||||
)
|
||||
}
|
||||
AppError::Mail(error) => {
|
||||
tracing::error!(%error, "SMTP request failed");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"smtp_error",
|
||||
"SMTP operation failed".into(),
|
||||
)
|
||||
}
|
||||
AppError::Config(error) => {
|
||||
tracing::error!(%error, "configuration error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "config_error", error)
|
||||
}
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(ErrorBody {
|
||||
error: ErrorEnvelope { code, message },
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::{collections::HashMap, process::Stdio, time::Duration};
|
||||
|
||||
use tokio::{io::AsyncWriteExt, process::Command, time::timeout};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
pub async fn run(command: &[String], vars: &[(&str, &str)], stdin: Option<&[u8]>) -> AppResult<()> {
|
||||
if command.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let vars: HashMap<&str, &str> = vars.iter().copied().collect();
|
||||
let expand = |value: &str| {
|
||||
let mut out = value.to_string();
|
||||
for (key, replacement) in &vars {
|
||||
out = out.replace(&format!("{{{key}}}"), replacement);
|
||||
}
|
||||
out
|
||||
};
|
||||
let program = expand(&command[0]);
|
||||
if program.is_empty() {
|
||||
return Err(AppError::config("hook program cannot be empty"));
|
||||
}
|
||||
let mut cmd = Command::new(program);
|
||||
for arg in command.iter().skip(1) {
|
||||
cmd.arg(expand(arg));
|
||||
}
|
||||
cmd.kill_on_drop(true)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if stdin.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
} else {
|
||||
cmd.stdin(Stdio::null());
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
if let (Some(data), Some(mut pipe)) = (stdin, child.stdin.take()) {
|
||||
pipe.write_all(data).await?;
|
||||
}
|
||||
let output = timeout(Duration::from_secs(30), child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| AppError::config("hook timed out after 30 seconds"))??;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(AppError::config(format!(
|
||||
"hook failed with {}: {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use lettre::{
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
message::Mailbox,
|
||||
transport::smtp::{authentication::Credentials, extension::ClientId},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
fn transport(config: &AppConfig) -> AppResult<AsyncSmtpTransport<Tokio1Executor>> {
|
||||
let smtp = &config.smtp;
|
||||
let mut builder = match smtp.kind.as_str() {
|
||||
"starttls" => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
||||
.map_err(|e| AppError::Mail(e.to_string()))?,
|
||||
"tls" => AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
||||
.map_err(|e| AppError::Mail(e.to_string()))?,
|
||||
_ => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host),
|
||||
}
|
||||
.port(smtp.port);
|
||||
|
||||
if !smtp.client_name.trim().is_empty() {
|
||||
builder = builder.hello_name(ClientId::Domain(smtp.client_name.trim().to_string()));
|
||||
}
|
||||
if !smtp.username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
smtp.username.clone(),
|
||||
smtp.password.clone(),
|
||||
));
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
pub fn sender_named(
|
||||
config: &AppConfig,
|
||||
fallback: &str,
|
||||
display_name: Option<&str>,
|
||||
) -> AppResult<Mailbox> {
|
||||
let address = if config.smtp.admin_email.is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
&config.smtp.admin_email
|
||||
};
|
||||
let parsed = address
|
||||
.parse()
|
||||
.map_err(|e| AppError::bad_request(format!("invalid sender address: {e}")))?;
|
||||
let name = display_name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(&config.smtp.admin_name);
|
||||
Ok(Mailbox::new(Some(name.to_string()), parsed))
|
||||
}
|
||||
|
||||
pub async fn send_text(
|
||||
config: &AppConfig,
|
||||
from_fallback: &str,
|
||||
to: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> AppResult<()> {
|
||||
send_text_named(config, from_fallback, None, to, subject, body).await
|
||||
}
|
||||
|
||||
pub async fn send_text_named(
|
||||
config: &AppConfig,
|
||||
from_fallback: &str,
|
||||
from_name: Option<&str>,
|
||||
to: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> AppResult<()> {
|
||||
let to: Mailbox = to
|
||||
.parse()
|
||||
.map_err(|e| AppError::bad_request(format!("invalid recipient: {e}")))?;
|
||||
let message = Message::builder()
|
||||
.from(sender_named(config, from_fallback, from_name)?)
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.body(body.to_owned())
|
||||
.map_err(|e| AppError::Mail(e.to_string()))?;
|
||||
transport(config)?
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|e| AppError::Mail(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod hooks;
|
||||
mod mailer;
|
||||
mod models;
|
||||
mod passwords;
|
||||
mod routes;
|
||||
mod sieve;
|
||||
mod state;
|
||||
mod ui;
|
||||
mod validation;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{HeaderName, HeaderValue, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use tower_http::{set_header::SetResponseHeaderLayer, timeout::TimeoutLayer, trace::TraceLayer};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::{
|
||||
auth::AuthStore,
|
||||
config::AppConfig,
|
||||
db::Db,
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Err(error) = run().await {
|
||||
eprintln!("postfixadmin-rs: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run() -> AppResult<()> {
|
||||
if std::env::args().any(|arg| arg == "--version" || arg == "-V") {
|
||||
println!("postfixadmin-rs {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("postfixadmin_rs=info,tower_http=info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Arc::new(AppConfig::load()?);
|
||||
let db = Db::connect(&config).await?;
|
||||
if config.database.dev_mock {
|
||||
db.initialize_dev_mock(&config).await?;
|
||||
}
|
||||
db.ping().await?;
|
||||
let warnings = db.check_schema(&config).await?;
|
||||
for warning in &warnings {
|
||||
tracing::warn!(%warning, "schema compatibility warning");
|
||||
}
|
||||
|
||||
if std::env::args().any(|arg| arg == "--check-config") {
|
||||
println!("configuration: ok");
|
||||
println!("database: ok");
|
||||
if config.database.dev_mock {
|
||||
println!("database mode: development mock");
|
||||
}
|
||||
if config.database.read_only {
|
||||
println!("database access: read-only");
|
||||
}
|
||||
for warning in warnings {
|
||||
println!("warning: {warning}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
auth: AuthStore::new(&config),
|
||||
config: config.clone(),
|
||||
db,
|
||||
});
|
||||
|
||||
let api = routes::router(&state);
|
||||
let inner = Router::<Arc<AppState>>::new()
|
||||
.nest("/api", api)
|
||||
.route("/assets/{*path}", get(ui::asset))
|
||||
.fallback(ui::index)
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(60),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
HeaderName::from_static("x-content-type-options"),
|
||||
HeaderValue::from_static("nosniff"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
HeaderName::from_static("x-frame-options"),
|
||||
HeaderValue::from_static("DENY"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
HeaderName::from_static("referrer-policy"),
|
||||
HeaderValue::from_static("no-referrer"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let base = config.normalized_base_path();
|
||||
let app: Router = if base == "/" {
|
||||
inner.with_state(state.clone())
|
||||
} else {
|
||||
Router::<Arc<AppState>>::new()
|
||||
.nest(&base, inner)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port)
|
||||
.parse()
|
||||
.map_err(|error| AppError::config(format!("invalid listen address: {error}")))?;
|
||||
tracing::info!(%addr, base_path = %base, "PostfixAdmin-RS listening");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| AppError::Io(std::io::Error::other(error)))?;
|
||||
Ok(())
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountKind {
|
||||
Admin,
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Role {
|
||||
GlobalAdmin,
|
||||
Admin,
|
||||
User,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn is_admin(self) -> bool {
|
||||
matches!(self, Self::GlobalAdmin | Self::Admin)
|
||||
}
|
||||
pub fn is_global(self) -> bool {
|
||||
self == Self::GlobalAdmin
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginRecord {
|
||||
pub password: String,
|
||||
pub active: bool,
|
||||
pub superadmin: bool,
|
||||
pub totp_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DomainSummary {
|
||||
pub domain: String,
|
||||
pub description: String,
|
||||
pub aliases: i64,
|
||||
pub mailboxes: i64,
|
||||
pub maxquota: i64,
|
||||
pub quota: i64,
|
||||
pub transport: String,
|
||||
pub backupmx: bool,
|
||||
pub active: bool,
|
||||
pub password_expiry: Option<i64>,
|
||||
pub dns_active: Option<bool>,
|
||||
pub dns_checked: Option<String>,
|
||||
pub alias_count: i64,
|
||||
pub mailbox_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DomainInput {
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub aliases: Option<i64>,
|
||||
pub mailboxes: Option<i64>,
|
||||
pub maxquota: Option<i64>,
|
||||
pub quota: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
#[serde(default)]
|
||||
pub backupmx: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
pub password_expiry: Option<i64>,
|
||||
#[serde(default = "default_true")]
|
||||
pub default_aliases: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MailboxSummary {
|
||||
pub username: String,
|
||||
pub name: String,
|
||||
pub maildir: String,
|
||||
pub quota: i64,
|
||||
pub local_part: String,
|
||||
pub domain: String,
|
||||
pub active: bool,
|
||||
pub smtp_active: Option<bool>,
|
||||
pub password_expiry: Option<String>,
|
||||
pub phone: String,
|
||||
pub email_other: String,
|
||||
pub used_quota_bytes: Option<i64>,
|
||||
pub vacation_active: bool,
|
||||
pub created: Option<String>,
|
||||
pub modified: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MailboxInput {
|
||||
pub username: Option<String>,
|
||||
pub local_part: Option<String>,
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
pub password: Option<String>,
|
||||
pub quota: Option<i64>,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
pub smtp_active: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
pub email_other: String,
|
||||
#[serde(default)]
|
||||
pub send_welcome: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AliasSummary {
|
||||
pub address: String,
|
||||
pub goto: Vec<String>,
|
||||
pub domain: String,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
pub is_mailbox: bool,
|
||||
pub status: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AliasInput {
|
||||
pub address: Option<String>,
|
||||
pub local_part: Option<String>,
|
||||
pub domain: String,
|
||||
pub goto: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
#[serde(default)]
|
||||
pub store_and_forward: bool,
|
||||
#[serde(default)]
|
||||
pub save_goto_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AliasDomainInput {
|
||||
pub alias_domain: String,
|
||||
pub target_domain: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AliasDomainSummary {
|
||||
pub alias_domain: String,
|
||||
pub target_domain: String,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AdminSummary {
|
||||
pub username: String,
|
||||
pub superadmin: bool,
|
||||
pub active: bool,
|
||||
pub phone: String,
|
||||
pub email_other: String,
|
||||
pub domains: Vec<String>,
|
||||
pub totp_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminInput {
|
||||
pub username: String,
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub superadmin: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
#[serde(default)]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
pub email_other: String,
|
||||
#[serde(default)]
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FetchmailInput {
|
||||
pub id: Option<i64>,
|
||||
pub mailbox: String,
|
||||
pub src_server: String,
|
||||
pub src_port: Option<i64>,
|
||||
pub src_auth: Option<String>,
|
||||
pub src_user: String,
|
||||
pub src_password: String,
|
||||
#[serde(default)]
|
||||
pub src_folder: String,
|
||||
#[serde(default = "default_poll_time")]
|
||||
pub poll_time: i64,
|
||||
#[serde(default)]
|
||||
pub fetchall: bool,
|
||||
#[serde(default)]
|
||||
pub keep: bool,
|
||||
pub protocol: Option<String>,
|
||||
#[serde(default)]
|
||||
pub usessl: bool,
|
||||
#[serde(default)]
|
||||
pub sslcertck: bool,
|
||||
#[serde(default)]
|
||||
pub sslcertpath: String,
|
||||
#[serde(default)]
|
||||
pub sslfingerprint: String,
|
||||
#[serde(default)]
|
||||
pub extra_options: String,
|
||||
#[serde(default)]
|
||||
pub mda: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
}
|
||||
fn default_poll_time() -> i64 {
|
||||
10
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FetchmailSummary {
|
||||
pub id: i64,
|
||||
pub domain: String,
|
||||
pub mailbox: String,
|
||||
pub src_server: String,
|
||||
pub src_port: Option<i64>,
|
||||
pub src_auth: Option<String>,
|
||||
pub src_user: String,
|
||||
pub src_folder: String,
|
||||
pub poll_time: i64,
|
||||
pub fetchall: bool,
|
||||
pub keep: bool,
|
||||
pub protocol: Option<String>,
|
||||
pub usessl: bool,
|
||||
pub sslcertck: bool,
|
||||
pub sslcertpath: String,
|
||||
pub sslfingerprint: String,
|
||||
pub extra_options: String,
|
||||
pub returned_text: String,
|
||||
pub mda: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VacationInput {
|
||||
pub email: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub interval_time: i64,
|
||||
pub active_from: String,
|
||||
pub active_until: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VacationSummary {
|
||||
pub email: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
pub interval_time: i64,
|
||||
pub active_from: String,
|
||||
pub active_until: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DkimKeyInput {
|
||||
pub id: Option<i64>,
|
||||
pub domain_name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default = "default_selector")]
|
||||
pub selector: String,
|
||||
pub private_key: Option<String>,
|
||||
pub public_key: Option<String>,
|
||||
}
|
||||
fn default_selector() -> String {
|
||||
"default".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DkimKeySummary {
|
||||
pub id: i64,
|
||||
pub domain_name: String,
|
||||
pub description: String,
|
||||
pub selector: String,
|
||||
pub public_key: String,
|
||||
pub has_private_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DkimSigningInput {
|
||||
pub id: Option<i64>,
|
||||
pub author: String,
|
||||
pub dkim_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
pub action: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppPasswordSummary {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppPasswordInput {
|
||||
pub description: String,
|
||||
pub password: String,
|
||||
pub current_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TotpExceptionInput {
|
||||
pub id: Option<i64>,
|
||||
pub ip: String,
|
||||
pub username: Option<String>,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TotpExceptionSummary {
|
||||
pub id: i64,
|
||||
pub ip: String,
|
||||
pub username: Option<String>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasswordChangeInput {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendMailInput {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BroadcastInput {
|
||||
pub name: String,
|
||||
pub subject: String,
|
||||
pub message: String,
|
||||
pub domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub mailboxes_only: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
use std::{process::Stdio, str::FromStr};
|
||||
|
||||
use argon2::{
|
||||
Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use md5::{Digest as _, Md5};
|
||||
use pwhash::{
|
||||
HashSetup,
|
||||
bcrypt::{self as pw_bcrypt, BcryptSetup, BcryptVariant},
|
||||
sha1_crypt, sha256_crypt, sha512_crypt, unix, unix_crypt,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Sha256, Sha512};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
db::{Db, Dialect, Table, row_i64},
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
const CRYPT64: &[u8; 64] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
/// Password schemes intentionally kept compatible with PostfixAdmin/Dovecot/Courier.
|
||||
/// Unknown `dovecot:METHOD` values are delegated to `doveadm pw` without a shell.
|
||||
pub const SUPPORTED_SCHEMES: &[&str] = &[
|
||||
"ARGON2ID",
|
||||
"ARGON2ID.B64",
|
||||
"ARGON2I",
|
||||
"ARGON2I.B64",
|
||||
"BLF-CRYPT",
|
||||
"BLF-CRYPT.B64",
|
||||
"CRYPT",
|
||||
"SHA512-CRYPT",
|
||||
"SHA512-CRYPT.B64",
|
||||
"SHA512.B64",
|
||||
"SHA512",
|
||||
"SHA256-CRYPT",
|
||||
"SHA256-CRYPT.B64",
|
||||
"SHA256",
|
||||
"SHA1-CRYPT",
|
||||
"SHA1",
|
||||
"SHA1.B64",
|
||||
"SHA",
|
||||
"SSHA",
|
||||
"MD5-CRYPT",
|
||||
"md5crypt",
|
||||
"PLAIN-MD5",
|
||||
"MD5",
|
||||
"MD5RAW",
|
||||
"PLAIN",
|
||||
"CLEAR",
|
||||
"cleartext",
|
||||
"SYSTEM",
|
||||
"AUTHLIB",
|
||||
"courier:md5",
|
||||
"courier:md5raw",
|
||||
"courier:sha1",
|
||||
"courier:ssha",
|
||||
"courier:sha256",
|
||||
"php_crypt",
|
||||
"php_crypt:DES",
|
||||
"php_crypt:MD5",
|
||||
"php_crypt:BLOWFISH",
|
||||
"php_crypt:SHA256",
|
||||
"php_crypt:SHA512",
|
||||
"dovecot:METHOD",
|
||||
];
|
||||
|
||||
pub fn supported_schemes() -> &'static [&'static str] {
|
||||
SUPPORTED_SCHEMES
|
||||
}
|
||||
|
||||
pub fn validate_new_password(config: &AppConfig, password: &str) -> AppResult<()> {
|
||||
if password.chars().count() < config.password.min_length {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"Password must contain at least {} characters",
|
||||
config.password.min_length
|
||||
)));
|
||||
}
|
||||
let letters = password.chars().filter(|c| c.is_alphabetic()).count();
|
||||
let digits = password.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
if letters < config.password.min_letters {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"Password must contain at least {} letters",
|
||||
config.password.min_letters
|
||||
)));
|
||||
}
|
||||
if digits < config.password.min_digits {
|
||||
return Err(AppError::bad_request(format!(
|
||||
"Password must contain at least {} digits",
|
||||
config.password.min_digits
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify an existing PostfixAdmin hash. The format stored in the database wins
|
||||
/// over the currently configured format whenever it identifies itself.
|
||||
pub async fn verify(config: &AppConfig, password: &str, stored: &str, username: &str) -> bool {
|
||||
if password.is_empty() || stored.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(result) = verify_native(password, stored) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let configured = config.password.scheme.trim();
|
||||
let mechanism = configured.to_ascii_uppercase();
|
||||
|
||||
// Legacy PostfixAdmin formats without a self-identifying prefix.
|
||||
if (mechanism == "MD5" || mechanism == "MD5RAW") && is_hex_digest(stored, 32) {
|
||||
return ct_eq(hex_md5(password).as_bytes(), stored.as_bytes());
|
||||
}
|
||||
let legacy_des = mechanism == "PHP_CRYPT:DES"
|
||||
|| mechanism == "SYSTEM"
|
||||
|| (mechanism == "AUTHLIB"
|
||||
&& config
|
||||
.password
|
||||
.authlib_default_flavor
|
||||
.eq_ignore_ascii_case("crypt"));
|
||||
if legacy_des && stored.len() == 13 && stored.bytes().all(is_crypt_char) {
|
||||
return unix_crypt::verify(password, stored);
|
||||
}
|
||||
if matches!(mechanism.as_str(), "CLEAR" | "CLEARTEXT") {
|
||||
return ct_eq(password.as_bytes(), stored.as_bytes());
|
||||
}
|
||||
|
||||
let method = prefixed_method(stored)
|
||||
.map(|value| value.to_ascii_uppercase())
|
||||
.or_else(|| mechanism.strip_prefix("DOVECOT:").map(str::to_owned))
|
||||
.unwrap_or_else(|| mechanism.clone());
|
||||
|
||||
verify_with_doveadm(config, password, stored, &method, username)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn hash(config: &AppConfig, password: &str, username: &str) -> AppResult<String> {
|
||||
validate_new_password(config, password)?;
|
||||
hash_unchecked(config, password, username).await
|
||||
}
|
||||
|
||||
/// Hashes an application-internal bearer secret with Argon2id regardless of the
|
||||
/// mailbox password scheme. Used for single-use recovery tokens only.
|
||||
pub fn hash_internal_secret(secret: &str) -> AppResult<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(secret.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::config(format!("argon2 failed: {e}")))?
|
||||
.to_string();
|
||||
Ok(format!("{{ARGON2ID}}{hash}"))
|
||||
}
|
||||
|
||||
/// Implements PostfixAdmin `$CONF['encrypt']`-style schemes.
|
||||
pub async fn hash_unchecked(
|
||||
config: &AppConfig,
|
||||
password: &str,
|
||||
username: &str,
|
||||
) -> AppResult<String> {
|
||||
let configured = config.password.scheme.trim();
|
||||
if configured.is_empty() {
|
||||
return Err(AppError::config("password.scheme cannot be empty"));
|
||||
}
|
||||
let mechanism = configured.to_ascii_uppercase();
|
||||
|
||||
if mechanism == "PHP_CRYPT" || mechanism.starts_with("PHP_CRYPT:") {
|
||||
return hash_php_crypt(configured, password);
|
||||
}
|
||||
if mechanism == "AUTHLIB" {
|
||||
return hash_authlib(config, password);
|
||||
}
|
||||
if let Some(flavor) = mechanism.strip_prefix("COURIER:") {
|
||||
return hash_courier(flavor, password);
|
||||
}
|
||||
if let Some(method) = mechanism.strip_prefix("DOVECOT:") {
|
||||
if let Some(value) = hash_native_named(method, password)? {
|
||||
return Ok(value);
|
||||
}
|
||||
return hash_with_doveadm(config, password, method, username).await;
|
||||
}
|
||||
|
||||
if let Some(value) = hash_native_named(&mechanism, password)? {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
// PostfixAdmin historically delegates unsupported Dovecot-compatible methods.
|
||||
hash_with_doveadm(config, password, &mechanism, username).await
|
||||
}
|
||||
|
||||
fn hash_native_named(mechanism: &str, password: &str) -> AppResult<Option<String>> {
|
||||
let value = match mechanism {
|
||||
"ARGON2ID" => format!("{{ARGON2ID}}{}", hash_argon(password, Algorithm::Argon2id)?),
|
||||
"ARGON2I" => format!("{{ARGON2I}}{}", hash_argon(password, Algorithm::Argon2i)?),
|
||||
"ARGON2ID.B64" => {
|
||||
let inner = hash_argon(password, Algorithm::Argon2id)?;
|
||||
format!("{{ARGON2ID.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||
}
|
||||
"ARGON2I.B64" => {
|
||||
let inner = hash_argon(password, Algorithm::Argon2i)?;
|
||||
format!("{{ARGON2I.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||
}
|
||||
"BLF-CRYPT" | "BCRYPT" => format!("{{BLF-CRYPT}}{}", bcrypt_hash(password, 10)?),
|
||||
"BLF-CRYPT.B64" => {
|
||||
let inner = bcrypt_hash(password, 10)?;
|
||||
format!("{{BLF-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||
}
|
||||
"CRYPT" | "SYSTEM" => format!("{{CRYPT}}{}", bcrypt_hash(password, 10)?),
|
||||
"SHA512-CRYPT" => format!("{{SHA512-CRYPT}}{}", sha512_hash(password, None)?),
|
||||
"SHA512-CRYPT.B64" | "SHA512.B64" => {
|
||||
let inner = sha512_hash(password, None)?;
|
||||
format!("{{SHA512-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||
}
|
||||
"SHA512" => format!(
|
||||
"{{SHA512}}{}",
|
||||
STANDARD.encode(Sha512::digest(password.as_bytes()))
|
||||
),
|
||||
"SHA256-CRYPT" => format!("{{SHA256-CRYPT}}{}", sha256_hash(password, None)?),
|
||||
"SHA256-CRYPT.B64" => {
|
||||
let inner = sha256_hash(password, None)?;
|
||||
format!("{{SHA256-CRYPT.B64}}{}", STANDARD.encode(inner.as_bytes()))
|
||||
}
|
||||
"SHA256" => format!(
|
||||
"{{SHA256}}{}",
|
||||
STANDARD.encode(Sha256::digest(password.as_bytes()))
|
||||
),
|
||||
"SHA1-CRYPT" => format!(
|
||||
"{{SHA1-CRYPT}}{}",
|
||||
sha1_crypt::hash(password)
|
||||
.map_err(|e| AppError::config(format!("SHA1-CRYPT failed: {e}")))?
|
||||
),
|
||||
"SHA1" => format!(
|
||||
"{{SHA1}}{}",
|
||||
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||
),
|
||||
"SHA1.B64" => format!(
|
||||
"{{SHA1.B64}}{}",
|
||||
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||
),
|
||||
"SHA" => format!(
|
||||
"{{SHA}}{}",
|
||||
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||
),
|
||||
"SSHA" => hash_ssha(password),
|
||||
"MD5-CRYPT" => {
|
||||
let salt = random_crypt_salt(8);
|
||||
format!("{{MD5-CRYPT}}{}", md5_crypt(password, &salt))
|
||||
}
|
||||
"MD5CRYPT" => {
|
||||
let salt = random_crypt_salt(8);
|
||||
md5_crypt(password, &salt)
|
||||
}
|
||||
"PLAIN-MD5" => format!("{{PLAIN-MD5}}{}", hex_md5(password)),
|
||||
// Historical `$CONF['encrypt'] = 'md5'` is unprefixed hex MD5.
|
||||
"MD5" => hex_md5(password),
|
||||
"MD5RAW" => format!("{{MD5RAW}}{}", hex_md5(password)),
|
||||
"PLAIN" => format!("{{PLAIN}}{password}"),
|
||||
"CLEAR" => format!("{{CLEAR}}{password}"),
|
||||
// Historical PostfixAdmin `cleartext` stores the password without a prefix.
|
||||
"CLEARTEXT" => password.to_owned(),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn hash_courier(flavor: &str, password: &str) -> AppResult<String> {
|
||||
let value = match flavor {
|
||||
"MD5" => format!(
|
||||
"{{MD5}}{}",
|
||||
STANDARD.encode(Md5::digest(password.as_bytes()))
|
||||
),
|
||||
"MD5RAW" => format!("{{MD5RAW}}{}", hex_md5(password)),
|
||||
"SHA" | "SHA1" => format!(
|
||||
"{{SHA}}{}",
|
||||
STANDARD.encode(Sha1::digest(password.as_bytes()))
|
||||
),
|
||||
"SSHA" => hash_ssha(password),
|
||||
"SHA256" => format!(
|
||||
"{{SHA256}}{}",
|
||||
STANDARD.encode(Sha256::digest(password.as_bytes()))
|
||||
),
|
||||
_ => {
|
||||
return Err(AppError::config(format!(
|
||||
"unsupported Courier password flavor: {flavor}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn hash_authlib(config: &AppConfig, password: &str) -> AppResult<String> {
|
||||
let flavor = config.password.authlib_default_flavor.trim();
|
||||
let canonical = flavor.to_ascii_uppercase();
|
||||
let body = match canonical.as_str() {
|
||||
"MD5RAW" => hex_md5(password),
|
||||
"MD5" => STANDARD.encode(Md5::digest(password.as_bytes())),
|
||||
"SHA" | "SHA1" => STANDARD.encode(Sha1::digest(password.as_bytes())),
|
||||
"CRYPT" => des_crypt_hash(password, "authlib crypt")?,
|
||||
_ => {
|
||||
return Err(AppError::config(format!(
|
||||
"authlib_default_flavor '{flavor}' is unsupported; use md5raw, md5, SHA or crypt"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(format!("{{{flavor}}}{body}"))
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn des_crypt_hash(password: &str, label: &str) -> AppResult<String> {
|
||||
unix_crypt::hash(password).map_err(|e| AppError::config(format!("{label} failed: {e}")))
|
||||
}
|
||||
|
||||
fn hash_php_crypt(specification: &str, password: &str) -> AppResult<String> {
|
||||
let parts: Vec<&str> = specification.split(':').collect();
|
||||
let method = parts
|
||||
.get(1)
|
||||
.copied()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("SHA512")
|
||||
.to_ascii_uppercase();
|
||||
let difficulty = parts.get(2).copied().filter(|s| !s.is_empty());
|
||||
let prefix = parts.get(3).copied().unwrap_or("");
|
||||
|
||||
let hash = match method.as_str() {
|
||||
"DES" => des_crypt_hash(password, "php_crypt:DES")?,
|
||||
"MD5" => md5_crypt(password, &random_crypt_salt(8)),
|
||||
"BLOWFISH" => {
|
||||
let cost = parse_range_u32(difficulty, 10, 4, 31, "BLOWFISH cost")?;
|
||||
bcrypt_hash(password, cost)?
|
||||
}
|
||||
"SHA256" => {
|
||||
let rounds = parse_optional_rounds(difficulty, 1000, 999_999_999, "SHA256 rounds")?;
|
||||
sha256_hash(password, rounds)?
|
||||
}
|
||||
"SHA512" => {
|
||||
let rounds = parse_optional_rounds(difficulty, 1000, 999_999_999, "SHA512 rounds")?;
|
||||
sha512_hash(password, rounds)?
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::config(format!(
|
||||
"unsupported php_crypt method: {method}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(format!("{prefix}{hash}"))
|
||||
}
|
||||
|
||||
fn hash_argon(password: &str, algorithm: Algorithm) -> AppResult<String> {
|
||||
let params = if algorithm == Algorithm::Argon2i {
|
||||
Params::new(32_768, 4, 1, None)
|
||||
} else {
|
||||
Params::new(65_536, 3, 1, None)
|
||||
}
|
||||
.map_err(|e| AppError::config(format!("invalid Argon2 parameters: {e}")))?;
|
||||
let argon = Argon2::new(algorithm, Version::V0x13, params);
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
argon
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::config(format!("Argon2 failed: {e}")))
|
||||
.map(|hash| hash.to_string())
|
||||
}
|
||||
|
||||
fn bcrypt_hash(password: &str, cost: u32) -> AppResult<String> {
|
||||
pw_bcrypt::hash_with(
|
||||
BcryptSetup {
|
||||
cost: Some(cost),
|
||||
variant: Some(BcryptVariant::V2y),
|
||||
..Default::default()
|
||||
},
|
||||
password,
|
||||
)
|
||||
.map_err(|e| AppError::config(format!("bcrypt failed: {e}")))
|
||||
}
|
||||
|
||||
#[allow(deprecated)] // Legacy PostfixAdmin compatibility; SHA256-CRYPT is not recommended for new deployments.
|
||||
fn sha256_hash(password: &str, rounds: Option<u32>) -> AppResult<String> {
|
||||
let result = match rounds {
|
||||
Some(rounds) => sha256_crypt::hash_with(
|
||||
HashSetup {
|
||||
salt: None,
|
||||
rounds: Some(rounds),
|
||||
},
|
||||
password,
|
||||
),
|
||||
None => sha256_crypt::hash(password),
|
||||
};
|
||||
result.map_err(|e| AppError::config(format!("SHA256-CRYPT failed: {e}")))
|
||||
}
|
||||
|
||||
fn sha512_hash(password: &str, rounds: Option<u32>) -> AppResult<String> {
|
||||
let result = match rounds {
|
||||
Some(rounds) => sha512_crypt::hash_with(
|
||||
HashSetup {
|
||||
salt: None,
|
||||
rounds: Some(rounds),
|
||||
},
|
||||
password,
|
||||
),
|
||||
None => sha512_crypt::hash(password),
|
||||
};
|
||||
result.map_err(|e| AppError::config(format!("SHA512-CRYPT failed: {e}")))
|
||||
}
|
||||
|
||||
fn hash_ssha(password: &str) -> String {
|
||||
let mut salt = [0u8; 4];
|
||||
rand::rng().fill_bytes(&mut salt);
|
||||
let mut digest = Sha1::new();
|
||||
digest.update(password.as_bytes());
|
||||
digest.update(salt);
|
||||
let mut bytes = digest.finalize().to_vec();
|
||||
bytes.extend_from_slice(&salt);
|
||||
format!("{{SSHA}}{}", STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
/// Returns `Some(result)` when the stored representation is self-identifying.
|
||||
/// Returns `None` for legacy unprefixed formats that require configuration.
|
||||
fn verify_native(password: &str, stored: &str) -> Option<bool> {
|
||||
if let Some(payload) = stored
|
||||
.strip_prefix("{PLAIN}")
|
||||
.or_else(|| stored.strip_prefix("{CLEAR}"))
|
||||
.or_else(|| stored.strip_prefix("{CLEARTEXT}"))
|
||||
{
|
||||
return Some(ct_eq(password.as_bytes(), payload.as_bytes()));
|
||||
}
|
||||
|
||||
if stored.starts_with("$1$")
|
||||
|| stored.starts_with("$2")
|
||||
|| stored.starts_with("$5$")
|
||||
|| stored.starts_with("$6$")
|
||||
|| stored.starts_with("$sha1$")
|
||||
|| stored.starts_with('_')
|
||||
{
|
||||
return Some(unix::verify(password, stored));
|
||||
}
|
||||
let (method, payload) = split_prefixed(stored)?;
|
||||
let method = method.to_ascii_uppercase();
|
||||
let result = match method.as_str() {
|
||||
"PLAIN" | "CLEAR" | "CLEARTEXT" => ct_eq(password.as_bytes(), payload.as_bytes()),
|
||||
"MD5-CRYPT" => md5_crypt_verify(password, payload),
|
||||
"BLF-CRYPT" | "BCRYPT" => pw_bcrypt::verify(password, payload),
|
||||
"CRYPT" => unix::verify(password, payload),
|
||||
"SHA1-CRYPT" => sha1_crypt::verify(password, payload),
|
||||
"SHA256-CRYPT" => sha256_crypt::verify(password, payload),
|
||||
"SHA512-CRYPT" => sha512_crypt::verify(password, payload),
|
||||
"BLF-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Bcrypt),
|
||||
"SHA256-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Sha256),
|
||||
"SHA512-CRYPT.B64" => verify_b64_unix(password, payload, UnixKind::Sha512),
|
||||
"MD5" => ct_eq(
|
||||
STANDARD.encode(Md5::digest(password.as_bytes())).as_bytes(),
|
||||
payload.as_bytes(),
|
||||
),
|
||||
"MD5RAW" | "PLAIN-MD5" => ct_eq(hex_md5(password).as_bytes(), payload.as_bytes()),
|
||||
"SHA" | "SHA1" | "SHA1.B64" => ct_eq(
|
||||
STANDARD
|
||||
.encode(Sha1::digest(password.as_bytes()))
|
||||
.as_bytes(),
|
||||
payload.as_bytes(),
|
||||
),
|
||||
"SHA256" => ct_eq(
|
||||
STANDARD
|
||||
.encode(Sha256::digest(password.as_bytes()))
|
||||
.as_bytes(),
|
||||
payload.as_bytes(),
|
||||
),
|
||||
"SHA512" => ct_eq(
|
||||
STANDARD
|
||||
.encode(Sha512::digest(password.as_bytes()))
|
||||
.as_bytes(),
|
||||
payload.as_bytes(),
|
||||
),
|
||||
"SSHA" => verify_ssha(password, payload),
|
||||
"ARGON2I" | "ARGON2ID" | "ARGON2D" => verify_argon(password, payload),
|
||||
"ARGON2I.B64" | "ARGON2ID.B64" | "ARGON2D.B64" => STANDARD
|
||||
.decode(payload)
|
||||
.ok()
|
||||
.and_then(|raw| String::from_utf8(raw).ok())
|
||||
.is_some_and(|hash| verify_argon(password, &hash)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(result)
|
||||
}
|
||||
|
||||
enum UnixKind {
|
||||
Bcrypt,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
fn verify_b64_unix(password: &str, payload: &str, kind: UnixKind) -> bool {
|
||||
let Ok(raw) = STANDARD.decode(payload) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(hash) = String::from_utf8(raw) else {
|
||||
return false;
|
||||
};
|
||||
match kind {
|
||||
UnixKind::Bcrypt => pw_bcrypt::verify(password, &hash),
|
||||
UnixKind::Sha256 => sha256_crypt::verify(password, &hash),
|
||||
UnixKind::Sha512 => sha512_crypt::verify(password, &hash),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_argon(password: &str, payload: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(payload) else {
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn verify_ssha(password: &str, payload: &str) -> bool {
|
||||
let Ok(decoded) = STANDARD.decode(payload) else {
|
||||
return false;
|
||||
};
|
||||
if decoded.len() < 21 {
|
||||
return false;
|
||||
}
|
||||
let (expected, salt) = decoded.split_at(20);
|
||||
let mut digest = Sha1::new();
|
||||
digest.update(password.as_bytes());
|
||||
digest.update(salt);
|
||||
let actual = digest.finalize();
|
||||
ct_eq(&actual[..], expected)
|
||||
}
|
||||
|
||||
fn is_crypt_char(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/')
|
||||
}
|
||||
|
||||
fn split_prefixed(stored: &str) -> Option<(&str, &str)> {
|
||||
let rest = stored.strip_prefix('{')?;
|
||||
let end = rest.find('}')?;
|
||||
let method = &rest[..end];
|
||||
if method.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((method, &rest[end + 1..]))
|
||||
}
|
||||
|
||||
fn prefixed_method(stored: &str) -> Option<&str> {
|
||||
split_prefixed(stored).map(|(method, _)| method)
|
||||
}
|
||||
|
||||
async fn verify_with_doveadm(
|
||||
config: &AppConfig,
|
||||
password: &str,
|
||||
stored: &str,
|
||||
method: &str,
|
||||
username: &str,
|
||||
) -> AppResult<bool> {
|
||||
validate_doveadm_method(method)?;
|
||||
let mut command = doveadm_command(config)?;
|
||||
command.args(["-s", method, "-t", stored]);
|
||||
if method.eq_ignore_ascii_case("DIGEST-MD5") {
|
||||
if username.is_empty() {
|
||||
return Err(AppError::config("dovecot:DIGEST-MD5 requires username"));
|
||||
}
|
||||
command.args(["-u", username]);
|
||||
}
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(password.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
}
|
||||
let output = child.wait_with_output().await?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
async fn hash_with_doveadm(
|
||||
config: &AppConfig,
|
||||
password: &str,
|
||||
scheme: &str,
|
||||
username: &str,
|
||||
) -> AppResult<String> {
|
||||
validate_doveadm_method(scheme)?;
|
||||
let mut command = doveadm_command(config)?;
|
||||
command.args(["-s", scheme]);
|
||||
if scheme.eq_ignore_ascii_case("DIGEST-MD5") {
|
||||
if username.is_empty() {
|
||||
return Err(AppError::config("dovecot:DIGEST-MD5 requires username"));
|
||||
}
|
||||
command.args(["-u", username]);
|
||||
}
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped());
|
||||
let mut child = command.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
// doveadm getpass() asks twice when generating a hash.
|
||||
stdin.write_all(password.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
stdin.write_all(password.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
}
|
||||
let output = child.wait_with_output().await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"doveadm pw failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return Err(AppError::config("doveadm pw returned an empty hash"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn doveadm_command(config: &AppConfig) -> AppResult<Command> {
|
||||
let mut parts = config.password.doveadm.split_whitespace();
|
||||
let program = parts
|
||||
.next()
|
||||
.ok_or_else(|| AppError::config("password.doveadm is empty"))?;
|
||||
let trailing: Vec<&str> = parts.collect();
|
||||
let has_pw = trailing.contains(&"pw");
|
||||
let mut command = Command::new(program);
|
||||
command.args(&trailing);
|
||||
if !has_pw {
|
||||
command.arg("pw");
|
||||
}
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn validate_doveadm_method(method: &str) -> AppResult<()> {
|
||||
if method.is_empty()
|
||||
|| !method
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || matches!(b, b'-' | b'.'))
|
||||
{
|
||||
return Err(AppError::config(format!(
|
||||
"invalid doveadm password method: {method}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh the mailbox password-expiry timestamp using the parent domain policy.
|
||||
pub async fn refresh_mailbox_expiry(config: &AppConfig, db: &Db, username: &str) -> AppResult<()> {
|
||||
if !db.has_column(Table::Mailbox, "password_expiry").await? {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((_, domain)) = username.rsplit_once('@') else {
|
||||
return Ok(());
|
||||
};
|
||||
let days = if config.features.password_expiration
|
||||
&& db.has_column(Table::Domain, "password_expiry").await?
|
||||
{
|
||||
let table = db.table(Table::Domain);
|
||||
let sql = db.q(&format!(
|
||||
"SELECT password_expiry FROM {table} WHERE domain=? LIMIT 1"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(db.pool())
|
||||
.await?
|
||||
.as_ref()
|
||||
.map(|row| row_i64(row, "password_expiry"))
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let days = if (1..=36_500).contains(&days) {
|
||||
days
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let expr = if days == 0 {
|
||||
"'9999-12-31 23:59:59'".to_owned()
|
||||
} else {
|
||||
match db.dialect() {
|
||||
Dialect::MySql => format!("DATE_ADD(CURRENT_TIMESTAMP, INTERVAL {days} DAY)"),
|
||||
Dialect::Postgres => format!("CURRENT_TIMESTAMP + INTERVAL '{days} days'"),
|
||||
Dialect::Sqlite => format!("datetime('now','+{days} days')"),
|
||||
}
|
||||
};
|
||||
let mailbox = db.table(Table::Mailbox);
|
||||
let sql = db.q(&format!(
|
||||
"UPDATE {mailbox} SET password_expiry={expr} WHERE username=?"
|
||||
));
|
||||
sqlx::query(&sql).bind(username).execute(db.pool()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_optional_rounds(
|
||||
value: Option<&str>,
|
||||
min: u32,
|
||||
max: u32,
|
||||
label: &str,
|
||||
) -> AppResult<Option<u32>> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(raw) => {
|
||||
let parsed = u32::from_str(raw)
|
||||
.map_err(|_| AppError::config(format!("invalid {label}: {raw}")))?;
|
||||
if !(min..=max).contains(&parsed) {
|
||||
return Err(AppError::config(format!(
|
||||
"invalid {label}: {parsed}; expected {min}..={max}"
|
||||
)));
|
||||
}
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_range_u32(
|
||||
value: Option<&str>,
|
||||
default: u32,
|
||||
min: u32,
|
||||
max: u32,
|
||||
label: &str,
|
||||
) -> AppResult<u32> {
|
||||
Ok(parse_optional_rounds(value, min, max, label)?.unwrap_or(default))
|
||||
}
|
||||
|
||||
fn is_hex_digest(value: &str, len: usize) -> bool {
|
||||
value.len() == len && value.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn hex_md5(password: &str) -> String {
|
||||
format!("{:x}", Md5::digest(password.as_bytes()))
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && bool::from(a.ct_eq(b))
|
||||
}
|
||||
|
||||
fn random_crypt_salt(len: usize) -> String {
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = vec![0u8; len];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes
|
||||
.into_iter()
|
||||
.map(|b| CRYPT64[(b as usize) & 63] as char)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn md5_crypt_verify(password: &str, stored: &str) -> bool {
|
||||
let mut parts = stored.split('$');
|
||||
if parts.next() != Some("") || parts.next() != Some("1") {
|
||||
return false;
|
||||
}
|
||||
let Some(salt) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
ct_eq(md5_crypt(password, salt).as_bytes(), stored.as_bytes())
|
||||
}
|
||||
|
||||
/// Native MD5-CRYPT retained for exact PostfixAdmin 3.x/4.x compatibility.
|
||||
pub fn md5_crypt(password: &str, raw_salt: &str) -> String {
|
||||
let password = password.as_bytes();
|
||||
let salt = raw_salt.split('$').next().unwrap_or(raw_salt).as_bytes();
|
||||
let salt = &salt[..salt.len().min(8)];
|
||||
|
||||
let mut ctx = Md5::new();
|
||||
ctx.update(password);
|
||||
ctx.update(b"$1$");
|
||||
ctx.update(salt);
|
||||
|
||||
let mut alt = Md5::new();
|
||||
alt.update(password);
|
||||
alt.update(salt);
|
||||
alt.update(password);
|
||||
let alt = alt.finalize();
|
||||
|
||||
let mut remaining = password.len();
|
||||
while remaining > 16 {
|
||||
ctx.update(&alt[..]);
|
||||
remaining -= 16;
|
||||
}
|
||||
ctx.update(&alt[..remaining]);
|
||||
|
||||
let mut n = password.len();
|
||||
while n > 0 {
|
||||
if n & 1 != 0 {
|
||||
ctx.update([0u8]);
|
||||
} else if let Some(first) = password.first() {
|
||||
ctx.update([*first]);
|
||||
}
|
||||
n >>= 1;
|
||||
}
|
||||
let mut digest: [u8; 16] = ctx.finalize().into();
|
||||
|
||||
for i in 0..1000usize {
|
||||
let mut loop_ctx = Md5::new();
|
||||
if i & 1 != 0 {
|
||||
loop_ctx.update(password);
|
||||
} else {
|
||||
loop_ctx.update(digest);
|
||||
}
|
||||
if i % 3 != 0 {
|
||||
loop_ctx.update(salt);
|
||||
}
|
||||
if i % 7 != 0 {
|
||||
loop_ctx.update(password);
|
||||
}
|
||||
if i & 1 != 0 {
|
||||
loop_ctx.update(digest);
|
||||
} else {
|
||||
loop_ctx.update(password);
|
||||
}
|
||||
digest = loop_ctx.finalize().into();
|
||||
}
|
||||
|
||||
let mut encoded = String::with_capacity(22);
|
||||
b64_from_24(digest[0], digest[6], digest[12], 4, &mut encoded);
|
||||
b64_from_24(digest[1], digest[7], digest[13], 4, &mut encoded);
|
||||
b64_from_24(digest[2], digest[8], digest[14], 4, &mut encoded);
|
||||
b64_from_24(digest[3], digest[9], digest[15], 4, &mut encoded);
|
||||
b64_from_24(digest[4], digest[10], digest[5], 4, &mut encoded);
|
||||
b64_from_24(0, 0, digest[11], 2, &mut encoded);
|
||||
format!("$1${}${encoded}", String::from_utf8_lossy(salt))
|
||||
}
|
||||
|
||||
fn b64_from_24(b2: u8, b1: u8, b0: u8, count: usize, out: &mut String) {
|
||||
let mut value = (u32::from(b2) << 16) | (u32::from(b1) << 8) | u32::from(b0);
|
||||
for _ in 0..count {
|
||||
out.push(CRYPT64[(value & 0x3f) as usize] as char);
|
||||
value >>= 6;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn md5crypt_known_vector() {
|
||||
let hash = md5_crypt("password", "hfT7jp2q");
|
||||
assert_eq!(hash, "$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.");
|
||||
assert!(md5_crypt_verify("password", &hash));
|
||||
assert!(!md5_crypt_verify("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_postfixadmin_reference_vectors() {
|
||||
let cases = [
|
||||
("{SHA1}cojt0Pw//L6ToM8G41aOKFIWh7w=", true),
|
||||
("{SHA1.B64}cojt0Pw//L6ToM8G41aOKFIWh7w=", true),
|
||||
("{PLAIN}test123", true),
|
||||
("{CLEAR}test123", true),
|
||||
("{CLEARTEXT}test123", true),
|
||||
("{SHA256}7NcYcNGWMxapfjrDQIyYNa2M8PPBvHA1J8MCZVNPda4=", true),
|
||||
(
|
||||
"{SHA512}2u9JU7l4M2XK1mFSI3IFBsxGxRZ80Wq1APpZeqCP+WTrJPsZaH8012Zfd4/LbFNY/ApbgeFmLPkPc6JnHFP5kQ==",
|
||||
true,
|
||||
),
|
||||
("{PLAIN-MD5}cc03e747a6afbbcbf8be7668acfebee5", true),
|
||||
("{MD5-CRYPT}$1$AIjpWveQ$2s3eEAbZiqkJhMYUIVR240", true),
|
||||
(
|
||||
"{BLF-CRYPT}$2y$05$cEEZv2h/NtLXII.emi2TP.rMZyB7VRSkyToXWBqqz6cXDoyay166q",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"{SHA512-CRYPT}$6$MViNQUSbWyXWL9wZ$63VsBU2a/ZFb9f/dK4EmaXABE9jAcNltR7y6a2tXLKoV5F5jMezno.2KpmtD3U0FDjfa7A.pkCluVMlZJ.F64.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"{CRYPT}$2y$05$ORqzr0AagWr25v3ixHD5QuMXympIoNTbipEFZz6aAmovGNoij2vDO",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"{SHA256-CRYPT}$5$CFly6wzfn2az3U8j$EhfQPTdjpMGAisfCjCKektLke5GGEmtdLVaCZSmsKw2",
|
||||
true,
|
||||
),
|
||||
("{SSHA}ZkqrSEAhvd0FTHaK1IxAQCRa5LWbxGQY", true),
|
||||
(
|
||||
"{ARGON2ID}$argon2id$v=19$m=65536,t=3,p=1$eaXP376O9/VxleLw9OQIxg$jOoDyECeRRV4eta3eSN/j0RdBgqaA1VBGAA/pbviI20",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"{ARGON2I}$argon2i$v=19$m=32768,t=4,p=1$xoOcAGa27k0Sr6ZPbA9ODw$wl/KAZVmJooD/35IFG5oGwyQiAREXrLss5BPS1PDKfA",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"{ARGON2ID.B64}JGFyZ29uMmlkJHY9MTkkbT02NTUzNix0PTMscD0xJEljdG9DWko1T04zWlYzM3I0TVMrNEEkMUVtNTJRWkdsRlJzNnBsRXpwVmtMeVd4dVNPRUZ2dUZnaVNhTmNlb08rOA==",
|
||||
true,
|
||||
),
|
||||
("{MD5}zAPnR6avu8v4vnZorP6+5Q==", true),
|
||||
("{MD5RAW}cc03e747a6afbbcbf8be7668acfebee5", true),
|
||||
];
|
||||
for (stored, expected) in cases {
|
||||
assert_eq!(verify_native("test123", stored), Some(expected), "{stored}");
|
||||
assert_eq!(
|
||||
verify_native("not-the-password", stored),
|
||||
Some(false),
|
||||
"{stored}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_postfixadmin_b64_crypt_vectors() {
|
||||
let cases = [
|
||||
"{SHA512-CRYPT.B64}JDYkR2JwY3NiZXNMWk9DdERXbiRYdXlhdEZTdy9oa3lyUFE0d24wenpGQTZrSlpTUE9QVWdPcjVRUC40bTRMTjEzdy81aWMvWTdDZllRMWVqSWlhNkd3Q2Z0ZnNjZEFpam9OWjl3OU5tLw==",
|
||||
"{SHA256-CRYPT.B64}JDUkUTZZS1ZzZS5sSVJoLndodCR6TWNOUVFVVkhtTmM1ME1SQk9TR3BEeGpRY2M1TzJTQ1lkbWhPN1YxeHlD",
|
||||
"{BLF-CRYPT.B64}JDJ5JDA1JEhlR0lBeGFHR2tNUGxjRWpyeFc0eU9oRjZZZ1NuTWVOTXFxNWp4bmFwVjUwdGU3c2x2L1VT",
|
||||
];
|
||||
for stored in cases {
|
||||
assert_eq!(verify_native("test123", stored), Some(true), "{stored}");
|
||||
assert_eq!(verify_native("wrong", stored), Some(false), "{stored}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn verifies_legacy_php_crypt_des_vector() {
|
||||
assert!(unix_crypt::verify("test123", "VXAXutUnpVYg6"));
|
||||
assert!(!unix_crypt::verify("wrong", "VXAXutUnpVYg6"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{AdminInput, AdminSummary},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<AdminSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
session.require_global()?;
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let da = state.db.table(Table::DomainAdmins);
|
||||
let phone = if state.db.has_column(Table::Admin, "phone").await? {
|
||||
"phone"
|
||||
} else {
|
||||
"'' AS phone"
|
||||
};
|
||||
let email_other = if state.db.has_column(Table::Admin, "email_other").await? {
|
||||
"email_other"
|
||||
} else {
|
||||
"'' AS email_other"
|
||||
};
|
||||
let totp = if state.db.has_column(Table::Admin, "totp_secret").await? {
|
||||
"totp_secret"
|
||||
} else {
|
||||
"NULL AS totp_secret"
|
||||
};
|
||||
let superadmin = state.db.flag("superadmin", "superadmin");
|
||||
let active = state.db.flag("active", "active");
|
||||
let sql = format!(
|
||||
"SELECT username,{superadmin},{active},{phone},{email_other},{totp} FROM {admin} ORDER BY username"
|
||||
);
|
||||
let rows = sqlx::query(&sql).fetch_all(state.db.pool()).await?;
|
||||
let mut result = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let username = row_string(&row, "username");
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT domain FROM {da} WHERE username=? AND active=? ORDER BY domain"
|
||||
));
|
||||
let domains = sqlx::query(&q)
|
||||
.bind(&username)
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| row_string(r, "domain"))
|
||||
.collect();
|
||||
result.push(AdminSummary {
|
||||
username,
|
||||
superadmin: row_bool(&row, "superadmin"),
|
||||
active: row_bool(&row, "active"),
|
||||
phone: row_string(&row, "phone"),
|
||||
email_other: row_string(&row, "email_other"),
|
||||
domains,
|
||||
totp_enabled: !row_string(&row, "totp_secret").is_empty(),
|
||||
});
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<AdminInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
input.username = validation::resolvable_email(&state.config, &input.username).await?;
|
||||
let password = input
|
||||
.password
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::bad_request("Password is required"))?;
|
||||
let hash = passwords::hash(&state.config, password, &input.username).await?;
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let check = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {admin} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&check)
|
||||
.bind(&input.username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("Administrator already exists"));
|
||||
}
|
||||
validate_domains(&state, &input.domains, input.superadmin).await?;
|
||||
let has_phone = state.db.has_column(Table::Admin, "phone").await?;
|
||||
let has_other = state.db.has_column(Table::Admin, "email_other").await?;
|
||||
let mut cols = vec![
|
||||
"username",
|
||||
"password",
|
||||
"superadmin",
|
||||
"created",
|
||||
"modified",
|
||||
"active",
|
||||
];
|
||||
let mut vals = vec!["?", "?", "?", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP", "?"];
|
||||
if has_phone {
|
||||
cols.insert(3, "phone");
|
||||
vals.insert(3, "?");
|
||||
}
|
||||
if has_other {
|
||||
let pos = if has_phone { 4 } else { 3 };
|
||||
cols.insert(pos, "email_other");
|
||||
vals.insert(pos, "?");
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {admin} ({}) VALUES ({})",
|
||||
cols.join(","),
|
||||
vals.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.username)
|
||||
.bind(hash)
|
||||
.bind(input.superadmin);
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_other {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
q.bind(input.active).execute(state.db.pool()).await?;
|
||||
sync_domains(&state, &input.username, &input.domains, input.superadmin).await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
"",
|
||||
"create_admin",
|
||||
&input.username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"username":input.username}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<AdminInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
input.username = validation::email(&input.username)?;
|
||||
if input.username != username {
|
||||
return Err(AppError::bad_request(
|
||||
"Renaming administrators is not supported",
|
||||
));
|
||||
}
|
||||
validate_domains(&state, &input.domains, input.superadmin).await?;
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let exists = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {admin} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&exists)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::not_found("Administrator not found"));
|
||||
}
|
||||
let has_phone = state.db.has_column(Table::Admin, "phone").await?;
|
||||
let has_other = state.db.has_column(Table::Admin, "email_other").await?;
|
||||
let new_hash = if let Some(password) = input.password.as_deref() {
|
||||
Some(passwords::hash(&state.config, password, &username).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut sets = vec![
|
||||
"superadmin=?".to_string(),
|
||||
"active=?".to_string(),
|
||||
"modified=CURRENT_TIMESTAMP".to_string(),
|
||||
];
|
||||
if has_phone {
|
||||
sets.push("phone=?".into());
|
||||
}
|
||||
if has_other {
|
||||
sets.push("email_other=?".into());
|
||||
}
|
||||
if new_hash.is_some() {
|
||||
sets.push("password=?".into());
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {admin} SET {} WHERE username=?",
|
||||
sets.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql).bind(input.superadmin).bind(input.active);
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_other {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
if let Some(hash) = new_hash {
|
||||
q = q.bind(hash);
|
||||
}
|
||||
q.bind(&username).execute(state.db.pool()).await?;
|
||||
sync_domains(&state, &username, &input.domains, input.superadmin).await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
"",
|
||||
"edit_admin",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
if username.eq_ignore_ascii_case(&session.username) {
|
||||
return Err(AppError::conflict(
|
||||
"You cannot delete the currently logged-in administrator",
|
||||
));
|
||||
}
|
||||
let da = state.db.table(Table::DomainAdmins);
|
||||
let admin = state.db.table(Table::Admin);
|
||||
let sql = state.db.q(&format!("DELETE FROM {da} WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let sql = state.db.q(&format!("DELETE FROM {admin} WHERE username=?"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::not_found("Administrator not found"));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
"",
|
||||
"delete_admin",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
async fn validate_domains(state: &AppState, domains: &[String], superadmin: bool) -> AppResult<()> {
|
||||
if superadmin {
|
||||
return Ok(());
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
for raw in domains {
|
||||
if raw == "ALL" {
|
||||
return Err(AppError::bad_request(
|
||||
"ALL can only be assigned to a global administrator",
|
||||
));
|
||||
}
|
||||
let domain = validation::domain(raw)?;
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::bad_request(format!("Unknown domain: {raw}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_domains(
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
domains: &[String],
|
||||
superadmin: bool,
|
||||
) -> AppResult<()> {
|
||||
let table = state.db.table(Table::DomainAdmins);
|
||||
let sql = state.db.q(&format!("DELETE FROM {table} WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let assigned: Vec<String> = if superadmin {
|
||||
vec!["ALL".into()]
|
||||
} else {
|
||||
domains.iter().map(|d| d.to_ascii_lowercase()).collect()
|
||||
};
|
||||
for domain in assigned {
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {table} (username,domain,created,active) VALUES (?,?,CURRENT_TIMESTAMP,?)"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.bind(domain)
|
||||
.bind(true)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,465 @@
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{ConnectInfo, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::verify_totp,
|
||||
error::{AppError, AppResult},
|
||||
models::{AccountKind, Role},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginInput {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default = "admin_kind")]
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
fn admin_kind() -> AccountKind {
|
||||
AccountKind::Admin
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct MfaInput {
|
||||
pub challenge: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginResponse {
|
||||
authenticated: bool,
|
||||
mfa_required: bool,
|
||||
challenge: Option<String>,
|
||||
csrf: Option<String>,
|
||||
username: String,
|
||||
role: Option<Role>,
|
||||
kind: AccountKind,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct MeResponse {
|
||||
username: String,
|
||||
role: Role,
|
||||
kind: AccountKind,
|
||||
csrf: String,
|
||||
domains: Vec<String>,
|
||||
language: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<LoginInput>,
|
||||
) -> AppResult<Response> {
|
||||
let username = input.username.trim().to_ascii_lowercase();
|
||||
if username.is_empty() || input.password.is_empty() {
|
||||
return Err(AppError::unauthorized("Invalid credentials"));
|
||||
}
|
||||
if !state.auth.login_allowed(peer.ip(), &username) {
|
||||
return Err(AppError::too_many("Too many login attempts"));
|
||||
}
|
||||
|
||||
let record = state.db.login_record(input.kind, &username).await?;
|
||||
let valid = if let Some(ref record) = record {
|
||||
record.active
|
||||
&& passwords::verify(&state.config, &input.password, &record.password, &username).await
|
||||
} else {
|
||||
let _ = passwords::verify(
|
||||
&state.config,
|
||||
&input.password,
|
||||
"$1$hfT7jp2q$G3yf0NUx7mUkX.LIFWQxN.",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
false
|
||||
};
|
||||
if !valid {
|
||||
return Err(AppError::unauthorized("Invalid credentials"));
|
||||
}
|
||||
let record = record.expect("valid implies record");
|
||||
state.auth.clear_attempts(peer.ip(), &username);
|
||||
|
||||
let domains = if input.kind == AccountKind::Admin {
|
||||
state.db.allowed_domains(&username).await?
|
||||
} else {
|
||||
username
|
||||
.split_once('@')
|
||||
.map(|(_, d)| vec![d.to_string()])
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let role = match input.kind {
|
||||
AccountKind::User => Role::User,
|
||||
AccountKind::Admin if record.superadmin || domains.iter().any(|d| d == "ALL") => {
|
||||
Role::GlobalAdmin
|
||||
}
|
||||
AccountKind::Admin => Role::Admin,
|
||||
};
|
||||
let domains: Arc<[String]> = domains.into();
|
||||
|
||||
let mail_password = if input.kind == AccountKind::User && state.config.sieve.enabled {
|
||||
Some(Arc::<str>::from(input.password.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let needs_mfa = state.config.features.totp
|
||||
&& record.totp_secret.as_deref().is_some_and(|s| !s.is_empty())
|
||||
&& !state
|
||||
.db
|
||||
.totp_exception(&peer.ip().to_string(), &username)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if needs_mfa {
|
||||
let challenge = state.auth.create_pending(
|
||||
username.clone(),
|
||||
input.kind,
|
||||
role,
|
||||
domains,
|
||||
peer.ip(),
|
||||
mail_password.clone(),
|
||||
);
|
||||
return Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(LoginResponse {
|
||||
authenticated: false,
|
||||
mfa_required: true,
|
||||
challenge: Some(challenge),
|
||||
csrf: None,
|
||||
username,
|
||||
role: None,
|
||||
kind: input.kind,
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let (token, session) =
|
||||
state
|
||||
.auth
|
||||
.create_session(username.clone(), input.kind, role, domains, mail_password);
|
||||
let jar = add_session_cookie(&state, jar, token);
|
||||
Ok((
|
||||
jar,
|
||||
Json(LoginResponse {
|
||||
authenticated: true,
|
||||
mfa_required: false,
|
||||
challenge: None,
|
||||
csrf: Some(session.csrf),
|
||||
username,
|
||||
role: Some(role),
|
||||
kind: input.kind,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn mfa(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
jar: CookieJar,
|
||||
Json(input): Json<MfaInput>,
|
||||
) -> AppResult<Response> {
|
||||
let pending = state
|
||||
.auth
|
||||
.take_pending(&input.challenge, peer.ip())
|
||||
.ok_or_else(|| AppError::unauthorized("MFA challenge expired"))?;
|
||||
let record = state
|
||||
.db
|
||||
.login_record(pending.kind, &pending.username)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::unauthorized("Account no longer exists"))?;
|
||||
let secret = record
|
||||
.totp_secret
|
||||
.ok_or_else(|| AppError::unauthorized("TOTP is not configured"))?;
|
||||
if !verify_totp(&secret, input.code.trim()) {
|
||||
return Err(AppError::unauthorized("Invalid TOTP code"));
|
||||
}
|
||||
let (token, session) = state.auth.create_session(
|
||||
pending.username.clone(),
|
||||
pending.kind,
|
||||
pending.role,
|
||||
pending.allowed_domains,
|
||||
pending.mail_password,
|
||||
);
|
||||
let jar = add_session_cookie(&state, jar, token);
|
||||
Ok((
|
||||
jar,
|
||||
Json(LoginResponse {
|
||||
authenticated: true,
|
||||
mfa_required: false,
|
||||
challenge: None,
|
||||
csrf: Some(session.csrf),
|
||||
username: pending.username,
|
||||
role: Some(pending.role),
|
||||
kind: pending.kind,
|
||||
}),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Response {
|
||||
if let Some(cookie) = jar.get("pa_session") {
|
||||
state.auth.revoke(cookie.value());
|
||||
}
|
||||
let mut removal = Cookie::new("pa_session", "");
|
||||
removal.set_path(state.config.normalized_base_path());
|
||||
(jar.remove(removal), StatusCode::NO_CONTENT).into_response()
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Json<MeResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
Ok(Json(MeResponse {
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
kind: session.kind,
|
||||
csrf: session.csrf,
|
||||
domains: session.allowed_domains.to_vec(),
|
||||
language: state.config.default_language.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn add_session_cookie(state: &AppState, jar: CookieJar, token: String) -> CookieJar {
|
||||
let mut cookie = Cookie::new("pa_session", token);
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(SameSite::Strict);
|
||||
cookie.set_secure(state.config.security.cookie_secure);
|
||||
cookie.set_path(state.config.normalized_base_path());
|
||||
jar.add(cookie)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RecoveryRequestInput {
|
||||
pub username: String,
|
||||
#[serde(default = "user_kind")]
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
fn user_kind() -> AccountKind {
|
||||
AccountKind::User
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct RecoveryResetInput {
|
||||
pub username: String,
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
#[serde(default = "user_kind")]
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
|
||||
pub async fn recovery_request(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Json(input): Json<RecoveryRequestInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
routes::require_writable(&state)?;
|
||||
use std::time::{Duration, Instant};
|
||||
let started = Instant::now();
|
||||
let username = input.username.trim().to_ascii_lowercase();
|
||||
let enabled = match input.kind {
|
||||
AccountKind::User => {
|
||||
state.config.features.forgotten_user_password_reset
|
||||
&& state.config.hooks.mailbox_postpassword.is_empty()
|
||||
}
|
||||
AccountKind::Admin => state.config.features.forgotten_admin_password_reset,
|
||||
};
|
||||
if !enabled {
|
||||
return Err(AppError::forbidden("Password recovery is disabled"));
|
||||
}
|
||||
if !state
|
||||
.auth
|
||||
.login_allowed(peer.ip(), &format!("recovery:{username}"))
|
||||
{
|
||||
return Err(AppError::too_many("Too many recovery requests"));
|
||||
}
|
||||
let table_key = match input.kind {
|
||||
AccountKind::User => crate::db::Table::Mailbox,
|
||||
AccountKind::Admin => crate::db::Table::Admin,
|
||||
};
|
||||
let has_token = state
|
||||
.db
|
||||
.has_column(table_key, "token")
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
&& state
|
||||
.db
|
||||
.has_column(table_key, "token_validity")
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_email = state
|
||||
.db
|
||||
.has_column(table_key, "email_other")
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if has_token {
|
||||
let table = state.db.table(table_key);
|
||||
let email_expr = if has_email {
|
||||
"email_other"
|
||||
} else {
|
||||
"'' AS email_other"
|
||||
};
|
||||
let active_expr = state.db.flag("active", "active");
|
||||
let sql = state.db.q(&format!(
|
||||
"SELECT {active_expr},{email_expr} FROM {table} WHERE username=? LIMIT 1"
|
||||
));
|
||||
if let Some(row) = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
&& crate::db::row_bool(&row, "active")
|
||||
{
|
||||
let token = crate::auth::random_token(18);
|
||||
let token_hash = passwords::hash_internal_secret(&token)?;
|
||||
let expiry = match state.db.dialect() {
|
||||
crate::db::Dialect::MySql => "DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)",
|
||||
crate::db::Dialect::Postgres => "CURRENT_TIMESTAMP + INTERVAL '1 hour'",
|
||||
crate::db::Dialect::Sqlite => "datetime('now','+1 hour')",
|
||||
};
|
||||
let cooldown = match state.db.dialect() {
|
||||
crate::db::Dialect::MySql => "DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)",
|
||||
crate::db::Dialect::Postgres => "CURRENT_TIMESTAMP - INTERVAL '5 minutes'",
|
||||
crate::db::Dialect::Sqlite => "datetime('now','-5 minutes')",
|
||||
};
|
||||
let update = state.db.q(&format!("UPDATE {table} SET token=?,token_validity={expiry},modified=CURRENT_TIMESTAMP WHERE username=? AND active=? AND (token IS NULL OR token='' OR modified<={cooldown})"));
|
||||
if sqlx::query(&update)
|
||||
.bind(token_hash)
|
||||
.bind(&username)
|
||||
.bind(true)
|
||||
.execute(state.db.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
== 1
|
||||
{
|
||||
let to = crate::db::row_string(&row, "email_other");
|
||||
if !to.trim().is_empty() {
|
||||
let base = state.config.normalized_base_path();
|
||||
let body = format!(
|
||||
"Password recovery code for {username}: {token}\n\nThe code expires in one hour. Open {base} and use the password recovery form."
|
||||
);
|
||||
let from = recovery_sender(&state.config, &username);
|
||||
if let Err(error) = crate::mailer::send_text(
|
||||
&state.config,
|
||||
&from,
|
||||
&to,
|
||||
"Password recovery",
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error,%username,"password recovery mail failed");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(%username,"password recovery requested but no email_other is configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed < Duration::from_secs(2) {
|
||||
tokio::time::sleep(Duration::from_secs(2) - elapsed).await;
|
||||
}
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"message":"If the account is eligible, a recovery code has been sent."}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn recovery_reset(
|
||||
State(state): State<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Json(input): Json<RecoveryResetInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
routes::require_writable(&state)?;
|
||||
let username = input.username.trim().to_ascii_lowercase();
|
||||
if !state
|
||||
.auth
|
||||
.login_allowed(peer.ip(), &format!("reset:{username}"))
|
||||
{
|
||||
return Err(AppError::too_many("Too many reset attempts"));
|
||||
}
|
||||
let enabled = match input.kind {
|
||||
AccountKind::User => {
|
||||
state.config.features.forgotten_user_password_reset
|
||||
&& state.config.hooks.mailbox_postpassword.is_empty()
|
||||
}
|
||||
AccountKind::Admin => state.config.features.forgotten_admin_password_reset,
|
||||
};
|
||||
if !enabled {
|
||||
return Err(AppError::forbidden("Password recovery is disabled"));
|
||||
}
|
||||
let table_key = match input.kind {
|
||||
AccountKind::User => crate::db::Table::Mailbox,
|
||||
AccountKind::Admin => crate::db::Table::Admin,
|
||||
};
|
||||
if !state.db.has_column(table_key, "token").await?
|
||||
|| !state.db.has_column(table_key, "token_validity").await?
|
||||
{
|
||||
return Err(AppError::conflict(
|
||||
"Database schema does not support password recovery",
|
||||
));
|
||||
}
|
||||
let table = state.db.table(table_key);
|
||||
let now = match state.db.dialect() {
|
||||
crate::db::Dialect::Sqlite => "datetime('now')",
|
||||
_ => "CURRENT_TIMESTAMP",
|
||||
};
|
||||
let sql=state.db.q(&format!("SELECT token FROM {table} WHERE username=? AND active=? AND token IS NOT NULL AND token<>'' AND token_validity>{now} LIMIT 1"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(true)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::unauthorized("Invalid or expired recovery code"))?;
|
||||
if !passwords::verify(
|
||||
&state.config,
|
||||
&input.token,
|
||||
&crate::db::row_string(&row, "token"),
|
||||
&username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(AppError::unauthorized("Invalid or expired recovery code"));
|
||||
}
|
||||
let hash = passwords::hash(&state.config, &input.new_password, &username).await?;
|
||||
let sql=state.db.q(&format!("UPDATE {table} SET password=?,token='',token_validity='2000-01-01 00:00:00',modified=CURRENT_TIMESTAMP WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(hash)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
if input.kind == AccountKind::User {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||
}
|
||||
state
|
||||
.auth
|
||||
.clear_attempts(peer.ip(), &format!("reset:{username}"));
|
||||
let domain = username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&username,
|
||||
domain,
|
||||
"password_recovery",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn recovery_sender(config: &crate::config::AppConfig, username: &str) -> String {
|
||||
if !config.smtp.admin_email.trim().is_empty() {
|
||||
return config.smtp.admin_email.trim().to_owned();
|
||||
}
|
||||
let domain = username
|
||||
.rsplit_once('@')
|
||||
.map(|(_, d)| d)
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or("localhost");
|
||||
format!("postmaster@{domain}")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::{
|
||||
db::Dialect,
|
||||
error::{AppError, AppResult},
|
||||
routes,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
use tokio::process::Command;
|
||||
|
||||
pub async fn download(State(state): State<Arc<AppState>>, jar: CookieJar) -> AppResult<Response> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
s.require_global()?;
|
||||
if !state.config.features.backup {
|
||||
return Err(AppError::forbidden("Backup is disabled"));
|
||||
}
|
||||
if state.db.dialect() == Dialect::Sqlite {
|
||||
let path = state
|
||||
.config
|
||||
.database
|
||||
.path
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.database.name);
|
||||
let bytes = tokio::fs::read(path).await?;
|
||||
return response(bytes, "postfixadmin.sqlite");
|
||||
}
|
||||
let mut cmd = if state.db.dialect() == Dialect::Postgres {
|
||||
let mut c = Command::new(&state.config.backup.pg_dump);
|
||||
c.arg("--no-owner")
|
||||
.arg("--no-privileges")
|
||||
.arg("--host")
|
||||
.arg(&state.config.database.host)
|
||||
.arg("--port")
|
||||
.arg(state.config.database.port.to_string())
|
||||
.arg("--username")
|
||||
.arg(&state.config.database.user)
|
||||
.arg(&state.config.database.name);
|
||||
c.env("PGPASSWORD", &state.config.database.password);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(&state.config.backup.mysql_dump);
|
||||
c.arg("--single-transaction")
|
||||
.arg("--skip-lock-tables")
|
||||
.arg("--host")
|
||||
.arg(&state.config.database.host)
|
||||
.arg("--port")
|
||||
.arg(state.config.database.port.to_string())
|
||||
.arg("--user")
|
||||
.arg(&state.config.database.user)
|
||||
.arg(&state.config.database.name);
|
||||
c.env("MYSQL_PWD", &state.config.database.password);
|
||||
c
|
||||
};
|
||||
let out = cmd
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !out.status.success() {
|
||||
tracing::error!(stderr=%String::from_utf8_lossy(&out.stderr),"database dump failed");
|
||||
return Err(AppError::config("Database dump command failed"));
|
||||
}
|
||||
response(out.stdout, "postfixadmin.sql")
|
||||
}
|
||||
fn response(bytes: Vec<u8>, name: &str) -> AppResult<Response> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&format!("attachment; filename=\"{name}\""))
|
||||
.map_err(|_| AppError::config("invalid backup filename"))?,
|
||||
);
|
||||
Ok((headers, Body::from(bytes)).into_response())
|
||||
}
|
||||
use axum::response::IntoResponse;
|
||||
@@ -0,0 +1,578 @@
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Serialize;
|
||||
use tokio::{io::AsyncWriteExt, process::Command};
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_i64, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{DkimKeyInput, DkimKeySummary, DkimSigningInput},
|
||||
routes::{self, rspamd_dkim},
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct DkimDnsRecord {
|
||||
pub source: String,
|
||||
pub domain: String,
|
||||
pub selector: String,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub record: String,
|
||||
}
|
||||
|
||||
impl DkimDnsRecord {
|
||||
pub(crate) fn from_value(source: &str, domain: &str, selector: &str, value: &str) -> Self {
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
let name = format!("{selector}._domainkey.{domain}");
|
||||
let escaped = value.replace('"', "\\\"");
|
||||
Self {
|
||||
source: source.to_string(),
|
||||
domain: domain.to_string(),
|
||||
selector: selector.to_string(),
|
||||
name: name.clone(),
|
||||
value,
|
||||
record: format!("{name} IN TXT \"{escaped}\""),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_complete(source: &str, domain: &str, selector: &str, record: &str) -> Self {
|
||||
let name = format!("{selector}._domainkey.{domain}");
|
||||
Self {
|
||||
source: source.to_string(),
|
||||
domain: domain.to_string(),
|
||||
selector: selector.to_string(),
|
||||
name,
|
||||
value: extract_txt_value(record).unwrap_or_else(|| record.trim().to_string()),
|
||||
record: record.trim().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DkimDnsResponse {
|
||||
read_only: bool,
|
||||
records: Vec<DkimDnsRecord>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn dns_records(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<DkimDnsResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
if !session.role.is_admin() && !state.config.dkim.user_read_only {
|
||||
return Err(AppError::forbidden(
|
||||
"DKIM read-only view is disabled for mailbox users",
|
||||
));
|
||||
}
|
||||
|
||||
let domains: Vec<String> = session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|domain| domain.as_str() != "ALL")
|
||||
.map(|domain| domain.to_ascii_lowercase())
|
||||
.collect();
|
||||
let mut records = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if state.config.features.dkim && state.db.has_table(Table::Dkim).await {
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT domain_name,selector,public_key FROM {table} ORDER BY domain_name,selector"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
for row in rows {
|
||||
let domain = row_string(&row, "domain_name").to_ascii_lowercase();
|
||||
if !session.role.is_global() && !domains.iter().any(|item| item == &domain) {
|
||||
continue;
|
||||
}
|
||||
let selector = row_string(&row, "selector");
|
||||
let public_key = row_string(&row, "public_key");
|
||||
if let Some(value) = public_key_to_dns_value(&public_key) {
|
||||
records.push(DkimDnsRecord::from_value(
|
||||
"postfixadmin",
|
||||
&domain,
|
||||
&selector,
|
||||
&value,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state.config.rspamd_dkim.enabled {
|
||||
match rspamd_dkim::read_only_dns_records(&state.config.rspamd_dkim, &domains).await {
|
||||
Ok(mut values) => records.append(&mut values),
|
||||
Err(_) => warnings
|
||||
.push("Rspamd DKIM records are not readable on this application node".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
records.sort_by(|a, b| {
|
||||
(&a.domain, &a.selector, &a.source).cmp(&(&b.domain, &b.selector, &b.source))
|
||||
});
|
||||
records
|
||||
.dedup_by(|a, b| a.domain == b.domain && a.selector == b.selector && a.record == b.record);
|
||||
|
||||
Ok(Json(DkimDnsResponse {
|
||||
read_only: !session.role.is_admin() || state.config.database.read_only,
|
||||
records,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_keys(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<DkimKeySummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
dkim_role(&state, &session)?;
|
||||
if !state.db.has_table(Table::Dkim).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT id,domain_name,description,selector,private_key,public_key FROM {table} ORDER BY domain_name,selector"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.filter(|row| session.can_manage_domain(&row_string(row, "domain_name")))
|
||||
.map(|row| DkimKeySummary {
|
||||
id: row_i64(row, "id"),
|
||||
domain_name: row_string(row, "domain_name"),
|
||||
description: row_string(row, "description"),
|
||||
selector: row_string(row, "selector"),
|
||||
public_key: row_string(row, "public_key"),
|
||||
has_private_key: !row_string(row, "private_key").is_empty(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<DkimKeyInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
input.domain_name = validation::domain(&input.domain_name)?;
|
||||
session.require_domain(&input.domain_name)?;
|
||||
validate_selector(&input.selector)?;
|
||||
let (private, public) = match (input.private_key.take(), input.public_key.take()) {
|
||||
(Some(private), Some(public)) => (private, public),
|
||||
(None, None) => generate_keypair().await?,
|
||||
_ => {
|
||||
return Err(AppError::bad_request(
|
||||
"Provide both private_key and public_key, or neither to auto-generate",
|
||||
));
|
||||
}
|
||||
};
|
||||
if private.len() > 32768 || public.len() > 32768 {
|
||||
return Err(AppError::bad_request("DKIM key is too large"));
|
||||
}
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let query = state.db.q(&format!(
|
||||
"INSERT INTO {table} (domain_name,description,selector,private_key,public_key,created,modified) VALUES (?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&input.domain_name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.selector)
|
||||
.bind(private)
|
||||
.bind(public)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&input.domain_name,
|
||||
"create_dkim_entry",
|
||||
&input.description,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn update_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<DkimKeyInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let get = state.db.q(&format!(
|
||||
"SELECT domain_name,private_key,public_key FROM {table} WHERE id=?"
|
||||
));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM key not found"))?;
|
||||
session.require_domain(&row_string(&row, "domain_name"))?;
|
||||
input.domain_name = validation::domain(&input.domain_name)?;
|
||||
session.require_domain(&input.domain_name)?;
|
||||
validate_selector(&input.selector)?;
|
||||
let private = input
|
||||
.private_key
|
||||
.take()
|
||||
.unwrap_or_else(|| row_string(&row, "private_key"));
|
||||
let public = input
|
||||
.public_key
|
||||
.take()
|
||||
.unwrap_or_else(|| row_string(&row, "public_key"));
|
||||
let query = state.db.q(&format!(
|
||||
"UPDATE {table} SET domain_name=?,description=?,selector=?,private_key=?,public_key=?,modified=CURRENT_TIMESTAMP WHERE id=?"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&input.domain_name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.selector)
|
||||
.bind(private)
|
||||
.bind(public)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let get = state.db.q(&format!(
|
||||
"SELECT domain_name,description FROM {table} WHERE id=?"
|
||||
));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM key not found"))?;
|
||||
let domain = row_string(&row, "domain_name");
|
||||
session.require_domain(&domain)?;
|
||||
let query = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||
sqlx::query(&query)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain,
|
||||
"delete_dkim_entry",
|
||||
&row_string(&row, "description"),
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<serde_json::Value>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let signing = state.db.table(Table::DkimSigning);
|
||||
let keys = state.db.table(Table::Dkim);
|
||||
if !state.db.has_table(Table::DkimSigning).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT s.id,s.author,s.dkim_id,k.domain_name,k.selector,k.description FROM {signing} s LEFT JOIN {keys} k ON k.id=s.dkim_id ORDER BY k.domain_name,k.selector,s.author"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.filter_map(|row| {
|
||||
let author = row_string(row, "author");
|
||||
let domain = author
|
||||
.rsplit_once('@')
|
||||
.map(|(_, d)| d.to_string())
|
||||
.unwrap_or_else(|| author.clone());
|
||||
if !session.can_manage_domain(&domain) {
|
||||
return None;
|
||||
}
|
||||
Some(serde_json::json!({
|
||||
"id": row_i64(row,"id"),
|
||||
"author": author,
|
||||
"dkim_id": row_i64(row,"dkim_id"),
|
||||
"domain_name": row_string(row,"domain_name"),
|
||||
"selector": row_string(row,"selector"),
|
||||
"description": row_string(row,"description")
|
||||
}))
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DkimSigningInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let author = validate_author(&input.author)?;
|
||||
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||
session.require_domain(domain)?;
|
||||
require_key_scope(&state, &session, input.dkim_id).await?;
|
||||
let table = state.db.table(Table::DkimSigning);
|
||||
let query = state.db.q(&format!(
|
||||
"INSERT INTO {table} (author,dkim_id,created,modified) VALUES (?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&author)
|
||||
.bind(input.dkim_id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"create_dkim_signing_entry",
|
||||
&author,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn update_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DkimSigningInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::DkimSigning);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT author FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM signing record not found"))?;
|
||||
let old = row_string(&row, "author");
|
||||
let old_domain = old.rsplit_once('@').map(|(_, d)| d).unwrap_or(&old);
|
||||
session.require_domain(old_domain)?;
|
||||
let author = validate_author(&input.author)?;
|
||||
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||
session.require_domain(domain)?;
|
||||
require_key_scope(&state, &session, input.dkim_id).await?;
|
||||
let query = state.db.q(&format!(
|
||||
"UPDATE {table} SET author=?,dkim_id=?,modified=CURRENT_TIMESTAMP WHERE id=?"
|
||||
));
|
||||
sqlx::query(&query)
|
||||
.bind(&author)
|
||||
.bind(input.dkim_id)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_signing(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
dkim_role(&state, &session)?;
|
||||
let table = state.db.table(Table::DkimSigning);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT author FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("DKIM signing record not found"))?;
|
||||
let author = row_string(&row, "author");
|
||||
let domain = author.rsplit_once('@').map(|(_, d)| d).unwrap_or(&author);
|
||||
session.require_domain(domain)?;
|
||||
let query = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||
sqlx::query(&query)
|
||||
.bind(id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn dkim_role(state: &AppState, session: &crate::auth::Session) -> AppResult<()> {
|
||||
if !state.config.features.dkim {
|
||||
return Err(AppError::forbidden("DKIM management is disabled"));
|
||||
}
|
||||
if state.config.features.dkim_all_admins {
|
||||
session.require_admin()
|
||||
} else {
|
||||
session.require_global()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_selector(selector: &str) -> AppResult<()> {
|
||||
if selector.is_empty()
|
||||
|| selector.len() > 63
|
||||
|| !selector
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
Err(AppError::bad_request("Invalid DKIM selector"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_author(value: &str) -> AppResult<String> {
|
||||
if value.contains('@') {
|
||||
validation::email(value)
|
||||
} else {
|
||||
validation::domain(value)
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_key_scope(
|
||||
state: &AppState,
|
||||
session: &crate::auth::Session,
|
||||
id: i64,
|
||||
) -> AppResult<()> {
|
||||
let table = state.db.table(Table::Dkim);
|
||||
let query = state
|
||||
.db
|
||||
.q(&format!("SELECT domain_name FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&query)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::bad_request("Unknown DKIM key"))?;
|
||||
session.require_domain(&row_string(&row, "domain_name"))
|
||||
}
|
||||
|
||||
async fn generate_keypair() -> AppResult<(String, String)> {
|
||||
let output = Command::new("openssl")
|
||||
.args([
|
||||
"genpkey",
|
||||
"-algorithm",
|
||||
"RSA",
|
||||
"-pkeyopt",
|
||||
"rsa_keygen_bits:2048",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"openssl key generation failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let private = String::from_utf8(output.stdout)
|
||||
.map_err(|_| AppError::config("openssl returned non-UTF8 private key"))?;
|
||||
let mut child = Command::new("openssl")
|
||||
.args(["pkey", "-pubout"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(private.as_bytes()).await?;
|
||||
}
|
||||
let output = child.wait_with_output().await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"openssl public key extraction failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let public = String::from_utf8(output.stdout)
|
||||
.map_err(|_| AppError::config("openssl returned non-UTF8 public key"))?;
|
||||
Ok((private.trim().into(), public.trim().into()))
|
||||
}
|
||||
|
||||
fn public_key_to_dns_value(public_key: &str) -> Option<String> {
|
||||
let value = public_key.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value.to_ascii_lowercase().contains("v=dkim1") && value.to_ascii_lowercase().contains("p=") {
|
||||
return Some(
|
||||
extract_txt_value(value).unwrap_or_else(|| value.trim_matches('"').to_string()),
|
||||
);
|
||||
}
|
||||
let encoded: String = value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.starts_with("-----BEGIN") && !line.starts_with("-----END"))
|
||||
.collect();
|
||||
if encoded.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("v=DKIM1; k=rsa; p={encoded}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_txt_value(record: &str) -> Option<String> {
|
||||
let trimmed = record.trim();
|
||||
let start = trimmed.find('"')? + 1;
|
||||
let end = trimmed.rfind('"')?;
|
||||
if end <= start {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed[start..end].replace("\\\"", "\""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pem_public_key_becomes_dns_value() {
|
||||
let pem = "-----BEGIN PUBLIC KEY-----\nABC123\nDEF456\n-----END PUBLIC KEY-----";
|
||||
assert_eq!(
|
||||
public_key_to_dns_value(pem).as_deref(),
|
||||
Some("v=DKIM1; k=rsa; p=ABC123DEF456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_record_is_complete() {
|
||||
let record =
|
||||
DkimDnsRecord::from_value("rspamd", "example.org", "mail", "v=DKIM1; k=rsa; p=ABC");
|
||||
assert_eq!(record.name, "mail._domainkey.example.org");
|
||||
assert_eq!(
|
||||
record.record,
|
||||
"mail._domainkey.example.org IN TXT \"v=DKIM1; k=rsa; p=ABC\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Serialize;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use trust_dns_resolver::TokioAsyncResolver;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_opt_bool, row_opt_i64, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
hooks,
|
||||
models::{DomainInput, DomainSummary},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<DomainSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
session.require_admin()?;
|
||||
let domain = state.db.table(Table::Domain);
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let has_dns = state.db.has_column(Table::Domain, "dns_active").await?;
|
||||
let has_dns_checked = state.db.has_column(Table::Domain, "dns_checked").await?;
|
||||
let expiry_expr = if has_expiry {
|
||||
"d.password_expiry"
|
||||
} else {
|
||||
"NULL AS password_expiry"
|
||||
};
|
||||
let backupmx_expr = state.db.flag("d.backupmx", "backupmx");
|
||||
let active_expr = state.db.flag("d.active", "active");
|
||||
let dns_expr = match (has_dns, has_dns_checked) {
|
||||
(true, true) => format!(
|
||||
"{}, {} AS dns_checked",
|
||||
state.db.flag("d.dns_active", "dns_active"),
|
||||
state.db.cast_text("d.dns_checked")
|
||||
),
|
||||
(true, false) => format!(
|
||||
"{}, NULL AS dns_checked",
|
||||
state.db.flag("d.dns_active", "dns_active")
|
||||
),
|
||||
(false, _) => "NULL AS dns_active, NULL AS dns_checked".into(),
|
||||
};
|
||||
let mut sql = format!(
|
||||
"SELECT d.domain,d.description,d.aliases,d.mailboxes,d.maxquota,d.quota,d.transport,{backupmx_expr},{active_expr},{expiry_expr},{dns_expr},\n (SELECT COUNT(*) FROM {alias} a WHERE a.domain=d.domain) AS alias_count,\n (SELECT COUNT(*) FROM {mailbox} m WHERE m.domain=d.domain) AS mailbox_count\n FROM {domain} d WHERE d.domain <> 'ALL'"
|
||||
);
|
||||
let mut query_domains = false;
|
||||
if !session.role.is_global() {
|
||||
if !session.allowed_domains.iter().any(|d| d.as_str() != "ALL") {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
sql.push_str(" AND d.domain IN (");
|
||||
for (i, _) in session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.enumerate()
|
||||
{
|
||||
if i > 0 {
|
||||
sql.push(',');
|
||||
}
|
||||
sql.push('?');
|
||||
}
|
||||
sql.push(')');
|
||||
query_domains = true;
|
||||
}
|
||||
sql.push_str(" ORDER BY d.domain");
|
||||
let sql = state.db.q(&sql);
|
||||
let mut query = sqlx::query(&sql);
|
||||
if query_domains {
|
||||
for d in session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
{
|
||||
query = query.bind(d.as_str());
|
||||
}
|
||||
}
|
||||
let rows = query.fetch_all(state.db.pool()).await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| DomainSummary {
|
||||
domain: row_string(r, "domain"),
|
||||
description: row_string(r, "description"),
|
||||
aliases: row_i64(r, "aliases"),
|
||||
mailboxes: row_i64(r, "mailboxes"),
|
||||
maxquota: row_i64(r, "maxquota"),
|
||||
quota: row_i64(r, "quota"),
|
||||
transport: row_string(r, "transport"),
|
||||
backupmx: row_bool(r, "backupmx"),
|
||||
active: row_bool(r, "active"),
|
||||
password_expiry: row_opt_i64(r, "password_expiry"),
|
||||
dns_active: row_opt_bool(r, "dns_active"),
|
||||
dns_checked: row_opt_string(r, "dns_checked"),
|
||||
alias_count: row_i64(r, "alias_count"),
|
||||
mailbox_count: row_i64(r, "mailbox_count"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(mut input): Json<DomainInput>,
|
||||
) -> AppResult<Json<DomainSummary>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
input.domain = validation::resolvable_domain(&state.config, &input.domain).await?;
|
||||
if state.config.features.transport
|
||||
&& input.transport.as_deref().is_some_and(|v| {
|
||||
!state
|
||||
.config
|
||||
.defaults
|
||||
.transport_options
|
||||
.iter()
|
||||
.any(|x| x == v)
|
||||
})
|
||||
{
|
||||
return Err(AppError::bad_request("Unsupported transport"));
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
let exists_sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||
if sqlx::query(&exists_sql)
|
||||
.bind(&input.domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("Domain already exists"));
|
||||
}
|
||||
let aliases = input.aliases.unwrap_or(state.config.defaults.aliases);
|
||||
let mailboxes = input.mailboxes.unwrap_or(state.config.defaults.mailboxes);
|
||||
let maxquota = input.maxquota.unwrap_or(state.config.defaults.maxquota_mb);
|
||||
let quota = input.quota.unwrap_or(state.config.defaults.domain_quota_mb);
|
||||
let transport = if state.config.features.transport {
|
||||
input
|
||||
.transport
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.config.defaults.transport_default.clone())
|
||||
} else {
|
||||
state.config.defaults.transport_default.clone()
|
||||
};
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let sql = if has_expiry {
|
||||
state.db.q(&format!("INSERT INTO {table} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active,password_expiry) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?,?)"))
|
||||
} else {
|
||||
state.db.q(&format!("INSERT INTO {table} (domain,description,aliases,mailboxes,maxquota,quota,transport,backupmx,created,modified,active) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.domain)
|
||||
.bind(&input.description)
|
||||
.bind(aliases)
|
||||
.bind(mailboxes)
|
||||
.bind(maxquota)
|
||||
.bind(quota)
|
||||
.bind(&transport)
|
||||
.bind(input.backupmx)
|
||||
.bind(input.active);
|
||||
if has_expiry {
|
||||
q = q.bind(input.password_expiry.unwrap_or(0));
|
||||
}
|
||||
q.execute(state.db.pool()).await?;
|
||||
|
||||
if input.default_aliases {
|
||||
let alias_table = state.db.table(Table::Alias);
|
||||
for (local, target) in &state.config.defaults.default_aliases {
|
||||
let address = format!("{local}@{}", input.domain);
|
||||
let goto = if target.contains('@') {
|
||||
target.clone()
|
||||
} else {
|
||||
format!("{target}@{}", input.domain)
|
||||
};
|
||||
let sql = state.db.q(&format!("INSERT INTO {alias_table} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"));
|
||||
sqlx::query(&sql)
|
||||
.bind(address)
|
||||
.bind(goto)
|
||||
.bind(&input.domain)
|
||||
.bind(true)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postcreation,
|
||||
&[("domain", &input.domain)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&input.domain,
|
||||
"create_domain",
|
||||
&input.domain,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(DomainSummary {
|
||||
domain: input.domain,
|
||||
description: input.description,
|
||||
aliases,
|
||||
mailboxes,
|
||||
maxquota,
|
||||
quota,
|
||||
transport,
|
||||
backupmx: input.backupmx,
|
||||
active: input.active,
|
||||
password_expiry: if has_expiry {
|
||||
Some(input.password_expiry.unwrap_or(0))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
dns_active: None,
|
||||
dns_checked: None,
|
||||
alias_count: if input.default_aliases {
|
||||
state.config.defaults.default_aliases.len() as i64
|
||||
} else {
|
||||
0
|
||||
},
|
||||
mailbox_count: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(domain_path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<DomainInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let domain_name = validation::domain(&domain_path)?;
|
||||
if validation::domain(&input.domain)? != domain_name {
|
||||
return Err(AppError::bad_request(
|
||||
"Renaming domains is not supported; create a new domain instead",
|
||||
));
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
let exists = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE domain=? LIMIT 1"));
|
||||
if sqlx::query(&exists)
|
||||
.bind(&domain_name)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::not_found("Domain not found"));
|
||||
}
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Domain, "password_expiry")
|
||||
.await?;
|
||||
let sql = if has_expiry {
|
||||
state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,password_expiry=?,modified=CURRENT_TIMESTAMP WHERE domain=?"))
|
||||
} else {
|
||||
state.db.q(&format!("UPDATE {table} SET description=?,aliases=?,mailboxes=?,maxquota=?,quota=?,transport=?,backupmx=?,active=?,modified=CURRENT_TIMESTAMP WHERE domain=?"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.description)
|
||||
.bind(input.aliases.unwrap_or(state.config.defaults.aliases))
|
||||
.bind(input.mailboxes.unwrap_or(state.config.defaults.mailboxes))
|
||||
.bind(input.maxquota.unwrap_or(state.config.defaults.maxquota_mb))
|
||||
.bind(input.quota.unwrap_or(state.config.defaults.domain_quota_mb))
|
||||
.bind(if state.config.features.transport {
|
||||
input
|
||||
.transport
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.defaults.transport_default)
|
||||
} else {
|
||||
&state.config.defaults.transport_default
|
||||
})
|
||||
.bind(input.backupmx)
|
||||
.bind(input.active);
|
||||
if has_expiry {
|
||||
q = q.bind(input.password_expiry.unwrap_or(0));
|
||||
}
|
||||
q = q.bind(&domain_name);
|
||||
q.execute(state.db.pool()).await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postedit,
|
||||
&[("domain", &domain_name)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain_name,
|
||||
"edit_domain",
|
||||
&domain_name,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(domain_path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_global()?;
|
||||
let domain_name = validation::domain(&domain_path)?;
|
||||
if state.config.features.alias_domain && state.db.has_table(Table::AliasDomain).await {
|
||||
let ad = state.db.table(Table::AliasDomain);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {ad} WHERE target_domain=? LIMIT 1"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(&domain_name)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict(
|
||||
"Domain is still the target of an alias domain",
|
||||
));
|
||||
}
|
||||
}
|
||||
let like = format!("%@{domain_name}");
|
||||
for (table, col, extra_like) in [
|
||||
(Table::DomainAdmins, "domain", false),
|
||||
(Table::Alias, "domain", false),
|
||||
(Table::Mailbox, "domain", false),
|
||||
(Table::AliasDomain, "alias_domain", false),
|
||||
(Table::Vacation, "domain", false),
|
||||
(Table::VacationNotification, "on_vacation", true),
|
||||
(Table::Quota, "username", true),
|
||||
(Table::Quota2, "username", true),
|
||||
(Table::Fetchmail, "mailbox", true),
|
||||
(Table::Log, "domain", false),
|
||||
] {
|
||||
if !state.db.has_table(table).await {
|
||||
continue;
|
||||
}
|
||||
let t = state.db.table(table);
|
||||
let sql = if extra_like {
|
||||
state
|
||||
.db
|
||||
.q(&format!("DELETE FROM {t} WHERE {col}=? OR {col} LIKE ?"))
|
||||
} else {
|
||||
state.db.q(&format!("DELETE FROM {t} WHERE {col}=?"))
|
||||
};
|
||||
let mut q = sqlx::query(&sql).bind(&domain_name);
|
||||
if extra_like {
|
||||
q = q.bind(&like);
|
||||
}
|
||||
q.execute(state.db.pool()).await?;
|
||||
}
|
||||
let table = state.db.table(Table::Domain);
|
||||
let sql = state.db.q(&format!("DELETE FROM {table} WHERE domain=?"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(&domain_name)
|
||||
.execute(state.db.pool())
|
||||
.await?
|
||||
.rows_affected()
|
||||
!= 1
|
||||
{
|
||||
return Err(AppError::not_found("Domain not found"));
|
||||
}
|
||||
hooks::run(
|
||||
&state.config.hooks.domain_postdeletion,
|
||||
&[("domain", &domain_name)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain_name,
|
||||
"delete_domain",
|
||||
&domain_name,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DnsRefreshResult {
|
||||
domain: String,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
pub async fn dns_refresh(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(domain_path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<DnsRefreshResult>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let domain = validation::domain(&domain_path)?;
|
||||
session.require_domain(&domain)?;
|
||||
if state.config.features.domain_dns_status_check == 0 {
|
||||
return Err(AppError::bad_request("DNS status checks are disabled"));
|
||||
}
|
||||
if !state.db.has_column(Table::Domain, "dns_active").await?
|
||||
|| !state.db.has_column(Table::Domain, "dns_checked").await?
|
||||
{
|
||||
return Err(AppError::bad_request(
|
||||
"Database schema has no dns_active/dns_checked columns",
|
||||
));
|
||||
}
|
||||
let active = check_dns(&domain, state.config.features.domain_dns_status_check).await;
|
||||
let table = state.db.table(Table::Domain);
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {table} SET dns_active=?, dns_checked=CURRENT_TIMESTAMP WHERE domain=?"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(active)
|
||||
.bind(&domain)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(DnsRefreshResult { domain, active }))
|
||||
}
|
||||
|
||||
async fn check_dns(domain: &str, mode: u8) -> bool {
|
||||
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if mode == 2 {
|
||||
let Ok(mx) = resolver.mx_lookup(domain).await else {
|
||||
return false;
|
||||
};
|
||||
for record in mx.iter() {
|
||||
let target = record.exchange().to_utf8();
|
||||
if resolver
|
||||
.lookup_ip(target)
|
||||
.await
|
||||
.is_ok_and(|ips| ips.iter().next().is_some())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let Ok(ns) = resolver.ns_lookup(domain).await else {
|
||||
return false;
|
||||
};
|
||||
for name in ns.iter() {
|
||||
let target = name.to_utf8();
|
||||
let Ok(ips) = resolver.lookup_ip(target).await else {
|
||||
continue;
|
||||
};
|
||||
for ip in ips.iter() {
|
||||
if authoritative_soa(domain, ip).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn authoritative_soa(domain: &str, ip: IpAddr) -> bool {
|
||||
let id: u16 = rand::random();
|
||||
let mut packet = Vec::with_capacity(512);
|
||||
packet.extend_from_slice(&id.to_be_bytes());
|
||||
packet.extend_from_slice(&0u16.to_be_bytes());
|
||||
packet.extend_from_slice(&1u16.to_be_bytes());
|
||||
packet.extend_from_slice(&[0u8; 6]);
|
||||
for label in domain.trim_end_matches('.').split('.') {
|
||||
if label.is_empty() || label.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
packet.push(label.len() as u8);
|
||||
packet.extend_from_slice(label.as_bytes());
|
||||
}
|
||||
packet.push(0);
|
||||
packet.extend_from_slice(&6u16.to_be_bytes());
|
||||
packet.extend_from_slice(&1u16.to_be_bytes());
|
||||
let bind = if ip.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" };
|
||||
let Ok(socket) = UdpSocket::bind(bind).await else {
|
||||
return false;
|
||||
};
|
||||
if socket.connect(SocketAddr::new(ip, 53)).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
if socket.send(&packet).await.ok() != Some(packet.len()) {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 4096];
|
||||
let Ok(Ok(size)) = timeout(Duration::from_secs(1), socket.recv(&mut buf)).await else {
|
||||
return false;
|
||||
};
|
||||
if size < 12 {
|
||||
return false;
|
||||
}
|
||||
let rid = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
let flags = u16::from_be_bytes([buf[2], buf[3]]);
|
||||
rid == id && flags & 0x8000 != 0 && flags & 0x0400 != 0 && flags & 0x000f == 0
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_opt_i64, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{FetchmailInput, FetchmailSummary},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FetchmailColumns {
|
||||
domain: bool,
|
||||
src_port: bool,
|
||||
usessl: bool,
|
||||
sslcertck: bool,
|
||||
sslcertpath: bool,
|
||||
sslfingerprint: bool,
|
||||
created: bool,
|
||||
modified: bool,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl FetchmailColumns {
|
||||
async fn detect(state: &AppState) -> AppResult<Self> {
|
||||
Ok(Self {
|
||||
domain: state.db.has_column(Table::Fetchmail, "domain").await?,
|
||||
src_port: state.db.has_column(Table::Fetchmail, "src_port").await?,
|
||||
usessl: state.db.has_column(Table::Fetchmail, "usessl").await?,
|
||||
sslcertck: state.db.has_column(Table::Fetchmail, "sslcertck").await?,
|
||||
sslcertpath: state.db.has_column(Table::Fetchmail, "sslcertpath").await?,
|
||||
sslfingerprint: state
|
||||
.db
|
||||
.has_column(Table::Fetchmail, "sslfingerprint")
|
||||
.await?,
|
||||
created: state.db.has_column(Table::Fetchmail, "created").await?,
|
||||
modified: state.db.has_column(Table::Fetchmail, "modified").await?,
|
||||
active: state.db.has_column(Table::Fetchmail, "active").await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<FetchmailSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
if !state.config.features.fetchmail {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let columns = FetchmailColumns::detect(&state).await?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let domain = if columns.domain {
|
||||
"domain"
|
||||
} else {
|
||||
"'' AS domain"
|
||||
};
|
||||
let port = if columns.src_port {
|
||||
"src_port"
|
||||
} else {
|
||||
"NULL AS src_port"
|
||||
};
|
||||
let fetchall = state.db.flag("fetchall", "fetchall");
|
||||
let keep = state.db.flag("keep", "keep");
|
||||
let usessl = if columns.usessl {
|
||||
state.db.flag("usessl", "usessl")
|
||||
} else {
|
||||
"0 AS usessl".into()
|
||||
};
|
||||
let sslcertck = if columns.sslcertck {
|
||||
state.db.flag("sslcertck", "sslcertck")
|
||||
} else {
|
||||
"0 AS sslcertck".into()
|
||||
};
|
||||
let sslcertpath = if columns.sslcertpath {
|
||||
"sslcertpath"
|
||||
} else {
|
||||
"'' AS sslcertpath"
|
||||
};
|
||||
let sslfingerprint = if columns.sslfingerprint {
|
||||
"sslfingerprint"
|
||||
} else {
|
||||
"'' AS sslfingerprint"
|
||||
};
|
||||
let active = if columns.active {
|
||||
state.db.flag("active", "active")
|
||||
} else {
|
||||
"1 AS active".into()
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id,{domain},mailbox,src_server,{port},src_auth,src_user,src_folder,poll_time,{fetchall},{keep},protocol,\
|
||||
{usessl},{sslcertck},{sslcertpath},{sslfingerprint},extra_options,returned_text,mda,{active} \
|
||||
FROM {table} ORDER BY mailbox,id"
|
||||
);
|
||||
let rows = sqlx::query(&sql).fetch_all(state.db.pool()).await?;
|
||||
let values = rows
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
let mailbox = row_string(row, "mailbox");
|
||||
if session.role.is_admin() {
|
||||
mailbox
|
||||
.rsplit_once('@')
|
||||
.is_some_and(|(_, domain)| session.can_manage_domain(domain))
|
||||
} else {
|
||||
mailbox == session.username
|
||||
}
|
||||
})
|
||||
.map(map)
|
||||
.collect();
|
||||
Ok(Json(values))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<FetchmailInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
require(&state, &session, &input.mailbox)?;
|
||||
let mailbox = validation::email(&input.mailbox)?;
|
||||
let (_, domain) = mailbox.rsplit_once('@').expect("validated email");
|
||||
assert_mailbox(&state, &mailbox).await?;
|
||||
validate(&state, &input, true)?;
|
||||
|
||||
let columns = FetchmailColumns::detect(&state).await?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let mut names = Vec::new();
|
||||
let mut values = Vec::new();
|
||||
if columns.domain {
|
||||
names.push("domain");
|
||||
values.push("?");
|
||||
}
|
||||
names.extend(["mailbox", "src_server"]);
|
||||
values.extend(["?", "?"]);
|
||||
if columns.src_port {
|
||||
names.push("src_port");
|
||||
values.push("?");
|
||||
}
|
||||
names.extend([
|
||||
"src_auth",
|
||||
"src_user",
|
||||
"src_password",
|
||||
"src_folder",
|
||||
"poll_time",
|
||||
"fetchall",
|
||||
"keep",
|
||||
"protocol",
|
||||
]);
|
||||
values.extend(["?", "?", "?", "?", "?", "?", "?", "?"]);
|
||||
if columns.usessl {
|
||||
names.push("usessl");
|
||||
values.push("?");
|
||||
}
|
||||
if columns.sslcertck {
|
||||
names.push("sslcertck");
|
||||
values.push("?");
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
names.push("sslcertpath");
|
||||
values.push("?");
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
names.push("sslfingerprint");
|
||||
values.push("?");
|
||||
}
|
||||
names.extend(["extra_options", "returned_text", "mda", "date"]);
|
||||
values.extend(["?", "?", "?", "CURRENT_TIMESTAMP"]);
|
||||
if columns.created {
|
||||
names.push("created");
|
||||
values.push("CURRENT_TIMESTAMP");
|
||||
}
|
||||
if columns.modified {
|
||||
names.push("modified");
|
||||
values.push("CURRENT_TIMESTAMP");
|
||||
}
|
||||
if columns.active {
|
||||
names.push("active");
|
||||
values.push("?");
|
||||
}
|
||||
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {table} ({}) VALUES ({})",
|
||||
names.join(","),
|
||||
values.join(",")
|
||||
));
|
||||
let mut query = sqlx::query(&sql);
|
||||
if columns.domain {
|
||||
query = query.bind(domain);
|
||||
}
|
||||
query = query.bind(&mailbox).bind(&input.src_server);
|
||||
if columns.src_port {
|
||||
query = query.bind(input.src_port.unwrap_or(0));
|
||||
}
|
||||
query = query
|
||||
.bind(input.src_auth.as_deref().unwrap_or("password"))
|
||||
.bind(&input.src_user)
|
||||
.bind(&input.src_password)
|
||||
.bind(&input.src_folder)
|
||||
.bind(input.poll_time)
|
||||
.bind(input.fetchall)
|
||||
.bind(input.keep)
|
||||
.bind(input.protocol.as_deref().unwrap_or("POP3"));
|
||||
if columns.usessl {
|
||||
query = query.bind(input.usessl);
|
||||
}
|
||||
if columns.sslcertck {
|
||||
query = query.bind(input.sslcertck);
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
query = query.bind(&input.sslcertpath);
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
query = query.bind(&input.sslfingerprint);
|
||||
}
|
||||
query = query.bind(&input.extra_options).bind("").bind(&input.mda);
|
||||
if columns.active {
|
||||
query = query.bind(input.active);
|
||||
}
|
||||
query.execute(state.db.pool()).await?;
|
||||
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"create_fetchmail",
|
||||
&mailbox,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<FetchmailInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT mailbox FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Fetchmail record not found"))?;
|
||||
let old = row_string(&row, "mailbox");
|
||||
require(&state, &session, &old)?;
|
||||
let mailbox = validation::email(&input.mailbox)?;
|
||||
require(&state, &session, &mailbox)?;
|
||||
assert_mailbox(&state, &mailbox).await?;
|
||||
validate(&state, &input, false)?;
|
||||
let (_, domain) = mailbox.rsplit_once('@').expect("validated email");
|
||||
|
||||
let columns = FetchmailColumns::detect(&state).await?;
|
||||
let mut sets = Vec::new();
|
||||
if columns.domain {
|
||||
sets.push("domain=?");
|
||||
}
|
||||
sets.extend(["mailbox=?", "src_server=?"]);
|
||||
if columns.src_port {
|
||||
sets.push("src_port=?");
|
||||
}
|
||||
sets.extend(["src_auth=?", "src_user=?"]);
|
||||
if !input.src_password.is_empty() {
|
||||
sets.push("src_password=?");
|
||||
}
|
||||
sets.extend([
|
||||
"src_folder=?",
|
||||
"poll_time=?",
|
||||
"fetchall=?",
|
||||
"keep=?",
|
||||
"protocol=?",
|
||||
]);
|
||||
if columns.usessl {
|
||||
sets.push("usessl=?");
|
||||
}
|
||||
if columns.sslcertck {
|
||||
sets.push("sslcertck=?");
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
sets.push("sslcertpath=?");
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
sets.push("sslfingerprint=?");
|
||||
}
|
||||
sets.extend(["extra_options=?", "mda=?"]);
|
||||
if columns.modified {
|
||||
sets.push("modified=CURRENT_TIMESTAMP");
|
||||
}
|
||||
if columns.active {
|
||||
sets.push("active=?");
|
||||
}
|
||||
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("UPDATE {table} SET {} WHERE id=?", sets.join(",")));
|
||||
let mut query = sqlx::query(&sql);
|
||||
if columns.domain {
|
||||
query = query.bind(domain);
|
||||
}
|
||||
query = query.bind(&mailbox).bind(&input.src_server);
|
||||
if columns.src_port {
|
||||
query = query.bind(input.src_port.unwrap_or(0));
|
||||
}
|
||||
query = query
|
||||
.bind(input.src_auth.as_deref().unwrap_or("password"))
|
||||
.bind(&input.src_user);
|
||||
if !input.src_password.is_empty() {
|
||||
query = query.bind(&input.src_password);
|
||||
}
|
||||
query = query
|
||||
.bind(&input.src_folder)
|
||||
.bind(input.poll_time)
|
||||
.bind(input.fetchall)
|
||||
.bind(input.keep)
|
||||
.bind(input.protocol.as_deref().unwrap_or("POP3"));
|
||||
if columns.usessl {
|
||||
query = query.bind(input.usessl);
|
||||
}
|
||||
if columns.sslcertck {
|
||||
query = query.bind(input.sslcertck);
|
||||
}
|
||||
if columns.sslcertpath {
|
||||
query = query.bind(&input.sslcertpath);
|
||||
}
|
||||
if columns.sslfingerprint {
|
||||
query = query.bind(&input.sslfingerprint);
|
||||
}
|
||||
query = query.bind(&input.extra_options).bind(&input.mda);
|
||||
if columns.active {
|
||||
query = query.bind(input.active);
|
||||
}
|
||||
query.bind(id).execute(state.db.pool()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let table = state.db.table(Table::Fetchmail);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT mailbox FROM {table} WHERE id=?"));
|
||||
let row = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Fetchmail record not found"))?;
|
||||
require(&state, &session, &row_string(&row, "mailbox"))?;
|
||||
let sql = state.db.q(&format!("DELETE FROM {table} WHERE id=?"));
|
||||
sqlx::query(&sql).bind(id).execute(state.db.pool()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
fn require(state: &AppState, session: &crate::auth::Session, mailbox: &str) -> AppResult<()> {
|
||||
if !state.config.features.fetchmail {
|
||||
return Err(AppError::forbidden("Fetchmail is disabled"));
|
||||
}
|
||||
let email = validation::email(mailbox)?;
|
||||
if session.role.is_admin() {
|
||||
let (_, domain) = email.rsplit_once('@').expect("validated email");
|
||||
session.require_domain(domain)
|
||||
} else if email == session.username {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Mailbox is outside this account"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(state: &AppState, input: &FetchmailInput, creating: bool) -> AppResult<()> {
|
||||
if input.poll_time < 1 {
|
||||
return Err(AppError::bad_request("poll_time must be positive"));
|
||||
}
|
||||
if input.src_server.trim().is_empty() || input.src_user.trim().is_empty() {
|
||||
return Err(AppError::bad_request("Source server and user are required"));
|
||||
}
|
||||
if creating && input.src_password.is_empty() {
|
||||
return Err(AppError::bad_request("Source password is required"));
|
||||
}
|
||||
if !state.config.features.fetchmail_extra_options
|
||||
&& (!input.extra_options.trim().is_empty() || !input.mda.trim().is_empty())
|
||||
{
|
||||
return Err(AppError::forbidden(
|
||||
"fetchmail extra options/MDA are disabled",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_mailbox(state: &AppState, username: &str) -> AppResult<()> {
|
||||
let table = state.db.table(Table::Mailbox);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {table} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&sql)
|
||||
.bind(username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
Err(AppError::bad_request("Target mailbox does not exist"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map(row: &sqlx::any::AnyRow) -> FetchmailSummary {
|
||||
FetchmailSummary {
|
||||
id: row_i64(row, "id"),
|
||||
domain: row_string(row, "domain"),
|
||||
mailbox: row_string(row, "mailbox"),
|
||||
src_server: row_string(row, "src_server"),
|
||||
src_port: row_opt_i64(row, "src_port"),
|
||||
src_auth: row_opt_string(row, "src_auth"),
|
||||
src_user: row_string(row, "src_user"),
|
||||
src_folder: row_string(row, "src_folder"),
|
||||
poll_time: row_i64(row, "poll_time"),
|
||||
fetchall: row_bool(row, "fetchall"),
|
||||
keep: row_bool(row, "keep"),
|
||||
protocol: row_opt_string(row, "protocol"),
|
||||
usessl: row_bool(row, "usessl"),
|
||||
sslcertck: row_bool(row, "sslcertck"),
|
||||
sslcertpath: row_string(row, "sslcertpath"),
|
||||
sslfingerprint: row_string(row, "sslfingerprint"),
|
||||
extra_options: row_string(row, "extra_options"),
|
||||
returned_text: row_string(row, "returned_text"),
|
||||
mda: row_string(row, "mda"),
|
||||
active: row_bool(row, "active"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::{
|
||||
db::{Table, row_string},
|
||||
error::AppResult,
|
||||
models::LogEntry,
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
#[derive(Deserialize)]
|
||||
pub struct Filter {
|
||||
pub domain: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
Query(f): Query<Filter>,
|
||||
) -> AppResult<Json<Vec<LogEntry>>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
s.require_admin()?;
|
||||
if !state.config.features.logging {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let t = state.db.table(Table::Log);
|
||||
let ts = state.db.cast_text("timestamp");
|
||||
let mut qsql = format!("SELECT {ts} AS timestamp,username,domain,action,data FROM {t}");
|
||||
let mut bind = None;
|
||||
if let Some(raw) = f.domain {
|
||||
let d = validation::domain(&raw)?;
|
||||
s.require_domain(&d)?;
|
||||
qsql.push_str(" WHERE domain=?");
|
||||
bind = Some(d);
|
||||
} else if !s.role.is_global() {
|
||||
let ds: Vec<_> = s
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.cloned()
|
||||
.collect();
|
||||
if ds.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
qsql.push_str(" WHERE domain IN (");
|
||||
qsql.push_str(&vec!["?"; ds.len()].join(","));
|
||||
qsql.push(')');
|
||||
let sql = state.db.q(&(qsql + " ORDER BY timestamp DESC"));
|
||||
let mut q = sqlx::query(&sql);
|
||||
for d in ds {
|
||||
q = q.bind(d);
|
||||
}
|
||||
let rows = q.fetch_all(state.db.pool()).await?;
|
||||
return Ok(Json(
|
||||
rows.iter()
|
||||
.take(f.limit.unwrap_or(200).min(1000))
|
||||
.map(map)
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
qsql.push_str(" ORDER BY timestamp DESC");
|
||||
let sql = state.db.q(&qsql);
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(b) = bind {
|
||||
q = q.bind(b);
|
||||
}
|
||||
let rows = q.fetch_all(state.db.pool()).await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.take(f.limit.unwrap_or(200).min(1000))
|
||||
.map(map)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
fn map(r: &sqlx::any::AnyRow) -> LogEntry {
|
||||
LogEntry {
|
||||
timestamp: row_string(r, "timestamp"),
|
||||
username: row_string(r, "username"),
|
||||
domain: row_string(r, "domain"),
|
||||
action: row_string(r, "action"),
|
||||
data: row_string(r, "data"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use axum::{Json, extract::State, http::HeaderMap};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_string},
|
||||
error::{AppError, AppResult},
|
||||
mailer,
|
||||
models::{BroadcastInput, SendMailInput},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
pub async fn send(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<SendMailInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
if !state.config.features.sendmail {
|
||||
return Err(AppError::forbidden("Send mail is disabled"));
|
||||
}
|
||||
if !session.role.is_global() && !state.config.features.sendmail_all_admins {
|
||||
return Err(AppError::forbidden(
|
||||
"Send mail is restricted to global administrators",
|
||||
));
|
||||
}
|
||||
|
||||
let to = validation::email(&input.to)?;
|
||||
if !session.role.is_global() {
|
||||
let (_, domain) = to.rsplit_once('@').expect("validated email has domain");
|
||||
session.require_domain(domain)?;
|
||||
}
|
||||
|
||||
mailer::send_text(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&to,
|
||||
&input.subject,
|
||||
&input.body,
|
||||
)
|
||||
.await?;
|
||||
let domain = to.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
state
|
||||
.db
|
||||
.log(&state.config, &session.username, domain, "send_mail", &to)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn broadcast(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<BroadcastInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
if !state.config.features.sendmail {
|
||||
return Err(AppError::forbidden("Broadcast mail is disabled"));
|
||||
}
|
||||
if !session.role.is_global() && !state.config.features.sendmail_all_admins {
|
||||
return Err(AppError::forbidden(
|
||||
"Broadcast is restricted to global administrators",
|
||||
));
|
||||
}
|
||||
if input.name.trim().is_empty()
|
||||
|| input.subject.trim().is_empty()
|
||||
|| input.message.trim().is_empty()
|
||||
{
|
||||
return Err(AppError::bad_request(
|
||||
"Sender name, subject and message are required",
|
||||
));
|
||||
}
|
||||
|
||||
let domains = selected_domains(&state, &session, &input.domains).await?;
|
||||
if domains.is_empty() {
|
||||
return Err(AppError::bad_request("Select at least one domain"));
|
||||
}
|
||||
|
||||
let mut recipients = BTreeSet::new();
|
||||
let mailbox_table = state.db.table(Table::Mailbox);
|
||||
let mailbox_sql = state.db.q(&format!(
|
||||
"SELECT username FROM {mailbox_table} WHERE domain=? AND active=? ORDER BY username"
|
||||
));
|
||||
|
||||
for domain in &domains {
|
||||
let rows = sqlx::query(&mailbox_sql)
|
||||
.bind(domain)
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
for row in rows {
|
||||
let address = row_string(&row, "username");
|
||||
if let Ok(address) = validation::email(&address) {
|
||||
recipients.insert(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input.mailboxes_only {
|
||||
let alias_table = state.db.table(Table::Alias);
|
||||
let alias_sql = state.db.q(&format!(
|
||||
"SELECT goto FROM {alias_table} WHERE domain=? AND active=? ORDER BY address"
|
||||
));
|
||||
for domain in &domains {
|
||||
let rows = sqlx::query(&alias_sql)
|
||||
.bind(domain)
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
for row in rows {
|
||||
for raw in row_string(&row, "goto").split(',') {
|
||||
if let Ok(address) = validation::email(raw.trim()) {
|
||||
recipients.insert(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut sent = 0usize;
|
||||
let mut failed = 0usize;
|
||||
for to in recipients {
|
||||
match mailer::send_text_named(
|
||||
&state.config,
|
||||
&session.username,
|
||||
Some(input.name.trim()),
|
||||
&to,
|
||||
&input.subject,
|
||||
&input.message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => sent += 1,
|
||||
Err(error) => {
|
||||
failed += 1;
|
||||
tracing::warn!(%error, %to, "broadcast delivery failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for domain in &domains {
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"broadcast_message",
|
||||
&input.name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": failed == 0,
|
||||
"sent": sent,
|
||||
"failed": failed
|
||||
})))
|
||||
}
|
||||
|
||||
async fn selected_domains(
|
||||
state: &AppState,
|
||||
session: &crate::auth::Session,
|
||||
requested: &[String],
|
||||
) -> AppResult<Vec<String>> {
|
||||
if !requested.is_empty() {
|
||||
let mut domains = Vec::with_capacity(requested.len());
|
||||
for raw in requested {
|
||||
let domain = validation::domain(raw)?;
|
||||
session.require_domain(&domain)?;
|
||||
if !domains.contains(&domain) {
|
||||
domains.push(domain);
|
||||
}
|
||||
}
|
||||
return Ok(domains);
|
||||
}
|
||||
|
||||
if !session.role.is_global() {
|
||||
return Ok(session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|domain| domain.as_str() != "ALL")
|
||||
.cloned()
|
||||
.collect());
|
||||
}
|
||||
|
||||
let table = state.db.table(Table::Domain);
|
||||
let sql = state.db.q(&format!(
|
||||
"SELECT domain FROM {table} WHERE domain<>? AND active=? ORDER BY domain"
|
||||
));
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind("ALL")
|
||||
.bind(true)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(rows.iter().map(|row| row_string(row, "domain")).collect())
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_opt_bool, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
hooks, mailer,
|
||||
models::{AccountKind, MailboxInput, MailboxSummary},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Filter {
|
||||
pub domain: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
Query(filter): Query<Filter>,
|
||||
) -> AppResult<Json<Vec<MailboxSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let vacation = state.db.table(Table::Vacation);
|
||||
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Mailbox, "password_expiry")
|
||||
.await?;
|
||||
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||
let has_created = state.db.has_column(Table::Mailbox, "created").await?;
|
||||
let has_modified = state.db.has_column(Table::Mailbox, "modified").await?;
|
||||
let active = state.db.flag("m.active", "active");
|
||||
let smtp = if has_smtp {
|
||||
state.db.flag("m.smtp_active", "smtp_active")
|
||||
} else {
|
||||
"NULL AS smtp_active".into()
|
||||
};
|
||||
let expiry = if has_expiry {
|
||||
format!(
|
||||
"{} AS password_expiry",
|
||||
state.db.cast_text("m.password_expiry")
|
||||
)
|
||||
} else {
|
||||
"NULL AS password_expiry".into()
|
||||
};
|
||||
let phone = if has_phone { "m.phone" } else { "'' AS phone" };
|
||||
let email = if has_email {
|
||||
"m.email_other"
|
||||
} else {
|
||||
"'' AS email_other"
|
||||
};
|
||||
let created = if has_created {
|
||||
format!("{} AS created", state.db.cast_text("m.created"))
|
||||
} else {
|
||||
"NULL AS created".into()
|
||||
};
|
||||
let modified = if has_modified {
|
||||
format!("{} AS modified", state.db.cast_text("m.modified"))
|
||||
} else {
|
||||
"NULL AS modified".into()
|
||||
};
|
||||
let vacation_expr = if state.db.has_table(Table::Vacation).await {
|
||||
state.db.flag(
|
||||
"CASE WHEN v.email IS NULL THEN 0 ELSE v.active END",
|
||||
"vacation_active",
|
||||
)
|
||||
} else {
|
||||
"0 AS vacation_active".into()
|
||||
};
|
||||
let vacation_join = if state.db.has_table(Table::Vacation).await {
|
||||
format!("LEFT JOIN {vacation} v ON v.email=m.username")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut used_expr = "NULL AS used_quota_bytes".to_string();
|
||||
let mut used_join = String::new();
|
||||
if state.config.features.used_quotas {
|
||||
let qt = if state.config.features.new_quota_table {
|
||||
Table::Quota2
|
||||
} else {
|
||||
Table::Quota
|
||||
};
|
||||
if state.db.has_table(qt).await {
|
||||
let q = state.db.table(qt);
|
||||
if matches!(qt, Table::Quota2) {
|
||||
used_expr = "q.bytes AS used_quota_bytes".into();
|
||||
used_join = format!("LEFT JOIN {q} q ON q.username=m.username");
|
||||
} else {
|
||||
used_expr = "q.current AS used_quota_bytes".into();
|
||||
used_join =
|
||||
format!("LEFT JOIN {q} q ON q.username=m.username AND q.path='quota/storage'");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut sql = format!(
|
||||
"SELECT m.username,m.name,m.maildir,m.quota,m.local_part,m.domain,{active},{smtp},{expiry},{phone},{email},{created},{modified},{used_expr},{vacation_expr} FROM {mailbox} m {vacation_join} {used_join} WHERE 1=1"
|
||||
);
|
||||
let mut binds = Vec::new();
|
||||
if session.kind == AccountKind::User {
|
||||
sql.push_str(" AND m.username=?");
|
||||
binds.push(session.username.clone());
|
||||
} else if let Some(raw) = filter.domain {
|
||||
let d = validation::domain(&raw)?;
|
||||
session.require_domain(&d)?;
|
||||
sql.push_str(" AND m.domain=?");
|
||||
binds.push(d);
|
||||
} else if !session.role.is_global() {
|
||||
let ds: Vec<_> = session
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.cloned()
|
||||
.collect();
|
||||
if ds.is_empty() {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
sql.push_str(" AND m.domain IN (");
|
||||
sql.push_str(&vec!["?"; ds.len()].join(","));
|
||||
sql.push(')');
|
||||
binds.extend(ds);
|
||||
}
|
||||
sql.push_str(" ORDER BY m.username");
|
||||
let sql = state.db.q(&sql);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for b in binds {
|
||||
q = q.bind(b);
|
||||
}
|
||||
let rows = q.fetch_all(state.db.pool()).await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| MailboxSummary {
|
||||
username: row_string(r, "username"),
|
||||
name: row_string(r, "name"),
|
||||
maildir: row_string(r, "maildir"),
|
||||
quota: row_i64(r, "quota"),
|
||||
local_part: row_string(r, "local_part"),
|
||||
domain: row_string(r, "domain"),
|
||||
active: row_bool(r, "active"),
|
||||
smtp_active: row_opt_bool(r, "smtp_active"),
|
||||
password_expiry: row_opt_string(r, "password_expiry"),
|
||||
phone: row_string(r, "phone"),
|
||||
email_other: row_string(r, "email_other"),
|
||||
used_quota_bytes: crate::db::row_opt_i64(r, "used_quota_bytes"),
|
||||
vacation_active: row_bool(r, "vacation_active"),
|
||||
created: row_opt_string(r, "created"),
|
||||
modified: row_opt_string(r, "modified"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<MailboxInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
let domain = validation::resolvable_domain(&state.config, &input.domain).await?;
|
||||
session.require_domain(&domain)?;
|
||||
let local = if let Some(u) = input.username.as_deref() {
|
||||
let e = validation::resolvable_email(&state.config, u).await?;
|
||||
let (l, d) = e.rsplit_once('@').unwrap();
|
||||
if d != domain {
|
||||
return Err(AppError::bad_request(
|
||||
"Mailbox domain does not match domain",
|
||||
));
|
||||
}
|
||||
l.to_string()
|
||||
} else {
|
||||
validation::local_part(
|
||||
input
|
||||
.local_part
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::bad_request("local_part or username is required"))?,
|
||||
)?
|
||||
};
|
||||
let username = format!("{local}@{domain}");
|
||||
let password = input
|
||||
.password
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::bad_request("Password is required"))?;
|
||||
enforce_mailbox_limit(&state, &domain).await?;
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let check = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&check)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict("Mailbox already exists"));
|
||||
}
|
||||
let check = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {alias} WHERE address=? LIMIT 1"));
|
||||
if sqlx::query(&check)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::conflict(
|
||||
"An alias with this address already exists",
|
||||
));
|
||||
}
|
||||
let quota_mb = mailbox_quota(&state, &domain, input.quota, None).await?;
|
||||
let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier);
|
||||
let maildir = maildir(&state.config, &domain, &local, &username)?;
|
||||
let hash = passwords::hash(&state.config, password, &username).await?;
|
||||
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||
let has_expiry = state
|
||||
.db
|
||||
.has_column(Table::Mailbox, "password_expiry")
|
||||
.await?;
|
||||
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||
let expiry = if has_expiry {
|
||||
password_expiry_days(&state, &domain).await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if has_email && !input.email_other.trim().is_empty() {
|
||||
let _ = validation::email(&input.email_other)?;
|
||||
}
|
||||
let mut cols = vec![
|
||||
"username",
|
||||
"password",
|
||||
"name",
|
||||
"maildir",
|
||||
"quota",
|
||||
"local_part",
|
||||
"domain",
|
||||
"created",
|
||||
"modified",
|
||||
"active",
|
||||
];
|
||||
let mut vals = vec![
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"?",
|
||||
"CURRENT_TIMESTAMP",
|
||||
"CURRENT_TIMESTAMP",
|
||||
"?",
|
||||
];
|
||||
if has_smtp {
|
||||
cols.push("smtp_active");
|
||||
vals.push("?");
|
||||
}
|
||||
if has_phone {
|
||||
cols.push("phone");
|
||||
vals.push("?");
|
||||
}
|
||||
if has_email {
|
||||
cols.push("email_other");
|
||||
vals.push("?");
|
||||
}
|
||||
if has_expiry {
|
||||
cols.push("password_expiry");
|
||||
vals.push(if expiry > 0 {
|
||||
"CURRENT_TIMESTAMP"
|
||||
} else {
|
||||
"'9999-12-31 23:59:59'"
|
||||
});
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"INSERT INTO {mailbox} ({}) VALUES ({})",
|
||||
cols.join(","),
|
||||
vals.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(hash)
|
||||
.bind(&input.name)
|
||||
.bind(&maildir)
|
||||
.bind(quota)
|
||||
.bind(&local)
|
||||
.bind(&domain)
|
||||
.bind(input.active);
|
||||
if has_smtp {
|
||||
q = q.bind(input.smtp_active.unwrap_or(true));
|
||||
}
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_email {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
q.execute(state.db.pool()).await?;
|
||||
if has_expiry {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||
}
|
||||
let has_desc = state.db.has_column(Table::Alias, "description").await?;
|
||||
let sql = if has_desc {
|
||||
state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,description,created,modified,active) VALUES (?,?,?,'',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||
} else {
|
||||
state.db.q(&format!("INSERT INTO {alias} (address,goto,domain,created,modified,active) VALUES (?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||
};
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.bind(&domain)
|
||||
.bind(input.active)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let quota_text = quota.to_string();
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postcreation,
|
||||
&[
|
||||
("username", &username),
|
||||
("domain", &domain),
|
||||
("maildir", &maildir),
|
||||
("quota", "a_text),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let subdirs_created = match create_mailbox_subdirs(&state.config, &username).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, %username, "mailbox created but automatic subfolder creation failed");
|
||||
false
|
||||
}
|
||||
};
|
||||
if input.send_welcome {
|
||||
let subject = "Welcome";
|
||||
if let Err(e) = mailer::send_text(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&username,
|
||||
subject,
|
||||
&state.config.defaults.welcome_text,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%e,"welcome mail failed after mailbox creation");
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
&domain,
|
||||
"create_mailbox",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"username":username,"maildir":maildir,"quota_bytes":quota,"subdirs_created":subdirs_created}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<MailboxInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
let (_, domain) = username.rsplit_once('@').unwrap();
|
||||
session.require_domain(domain)?;
|
||||
if let Some(u) = input.username.as_deref()
|
||||
&& validation::email(u)? != username
|
||||
{
|
||||
return Err(AppError::bad_request("Renaming mailboxes is not supported"));
|
||||
}
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let exists = state
|
||||
.db
|
||||
.q(&format!("SELECT 1 FROM {mailbox} WHERE username=? LIMIT 1"));
|
||||
if sqlx::query(&exists)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::not_found("Mailbox not found"));
|
||||
}
|
||||
let quota_mb = mailbox_quota(&state, domain, input.quota, Some(&username)).await?;
|
||||
let quota = quota_mb.saturating_mul(state.config.defaults.quota_multiplier);
|
||||
let has_smtp = state.db.has_column(Table::Mailbox, "smtp_active").await?;
|
||||
let has_phone = state.db.has_column(Table::Mailbox, "phone").await?;
|
||||
let has_email = state.db.has_column(Table::Mailbox, "email_other").await?;
|
||||
if has_email && !input.email_other.trim().is_empty() {
|
||||
let _ = validation::email(&input.email_other)?;
|
||||
}
|
||||
let mut sets = vec![
|
||||
"name=?",
|
||||
"quota=?",
|
||||
"active=?",
|
||||
"modified=CURRENT_TIMESTAMP",
|
||||
];
|
||||
let new_hash = if let Some(p) = input.password.as_deref() {
|
||||
Some(passwords::hash(&state.config, p, &username).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let password_changed = new_hash.is_some();
|
||||
if has_smtp {
|
||||
sets.push("smtp_active=?");
|
||||
}
|
||||
if has_phone {
|
||||
sets.push("phone=?");
|
||||
}
|
||||
if has_email {
|
||||
sets.push("email_other=?");
|
||||
}
|
||||
if new_hash.is_some() {
|
||||
sets.push("password=?");
|
||||
}
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {mailbox} SET {} WHERE username=?",
|
||||
sets.join(",")
|
||||
));
|
||||
let mut q = sqlx::query(&sql)
|
||||
.bind(&input.name)
|
||||
.bind(quota)
|
||||
.bind(input.active);
|
||||
if has_smtp {
|
||||
q = q.bind(input.smtp_active.unwrap_or(true));
|
||||
}
|
||||
if has_phone {
|
||||
q = q.bind(&input.phone);
|
||||
}
|
||||
if has_email {
|
||||
q = q.bind(&input.email_other);
|
||||
}
|
||||
if let Some(hash) = new_hash {
|
||||
q = q.bind(hash);
|
||||
}
|
||||
q.bind(&username).execute(state.db.pool()).await?;
|
||||
if password_changed {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &username).await?;
|
||||
}
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let sql = state.db.q(&format!(
|
||||
"UPDATE {alias} SET active=?,modified=CURRENT_TIMESTAMP WHERE address=? AND goto=?"
|
||||
));
|
||||
sqlx::query(&sql)
|
||||
.bind(input.active)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let local = username.split('@').next().unwrap_or("");
|
||||
let md = maildir(&state.config, domain, local, &username)?;
|
||||
let quota_text = quota.to_string();
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postedit,
|
||||
&[
|
||||
("username", &username),
|
||||
("domain", domain),
|
||||
("maildir", &md),
|
||||
("quota", "a_text),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"edit_mailbox",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path_user): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
let username = validation::email(&path_user)?;
|
||||
let (_, domain) = username.rsplit_once('@').unwrap();
|
||||
session.require_domain(domain)?;
|
||||
let mailbox = state.db.table(Table::Mailbox);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT maildir FROM {mailbox} WHERE username=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Mailbox not found"))?;
|
||||
let maildir = row_string(&row, "maildir");
|
||||
for (table, col) in [
|
||||
(Table::Fetchmail, "mailbox"),
|
||||
(Table::Vacation, "email"),
|
||||
(Table::Quota, "username"),
|
||||
(Table::Quota2, "username"),
|
||||
(Table::MailboxAppPassword, "username"),
|
||||
(Table::TotpException, "username"),
|
||||
] {
|
||||
if state.db.has_table(table).await {
|
||||
let t = state.db.table(table);
|
||||
let sql = state.db.q(&format!("DELETE FROM {t} WHERE {col}=?"));
|
||||
let _ = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if state.db.has_table(Table::VacationNotification).await {
|
||||
let t = state.db.table(Table::VacationNotification);
|
||||
let sql = state.db.q(&format!(
|
||||
"DELETE FROM {t} WHERE on_vacation=? OR notified=?"
|
||||
));
|
||||
let _ = sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
let alias = state.db.table(Table::Alias);
|
||||
let sql = state.db.q(&format!("DELETE FROM {alias} WHERE address=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("DELETE FROM {mailbox} WHERE username=?"));
|
||||
sqlx::query(&sql)
|
||||
.bind(&username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postdeletion,
|
||||
&[
|
||||
("username", &username),
|
||||
("domain", domain),
|
||||
("maildir", &maildir),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"delete_mailbox",
|
||||
&username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn maildir(
|
||||
config: &crate::config::AppConfig,
|
||||
domain: &str,
|
||||
local: &str,
|
||||
username: &str,
|
||||
) -> AppResult<String> {
|
||||
let mut value = if !config.defaults.maildir_template.trim().is_empty() {
|
||||
config
|
||||
.defaults
|
||||
.maildir_template
|
||||
.replace("{domain}", domain)
|
||||
.replace("{local_part}", local)
|
||||
.replace("{username}", username)
|
||||
} else if config.defaults.domain_path {
|
||||
if config.defaults.domain_in_mailbox {
|
||||
format!("{domain}/{username}/")
|
||||
} else {
|
||||
format!("{domain}/{local}/")
|
||||
}
|
||||
} else {
|
||||
format!("{username}/")
|
||||
};
|
||||
value = value.trim().to_string();
|
||||
if value.is_empty() || value.starts_with('/') || value.contains("..") || value.contains('\0') {
|
||||
return Err(AppError::config("generated maildir path is unsafe"));
|
||||
}
|
||||
if !value.ends_with('/') {
|
||||
value.push('/');
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn create_mailbox_subdirs(
|
||||
config: &crate::config::AppConfig,
|
||||
username: &str,
|
||||
) -> AppResult<bool> {
|
||||
if config.defaults.create_mailbox_subdirs.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let program = config
|
||||
.password
|
||||
.doveadm
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or_else(|| AppError::config("password.doveadm is empty"))?;
|
||||
for subdir in &config.defaults.create_mailbox_subdirs {
|
||||
let mailbox = format!(
|
||||
"{}{}",
|
||||
config.defaults.create_mailbox_subdirs_prefix,
|
||||
subdir.trim()
|
||||
);
|
||||
let output = Command::new(program)
|
||||
.args(["mailbox", "create", "-u", username, "-s", &mailbox])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Err(AppError::config(format!(
|
||||
"doveadm mailbox create failed for {mailbox}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
async fn enforce_mailbox_limit(state: &AppState, domain: &str) -> AppResult<()> {
|
||||
let d = state.db.table(Table::Domain);
|
||||
let m = state.db.table(Table::Mailbox);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT mailboxes FROM {d} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Domain not found"))?;
|
||||
let limit = row_i64(&row, "mailboxes");
|
||||
if limit < 0 {
|
||||
return Err(AppError::forbidden(
|
||||
"Mailboxes are disabled for this domain",
|
||||
));
|
||||
}
|
||||
if limit == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT COUNT(*) AS n FROM {m} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
if row_i64(&row, "n") >= limit {
|
||||
return Err(AppError::conflict("Domain mailbox limit reached"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn mailbox_quota(
|
||||
state: &AppState,
|
||||
domain: &str,
|
||||
requested: Option<i64>,
|
||||
exclude: Option<&str>,
|
||||
) -> AppResult<i64> {
|
||||
if !state.config.features.quota {
|
||||
return Ok(0);
|
||||
}
|
||||
let d = state.db.table(Table::Domain);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT maxquota,quota FROM {d} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Domain not found"))?;
|
||||
let max = row_i64(&row, "maxquota");
|
||||
let q = requested.unwrap_or(max.max(0));
|
||||
if q < 0 {
|
||||
return Err(AppError::bad_request("Quota cannot be negative"));
|
||||
}
|
||||
if max > 0 && q > max {
|
||||
return Err(AppError::bad_request(
|
||||
"Mailbox quota exceeds domain maxquota",
|
||||
));
|
||||
}
|
||||
if state.config.features.domain_quota {
|
||||
let domain_limit = row_i64(&row, "quota");
|
||||
if domain_limit > 0 {
|
||||
let m = state.db.table(Table::Mailbox);
|
||||
let (sql, bind_exclude) = if exclude.is_some() {
|
||||
(state.db.q(&format!("SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=? AND username<>?")),true)
|
||||
} else {
|
||||
(
|
||||
state.db.q(&format!(
|
||||
"SELECT COALESCE(SUM(quota),0) AS total FROM {m} WHERE domain=?"
|
||||
)),
|
||||
false,
|
||||
)
|
||||
};
|
||||
let mut query = sqlx::query(&sql).bind(domain);
|
||||
if bind_exclude {
|
||||
query = query.bind(exclude.unwrap());
|
||||
}
|
||||
let r = query.fetch_one(state.db.pool()).await?;
|
||||
let current = row_i64(&r, "total") / state.config.defaults.quota_multiplier.max(1);
|
||||
if current.saturating_add(q) > domain_limit {
|
||||
return Err(AppError::bad_request("Domain quota would be exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
async fn password_expiry_days(state: &AppState, domain: &str) -> AppResult<i64> {
|
||||
if !state.config.features.password_expiration {
|
||||
return Ok(0);
|
||||
}
|
||||
let d = state.db.table(Table::Domain);
|
||||
let sql = state
|
||||
.db
|
||||
.q(&format!("SELECT password_expiry FROM {d} WHERE domain=?"));
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(domain)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?;
|
||||
Ok(row
|
||||
.as_ref()
|
||||
.map(|r| row_i64(r, "password_expiry"))
|
||||
.unwrap_or(0))
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
http::HeaderMap,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
auth::{Session, verify_csrf},
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub mod admins;
|
||||
pub mod aliases;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod dkim;
|
||||
pub mod domains;
|
||||
pub mod fetchmail;
|
||||
pub mod logs;
|
||||
pub mod mail;
|
||||
pub mod mailboxes;
|
||||
pub mod preferences;
|
||||
pub mod rspamd_dkim;
|
||||
pub mod security;
|
||||
pub mod sieve;
|
||||
pub mod system;
|
||||
pub mod vacation;
|
||||
|
||||
pub fn router(state: &AppState) -> Router<Arc<AppState>> {
|
||||
let limit = state
|
||||
.config
|
||||
.security
|
||||
.request_body_limit_mb
|
||||
.saturating_mul(1024 * 1024);
|
||||
let router = Router::new()
|
||||
.route("/health", get(system::health))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/mfa", post(auth::mfa))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route("/auth/recovery/request", post(auth::recovery_request))
|
||||
.route("/auth/recovery/reset", post(auth::recovery_reset))
|
||||
.route("/overview", get(system::overview))
|
||||
.route("/admins", get(admins::list).post(admins::create))
|
||||
.route(
|
||||
"/admins/{username}",
|
||||
put(admins::update).delete(admins::remove),
|
||||
)
|
||||
.route("/domains", get(domains::list).post(domains::create))
|
||||
.route(
|
||||
"/domains/{domain}",
|
||||
put(domains::update).delete(domains::remove),
|
||||
)
|
||||
.route("/domains/{domain}/dns-refresh", post(domains::dns_refresh))
|
||||
.route("/mailboxes", get(mailboxes::list).post(mailboxes::create))
|
||||
.route(
|
||||
"/mailboxes/{username}",
|
||||
put(mailboxes::update).delete(mailboxes::remove),
|
||||
)
|
||||
.route("/aliases", get(aliases::list).post(aliases::create))
|
||||
.route(
|
||||
"/aliases/delete-inactive/{domain}",
|
||||
post(aliases::delete_inactive),
|
||||
)
|
||||
.route(
|
||||
"/aliases/{address}",
|
||||
put(aliases::update).delete(aliases::remove),
|
||||
)
|
||||
.route(
|
||||
"/alias-domains",
|
||||
get(aliases::list_domains).post(aliases::create_domain),
|
||||
)
|
||||
.route(
|
||||
"/alias-domains/{domain}",
|
||||
put(aliases::update_domain).delete(aliases::remove_domain),
|
||||
)
|
||||
.route("/fetchmail", get(fetchmail::list).post(fetchmail::create))
|
||||
.route(
|
||||
"/fetchmail/{id}",
|
||||
put(fetchmail::update).delete(fetchmail::remove),
|
||||
)
|
||||
.route(
|
||||
"/vacation/{username}",
|
||||
get(vacation::get)
|
||||
.put(vacation::set)
|
||||
.delete(vacation::remove),
|
||||
)
|
||||
.route("/logs", get(logs::list))
|
||||
.route("/dkim/records", get(dkim::dns_records))
|
||||
.route("/dkim/keys", get(dkim::list_keys).post(dkim::create_key))
|
||||
.route(
|
||||
"/dkim/keys/{id}",
|
||||
put(dkim::update_key).delete(dkim::remove_key),
|
||||
)
|
||||
.route(
|
||||
"/dkim/signing",
|
||||
get(dkim::list_signing).post(dkim::create_signing),
|
||||
)
|
||||
.route(
|
||||
"/dkim/signing/{id}",
|
||||
put(dkim::update_signing).delete(dkim::remove_signing),
|
||||
)
|
||||
.route("/rspamd-dkim", get(rspamd_dkim::list))
|
||||
.route("/rspamd-dkim/configure", post(rspamd_dkim::configure))
|
||||
.route("/rspamd-dkim/generate", post(rspamd_dkim::generate))
|
||||
.route("/rspamd-dkim/adopt", post(rspamd_dkim::adopt))
|
||||
.route(
|
||||
"/rspamd-dkim/import-legacy",
|
||||
post(rspamd_dkim::import_legacy_domain_blocks),
|
||||
)
|
||||
.route("/rspamd-dkim/test", get(rspamd_dkim::test_config))
|
||||
.route("/rspamd-dkim/reload", post(rspamd_dkim::reload))
|
||||
.route("/rspamd-dkim/{domain}", delete(rspamd_dkim::unmap))
|
||||
.route("/rspamd-dkim/{domain}/purge", post(rspamd_dkim::purge))
|
||||
.route("/security/password", post(security::change_password))
|
||||
.route("/security/totp/setup", post(security::totp_setup))
|
||||
.route("/security/totp/confirm", post(security::totp_confirm))
|
||||
.route("/security/totp", delete(security::totp_disable))
|
||||
.route(
|
||||
"/security/totp-exceptions",
|
||||
get(security::list_exceptions).post(security::add_exception),
|
||||
)
|
||||
.route(
|
||||
"/security/totp-exceptions/{id}",
|
||||
delete(security::remove_exception),
|
||||
)
|
||||
.route(
|
||||
"/security/app-passwords",
|
||||
get(security::list_app_passwords).post(security::add_app_password),
|
||||
)
|
||||
.route(
|
||||
"/security/app-passwords/{id}",
|
||||
delete(security::remove_app_password),
|
||||
)
|
||||
.route("/mail/send", post(mail::send))
|
||||
.route("/mail/broadcast", post(mail::broadcast))
|
||||
.route("/backup", get(backup::download))
|
||||
.route("/preferences", get(preferences::list))
|
||||
.route(
|
||||
"/preferences/{key}",
|
||||
put(preferences::set).delete(preferences::remove),
|
||||
)
|
||||
.route("/system/schema", get(system::schema))
|
||||
.route("/system/features", get(system::features));
|
||||
|
||||
let router = if state.config.sieve.enabled {
|
||||
router
|
||||
.route("/sieve/status", get(sieve::status))
|
||||
.route("/sieve/scripts", get(sieve::list_scripts))
|
||||
.route(
|
||||
"/sieve/scripts/{name}",
|
||||
get(sieve::get_script)
|
||||
.put(sieve::put_script)
|
||||
.delete(sieve::delete_script),
|
||||
)
|
||||
.route(
|
||||
"/sieve/scripts/{name}/activate",
|
||||
post(sieve::activate_script),
|
||||
)
|
||||
.route("/sieve/scripts/{name}/rename", post(sieve::rename_script))
|
||||
.route("/sieve/deactivate", post(sieve::deactivate_script))
|
||||
.route("/sieve/check", post(sieve::check_script))
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
router.layer(DefaultBodyLimit::max(limit))
|
||||
}
|
||||
|
||||
pub fn session(state: &AppState, jar: &CookieJar) -> AppResult<Session> {
|
||||
let token = jar
|
||||
.get("pa_session")
|
||||
.map(|c| c.value())
|
||||
.ok_or_else(|| AppError::unauthorized("Login required"))?;
|
||||
state
|
||||
.auth
|
||||
.get_session(token)
|
||||
.ok_or_else(|| AppError::unauthorized("Session expired"))
|
||||
}
|
||||
|
||||
pub fn require_writable(state: &AppState) -> AppResult<()> {
|
||||
if state.config.database.read_only {
|
||||
return Err(AppError::forbidden(
|
||||
"Database is read-only in this development session",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mutation_session(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
headers: &HeaderMap,
|
||||
) -> AppResult<Session> {
|
||||
let session = session(state, jar)?;
|
||||
verify_csrf(
|
||||
&session,
|
||||
headers.get("x-csrf-token").and_then(|v| v.to_str().ok()),
|
||||
)?;
|
||||
require_writable(state)?;
|
||||
Ok(session)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
db::{Table, row_string},
|
||||
error::{AppError, AppResult},
|
||||
routes,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Preference {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PreferenceInput {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<Preference>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
session.require_admin()?;
|
||||
if !state.db.has_table(Table::AdminPreferences).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let table = state.db.table(Table::AdminPreferences);
|
||||
let sql = state.db.q(&format!(
|
||||
"SELECT pref_key,pref_value FROM {table} WHERE username=? ORDER BY pref_key"
|
||||
));
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(&session.username)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|row| Preference {
|
||||
key: row_string(row, "pref_key"),
|
||||
value: row_string(row, "pref_value"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<PreferenceInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
validate_key(&key)?;
|
||||
if input.value.len() > 2048 {
|
||||
return Err(AppError::bad_request("Preference value is too long"));
|
||||
}
|
||||
if !state.db.has_table(Table::AdminPreferences).await {
|
||||
return Err(AppError::conflict(
|
||||
"admin_preferences table is not available",
|
||||
));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.set_admin_pref(&session.username, &key, &input.value)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
session.require_admin()?;
|
||||
validate_key(&key)?;
|
||||
if state.db.has_table(Table::AdminPreferences).await {
|
||||
state.db.delete_admin_pref(&session.username, &key).await?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
fn validate_key(key: &str) -> AppResult<()> {
|
||||
if key.is_empty()
|
||||
|| key.len() > 64
|
||||
|| !key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||
{
|
||||
return Err(AppError::bad_request("Invalid preference key"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,420 @@
|
||||
use crate::{
|
||||
auth,
|
||||
db::{Table, row_i64, row_opt_string, row_string},
|
||||
error::{AppError, AppResult},
|
||||
hooks,
|
||||
models::{
|
||||
AccountKind, AppPasswordInput, AppPasswordSummary, PasswordChangeInput, TotpExceptionInput,
|
||||
TotpExceptionSummary,
|
||||
},
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use rand::RngCore;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TotpConfirm {
|
||||
pub secret: String,
|
||||
pub code: String,
|
||||
pub current_password: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct TotpDisable {
|
||||
pub current_password: String,
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<PasswordChangeInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let (table, col) = account_table(&s);
|
||||
let t = state.db.table(table);
|
||||
let q = state
|
||||
.db
|
||||
.q(&format!("SELECT password FROM {t} WHERE {col}=?"));
|
||||
let r = sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||
if !passwords::verify(
|
||||
&state.config,
|
||||
&input.current_password,
|
||||
&row_string(&r, "password"),
|
||||
&s.username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(AppError::forbidden("Current password is incorrect"));
|
||||
}
|
||||
let hash = passwords::hash(&state.config, &input.new_password, &s.username).await?;
|
||||
let q = state.db.q(&format!(
|
||||
"UPDATE {t} SET password=?,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(hash)
|
||||
.bind(&s.username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
if s.kind == AccountKind::User {
|
||||
passwords::refresh_mailbox_expiry(&state.config, &state.db, &s.username).await?;
|
||||
}
|
||||
let domain = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
if s.kind == AccountKind::User {
|
||||
let hook_stdin = format!("{}\0{}\0", input.current_password, input.new_password);
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_postpassword,
|
||||
&[("username", &s.username), ("domain", domain)],
|
||||
Some(hook_stdin.as_bytes()),
|
||||
)
|
||||
.await?;
|
||||
if let Some(cookie) = jar.get("pa_session") {
|
||||
state
|
||||
.auth
|
||||
.update_mail_password(cookie.value(), input.new_password.clone());
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&s.username,
|
||||
domain,
|
||||
"edit_password",
|
||||
&s.username,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn totp_setup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if !state.config.features.totp {
|
||||
return Err(AppError::forbidden("TOTP is disabled"));
|
||||
}
|
||||
let (table, _) = account_table(&s);
|
||||
if !state.db.has_column(table, "totp_secret").await? {
|
||||
return Err(AppError::conflict("Database schema has no TOTP field"));
|
||||
}
|
||||
let mut bytes = [0u8; 20];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
let secret = BASE32_NOPAD.encode(&bytes);
|
||||
let label = urlencoding::encode(&s.username);
|
||||
let issuer = urlencoding::encode("PostfixAdmin-RS");
|
||||
let uri = format!(
|
||||
"otpauth://totp/{issuer}:{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30"
|
||||
);
|
||||
Ok(Json(serde_json::json!({"secret":secret,"otpauth_uri":uri})))
|
||||
}
|
||||
|
||||
pub async fn totp_confirm(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TotpConfirm>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if !state.config.features.totp {
|
||||
return Err(AppError::forbidden("TOTP is disabled"));
|
||||
}
|
||||
verify_current(&state, &s, &input.current_password).await?;
|
||||
if !auth::verify_totp(&input.secret, &input.code) {
|
||||
return Err(AppError::bad_request("Invalid TOTP code"));
|
||||
}
|
||||
let (table, col) = account_table(&s);
|
||||
let t = state.db.table(table);
|
||||
if !state.db.has_column(table, "totp_secret").await? {
|
||||
return Err(AppError::conflict("Database schema has no TOTP field"));
|
||||
}
|
||||
let q = state.db.q(&format!(
|
||||
"UPDATE {t} SET totp_secret=?,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(input.secret.trim().to_ascii_uppercase())
|
||||
.bind(&s.username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let d = s.username.rsplit_once('@').map(|(_, d)| d).unwrap_or("");
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_totp_change,
|
||||
&[("username", &s.username), ("domain", d)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn totp_disable(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TotpDisable>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
verify_current(&state, &s, &input.current_password).await?;
|
||||
let (table, col) = account_table(&s);
|
||||
let t = state.db.table(table);
|
||||
if state.db.has_column(table, "totp_secret").await? {
|
||||
let q = state.db.q(&format!(
|
||||
"UPDATE {t} SET totp_secret=NULL,modified=CURRENT_TIMESTAMP WHERE {col}=?"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_exceptions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<TotpExceptionSummary>>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
if !state.config.features.totp || !state.db.has_table(Table::TotpException).await {
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let t = state.db.table(Table::TotpException);
|
||||
let rows = if s.role.is_global() {
|
||||
sqlx::query(&format!(
|
||||
"SELECT id,ip,username,description FROM {t} ORDER BY id"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
} else {
|
||||
let q=state.db.q(&format!("SELECT id,ip,username,description FROM {t} WHERE username=? OR username IS NULL ORDER BY id"));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
};
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| TotpExceptionSummary {
|
||||
id: row_i64(r, "id"),
|
||||
ip: row_string(r, "ip"),
|
||||
username: row_opt_string(r, "username"),
|
||||
description: row_string(r, "description"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn add_exception(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<TotpExceptionInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if !state.config.features.totp {
|
||||
return Err(AppError::forbidden("TOTP is disabled"));
|
||||
}
|
||||
let ip: std::net::IpAddr = input
|
||||
.ip
|
||||
.parse()
|
||||
.map_err(|_| AppError::bad_request("Invalid IP address"))?;
|
||||
let username = if s.role.is_global() {
|
||||
input.username.as_deref().unwrap_or(&s.username).to_string()
|
||||
} else {
|
||||
s.username.clone()
|
||||
};
|
||||
let t = state.db.table(Table::TotpException);
|
||||
let q = state.db.q(&format!(
|
||||
"INSERT INTO {t} (ip,username,description) VALUES (?,?,?)"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(ip.to_string())
|
||||
.bind(&username)
|
||||
.bind(&input.description)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
let ip_text = ip.to_string();
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_totp_exception_add,
|
||||
&[("username", &username), ("ip", &ip_text)],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_exception(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let t = state.db.table(Table::TotpException);
|
||||
let get = state
|
||||
.db
|
||||
.q(&format!("SELECT username,ip FROM {t} WHERE id=?"));
|
||||
let r = sqlx::query(&get)
|
||||
.bind(id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("TOTP exception not found"))?;
|
||||
let owner = row_opt_string(&r, "username");
|
||||
if !s.role.is_global() && owner.as_deref() != Some(&s.username) {
|
||||
return Err(AppError::forbidden(
|
||||
"TOTP exception is outside this account",
|
||||
));
|
||||
}
|
||||
let q = state.db.q(&format!("DELETE FROM {t} WHERE id=?"));
|
||||
sqlx::query(&q).bind(id).execute(state.db.pool()).await?;
|
||||
let ip_text = row_string(&r, "ip");
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_totp_exception_delete,
|
||||
&[
|
||||
("username", owner.as_deref().unwrap_or("")),
|
||||
("ip", &ip_text),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn list_app_passwords(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Vec<AppPasswordSummary>>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
if !state.config.features.app_passwords || !state.db.has_table(Table::MailboxAppPassword).await
|
||||
{
|
||||
return Ok(Json(Vec::new()));
|
||||
}
|
||||
let t = state.db.table(Table::MailboxAppPassword);
|
||||
let rows = if s.role.is_global() {
|
||||
sqlx::query(&format!(
|
||||
"SELECT id,username,description FROM {t} ORDER BY username,id"
|
||||
))
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
} else {
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT id,username,description FROM {t} WHERE username=? ORDER BY id"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
};
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| AppPasswordSummary {
|
||||
id: row_i64(r, "id"),
|
||||
username: row_string(r, "username"),
|
||||
description: row_string(r, "description"),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn add_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<AppPasswordInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
if s.kind != AccountKind::User {
|
||||
return Err(AppError::forbidden(
|
||||
"Application passwords are created by mailbox users",
|
||||
));
|
||||
}
|
||||
if !state.config.features.app_passwords {
|
||||
return Err(AppError::forbidden("Application passwords are disabled"));
|
||||
}
|
||||
if input.description.trim().is_empty() {
|
||||
return Err(AppError::bad_request("Description is required"));
|
||||
}
|
||||
verify_current(&state, &s, &input.current_password).await?;
|
||||
let hash = passwords::hash(&state.config, &input.password, &s.username).await?;
|
||||
let t = state.db.table(Table::MailboxAppPassword);
|
||||
let q = state.db.q(&format!(
|
||||
"INSERT INTO {t} (username,description,password_hash) VALUES (?,?,?)"
|
||||
));
|
||||
sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.bind(&input.description)
|
||||
.bind(&hash)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
hooks::run(
|
||||
&state.config.hooks.mailbox_post_app_password,
|
||||
&[
|
||||
("username", &s.username),
|
||||
("description", &input.description),
|
||||
],
|
||||
Some(hash.as_bytes()),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let t = state.db.table(Table::MailboxAppPassword);
|
||||
let q = if s.role.is_global() {
|
||||
state.db.q(&format!("DELETE FROM {t} WHERE id=?"))
|
||||
} else {
|
||||
state
|
||||
.db
|
||||
.q(&format!("DELETE FROM {t} WHERE id=? AND username=?"))
|
||||
};
|
||||
let mut x = sqlx::query(&q).bind(id);
|
||||
if !s.role.is_global() {
|
||||
x = x.bind(&s.username);
|
||||
}
|
||||
if x.execute(state.db.pool()).await?.rows_affected() != 1 {
|
||||
return Err(AppError::not_found("Application password not found"));
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
fn account_table(s: &crate::auth::Session) -> (Table, &'static str) {
|
||||
match s.kind {
|
||||
AccountKind::Admin => (Table::Admin, "username"),
|
||||
AccountKind::User => (Table::Mailbox, "username"),
|
||||
}
|
||||
}
|
||||
async fn verify_current(state: &AppState, s: &crate::auth::Session, p: &str) -> AppResult<()> {
|
||||
let (t, c) = account_table(s);
|
||||
let table = state.db.table(t);
|
||||
let q = state
|
||||
.db
|
||||
.q(&format!("SELECT password FROM {table} WHERE {c}=?"));
|
||||
let r = sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||
if passwords::verify(&state.config, p, &row_string(&r, "password"), &s.username).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Current password is incorrect"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::verify_csrf,
|
||||
error::{AppError, AppResult},
|
||||
models::AccountKind,
|
||||
routes,
|
||||
sieve::{ManageSieveClient, SieveCredentials, SieveError, SieveScript},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusResponse {
|
||||
connected: bool,
|
||||
port: u16,
|
||||
tls_mode: String,
|
||||
implementation: Option<String>,
|
||||
extensions: Vec<String>,
|
||||
sasl: Vec<String>,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScriptsResponse {
|
||||
scripts: Vec<SieveScript>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScriptResponse {
|
||||
name: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ScriptBody {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenameBody {
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ValidationResponse {
|
||||
valid: bool,
|
||||
warning: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionResponse {
|
||||
ok: bool,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
fn credentials(state: &AppState, session: &crate::auth::Session) -> AppResult<SieveCredentials> {
|
||||
if !state.config.sieve.enabled {
|
||||
return Err(AppError::forbidden("ManageSieve is disabled"));
|
||||
}
|
||||
if session.kind != AccountKind::User {
|
||||
return Err(AppError::forbidden(
|
||||
"ManageSieve is available to mailbox users only",
|
||||
));
|
||||
}
|
||||
let password = session.mail_password().ok_or_else(|| {
|
||||
AppError::unauthorized("ManageSieve credentials are not available; sign in again")
|
||||
})?;
|
||||
Ok(SieveCredentials {
|
||||
host: state.config.sieve.host.clone(),
|
||||
port: state.config.sieve.port,
|
||||
tls_mode: state.config.sieve.tls_mode.clone(),
|
||||
allow_insecure_auth: state.config.sieve.allow_insecure_auth,
|
||||
username: session.username.clone(),
|
||||
password: password.to_string(),
|
||||
timeout: Duration::from_secs(state.config.sieve.timeout_secs),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_error(error: SieveError) -> AppError {
|
||||
match error {
|
||||
SieveError::Protocol(message) | SieveError::ServerRejected(message) => {
|
||||
AppError::bad_request(message)
|
||||
}
|
||||
SieveError::ScriptTooLarge => AppError::bad_request("Sieve script exceeds the 1 MiB limit"),
|
||||
SieveError::InsecureAuthenticationBlocked => AppError::forbidden(
|
||||
"ManageSieve plaintext authentication is disabled; use TLS/STARTTLS or explicitly opt in",
|
||||
),
|
||||
SieveError::AuthenticationFailed(message) => {
|
||||
AppError::unauthorized(format!("ManageSieve authentication failed: {message}"))
|
||||
}
|
||||
SieveError::ConnectionFailed(message) | SieveError::TlsFailed(message) => {
|
||||
AppError::bad_gateway(message)
|
||||
}
|
||||
SieveError::Timeout => AppError::bad_gateway("ManageSieve request timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(state: &AppState, session: &crate::auth::Session) -> AppResult<ManageSieveClient> {
|
||||
ManageSieveClient::connect(&credentials(state, session)?)
|
||||
.await
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
fn mutation_session(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
headers: &HeaderMap,
|
||||
) -> AppResult<crate::auth::Session> {
|
||||
let session = routes::session(state, jar)?;
|
||||
verify_csrf(
|
||||
&session,
|
||||
headers
|
||||
.get("x-csrf-token")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
)?;
|
||||
if state.config.sieve.read_only {
|
||||
return Err(AppError::forbidden("ManageSieve is read-only"));
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<StatusResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let capabilities = client.capabilities().clone();
|
||||
client.logout().await;
|
||||
Ok(Json(StatusResponse {
|
||||
connected: true,
|
||||
port: state.config.sieve.port,
|
||||
tls_mode: state.config.sieve.tls_mode.clone(),
|
||||
implementation: capabilities.implementation,
|
||||
extensions: capabilities.extensions,
|
||||
sasl: capabilities.sasl,
|
||||
read_only: state.config.sieve.read_only,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_scripts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<ScriptsResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let scripts = client.list_scripts().await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ScriptsResponse { scripts }))
|
||||
}
|
||||
|
||||
pub async fn get_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<ScriptResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let content = client.get_script(&name).await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ScriptResponse { name, content }))
|
||||
}
|
||||
|
||||
pub async fn check_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
Json(body): Json<ScriptBody>,
|
||||
) -> AppResult<Json<ValidationResponse>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let result = client.check_script(&body.content).await;
|
||||
client.logout().await;
|
||||
let response = match result {
|
||||
Ok(warning) => ValidationResponse {
|
||||
valid: true,
|
||||
warning,
|
||||
error: None,
|
||||
},
|
||||
Err(SieveError::ServerRejected(message)) => ValidationResponse {
|
||||
valid: false,
|
||||
warning: None,
|
||||
error: Some(message),
|
||||
},
|
||||
Err(error) => return Err(map_error(error)),
|
||||
};
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn put_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<ScriptBody>,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
let warning = client
|
||||
.put_script(&name, &body.content)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse { ok: true, warning }))
|
||||
}
|
||||
|
||||
pub async fn activate_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client
|
||||
.activate_script(Some(&name))
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn deactivate_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client.activate_script(None).await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client.delete_script(&name).await.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn rename_script(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<RenameBody>,
|
||||
) -> AppResult<Json<ActionResponse>> {
|
||||
let session = mutation_session(&state, &jar, &headers)?;
|
||||
let mut client = connect(&state, &session).await?;
|
||||
client
|
||||
.rename_script(&name, &body.new_name)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
client.logout().await;
|
||||
Ok(Json(ActionResponse {
|
||||
ok: true,
|
||||
warning: None,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use crate::{
|
||||
db::{Table, row_i64},
|
||||
error::AppResult,
|
||||
passwords, routes,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{Json, extract::State};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
#[derive(Serialize)]
|
||||
pub struct Health {
|
||||
status: &'static str,
|
||||
version: &'static str,
|
||||
}
|
||||
pub async fn health(State(state): State<Arc<AppState>>) -> AppResult<Json<Health>> {
|
||||
state.db.ping().await?;
|
||||
Ok(Json(Health {
|
||||
status: "ok",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
pub async fn overview(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
let domains = count(&state, Table::Domain, "domain<>'ALL'").await?;
|
||||
let mailboxes = count_scoped(&state, Table::Mailbox, "domain", &s).await?;
|
||||
let aliases = count_scoped(&state, Table::Alias, "domain", &s).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"domains":if s.role.is_global(){domains}else{s.allowed_domains.iter().filter(|d|d.as_str()!="ALL").count() as i64},"mailboxes":mailboxes,"aliases":aliases,"role":s.role,"username":s.username,"features":&state.config.features}),
|
||||
))
|
||||
}
|
||||
pub async fn schema(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let s = routes::session(&state, &jar)?;
|
||||
s.require_global()?;
|
||||
let warnings = state.db.check_schema(&state.config).await?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":warnings.is_empty(),"warnings":warnings,"database_backend":state.config.database.backend}),
|
||||
))
|
||||
}
|
||||
pub async fn features(
|
||||
State(state): State<Arc<AppState>>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let _ = routes::session(&state, &jar)?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"features":&state.config.features,"defaults":{"page_size":state.config.defaults.page_size,"quota_multiplier":state.config.defaults.quota_multiplier,"quota_level_med_pct":state.config.defaults.quota_level_med_pct,"quota_level_high_pct":state.config.defaults.quota_level_high_pct,"motd_user":state.config.defaults.motd_user,"motd_admin":state.config.defaults.motd_admin,"motd_superadmin":state.config.defaults.motd_superadmin},"password":{"configured":state.config.password.scheme,"supported":passwords::supported_schemes()},"development_mock":state.config.database.dev_mock,"database_read_only":state.config.database.read_only,"dkim":{"default_backend":state.config.dkim.default_backend,"user_read_only":state.config.dkim.user_read_only},"rspamd_dkim":{"enabled":state.config.rspamd_dkim.enabled,"default_selector":state.config.rspamd_dkim.default_selector,"development_mock":state.config.rspamd_dkim.dev_mock},"sieve":{"enabled":state.config.sieve.enabled,"port":state.config.sieve.port,"tls_mode":state.config.sieve.tls_mode,"read_only":state.config.sieve.read_only},"version":env!("CARGO_PKG_VERSION")}),
|
||||
))
|
||||
}
|
||||
async fn count(state: &AppState, table: Table, where_sql: &str) -> AppResult<i64> {
|
||||
let t = state.db.table(table);
|
||||
let r = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {t} WHERE {where_sql}"))
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
Ok(row_i64(&r, "n"))
|
||||
}
|
||||
async fn count_scoped(
|
||||
state: &AppState,
|
||||
table: Table,
|
||||
col: &str,
|
||||
s: &crate::auth::Session,
|
||||
) -> AppResult<i64> {
|
||||
let t = state.db.table(table);
|
||||
if s.role.is_global() {
|
||||
let r = sqlx::query(&format!("SELECT COUNT(*) AS n FROM {t}"))
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
return Ok(row_i64(&r, "n"));
|
||||
}
|
||||
if !s.role.is_admin() {
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT COUNT(*) AS n FROM {t} WHERE {}=?",
|
||||
if matches!(table, Table::Mailbox) {
|
||||
"username"
|
||||
} else {
|
||||
"address"
|
||||
}
|
||||
));
|
||||
let r = sqlx::query(&q)
|
||||
.bind(&s.username)
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
return Ok(row_i64(&r, "n"));
|
||||
}
|
||||
let ds: Vec<_> = s
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.filter(|d| d.as_str() != "ALL")
|
||||
.collect();
|
||||
if ds.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let q = state.db.q(&format!(
|
||||
"SELECT COUNT(*) AS n FROM {t} WHERE {col} IN ({})",
|
||||
vec!["?"; ds.len()].join(",")
|
||||
));
|
||||
let mut x = sqlx::query(&q);
|
||||
for d in ds {
|
||||
x = x.bind(d.as_str());
|
||||
}
|
||||
let r = x.fetch_one(state.db.pool()).await?;
|
||||
Ok(row_i64(&r, "n"))
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use crate::{
|
||||
db::{Table, row_bool, row_i64, row_string},
|
||||
error::{AppError, AppResult},
|
||||
models::{AccountKind, VacationInput, VacationSummary},
|
||||
routes,
|
||||
state::AppState,
|
||||
validation,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
jar: CookieJar,
|
||||
) -> AppResult<Json<Option<VacationSummary>>> {
|
||||
let session = routes::session(&state, &jar)?;
|
||||
let u = validation::email(&path)?;
|
||||
authorize(&state, &session, &u, false)?;
|
||||
if !state.config.features.vacation || !state.db.has_table(Table::Vacation).await {
|
||||
return Ok(Json(None));
|
||||
}
|
||||
let t = state.db.table(Table::Vacation);
|
||||
let af = state.db.cast_text("activefrom");
|
||||
let au = state.db.cast_text("activeuntil");
|
||||
let active = state.db.flag("active", "active");
|
||||
let q=state.db.q(&format!("SELECT email,subject,body,interval_time,{af} AS activefrom,{au} AS activeuntil,{active} FROM {t} WHERE email=?"));
|
||||
let row = sqlx::query(&q)
|
||||
.bind(&u)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(row.as_ref().map(map)))
|
||||
}
|
||||
pub async fn set(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<VacationInput>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let u = validation::email(&path)?;
|
||||
authorize(&state, &session, &u, true)?;
|
||||
if validation::email(&input.email)? != u {
|
||||
return Err(AppError::bad_request(
|
||||
"Vacation email does not match mailbox",
|
||||
));
|
||||
}
|
||||
if input.subject.len() > 255 || input.body.len() > 65535 {
|
||||
return Err(AppError::bad_request("Vacation message is too long"));
|
||||
}
|
||||
let (_, domain) = u.rsplit_once('@').unwrap();
|
||||
let t = state.db.table(Table::Vacation);
|
||||
let check = state.db.q(&format!("SELECT 1 FROM {t} WHERE email=?"));
|
||||
let exists = sqlx::query(&check)
|
||||
.bind(&u)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await?
|
||||
.is_some();
|
||||
let q = if exists {
|
||||
state.db.q(&format!("UPDATE {t} SET subject=?,body=?,activefrom=?,activeuntil=?,interval_time=?,modified=CURRENT_TIMESTAMP,active=? WHERE email=?"))
|
||||
} else {
|
||||
state.db.q(&format!("INSERT INTO {t} (email,subject,body,activefrom,activeuntil,cache,domain,interval_time,created,modified,active) VALUES (?,?,?,?,?,'',?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)"))
|
||||
};
|
||||
if exists {
|
||||
sqlx::query(&q)
|
||||
.bind(&input.subject)
|
||||
.bind(&input.body)
|
||||
.bind(&input.active_from)
|
||||
.bind(&input.active_until)
|
||||
.bind(input.interval_time)
|
||||
.bind(input.active)
|
||||
.bind(&u)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query(&q)
|
||||
.bind(&u)
|
||||
.bind(&input.subject)
|
||||
.bind(&input.body)
|
||||
.bind(&input.active_from)
|
||||
.bind(&input.active_until)
|
||||
.bind(domain)
|
||||
.bind(input.interval_time)
|
||||
.bind(input.active)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
}
|
||||
state
|
||||
.db
|
||||
.log(
|
||||
&state.config,
|
||||
&session.username,
|
||||
domain,
|
||||
"edit_vacation",
|
||||
&u,
|
||||
)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
pub async fn remove(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let session = routes::mutation_session(&state, &jar, &headers)?;
|
||||
let u = validation::email(&path)?;
|
||||
authorize(&state, &session, &u, true)?;
|
||||
let t = state.db.table(Table::Vacation);
|
||||
let q = state.db.q(&format!("DELETE FROM {t} WHERE email=?"));
|
||||
sqlx::query(&q).bind(&u).execute(state.db.pool()).await?;
|
||||
if state.db.has_table(Table::VacationNotification).await {
|
||||
let n = state.db.table(Table::VacationNotification);
|
||||
let q = state.db.q(&format!("DELETE FROM {n} WHERE on_vacation=?"));
|
||||
let _ = sqlx::query(&q).bind(&u).execute(state.db.pool()).await;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
fn authorize(state: &AppState, s: &crate::auth::Session, u: &str, write: bool) -> AppResult<()> {
|
||||
if !state.config.features.vacation {
|
||||
return Err(AppError::forbidden("Vacation is disabled"));
|
||||
}
|
||||
if s.kind == AccountKind::User {
|
||||
if !state.config.features.vacation_control || s.username != u {
|
||||
return Err(AppError::forbidden(
|
||||
"Vacation control is disabled for this user",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if write && !state.config.features.vacation_control_admin && !s.role.is_global() {
|
||||
return Err(AppError::forbidden(
|
||||
"Vacation control is disabled for domain administrators",
|
||||
));
|
||||
}
|
||||
let (_, d) = u.rsplit_once('@').unwrap();
|
||||
s.require_domain(d)
|
||||
}
|
||||
}
|
||||
fn map(r: &sqlx::any::AnyRow) -> VacationSummary {
|
||||
VacationSummary {
|
||||
email: row_string(r, "email"),
|
||||
subject: row_string(r, "subject"),
|
||||
body: row_string(r, "body"),
|
||||
interval_time: row_i64(r, "interval_time"),
|
||||
active_from: row_string(r, "activefrom"),
|
||||
active_until: row_string(r, "activeuntil"),
|
||||
active: row_bool(r, "active"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::error::SieveError;
|
||||
|
||||
const MAX_LINE_BYTES: usize = 64 * 1024;
|
||||
const MAX_RESPONSE_LINES: usize = 256;
|
||||
const MAX_LITERAL_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub const MAX_SCRIPT_BYTES: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SieveCredentials {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
|
||||
pub tls_mode: String,
|
||||
pub allow_insecure_auth: bool,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SieveCapabilities {
|
||||
pub implementation: Option<String>,
|
||||
pub sasl: Vec<String>,
|
||||
pub extensions: Vec<String>,
|
||||
pub starttls: bool,
|
||||
pub raw: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SieveScript {
|
||||
pub name: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ManageSieveClient {
|
||||
stream: SieveStream,
|
||||
timeout: Duration,
|
||||
capabilities: SieveCapabilities,
|
||||
}
|
||||
|
||||
impl ManageSieveClient {
|
||||
pub async fn connect(creds: &SieveCredentials) -> Result<Self, SieveError> {
|
||||
let connect_future = TcpStream::connect((creds.host.as_str(), creds.port));
|
||||
let mut tcp = timeout(creds.timeout, connect_future)
|
||||
.await
|
||||
.map_err(|_| SieveError::Timeout)?
|
||||
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||
tcp.set_nodelay(true)
|
||||
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
let (mut stream, greeting) = match creds.tls_mode.as_str() {
|
||||
"implicit" => {
|
||||
let tls = async_native_tls::TlsConnector::new();
|
||||
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
|
||||
.await
|
||||
.map_err(|_| SieveError::Timeout)?
|
||||
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
|
||||
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
|
||||
(SieveStream::Tls(tls_stream), greeting)
|
||||
}
|
||||
"starttls" => {
|
||||
let greeting = read_response(&mut tcp, creds.timeout).await?;
|
||||
ensure_ok(&greeting)?;
|
||||
let initial_caps = parse_capabilities(&greeting.lines);
|
||||
if !initial_caps.starttls {
|
||||
return Err(SieveError::TlsFailed(
|
||||
"server does not advertise STARTTLS".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
write_all_timeout(&mut tcp, b"STARTTLS\r\n", creds.timeout).await?;
|
||||
let reply = read_response(&mut tcp, creds.timeout).await?;
|
||||
ensure_ok(&reply)?;
|
||||
|
||||
let tls = async_native_tls::TlsConnector::new();
|
||||
let mut tls_stream = timeout(creds.timeout, tls.connect(&creds.host, tcp))
|
||||
.await
|
||||
.map_err(|_| SieveError::Timeout)?
|
||||
.map_err(|e| SieveError::TlsFailed(e.to_string()))?;
|
||||
|
||||
let greeting = read_response(&mut tls_stream, creds.timeout).await?;
|
||||
(SieveStream::Tls(tls_stream), greeting)
|
||||
}
|
||||
"none" => {
|
||||
let greeting = read_response(&mut tcp, creds.timeout).await?;
|
||||
(SieveStream::Plain(tcp), greeting)
|
||||
}
|
||||
other => {
|
||||
return Err(SieveError::Protocol(format!(
|
||||
"unsupported TLS mode: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
ensure_ok(&greeting)?;
|
||||
let capabilities = parse_capabilities(&greeting.lines);
|
||||
|
||||
if creds.tls_mode == "none" && !creds.allow_insecure_auth {
|
||||
return Err(SieveError::InsecureAuthenticationBlocked);
|
||||
}
|
||||
if !capabilities
|
||||
.sasl
|
||||
.iter()
|
||||
.any(|mechanism| mechanism.eq_ignore_ascii_case("PLAIN"))
|
||||
{
|
||||
return Err(SieveError::AuthenticationFailed(
|
||||
"server does not advertise SASL PLAIN".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let plain = format!("\0{}\0{}", creds.username, creds.password);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(plain.as_bytes());
|
||||
let command = format!("AUTHENTICATE \"PLAIN\" \"{encoded}\"\r\n");
|
||||
write_all_timeout(&mut stream, command.as_bytes(), creds.timeout).await?;
|
||||
let auth = read_response(&mut stream, creds.timeout).await?;
|
||||
if auth.status == ResponseStatus::No || auth.status == ResponseStatus::Bye {
|
||||
return Err(SieveError::AuthenticationFailed(auth.human_message()));
|
||||
}
|
||||
ensure_ok(&auth)?;
|
||||
|
||||
Ok(Self {
|
||||
stream,
|
||||
timeout: creds.timeout,
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &SieveCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
pub async fn list_scripts(&mut self) -> Result<Vec<SieveScript>, SieveError> {
|
||||
let response = self.command("LISTSCRIPTS\r\n").await?;
|
||||
ensure_ok(&response)?;
|
||||
|
||||
let mut scripts = Vec::new();
|
||||
for line in response.lines {
|
||||
if let Some(literal) = line.literal {
|
||||
let name = String::from_utf8(literal)
|
||||
.map_err(|_| SieveError::Protocol("script name is not UTF-8".into()))?;
|
||||
scripts.push(SieveScript {
|
||||
name,
|
||||
active: line.after.trim().eq_ignore_ascii_case("ACTIVE"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((name, rest)) = parse_quoted(&line.before) {
|
||||
scripts.push(SieveScript {
|
||||
name,
|
||||
active: rest.trim().eq_ignore_ascii_case("ACTIVE"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(scripts)
|
||||
}
|
||||
|
||||
pub async fn get_script(&mut self, name: &str) -> Result<String, SieveError> {
|
||||
let command = format!("GETSCRIPT {}\r\n", quote(name)?);
|
||||
let response = self.command(&command).await?;
|
||||
ensure_ok(&response)?;
|
||||
let Some(line) = response.lines.first() else {
|
||||
return Err(SieveError::Protocol(
|
||||
"GETSCRIPT returned no script body".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let bytes = if let Some(literal) = &line.literal {
|
||||
literal.clone()
|
||||
} else if let Some((value, _)) = parse_quoted(&line.before) {
|
||||
value.into_bytes()
|
||||
} else {
|
||||
return Err(SieveError::Protocol(
|
||||
"GETSCRIPT returned an unsupported string encoding".into(),
|
||||
));
|
||||
};
|
||||
|
||||
String::from_utf8(bytes).map_err(|_| SieveError::Protocol("script is not UTF-8".into()))
|
||||
}
|
||||
|
||||
pub async fn check_script(&mut self, script: &str) -> Result<Option<String>, SieveError> {
|
||||
self.send_script_command("CHECKSCRIPT", None, script).await
|
||||
}
|
||||
|
||||
pub async fn put_script(
|
||||
&mut self,
|
||||
name: &str,
|
||||
script: &str,
|
||||
) -> Result<Option<String>, SieveError> {
|
||||
if script.is_empty() {
|
||||
return Err(SieveError::Protocol(
|
||||
"empty Sieve scripts are not accepted".into(),
|
||||
));
|
||||
}
|
||||
if script.len() > MAX_SCRIPT_BYTES {
|
||||
return Err(SieveError::ScriptTooLarge);
|
||||
}
|
||||
|
||||
let have_space = format!("HAVESPACE {} {}\r\n", quote(name)?, script.len());
|
||||
let response = self.command(&have_space).await?;
|
||||
ensure_ok(&response)?;
|
||||
|
||||
self.send_script_command("PUTSCRIPT", Some(name), script)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn activate_script(&mut self, name: Option<&str>) -> Result<(), SieveError> {
|
||||
let name = match name {
|
||||
Some(name) => quote(name)?,
|
||||
None => "\"\"".to_string(),
|
||||
};
|
||||
let response = self.command(&format!("SETACTIVE {name}\r\n")).await?;
|
||||
ensure_ok(&response)
|
||||
}
|
||||
|
||||
pub async fn delete_script(&mut self, name: &str) -> Result<(), SieveError> {
|
||||
let response = self
|
||||
.command(&format!("DELETESCRIPT {}\r\n", quote(name)?))
|
||||
.await?;
|
||||
ensure_ok(&response)
|
||||
}
|
||||
|
||||
pub async fn rename_script(&mut self, name: &str, new_name: &str) -> Result<(), SieveError> {
|
||||
let response = self
|
||||
.command(&format!(
|
||||
"RENAMESCRIPT {} {}\r\n",
|
||||
quote(name)?,
|
||||
quote(new_name)?
|
||||
))
|
||||
.await?;
|
||||
ensure_ok(&response)
|
||||
}
|
||||
|
||||
pub async fn logout(&mut self) {
|
||||
let _ = self.command("LOGOUT\r\n").await;
|
||||
}
|
||||
|
||||
async fn command(&mut self, command: &str) -> Result<ProtocolResponse, SieveError> {
|
||||
write_all_timeout(&mut self.stream, command.as_bytes(), self.timeout).await?;
|
||||
read_response(&mut self.stream, self.timeout).await
|
||||
}
|
||||
|
||||
async fn send_script_command(
|
||||
&mut self,
|
||||
verb: &str,
|
||||
name: Option<&str>,
|
||||
script: &str,
|
||||
) -> Result<Option<String>, SieveError> {
|
||||
if script.len() > MAX_SCRIPT_BYTES {
|
||||
return Err(SieveError::ScriptTooLarge);
|
||||
}
|
||||
let header = match name {
|
||||
Some(name) => format!("{verb} {} {{{}+}}\r\n", quote(name)?, script.len()),
|
||||
None => format!("{verb} {{{}+}}\r\n", script.len()),
|
||||
};
|
||||
|
||||
let mut command = Vec::with_capacity(header.len() + script.len() + 2);
|
||||
command.extend_from_slice(header.as_bytes());
|
||||
command.extend_from_slice(script.as_bytes());
|
||||
command.extend_from_slice(b"\r\n");
|
||||
write_all_timeout(&mut self.stream, &command, self.timeout).await?;
|
||||
let response = read_response(&mut self.stream, self.timeout).await?;
|
||||
ensure_ok(&response)?;
|
||||
|
||||
if response.code.as_deref() == Some("WARNINGS") {
|
||||
Ok(Some(response.human_message()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SieveStream {
|
||||
Tls(async_native_tls::TlsStream<TcpStream>),
|
||||
Plain(TcpStream),
|
||||
}
|
||||
|
||||
impl AsyncRead for SieveStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::Tls(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||
Self::Plain(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for SieveStream {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
Self::Tls(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||
Self::Plain(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::Tls(stream) => Pin::new(stream).poll_flush(cx),
|
||||
Self::Plain(stream) => Pin::new(stream).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::Tls(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||
Self::Plain(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ResponseStatus {
|
||||
Ok,
|
||||
No,
|
||||
Bye,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WireLine {
|
||||
before: String,
|
||||
literal: Option<Vec<u8>>,
|
||||
after: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProtocolResponse {
|
||||
status: ResponseStatus,
|
||||
code: Option<String>,
|
||||
final_line: WireLine,
|
||||
lines: Vec<WireLine>,
|
||||
}
|
||||
|
||||
impl ProtocolResponse {
|
||||
fn human_message(&self) -> String {
|
||||
if let Some(literal) = &self.final_line.literal {
|
||||
return String::from_utf8_lossy(literal).trim().to_string();
|
||||
}
|
||||
|
||||
let text = format!("{}{}", self.final_line.before, self.final_line.after);
|
||||
|
||||
if let Some(pos) = text.rfind('"') {
|
||||
let prefix = &text[..pos];
|
||||
if let Some(start) = prefix.rfind('"') {
|
||||
return unescape_quoted(&text[start + 1..pos]);
|
||||
}
|
||||
}
|
||||
text.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_all_timeout<S: AsyncWrite + Unpin>(
|
||||
stream: &mut S,
|
||||
bytes: &[u8],
|
||||
duration: Duration,
|
||||
) -> Result<(), SieveError> {
|
||||
timeout(duration, async {
|
||||
stream.write_all(bytes).await?;
|
||||
stream.flush().await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| SieveError::Timeout)?
|
||||
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))
|
||||
}
|
||||
|
||||
async fn read_response<S: AsyncRead + Unpin>(
|
||||
stream: &mut S,
|
||||
duration: Duration,
|
||||
) -> Result<ProtocolResponse, SieveError> {
|
||||
let mut lines = Vec::new();
|
||||
for _ in 0..MAX_RESPONSE_LINES {
|
||||
let line = timeout(duration, read_wire_line(stream))
|
||||
.await
|
||||
.map_err(|_| SieveError::Timeout)??;
|
||||
if let Some((status, code)) = parse_status(&line) {
|
||||
return Ok(ProtocolResponse {
|
||||
status,
|
||||
code,
|
||||
final_line: line,
|
||||
lines,
|
||||
});
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
Err(SieveError::Protocol(
|
||||
"response exceeded the maximum number of lines".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn read_wire_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<WireLine, SieveError> {
|
||||
let before_bytes = read_crlf_line(stream).await?;
|
||||
let before = String::from_utf8(before_bytes)
|
||||
.map_err(|_| SieveError::Protocol("response line is not UTF-8".into()))?;
|
||||
|
||||
let Some(size) = trailing_literal_size(&before)? else {
|
||||
return Ok(WireLine {
|
||||
before,
|
||||
literal: None,
|
||||
after: String::new(),
|
||||
});
|
||||
};
|
||||
|
||||
if size > MAX_LITERAL_BYTES {
|
||||
return Err(SieveError::Protocol(format!(
|
||||
"server literal exceeds {MAX_LITERAL_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
let mut literal = vec![0u8; size];
|
||||
stream
|
||||
.read_exact(&mut literal)
|
||||
.await
|
||||
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
let after_bytes = read_crlf_line(stream).await?;
|
||||
let after = String::from_utf8(after_bytes)
|
||||
.map_err(|_| SieveError::Protocol("literal suffix is not UTF-8".into()))?;
|
||||
|
||||
Ok(WireLine {
|
||||
before,
|
||||
literal: Some(literal),
|
||||
after,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_crlf_line<S: AsyncRead + Unpin>(stream: &mut S) -> Result<Vec<u8>, SieveError> {
|
||||
let mut out = Vec::with_capacity(128);
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
if out.len() >= MAX_LINE_BYTES {
|
||||
return Err(SieveError::Protocol("response line is too long".into()));
|
||||
}
|
||||
stream
|
||||
.read_exact(&mut byte)
|
||||
.await
|
||||
.map_err(|e| SieveError::ConnectionFailed(e.to_string()))?;
|
||||
out.push(byte[0]);
|
||||
if out.len() >= 2 && &out[out.len() - 2..] == b"\r\n" {
|
||||
out.truncate(out.len() - 2);
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trailing_literal_size(line: &str) -> Result<Option<usize>, SieveError> {
|
||||
let trimmed = line.trim_end();
|
||||
let Some(open) = trimmed.rfind('{') else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !trimmed.ends_with('}') {
|
||||
return Ok(None);
|
||||
}
|
||||
let raw = &trimmed[open + 1..trimmed.len() - 1];
|
||||
|
||||
if raw.ends_with('+') {
|
||||
return Err(SieveError::Protocol(
|
||||
"server sent an invalid non-synchronizing literal".into(),
|
||||
));
|
||||
}
|
||||
let size = raw
|
||||
.parse::<usize>()
|
||||
.map_err(|_| SieveError::Protocol("invalid literal length".into()))?;
|
||||
Ok(Some(size))
|
||||
}
|
||||
|
||||
fn parse_status(line: &WireLine) -> Option<(ResponseStatus, Option<String>)> {
|
||||
let text = line.before.trim_start();
|
||||
let (status, rest) = if let Some(rest) = strip_token_ci(text, "OK") {
|
||||
(ResponseStatus::Ok, rest)
|
||||
} else if let Some(rest) = strip_token_ci(text, "NO") {
|
||||
(ResponseStatus::No, rest)
|
||||
} else {
|
||||
let rest = strip_token_ci(text, "BYE")?;
|
||||
(ResponseStatus::Bye, rest)
|
||||
};
|
||||
|
||||
let rest = rest.trim_start();
|
||||
let code = rest
|
||||
.strip_prefix('(')
|
||||
.and_then(|inside| inside.split_once(')'))
|
||||
.map(|(inside, _)| {
|
||||
inside
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or(inside)
|
||||
.to_ascii_uppercase()
|
||||
});
|
||||
Some((status, code))
|
||||
}
|
||||
|
||||
fn strip_token_ci<'a>(text: &'a str, token: &str) -> Option<&'a str> {
|
||||
if text.len() < token.len() || !text[..token.len()].eq_ignore_ascii_case(token) {
|
||||
return None;
|
||||
}
|
||||
let rest = &text[token.len()..];
|
||||
if rest.is_empty()
|
||||
|| rest.chars().next().is_some_and(char::is_whitespace)
|
||||
|| rest.starts_with('(')
|
||||
{
|
||||
Some(rest)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_ok(response: &ProtocolResponse) -> Result<(), SieveError> {
|
||||
match response.status {
|
||||
ResponseStatus::Ok => Ok(()),
|
||||
ResponseStatus::No | ResponseStatus::Bye => {
|
||||
Err(SieveError::ServerRejected(response.human_message()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_capabilities(lines: &[WireLine]) -> SieveCapabilities {
|
||||
let mut raw = BTreeMap::new();
|
||||
for line in lines {
|
||||
if line.literal.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some((name, rest)) = parse_quoted(&line.before) else {
|
||||
continue;
|
||||
};
|
||||
let value = parse_quoted(rest).map(|(value, _)| value);
|
||||
raw.insert(name.to_ascii_uppercase(), value);
|
||||
}
|
||||
|
||||
let implementation = raw.get("IMPLEMENTATION").cloned().flatten();
|
||||
let sasl = raw
|
||||
.get("SASL")
|
||||
.and_then(Clone::clone)
|
||||
.map(|value| value.split_whitespace().map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
let extensions = raw
|
||||
.get("SIEVE")
|
||||
.and_then(Clone::clone)
|
||||
.map(|value| value.split_whitespace().map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
let starttls = raw.contains_key("STARTTLS");
|
||||
|
||||
SieveCapabilities {
|
||||
implementation,
|
||||
sasl,
|
||||
extensions,
|
||||
starttls,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
fn quote(value: &str) -> Result<String, SieveError> {
|
||||
if value.is_empty() {
|
||||
return Ok("\"\"".into());
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
.any(|c| c == '\r' || c == '\n' || c == '\0' || c.is_control())
|
||||
{
|
||||
return Err(SieveError::Protocol(
|
||||
"script name contains unsupported control characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"\"{}\"",
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_quoted(text: &str) -> Option<(String, &str)> {
|
||||
let text = text.trim_start();
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.first() != Some(&b'"') {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut escaped = false;
|
||||
for (idx, ch) in text[1..].char_indices() {
|
||||
if escaped {
|
||||
out.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\\' => escaped = true,
|
||||
'"' => {
|
||||
let end = 1 + idx + ch.len_utf8();
|
||||
return Some((out, &text[end..]));
|
||||
}
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn unescape_quoted(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut escaped = false;
|
||||
for ch in text.chars() {
|
||||
if escaped {
|
||||
out.push(ch);
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quotes_script_names() {
|
||||
assert_eq!(quote("main").unwrap(), "\"main\"");
|
||||
assert_eq!(quote("a\\b\"c").unwrap(), "\"a\\\\b\\\"c\"");
|
||||
assert!(quote("bad\nname").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_quoted_strings() {
|
||||
let (value, rest) = parse_quoted(" \"main\\\"script\" ACTIVE").unwrap();
|
||||
assert_eq!(value, "main\"script");
|
||||
assert_eq!(rest.trim(), "ACTIVE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_literal_length_only_at_end_of_wire_prefix() {
|
||||
assert_eq!(trailing_literal_size("{54}").unwrap(), Some(54));
|
||||
assert_eq!(trailing_literal_size("NO (X) {31}").unwrap(), Some(31));
|
||||
assert_eq!(trailing_literal_size("\"name\"").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_capabilities() {
|
||||
let lines = vec![
|
||||
WireLine {
|
||||
before: "\"IMPLEMENTATION\" \"Dovecot Pigeonhole\"".into(),
|
||||
literal: None,
|
||||
after: String::new(),
|
||||
},
|
||||
WireLine {
|
||||
before: "\"SASL\" \"PLAIN\"".into(),
|
||||
literal: None,
|
||||
after: String::new(),
|
||||
},
|
||||
WireLine {
|
||||
before: "\"SIEVE\" \"fileinto vacation\"".into(),
|
||||
literal: None,
|
||||
after: String::new(),
|
||||
},
|
||||
WireLine {
|
||||
before: "\"STARTTLS\"".into(),
|
||||
literal: None,
|
||||
after: String::new(),
|
||||
},
|
||||
];
|
||||
let caps = parse_capabilities(&lines);
|
||||
assert_eq!(caps.implementation.as_deref(), Some("Dovecot Pigeonhole"));
|
||||
assert_eq!(caps.sasl, vec!["PLAIN"]);
|
||||
assert_eq!(caps.extensions, vec!["fileinto", "vacation"]);
|
||||
assert!(caps.starttls);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plaintext_manage_sieve_round_trip_requires_explicit_opt_in() {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut stream = BufReader::new(stream);
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"\"IMPLEMENTATION\" \"PostfixAdmin-RS test\"\r\n\"SASL\" \"PLAIN\"\r\n\"SIEVE\" \"fileinto vacation\"\r\nOK\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut line = String::new();
|
||||
stream.read_line(&mut line).await.unwrap();
|
||||
assert!(line.starts_with("AUTHENTICATE \"PLAIN\" "));
|
||||
stream.get_mut().write_all(b"OK\r\n").await.unwrap();
|
||||
|
||||
line.clear();
|
||||
stream.read_line(&mut line).await.unwrap();
|
||||
assert_eq!(line, "LISTSCRIPTS\r\n");
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"\"main\" ACTIVE\r\nOK\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
line.clear();
|
||||
stream.read_line(&mut line).await.unwrap();
|
||||
assert_eq!(line, "LOGOUT\r\n");
|
||||
stream.get_mut().write_all(b"OK\r\n").await.unwrap();
|
||||
});
|
||||
|
||||
let creds = SieveCredentials {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
tls_mode: "none".into(),
|
||||
allow_insecure_auth: true,
|
||||
username: "alice@example.com".into(),
|
||||
password: "secret".into(),
|
||||
timeout: Duration::from_secs(2),
|
||||
};
|
||||
let mut client = ManageSieveClient::connect(&creds).await.unwrap();
|
||||
let scripts = client.list_scripts().await.unwrap();
|
||||
assert_eq!(scripts.len(), 1);
|
||||
assert_eq!(scripts[0].name, "main");
|
||||
assert!(scripts[0].active);
|
||||
client.logout().await;
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SieveError {
|
||||
ConnectionFailed(String),
|
||||
TlsFailed(String),
|
||||
AuthenticationFailed(String),
|
||||
InsecureAuthenticationBlocked,
|
||||
Protocol(String),
|
||||
ServerRejected(String),
|
||||
Timeout,
|
||||
ScriptTooLarge,
|
||||
}
|
||||
|
||||
impl fmt::Display for SieveError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ConnectionFailed(msg) => write!(f, "ManageSieve connection failed: {msg}"),
|
||||
Self::TlsFailed(msg) => write!(f, "ManageSieve TLS failed: {msg}"),
|
||||
Self::AuthenticationFailed(msg) => {
|
||||
write!(f, "ManageSieve authentication failed: {msg}")
|
||||
}
|
||||
Self::InsecureAuthenticationBlocked => write!(
|
||||
f,
|
||||
"ManageSieve plaintext authentication is blocked; use STARTTLS/implicit TLS or explicitly enable sieve.allow_insecure_auth"
|
||||
),
|
||||
Self::Protocol(msg) => write!(f, "ManageSieve protocol error: {msg}"),
|
||||
Self::ServerRejected(msg) => {
|
||||
write!(f, "ManageSieve server rejected the command: {msg}")
|
||||
}
|
||||
Self::Timeout => write!(f, "ManageSieve operation timed out"),
|
||||
Self::ScriptTooLarge => {
|
||||
write!(f, "Sieve script exceeds the PostfixAdmin-RS size limit")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SieveError {}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
|
||||
pub use client::{ManageSieveClient, SieveCredentials, SieveScript};
|
||||
pub use error::SieveError;
|
||||
@@ -0,0 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{auth::AuthStore, config::AppConfig, db::Db};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<AppConfig>,
|
||||
pub db: Db,
|
||||
pub auth: AuthStore,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
const INDEX: &str = include_str!("web/index.html");
|
||||
const ICONS: &str = include_str!("web/icons.svg");
|
||||
const EN: &str = include_str!("web/i18n/en.json");
|
||||
const PL: &str = include_str!("web/i18n/pl.json");
|
||||
const CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/postfixadmin-rs.css"));
|
||||
const JS: &str = include_str!(concat!(env!("OUT_DIR"), "/postfixadmin-rs.js"));
|
||||
const ASSET_ID: &str = env!("POSTFIXADMIN_RS_ASSET_ID");
|
||||
|
||||
pub async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
|
||||
let base = state.config.normalized_base_path();
|
||||
let base = if base == "/" {
|
||||
"/".into()
|
||||
} else {
|
||||
format!("{base}/")
|
||||
};
|
||||
Html(
|
||||
INDEX
|
||||
.replace("__BASE_PATH__", &base)
|
||||
.replace("__ASSET_ID__", ASSET_ID)
|
||||
.replace("__VERSION__", env!("CARGO_PKG_VERSION"))
|
||||
.replace("__LANG__", &state.config.default_language),
|
||||
)
|
||||
}
|
||||
pub async fn asset(Path(path): Path<String>) -> Response {
|
||||
let path = path.trim_start_matches('/');
|
||||
let (body, ctype, immutable) = match path {
|
||||
p if p == format!("app.{ASSET_ID}.css") => (CSS, "text/css; charset=utf-8", true),
|
||||
p if p == format!("app.{ASSET_ID}.js") => {
|
||||
(JS, "application/javascript; charset=utf-8", true)
|
||||
}
|
||||
p if p == format!("icons.{ASSET_ID}.svg") => (ICONS, "image/svg+xml", true),
|
||||
"icons.svg" => (ICONS, "image/svg+xml", false),
|
||||
"i18n/en.json" => (EN, "application/json; charset=utf-8", false),
|
||||
"i18n/pl.json" => (PL, "application/json; charset=utf-8", false),
|
||||
_ => ("", "text/plain", false),
|
||||
};
|
||||
if body.is_empty() {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ctype));
|
||||
h.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(if immutable {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
}),
|
||||
);
|
||||
(h, body).into_response()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use tokio::time::timeout;
|
||||
use trust_dns_resolver::{TokioAsyncResolver, proto::rr::RecordType};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
static LOCAL_PART: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$").expect("valid regex"));
|
||||
static DOMAIN_LABEL: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$").expect("valid regex")
|
||||
});
|
||||
|
||||
pub fn domain(value: &str) -> AppResult<String> {
|
||||
let value = value.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if value.is_empty() || value.len() > 253 || !value.contains('.') {
|
||||
return Err(AppError::bad_request("Invalid domain"));
|
||||
}
|
||||
if !value
|
||||
.split('.')
|
||||
.all(|label| label.len() <= 63 && DOMAIN_LABEL.is_match(label))
|
||||
{
|
||||
return Err(AppError::bad_request("Invalid domain"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub async fn resolvable_domain(config: &AppConfig, value: &str) -> AppResult<String> {
|
||||
let domain = domain(value)?;
|
||||
if !config.features.emailcheck_resolve_domain {
|
||||
return Ok(domain);
|
||||
}
|
||||
|
||||
let resolver = TokioAsyncResolver::tokio_from_system_conf()
|
||||
.map_err(|error| AppError::config(format!("DNS resolver setup failed: {error}")))?;
|
||||
let name = domain.clone();
|
||||
let lookup = async {
|
||||
if resolver.lookup_ip(name.as_str()).await.is_ok() {
|
||||
return true;
|
||||
}
|
||||
if resolver.mx_lookup(name.as_str()).await.is_ok() {
|
||||
return true;
|
||||
}
|
||||
resolver.lookup(name.as_str(), RecordType::NS).await.is_ok()
|
||||
};
|
||||
match timeout(Duration::from_secs(5), lookup).await {
|
||||
Ok(true) => Ok(domain),
|
||||
Ok(false) => Err(AppError::bad_request(format!(
|
||||
"Domain has no A, AAAA, MX or NS record: {domain}"
|
||||
))),
|
||||
Err(_) => Err(AppError::bad_request(format!(
|
||||
"DNS lookup timed out for domain: {domain}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email(value: &str) -> AppResult<String> {
|
||||
let value = value.trim();
|
||||
let Some((local, host)) = value.rsplit_once('@') else {
|
||||
return Err(AppError::bad_request("Invalid email address"));
|
||||
};
|
||||
if local.is_empty() || local.len() > 64 || !LOCAL_PART.is_match(local) {
|
||||
return Err(AppError::bad_request("Invalid email local part"));
|
||||
}
|
||||
let host = domain(host)?;
|
||||
Ok(format!("{local}@{host}"))
|
||||
}
|
||||
|
||||
pub async fn resolvable_email(config: &AppConfig, value: &str) -> AppResult<String> {
|
||||
let email = email(value)?;
|
||||
let (_, host) = email.rsplit_once('@').expect("validated email has domain");
|
||||
resolvable_domain(config, host).await?;
|
||||
Ok(email)
|
||||
}
|
||||
|
||||
pub fn local_part(value: &str) -> AppResult<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > 64 || !LOCAL_PART.is_match(value) {
|
||||
return Err(AppError::bad_request("Invalid mailbox/alias local part"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub fn alias_destination(value: &str, allow_unsafe: bool) -> AppResult<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.contains(['\r', '\n', '\0']) || value.len() > 1000 {
|
||||
return Err(AppError::bad_request("Invalid alias destination"));
|
||||
}
|
||||
if !allow_unsafe && (value.starts_with('|') || value.starts_with('/') || value.starts_with(':'))
|
||||
{
|
||||
return Err(AppError::bad_request(
|
||||
"Pipe/file alias destinations are disabled by the Rust security profile",
|
||||
));
|
||||
}
|
||||
if value.contains('@') {
|
||||
return email(value);
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub async fn alias_destination_checked(
|
||||
config: &AppConfig,
|
||||
value: &str,
|
||||
allow_unsafe: bool,
|
||||
) -> AppResult<String> {
|
||||
let destination = alias_destination(value, allow_unsafe)?;
|
||||
if let Some((_, domain)) = destination.rsplit_once('@') {
|
||||
resolvable_domain(config, domain).await?;
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;--bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310;--line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f;--accent:#8fb9a4;--accent2:#a4c9b6;--accentink:#102018;--danger:#e18f8a;--warn:#d0ad70;--success:#a7c892;--field:#121713;--selected:#28352e;--hover:#202621;--shadow:rgba(0,0,0,.32);--font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--mono:ui-monospace,SFMono-Regular,Consolas,monospace;--r:4px;--control:32px}html[data-theme=light]{color-scheme:light;--bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9;--line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d;--accent:#507b67;--accent2:#416b58;--accentink:#fff;--danger:#a94c48;--warn:#8a6930;--success:#587a49;--field:#fff;--selected:#dce8e0;--hover:#e8ede8;--shadow:rgba(31,39,33,.16)}
|
||||
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box;scrollbar-color:var(--line2) var(--panel);scrollbar-width:thin}html,body,#app{height:100%}body{margin:0;background:var(--bg);color:var(--text);font:13px/1.45 var(--font);overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}input,select,textarea{width:100%;min-height:var(--control);border:1px solid var(--line2);border-radius:3px;background:var(--field);padding:6px 8px;outline:0}textarea{min-height:96px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}button{cursor:pointer}.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:var(--control);padding:5px 10px;border:1px solid var(--line2);border-radius:3px;background:var(--panel2);color:var(--text);font-weight:650}.btn:hover{border-color:var(--accent)}.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accentink)}.btn.danger{color:var(--danger)}.btn.ghost{background:transparent;border-color:transparent}.icon{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}.muted{color:var(--muted)}.mono{font-family:var(--mono)}.badge{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:1px 7px;font-size:10px;color:var(--muted)}.badge.ok{color:var(--success)}.badge.off{color:var(--subtle)}.empty{padding:32px;text-align:center;color:var(--muted)}.boot{height:100%;display:grid;place-content:center;justify-items:center;gap:12px}.boot svg{width:36px;height:36px;fill:none;stroke:var(--accent);stroke-width:1.8}.toast{position:fixed;right:18px;bottom:18px;z-index:50;max-width:420px;padding:10px 12px;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel3);box-shadow:0 10px 30px var(--shadow)}.toast.error{color:var(--danger)}dialog{width:min(620px,calc(100vw - 30px));max-height:90vh;padding:0;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel);color:var(--text);box-shadow:0 20px 60px var(--shadow)}dialog::backdrop{background:rgba(0,0,0,.56)}
|
||||
@@ -0,0 +1,54 @@
|
||||
.login-view{height:100%;display:grid;place-items:center;padding:24px}.login-panel{width:min(100%,370px);border:1px solid var(--line);background:var(--panel);border-radius:var(--r);box-shadow:0 18px 46px var(--shadow)}.login-brand{display:flex;gap:11px;align-items:center;padding:18px 20px;border-bottom:1px solid var(--line)}.login-brand svg{width:27px;height:27px;color:var(--accent)}.login-brand strong{display:block;font-size:16px}.login-brand small{color:var(--muted)}.login-form{display:grid;gap:13px;padding:20px}.field span{display:block;margin-bottom:5px;color:var(--muted);font-size:11px;font-weight:650}.login-footer{padding:0 20px 16px;color:var(--subtle);font:10px var(--mono);text-align:center}.shell{height:100%;display:grid;grid-template-rows:44px minmax(0,1fr)}.topbar{display:grid;grid-template-columns:230px 1fr auto;align-items:center;gap:12px;padding:0 10px;border-bottom:1px solid var(--line);background:var(--panel)}.brand{display:flex;align-items:center;gap:8px;font-weight:700}.brand svg{color:var(--accent)}.search{max-width:540px;position:relative}.search input{height:30px;min-height:30px;padding-left:30px}.search svg{position:absolute;left:9px;top:7px;color:var(--muted)}.top-actions{display:flex;align-items:center;gap:4px}.who{padding:0 8px;border-left:1px solid var(--line);line-height:1.2}.who strong{display:block;font-size:11px;max-width:190px;overflow:hidden;text-overflow:ellipsis}.who span{font-size:9px;color:var(--success)}.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)}.sidebar{min-height:0;padding:10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto}.nav{display:grid;gap:3px}.nav button{display:flex;align-items:center;gap:9px;width:100%;height:34px;padding:0 9px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);text-align:left}.nav button:hover{background:var(--hover);color:var(--text)}.nav button.active{background:var(--selected);color:var(--text);border-color:var(--line)}.nav button svg{width:16px;height:16px}.nav-section{margin:14px 8px 5px;color:var(--subtle);font-size:9px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.sidebar-foot{margin-top:18px;padding:10px 8px;border-top:1px solid var(--line);color:var(--subtle);font:9px var(--mono)}.content{min-width:0;min-height:0;overflow:auto;padding:20px}.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}.page-head h1{margin:0;font-size:20px;letter-spacing:-.3px}.page-head p{margin:3px 0 0;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap}.cards{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:10px}.card{padding:14px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}.card .k{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em}.card .v{margin-top:5px;font-size:25px;font-weight:720}.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden}.panel-head{display:flex;justify-content:space-between;align-items:center;min-height:40px;padding:0 11px;border-bottom:1px solid var(--line);font-weight:650}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;white-space:nowrap}th,td{height:38px;padding:0 10px;border-bottom:1px solid var(--line);text-align:left}th{position:sticky;top:0;background:var(--panel2);color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.04em}tbody tr:hover{background:var(--hover)}td.actions-cell{text-align:right}.mini{min-height:26px;height:26px;padding:2px 7px;font-size:11px}.modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--line)}.modal-head h2{margin:0;font-size:15px}.modal-body{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:14px;overflow:auto}.modal-body .wide{grid-column:1/-1}.modal-foot{display:flex;justify-content:flex-end;gap:7px;padding:12px 14px;border-top:1px solid var(--line)}.codebox{padding:12px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);font:11px/1.5 var(--mono);white-space:pre-wrap;word-break:break-all}.toolbar{display:flex;gap:7px;align-items:center;margin-bottom:10px}.toolbar select{max-width:260px}.feature-grid{display:grid;grid-template-columns:repeat(3,minmax(180px,1fr));gap:8px}.feature{padding:10px;border:1px solid var(--line);background:var(--panel);border-radius:var(--r);display:flex;justify-content:space-between;gap:10px}
|
||||
.spacer{height:12px}.panel-pad{padding:14px}.panel-margin{margin:10px}.small-value{font-size:17px!important}.section-title{margin:18px 2px 8px;color:var(--muted);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.two-col{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.stack-form{display:grid;gap:12px}.stack-form h3,.panel-pad h3{margin-top:0}.checkbox-row{display:flex!important;align-items:center;gap:8px}.checkbox-row input{width:auto;min-height:auto}.checkbox-row span{margin:0!important}.login-form .actions{justify-content:center}.login-form .btn.ghost{font-size:11px;color:var(--muted)}
|
||||
|
||||
.compact-select{width:auto;min-width:150px;max-width:240px}
|
||||
.modal-body-single{grid-template-columns:1fr}.modal-message{font-size:13px;line-height:1.55;white-space:pre-wrap}.modal-help{margin-top:-4px}.dns-record{max-height:180px;overflow:auto}.status-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.status-line .mono{font-size:11px}.panel-actions{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.path-cell{max-width:420px;overflow:hidden;text-overflow:ellipsis}
|
||||
.notice{margin:10px 0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);font-size:11px;line-height:1.5}.notice.warning{border-color:var(--warn);color:var(--warn)}
|
||||
.language-select{min-width:82px;max-width:96px;height:28px;min-height:28px;padding:2px 24px 2px 8px;font-size:10px}.login-footer{display:flex;align-items:center;justify-content:space-between;gap:10px}.login-language{display:flex;align-items:center;gap:6px;font:10px var(--sans);color:var(--muted)}.login-language span{white-space:nowrap}.login-language select{min-width:88px;height:26px;min-height:26px}
|
||||
|
||||
/* 0.0.1-dev domain-focused administration */
|
||||
.theme-toggle{width:34px;min-width:34px;height:30px;min-height:30px;padding:0;border-color:var(--line);background:var(--panel2);color:var(--text)}
|
||||
.theme-toggle:hover{background:var(--hover);border-color:var(--accent)}
|
||||
.theme-toggle .icon{width:18px;height:18px;display:block;flex:0 0 auto}
|
||||
.theme-fallback{display:none;font-size:17px;line-height:1}
|
||||
@supports not (stroke: currentColor){.theme-toggle .icon{display:none}.theme-fallback{display:block}}
|
||||
.actions-cell{white-space:nowrap}.actions-cell .btn+.btn{margin-left:6px}.action-btn .icon{width:14px;height:14px}
|
||||
.domain-link{display:inline-flex;align-items:center;gap:7px;padding:5px 7px;border:0;border-radius:3px;background:transparent;color:var(--text);font:inherit;font-family:var(--mono)}
|
||||
.domain-link:hover{background:var(--hover);color:var(--accent2)}.domain-link .icon:last-child{width:13px;height:13px;color:var(--subtle)}
|
||||
.metric-inline{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.metric-inline .icon{width:14px;height:14px;color:var(--muted)}
|
||||
.progress-cell{min-width:120px}.progress-meta{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:11px}.progress-meta .icon{width:13px;height:13px;color:var(--muted)}
|
||||
.progress-track{display:block;width:100%;height:5px;border:0;border-radius:999px;overflow:hidden;background:var(--line);appearance:none;-webkit-appearance:none}.progress-track::-webkit-progress-bar{background:var(--line);border-radius:999px}.progress-track::-webkit-progress-value{background:var(--accent);border-radius:999px}.progress-track::-moz-progress-bar{background:var(--accent);border-radius:999px}
|
||||
.progress-track.unlimited::-webkit-progress-value{background:linear-gradient(90deg,var(--line2),var(--accent),var(--line2));opacity:.55}.progress-track.unlimited::-moz-progress-bar{background:var(--accent);opacity:.55}
|
||||
.list-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;margin:0 0 10px;padding:9px 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2)}
|
||||
.domain-picker label{display:flex;align-items:center;gap:8px}.domain-picker label>span{color:var(--muted);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.domain-picker select{min-width:230px}
|
||||
.list-summary{display:flex;align-items:center;gap:14px;color:var(--muted);font-size:11px}.list-summary span{display:flex;align-items:center;gap:5px}.list-summary .icon{width:14px;height:14px}
|
||||
.domain-cards .card{min-height:92px}.metric-card{display:grid;grid-template-columns:30px minmax(0,1fr);gap:10px;align-items:start}.metric-card>.icon{width:24px;height:24px;color:var(--accent);margin-top:2px}.metric-card .progress-cell{margin-top:7px;min-width:0}
|
||||
.workspace-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}.workspace-grid .section-title{margin-top:0}.workspace-grid .panel{min-width:0}
|
||||
.backend-tabs{display:flex;gap:7px;margin:0 0 12px;padding:4px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);width:max-content;max-width:100%}.backend-tab{display:flex;align-items:center;gap:7px;min-height:32px;padding:5px 10px;border:1px solid transparent;border-radius:3px;background:transparent;color:var(--muted);font-weight:650}.backend-tab:hover{color:var(--text);background:var(--hover)}.backend-tab.active{border-color:var(--line2);background:var(--panel);color:var(--text)}.backend-tab .icon{width:15px;height:15px}
|
||||
.backend-info{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:10px;padding:10px 12px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);color:var(--muted);font-size:11px}.backend-info>div{display:flex;align-items:center;gap:7px;color:var(--text)}.backend-info .icon{width:16px;height:16px;color:var(--accent)}
|
||||
@media(max-width:1100px){.workspace-grid{grid-template-columns:1fr}.domain-cards{grid-template-columns:repeat(2,minmax(150px,1fr))}}
|
||||
@media(max-width:650px){.list-toolbar{align-items:stretch;flex-direction:column}.domain-picker label{align-items:stretch;flex-direction:column}.domain-picker select{min-width:0;max-width:none;width:100%}.list-summary{justify-content:space-between}.backend-tabs{width:100%}.backend-tab{flex:1}.backend-info{align-items:flex-start;flex-direction:column}}
|
||||
|
||||
/* 0.0.1-dev feedback, quota levels and cached icon fixes */
|
||||
.topbar{position:relative}.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;z-index:10;background:transparent}.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1s ease-in-out infinite}.route-progress[hidden]{display:none}@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
|
||||
.api-busy .domain-picker .domain-loading{display:inline-flex}.domain-loading{display:none;align-items:center;color:var(--accent)}.domain-loading .icon{width:14px;height:14px;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.theme-toggle{outline:1px solid color-mix(in srgb,var(--text) 18%,transparent)}.theme-toggle .icon{color:var(--accent2);stroke-width:2.2}.theme-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.progress-meta{justify-content:flex-start}.progress-meta strong{margin-left:auto;color:var(--subtle);font-size:9px;font-weight:750}.progress-track.medium::-webkit-progress-value{background:var(--warn)}.progress-track.medium::-moz-progress-bar{background:var(--warn)}.progress-track.high::-webkit-progress-value{background:var(--danger)}.progress-track.high::-moz-progress-bar{background:var(--danger)}
|
||||
.actions-cell .btn+.btn{margin-left:9px}.actions-cell .btn.danger{border-color:color-mix(in srgb,var(--danger) 36%,var(--line2))}
|
||||
.input-action{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.input-action .btn{white-space:nowrap}
|
||||
|
||||
/* 0.0.1-dev mailbox-user workspace */
|
||||
.user-overview-cards .mailbox-address{font-size:15px!important;font-family:var(--mono);overflow-wrap:anywhere}
|
||||
.user-overview-cards .quota-card .progress-cell{min-width:0;margin-top:8px}
|
||||
.user-account-grid .panel{min-width:0}.account-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px}
|
||||
.account-facts>div{display:grid;gap:3px;padding-bottom:8px;border-bottom:1px solid var(--line)}.account-facts span{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.account-facts strong{font-size:12px;font-weight:650;overflow-wrap:anywhere}
|
||||
.delivery-targets{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}
|
||||
.readonly-domain-value{display:flex;align-items:center;gap:7px;min-height:30px;padding:5px 9px;border:1px solid var(--line);border-radius:var(--r);background:var(--field);color:var(--text)}.readonly-domain-value>.icon{width:14px;height:14px;color:var(--muted)}.readonly-domain-value>.icon:last-child{width:12px;height:12px;margin-left:2px;color:var(--subtle)}
|
||||
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.form-grid .wide{grid-column:1/-1}.vacation-user-panel textarea{min-height:180px}
|
||||
.security-grid h3{margin:0 0 12px}.security-actions{align-items:flex-start}
|
||||
.sieve-status{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:10px;color:var(--muted);font-size:11px}
|
||||
.sieve-layout{display:grid;grid-template-columns:240px minmax(0,1fr);gap:12px;min-height:470px}.sieve-list{display:flex;flex-direction:column}.sieve-list-body{display:grid;gap:4px;padding:7px}.sieve-list-foot{margin-top:auto;padding:8px;border-top:1px solid var(--line)}
|
||||
.sieve-script{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:8px;border:1px solid transparent;border-radius:var(--r);background:transparent;color:var(--text);text-align:left}.sieve-script>span:first-child{display:flex;align-items:center;gap:7px;min-width:0}.sieve-script .icon{width:14px;height:14px;color:var(--muted)}.sieve-script:hover{background:var(--hover)}.sieve-script.selected{border-color:var(--line2);background:var(--panel2)}
|
||||
.sieve-editor-panel{display:flex;flex-direction:column;min-width:0}.sieve-editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;border-bottom:1px solid var(--line)}.sieve-editor-head>div:first-child{display:flex;align-items:center;gap:8px}.sieve-editor{flex:1;width:100%;min-height:420px;resize:vertical;border:0;border-radius:0;background:var(--field);padding:14px;font:12px/1.55 var(--mono);color:var(--text);outline:none}.sieve-editor:focus{box-shadow:inset 0 0 0 1px var(--accent)}.sieve-empty{min-height:420px;display:grid;place-items:center}.sieve-capabilities{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.compact-empty{padding:14px 8px}
|
||||
@media(max-width:900px){.sieve-layout{grid-template-columns:1fr}.sieve-list-body{grid-template-columns:repeat(2,minmax(0,1fr))}.sieve-editor-head{align-items:flex-start;flex-direction:column}.account-facts{grid-template-columns:1fr}}
|
||||
@media(max-width:560px){.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.sieve-list-body{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,2 @@
|
||||
@media(max-width:850px){.body{grid-template-columns:58px minmax(0,1fr)}.sidebar{padding:8px}.nav button{justify-content:center;padding:0}.nav button span,.nav-section,.sidebar-foot,.brand span,.search{display:none}.topbar{grid-template-columns:40px 1fr}.who strong{max-width:120px}.cards{grid-template-columns:repeat(2,1fr)}.feature-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.content{padding:12px}.cards{grid-template-columns:1fr 1fr}.page-head{display:block}.page-head .actions{margin-top:10px}.modal-body{grid-template-columns:1fr}.modal-body .wide{grid-column:auto}.who{display:none}.feature-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:860px){.two-col{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,4 @@
|
||||
00-tokens.css
|
||||
10-base.css
|
||||
20-app.css
|
||||
30-responsive.css
|
||||
@@ -0,0 +1,420 @@
|
||||
{
|
||||
"language": "en",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"Filtruj bieżący widok…": "Filter current view…",
|
||||
"Język": "Language",
|
||||
"Zmień motyw": "Change theme",
|
||||
"Wyloguj": "Sign out",
|
||||
"Przełącz na ciemny motyw": "Switch to dark theme",
|
||||
"Przełącz na jasny motyw": "Switch to light theme",
|
||||
"Przegląd": "Overview",
|
||||
"Domeny": "Domains",
|
||||
"Skrzynki": "Mailboxes",
|
||||
"Aliasy": "Aliases",
|
||||
"Administratorzy": "Administrators",
|
||||
"Logi": "Logs",
|
||||
"Wyślij / broadcast": "Send / broadcast",
|
||||
"Bezpieczeństwo": "Security",
|
||||
"Zarządzanie": "Management",
|
||||
"Usługi": "Services",
|
||||
"Konto i system": "Account & system",
|
||||
"Ładowanie…": "Loading…",
|
||||
"Anuluj": "Cancel",
|
||||
"Zapisz": "Save",
|
||||
"Zapisano": "Saved",
|
||||
"Skopiuj": "Copy",
|
||||
"Zamknij": "Close",
|
||||
"Skopiowano": "Copied",
|
||||
"Nie udało się skopiować do schowka": "Could not copy to clipboard",
|
||||
"Potwierdź": "Confirm",
|
||||
"Dalej": "Continue",
|
||||
"Odzyskiwanie hasła": "Password recovery",
|
||||
"Adres konta": "Account address",
|
||||
"Typ konta": "Account type",
|
||||
"Użytkownik skrzynki": "Mailbox user",
|
||||
"Jeśli konto spełnia warunki, kod został wysłany.": "If the account is eligible, a recovery code has been sent.",
|
||||
"Ustaw nowe hasło": "Set a new password",
|
||||
"Kod odzyskiwania": "Recovery code",
|
||||
"Nowe hasło": "New password",
|
||||
"Hasło zostało zmienione.": "Password has been changed.",
|
||||
"Panel administracyjny poczty": "Mail administration panel",
|
||||
"Hasło": "Password",
|
||||
"Zaloguj": "Sign in",
|
||||
"Odzyskaj hasło": "Recover password",
|
||||
"Mam kod": "I have a code",
|
||||
"Weryfikacja TOTP": "TOTP verification",
|
||||
"Kod 6-cyfrowy": "6-digit code",
|
||||
"Zweryfikuj": "Verify",
|
||||
"Tak": "Yes",
|
||||
"Nie": "No",
|
||||
"niedostarczalny": "undeliverable",
|
||||
"zewnetrzny": "external",
|
||||
"wylaczony": "disabled",
|
||||
"haslo wygaslo": "password expired",
|
||||
"wyłączony": "disabled",
|
||||
"Stan wirtualnej poczty i aktywnych funkcji": "Virtual mail status and enabled features",
|
||||
"Rola": "Role",
|
||||
"Aktywne moduły": "Enabled modules",
|
||||
"Limity, transport, DNS i stan domen": "Limits, transport, DNS and domain status",
|
||||
"Domena": "Domain",
|
||||
"Opis": "Description",
|
||||
"Max quota": "Max quota",
|
||||
"Aktywna": "Active",
|
||||
"Edytuj": "Edit",
|
||||
"Usuń": "Delete",
|
||||
"Nowa domena": "New domain",
|
||||
"Usuń domenę": "Delete domain",
|
||||
"Usunięto": "Deleted",
|
||||
"DNS odświeżony": "DNS refreshed",
|
||||
"Edytuj domenę": "Edit domain",
|
||||
"Limit aliasów": "Alias limit",
|
||||
"Limit skrzynek": "Mailbox limit",
|
||||
"Max quota skrzynki [MB]": "Mailbox max quota [MB]",
|
||||
"Quota domeny [MB]": "Domain quota [MB]",
|
||||
"Ważność hasła [dni]": "Password validity [days]",
|
||||
"Domena jest backup MX": "Domain is backup MX",
|
||||
"Aktywna domena": "Active domain",
|
||||
"Aliasy domyślne": "Default aliases",
|
||||
"Utwórz abuse/hostmaster/postmaster/webmaster": "Create abuse/hostmaster/postmaster/webmaster",
|
||||
"Konta, quota, SMTP, dane odzyskiwania i vacation": "Accounts, quota, SMTP, recovery data and vacation",
|
||||
"Adres": "Address",
|
||||
"Nazwa": "Name",
|
||||
"użyte": "used",
|
||||
"Nowa skrzynka": "New mailbox",
|
||||
"Usuń skrzynkę": "Delete mailbox",
|
||||
"Skrzynka usunięta": "Mailbox deleted",
|
||||
"Edytuj skrzynkę": "Edit mailbox",
|
||||
"Adres e-mail": "Email address",
|
||||
"Nazwa wyświetlana": "Display name",
|
||||
"Nowe hasło (puste = bez zmian)": "New password (empty = unchanged)",
|
||||
"Quota [MB]": "Quota [MB]",
|
||||
"Telefon": "Phone",
|
||||
"Drugi e-mail / recovery": "Secondary email / recovery",
|
||||
"Logowanie i dostarczanie aktywne": "Login and delivery enabled",
|
||||
"SMTP aktywny": "SMTP active",
|
||||
"Zezwalaj na SMTP AUTH": "Allow SMTP AUTH",
|
||||
"Wiadomość powitalna": "Welcome message",
|
||||
"Wyślij po utworzeniu": "Send after creation",
|
||||
"Usuń nieaktywne": "Delete inactive",
|
||||
"Nowy alias": "New alias",
|
||||
"Alias domeny": "Alias domain",
|
||||
"Cel": "Destination",
|
||||
"Aktywny": "Active",
|
||||
"Aliasy domen": "Alias domains",
|
||||
"Domena docelowa": "Target domain",
|
||||
"Przekierowania, self-aliasy skrzynek i alias-domains": "Forwards, mailbox self-aliases and alias domains",
|
||||
"Usuń nieaktywne aliasy": "Delete inactive aliases",
|
||||
"Alias usunięty": "Alias deleted",
|
||||
"Usuń alias": "Delete alias",
|
||||
"Edytuj AD": "Edit alias domain",
|
||||
"Usuń AD": "Delete alias domain",
|
||||
"Usuń alias domeny": "Delete alias domain",
|
||||
"Adres aliasu": "Alias address",
|
||||
"Cele (jeden na linię)": "Destinations (one per line)",
|
||||
"Jeśli adres jest skrzynką, zachowaj lokalne dostarczanie": "If the address is a mailbox, keep local delivery",
|
||||
"Zapamiętaj cele": "Remember destinations",
|
||||
"Zapisz jako domyślny cel nowych aliasów dla tego administratora": "Save as the default destination for new aliases for this administrator",
|
||||
"Alias aktywny": "Alias active",
|
||||
"Edytuj alias": "Edit alias",
|
||||
"Domena aliasowa": "Alias domain",
|
||||
"Alias domeny aktywny": "Alias domain active",
|
||||
"Edytuj alias domeny": "Edit alias domain",
|
||||
"Nowy alias domeny": "New alias domain",
|
||||
"Global-admin i administratorzy domen": "Global admin and domain administrators",
|
||||
"Nowy administrator": "New administrator",
|
||||
"Recovery": "Recovery",
|
||||
"Usuń administratora": "Delete administrator",
|
||||
"Edytuj administratora": "Edit administrator",
|
||||
"Login e-mail": "Login email",
|
||||
"Domeny (po przecinku)": "Domains (comma-separated)",
|
||||
"Pełny dostęp do wszystkich domen": "Full access to all domains",
|
||||
"Konto aktywne": "Account active",
|
||||
"Pobieranie poczty ze zdalnych skrzynek": "Fetch mail from remote mailboxes",
|
||||
"Nowy wpis": "New entry",
|
||||
"Serwer": "Server",
|
||||
"Użytkownik": "User",
|
||||
"Protokół": "Protocol",
|
||||
"Co ile": "Interval",
|
||||
"Usuń fetchmail": "Delete fetchmail",
|
||||
"Usunąć wpis fetchmail?": "Delete this fetchmail entry?",
|
||||
"Edytuj fetchmail": "Edit fetchmail",
|
||||
"Nowy fetchmail": "New fetchmail entry",
|
||||
"Docelowa skrzynka": "Target mailbox",
|
||||
"Serwer źródłowy": "Source server",
|
||||
"Użytkownik źródłowy": "Source user",
|
||||
"Nowe hasło źródłowe (puste = bez zmian)": "New source password (empty = unchanged)",
|
||||
"Hasło źródłowe": "Source password",
|
||||
"Folder źródłowy": "Source folder",
|
||||
"Interwał [min]": "Interval [min]",
|
||||
"Użyj SSL": "Use SSL",
|
||||
"Weryfikacja certyfikatu": "Certificate verification",
|
||||
"Weryfikuj certyfikat serwera": "Verify server certificate",
|
||||
"Pozostaw wiadomości na serwerze": "Keep messages on server",
|
||||
"Pobierz także stare": "Fetch old messages too",
|
||||
"Wpis aktywny": "Entry active",
|
||||
"Skrzynka dla autorespondera": "Mailbox for autoresponder",
|
||||
"Podaj pełny adres skrzynki, np. user@example.org.": "Enter the full mailbox address, e.g. user@example.org.",
|
||||
"Autoresponder": "Autoresponder",
|
||||
"Wybierz skrzynkę ponownie z menu.": "Select the mailbox again from the menu.",
|
||||
"Wyłącz/usuń": "Disable/delete",
|
||||
"Usuń vacation": "Delete vacation",
|
||||
"Vacation usunięty": "Vacation deleted",
|
||||
"Interwał odpowiedzi [s]": "Reply interval [s]",
|
||||
"Autoresponder aktywny": "Autoresponder enabled",
|
||||
"PostfixAdmin DB oraz podpisywanie hosta przez Rspamd": "PostfixAdmin DB and host signing via Rspamd",
|
||||
"PostfixAdmin DKIM (tabele DB)": "PostfixAdmin DKIM (DB tables)",
|
||||
"PostfixAdmin DKIM signing": "PostfixAdmin DKIM signing",
|
||||
"Domena klucza": "Key domain",
|
||||
"Autor": "Author",
|
||||
"Klucz": "Key",
|
||||
"Plik": "File",
|
||||
"Ścieżka private key": "Private key path",
|
||||
"NIESKONFIGUROWANE": "NOT CONFIGURED",
|
||||
"MAPY AKTYWNE": "MAPS ACTIVE",
|
||||
"Skonfiguruj mapy": "Configure maps",
|
||||
"Import Rspamd DKIM": "Import Rspamd DKIM",
|
||||
"Test config": "Test config",
|
||||
"Reload Rspamd": "Reload Rspamd",
|
||||
"Nowy klucz DKIM Rspamd": "New Rspamd DKIM key",
|
||||
"Podepnij istniejący klucz Rspamd": "Adopt existing Rspamd key",
|
||||
"Usuń plik": "Delete key file",
|
||||
"Odłącz": "Unmap",
|
||||
"BRAK": "MISSING",
|
||||
"Usuń klucz DKIM": "Delete DKIM key",
|
||||
"Edytuj klucz DKIM": "Edit DKIM key",
|
||||
"Nowy klucz DKIM": "New DKIM key",
|
||||
"Edytuj map.": "Edit mapping",
|
||||
"Usuń map.": "Delete mapping",
|
||||
"Usuń mapowanie DKIM": "Delete DKIM mapping",
|
||||
"Edytuj mapowanie DKIM": "Edit DKIM mapping",
|
||||
"Nowe mapowanie DKIM": "New DKIM mapping",
|
||||
"Mapy skonfigurowane; wykonaj jednorazowy reload Rspamd.": "Maps configured; perform a one-time Rspamd reload.",
|
||||
"Mapy Rspamd skonfigurowane": "Rspamd maps configured",
|
||||
"Przenieść poprawne wpisy domain { path/selector } do zarządzanych map? Powstanie kopia konfiguracji, zaimportowane stare bloki zostaną usunięte, a przy błędzie configtest nastąpi rollback.": "Move valid domain { path/selector } entries to managed maps? A configuration backup will be created, imported legacy blocks will be removed, and configtest failure will trigger rollback.",
|
||||
"Import DKIM": "Import DKIM",
|
||||
"Rspamd przeładowany": "Rspamd reloaded",
|
||||
"Odłącz DKIM": "Unmap DKIM",
|
||||
"Domena odłączona od map Rspamd": "Domain removed from Rspamd maps",
|
||||
"Usuń klucz Rspamd": "Delete Rspamd key",
|
||||
"Klucz Rspamd usunięty": "Rspamd key deleted",
|
||||
"Zastąp istniejące mapowanie": "Replace existing mapping",
|
||||
"Używaj dopiero po przygotowaniu nowego rekordu DNS.": "Use only after preparing the new DNS record.",
|
||||
"Reload po zapisie": "Reload after save",
|
||||
"Uruchom skonfigurowane polecenie reload Rspamd.": "Run the configured Rspamd reload command.",
|
||||
"Klucz wygenerowany. Opublikuj rekord DNS.": "Key generated. Publish the DNS record.",
|
||||
"Nowy private key (puste = zachowaj)": "New private key (empty = keep current)",
|
||||
"Private key (puste = wygeneruj)": "Private key (empty = generate)",
|
||||
"Nowy public key (puste = zachowaj)": "New public key (empty = keep current)",
|
||||
"Public key (puste = wygeneruj)": "Public key (empty = generate)",
|
||||
"Najpierw utwórz klucz DKIM.": "Create a DKIM key first.",
|
||||
"Autor (domena lub e-mail)": "Author (domain or email)",
|
||||
"Audyt zmian zgodny z tabelą log PostfixAdmin": "Audit trail compatible with the PostfixAdmin log table",
|
||||
"Czas": "Time",
|
||||
"Akcja": "Action",
|
||||
"Dane": "Data",
|
||||
"Wiadomości": "Messages",
|
||||
"Sendmail i broadcast przez skonfigurowany SMTP": "Sendmail and broadcast through configured SMTP",
|
||||
"Wyślij wiadomość": "Send message",
|
||||
"Do": "Until",
|
||||
"Temat": "Subject",
|
||||
"Treść": "Message",
|
||||
"Wyślij": "Send",
|
||||
"Nazwa nadawcy": "Sender name",
|
||||
"Tylko skrzynki, bez celów aliasów": "Mailboxes only, excluding alias destinations",
|
||||
"Wysłano": "Sent",
|
||||
"Konto": "Account",
|
||||
"Wyjątki TOTP": "TOTP exceptions",
|
||||
"Hasło, TOTP, wyjątki i application passwords": "Password, TOTP, exceptions and application passwords",
|
||||
"Zmień hasło": "Change password",
|
||||
"Skonfiguruj TOTP": "Configure TOTP",
|
||||
"Wyłącz TOTP": "Disable TOTP",
|
||||
"Dodaj wyjątek TOTP": "Add TOTP exception",
|
||||
"Nowe application password": "New application password",
|
||||
"Zmiana hasła": "Change password",
|
||||
"Obecne hasło": "Current password",
|
||||
"Potwierdź TOTP": "Confirm TOTP",
|
||||
"Sekret": "Secret",
|
||||
"URI dla aplikacji": "Application URI",
|
||||
"Wyjątek TOTP": "TOTP exception",
|
||||
"Adres IP": "IP address",
|
||||
"Konto (opcjonalnie)": "Account (optional)",
|
||||
"Nowe hasło aplikacyjne": "New application password",
|
||||
"Obecne hasło konta": "Current account password",
|
||||
"Usuń wyjątek": "Delete exception",
|
||||
"Usuń wyjątek TOTP": "Delete TOTP exception",
|
||||
"Usuń app": "Delete app password",
|
||||
"Usuń application password": "Delete application password",
|
||||
"Zgodność schematu i konfiguracja runtime": "Schema compatibility and runtime configuration",
|
||||
"Tryb DB": "DB mode",
|
||||
"Hasła": "Passwords",
|
||||
"Wersja": "Version",
|
||||
"UWAGI": "WARNINGS",
|
||||
"Ostrzeżenia schematu": "Schema warnings",
|
||||
"Brak ostrzeżeń.": "No warnings.",
|
||||
"Kompatybilne schematy haseł": "Compatible password schemes",
|
||||
"Application passwords": "Application passwords",
|
||||
"Application password": "Application password",
|
||||
"Global admin": "Global admin",
|
||||
"Store & forward": "Store & forward",
|
||||
"Fetch all": "Fetch all",
|
||||
"Keep": "Keep",
|
||||
"RSA bits": "RSA bits",
|
||||
"Selector": "Selector",
|
||||
"Public key": "Public key",
|
||||
"Private key": "Private key",
|
||||
"Backup MX": "Backup MX",
|
||||
"Backend": "Backend",
|
||||
"Schema": "Schema",
|
||||
"Połączenie z bazą tylko do odczytu": "Read-only database connection",
|
||||
"Skrzynki, aliasy i ustawienia tej domeny": "Mailboxes, aliases and settings for this domain",
|
||||
"Zarządzanie kluczami DKIM": "DKIM key management",
|
||||
"Klucze i mapowania przechowywane w bazie PostfixAdmin": "Keys and mappings stored in the PostfixAdmin database",
|
||||
"Klucze podpisujące hosta zarządzane przez Rspamd": "Host signing keys managed by Rspamd",
|
||||
"Konfigurowalny backend oparty o tabele DKIM PostfixAdmin.": "Configurable backend based on PostfixAdmin DKIM tables.",
|
||||
"Klucz DKIM": "DKIM key",
|
||||
"Mapowanie": "Mapping",
|
||||
"Status": "Status",
|
||||
"Domena nie istnieje": "Domain not found",
|
||||
"Generuj": "Generate",
|
||||
"Hasło nowej skrzynki": "New mailbox password",
|
||||
"Skrzynka zapisana": "Mailbox saved",
|
||||
"Skrzynka utworzona": "Mailbox created",
|
||||
"Hasło administratora": "Administrator password",
|
||||
"Administrator zapisany": "Administrator saved",
|
||||
"Administrator utworzony": "Administrator created",
|
||||
"Wygasanie hasła": "Password expiry",
|
||||
"Filtry": "Filters",
|
||||
"Twoja skrzynka pocztowa": "Your mailbox",
|
||||
"Nie znaleziono danych skrzynki dla zalogowanego konta.": "No mailbox data was found for the signed-in account.",
|
||||
"Dane skrzynki": "Mailbox details",
|
||||
"Nieaktywna": "Inactive",
|
||||
"E-mail odzyskiwania": "Recovery email",
|
||||
"Utworzona": "Created",
|
||||
"Zmodyfikowana": "Modified",
|
||||
"Dostarczanie i aliasy": "Delivery and aliases",
|
||||
"Cele": "Targets",
|
||||
"Brak celów aliasu.": "No alias targets.",
|
||||
"Automatyczna odpowiedź dla Twojej skrzynki": "Automatic reply for your mailbox",
|
||||
"Od": "From",
|
||||
"Zapisz Vacation": "Save vacation",
|
||||
"Vacation zapisany": "Vacation saved",
|
||||
"ManageSieve — reguły po stronie serwera": "ManageSieve — server-side rules",
|
||||
"Nowy skrypt": "New script",
|
||||
"Skrypty": "Scripts",
|
||||
"Brak skryptów Sieve.": "No Sieve scripts.",
|
||||
"Sprawdź": "Validate",
|
||||
"Aktywuj": "Activate",
|
||||
"Zmień nazwę": "Rename",
|
||||
"Dezaktywuj wszystkie": "Deactivate all",
|
||||
"Utwórz skrypt, aby rozpocząć zarządzanie filtrami.": "Create a script to start managing filters.",
|
||||
"Rozszerzenia serwera": "Server extensions",
|
||||
"Brak danych.": "No data.",
|
||||
"Nowy skrypt Sieve": "New Sieve script",
|
||||
"Nazwa skryptu": "Script name",
|
||||
"Utwórz": "Create",
|
||||
"Skrypt utworzony": "Script created",
|
||||
"Skrypt jest poprawny": "Script is valid",
|
||||
"Skrypt zawiera błąd": "The script contains an error",
|
||||
"Skrypt zapisany": "Script saved",
|
||||
"Skrypt aktywowany": "Script activated",
|
||||
"Skrypty dezaktywowane": "Scripts deactivated",
|
||||
"Zmień nazwę skryptu": "Rename script",
|
||||
"Nowa nazwa": "New name",
|
||||
"Zmień": "Rename",
|
||||
"Nazwa zmieniona": "Name changed",
|
||||
"Usuń skrypt Sieve": "Delete Sieve script",
|
||||
"Skrypt usunięty": "Script deleted",
|
||||
"Hasło, TOTP i application passwords": "Password, TOTP and application passwords",
|
||||
"Powtórz nowe hasło": "Repeat new password",
|
||||
"Dodatkowe zabezpieczenia": "Additional security",
|
||||
"TOTP jest wyłączone w konfiguracji.": "TOTP is disabled in the configuration.",
|
||||
"Nowe hasła nie są identyczne": "The new passwords do not match",
|
||||
"Hasło zostało zmienione": "Password has been changed",
|
||||
"Quota": "Quota",
|
||||
"Nieaktywny": "Inactive",
|
||||
"Vacation": "Vacation",
|
||||
"Dodaj reguły poniżej.": "Add rules below.",
|
||||
"Reguła dodana do edytora — sprawdź i zapisz skrypt": "Rule added to the editor — validate and save the script",
|
||||
"Źródło": "Source",
|
||||
"Pokaż": "Show",
|
||||
"Rekord DNS": "DNS record",
|
||||
"Rekordy DNS DKIM dla Twojej domeny — tylko do odczytu": "DKIM DNS records for your domain — read-only",
|
||||
"Brak skonfigurowanego rekordu DKIM dla tej domeny.": "No DKIM record is configured for this domain.",
|
||||
"Rspamd DKIM records are not readable on this application node": "Rspamd DKIM records are not readable on this application node"
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"match": "^Usunąć (.+?) wraz z danymi domeny\\?$",
|
||||
"replace": "Delete $1 and all domain data?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć skrzynkę (.+?)\\?$",
|
||||
"replace": "Delete mailbox $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć wszystkie nieaktywne forwardy w domenie (.+?)\\? Tej operacji nie można cofnąć\\.$",
|
||||
"replace": "Delete all inactive forwards in domain $1? This cannot be undone."
|
||||
},
|
||||
{
|
||||
"match": "^Usunięto: (\\d+), pominięto: (\\d+)$",
|
||||
"replace": "Deleted: $1, skipped: $2"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć alias (.+?)\\?$",
|
||||
"replace": "Delete alias $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć alias domeny (.+?)\\?$",
|
||||
"replace": "Delete alias domain $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć administratora (.+?)\\?$",
|
||||
"replace": "Delete administrator $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Wyłączyć i usunąć autoresponder dla (.+?)\\?$",
|
||||
"replace": "Disable and delete autoresponder for $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć klucz (.+?)\\?$",
|
||||
"replace": "Delete key $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć mapowanie dla (.+?)\\?$",
|
||||
"replace": "Delete mapping for $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć (.+?) z map Rspamd\\? Plik klucza zostanie na dysku\\.$",
|
||||
"replace": "Remove $1 from Rspamd maps? The key file will remain on disk."
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć mapowanie i prywatny klucz (.+?) z dysku\\?$",
|
||||
"replace": "Delete the mapping and private key $1 from disk?"
|
||||
},
|
||||
{
|
||||
"match": "^Broadcast: wysłano (\\d+), błędy (\\d+)$",
|
||||
"replace": "Broadcast: sent $1, failed $2"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć wyjątek (.+?)\\?$",
|
||||
"replace": "Delete exception $1?"
|
||||
},
|
||||
{
|
||||
"match": "^Usunąć application password: (.+?)\\?$",
|
||||
"replace": "Delete application password: $1?"
|
||||
},
|
||||
{
|
||||
"match": "^(.+?) / użyte (.+?)$",
|
||||
"replace": "$1 / used $2"
|
||||
},
|
||||
{
|
||||
"match": "^Zaimportowano: (\\d+)$",
|
||||
"replace": "Imported: $1"
|
||||
},
|
||||
{
|
||||
"match": "^Pominięto: (\\d+)$",
|
||||
"replace": "Skipped: $1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"language": "pl",
|
||||
"name": "Polski",
|
||||
"messages": {
|
||||
"Application passwords": "Hasła aplikacyjne",
|
||||
"Application password": "Hasło aplikacyjne",
|
||||
"Store & forward": "Zachowaj i przekieruj",
|
||||
"Global admin": "Globalny administrator",
|
||||
"Fetch all": "Pobierz wszystko",
|
||||
"Keep": "Pozostaw",
|
||||
"Private key": "Klucz prywatny",
|
||||
"Public key": "Klucz publiczny",
|
||||
"Selector": "Selektor",
|
||||
"RSA bits": "Bity RSA",
|
||||
"Backend": "Backend",
|
||||
"Schema": "Schemat",
|
||||
"Backup MX": "Backup MX",
|
||||
"Recovery": "Odzyskiwanie",
|
||||
"Mailbox": "Skrzynka",
|
||||
"Broadcast": "Broadcast",
|
||||
"Vacation": "Vacation",
|
||||
"Fetchmail": "Fetchmail",
|
||||
"DKIM": "DKIM",
|
||||
"System": "System",
|
||||
"POP/IMAP": "POP/IMAP",
|
||||
"SMTP": "SMTP",
|
||||
"SSL/TLS": "SSL/TLS",
|
||||
"MDA": "MDA",
|
||||
"Extra options": "Opcje dodatkowe",
|
||||
"Global-admin i administratorzy domen": "Global-admin i administratorzy domen",
|
||||
"READ ONLY": "TYLKO ODCZYT",
|
||||
"Invalid credentials": "Nieprawidłowe dane logowania",
|
||||
"Too many login attempts": "Zbyt wiele prób logowania",
|
||||
"Session expired": "Sesja wygasła",
|
||||
"Login required": "Wymagane logowanie",
|
||||
"MFA challenge expired": "Wyzwanie MFA wygasło",
|
||||
"Invalid TOTP code": "Nieprawidłowy kod TOTP",
|
||||
"TOTP is not configured": "TOTP nie jest skonfigurowane",
|
||||
"Account no longer exists": "Konto już nie istnieje",
|
||||
"Password recovery is disabled": "Odzyskiwanie hasła jest wyłączone",
|
||||
"Too many recovery requests": "Zbyt wiele żądań odzyskiwania hasła",
|
||||
"Too many reset attempts": "Zbyt wiele prób resetowania hasła",
|
||||
"Invalid or expired recovery code": "Nieprawidłowy lub wygasły kod odzyskiwania",
|
||||
"Database schema does not support password recovery": "Schemat bazy nie obsługuje odzyskiwania hasła",
|
||||
"Database is read-only in this development session": "Baza jest tylko do odczytu w tej sesji deweloperskiej",
|
||||
"If the account is eligible, a recovery code has been sent.": "Jeśli konto spełnia warunki, kod odzyskiwania został wysłany.",
|
||||
"DKIM is disabled.": "DKIM jest wyłączony.",
|
||||
"ACTIVE": "AKTYWNA",
|
||||
"DISABLED": "WYŁĄCZONA",
|
||||
"PostfixAdmin DKIM": "PostfixAdmin DKIM",
|
||||
"Rspamd DKIM": "Rspamd DKIM",
|
||||
"Generuj": "Generuj",
|
||||
"Hasło nowej skrzynki": "Hasło nowej skrzynki",
|
||||
"Skrzynka zapisana": "Skrzynka zapisana",
|
||||
"Skrzynka utworzona": "Skrzynka utworzona",
|
||||
"Hasło administratora": "Hasło administratora",
|
||||
"Administrator zapisany": "Administrator zapisany",
|
||||
"Administrator utworzony": "Administrator utworzony"
|
||||
},
|
||||
"patterns": []
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="icon-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></symbol>
|
||||
<symbol id="icon-mail-open" viewBox="0 0 24 24"><path d="M3 10.5 12 4l9 6.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="m3 11 9 6 9-6"/></symbol>
|
||||
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21h-4v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H3v-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.6V3h4v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1v4H21a1.7 1.7 0 0 0-1.6 1Z"/></symbol>
|
||||
<symbol id="icon-log-out" viewBox="0 0 24 24"><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/><path d="M21 19V5a2 2 0 0 0-2-2h-6"/></symbol>
|
||||
<symbol id="icon-square-pen" viewBox="0 0 24 24"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4Z"/></symbol>
|
||||
<symbol id="icon-folder-plus" viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M12 11v6M9 14h6"/></symbol>
|
||||
<symbol id="icon-folder" viewBox="0 0 24 24"><path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></symbol>
|
||||
<symbol id="icon-inbox" viewBox="0 0 24 24"><path d="M4 4h16l2 10v6H2v-6Z"/><path d="M2 14h5l2 3h6l2-3h5"/></symbol>
|
||||
<symbol id="icon-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></symbol>
|
||||
<symbol id="icon-file-text" viewBox="0 0 24 24"><path d="M6 2h9l5 5v15H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z"/><path d="M14 2v6h6M8 13h8M8 17h6"/></symbol>
|
||||
<symbol id="icon-archive" viewBox="0 0 24 24"><path d="M3 5h18v4H3Z"/><path d="M5 9v11h14V9M9 13h6"/></symbol>
|
||||
<symbol id="icon-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/></symbol>
|
||||
<symbol id="icon-shield-alert" viewBox="0 0 24 24"><path d="M12 3 4 6v6c0 5 3.4 8 8 9 4.6-1 8-4 8-9V6Z"/><path d="M12 8v5M12 17h.01"/></symbol>
|
||||
<symbol id="icon-refresh-cw" viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M4 17v-5h5"/><path d="M6.1 8a7 7 0 0 1 11.2-1L20 12M4 12l2.7 5a7 7 0 0 0 11.2-1"/></symbol>
|
||||
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="icon-arrow-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
|
||||
<symbol id="icon-reply" viewBox="0 0 24 24"><path d="m9 17-6-5 6-5v3c7 0 10 3 12 7-3-2-6-3-12-2Z"/></symbol>
|
||||
<symbol id="icon-reply-all" viewBox="0 0 24 24"><path d="m7 17-5-5 5-5v3c6 0 9 3 11 7-3-2-5-3-11-2Z"/><path d="m13 7 5 5-3 3"/></symbol>
|
||||
<symbol id="icon-forward" viewBox="0 0 24 24"><path d="m15 17 6-5-6-5v3c-7 0-10 3-12 7 3-2 6-3 12-2Z"/></symbol>
|
||||
<symbol id="icon-star" viewBox="0 0 24 24"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3l-5.6 2.9 1.1-6.2L3 9.6l6.2-.9Z"/></symbol>
|
||||
<symbol id="icon-paperclip" viewBox="0 0 24 24"><path d="m20.5 11.5-8.8 8.8a6 6 0 0 1-8.5-8.5L13.5 1.5a4 4 0 0 1 5.7 5.7L8.8 17.6a2 2 0 1 1-2.8-2.8l9.5-9.5"/></symbol>
|
||||
<symbol id="icon-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||
<symbol id="icon-bold" viewBox="0 0 24 24"><path d="M8 5h6a4 4 0 0 1 0 8H8Z"/><path d="M8 13h7a4 4 0 0 1 0 8H8Z"/></symbol>
|
||||
<symbol id="icon-italic" viewBox="0 0 24 24"><path d="M10 5h8M6 19h8M14 5l-4 14"/></symbol>
|
||||
<symbol id="icon-underline" viewBox="0 0 24 24"><path d="M7 4v7a5 5 0 0 0 10 0V4M5 21h14"/></symbol>
|
||||
<symbol id="icon-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||
<symbol id="icon-list-ordered" viewBox="0 0 24 24"><path d="M9 6h12M9 12h12M9 18h12"/><path d="M3 5h2v3M3 11h2l-2 3h2M3 17h2v4H3"/></symbol>
|
||||
<symbol id="icon-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"/><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1"/></symbol>
|
||||
<symbol id="icon-remove-format" viewBox="0 0 24 24"><path d="M4 7V4h16v3M9 20h6M12 4v16"/><path d="m17 13 4 4M21 13l-4 4"/></symbol>
|
||||
<symbol id="icon-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
|
||||
|
||||
<symbol id="icon-user-round" viewBox="0 0 24 24"><path d="M18 20a6 6 0 0 0-12 0"/><circle cx="12" cy="10" r="4"/></symbol>
|
||||
<symbol id="icon-calendar-days" viewBox="0 0 24 24"><path d="M8 2v4M16 2v4M3 10h18"/><rect x="3" y="4" width="18" height="18" rx="2"/></symbol>
|
||||
<symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="m21 15-5-5L5 19"/></symbol>
|
||||
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 6h10M18 6h2M4 12h2M10 12h10M4 18h7M15 18h5"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="12" r="2"/><circle cx="13" cy="18" r="2"/></symbol>
|
||||
<symbol id="icon-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="icon-strikethrough" viewBox="0 0 24 24"><path d="M16 4H9a4 4 0 0 0-3.7 2.5M8 20h7a4 4 0 0 0 3.8-2.8M4 12h16"/></symbol>
|
||||
<symbol id="icon-quote" viewBox="0 0 24 24"><path d="M9 11H5a4 4 0 0 1 4-4v10H5M19 11h-4a4 4 0 0 1 4-4v10h-4"/></symbol>
|
||||
<symbol id="icon-align-left" viewBox="0 0 24 24"><path d="M4 6h16M4 10h10M4 14h16M4 18h10"/></symbol>
|
||||
<symbol id="icon-align-center" viewBox="0 0 24 24"><path d="M4 6h16M7 10h10M4 14h16M7 18h10"/></symbol>
|
||||
<symbol id="icon-align-right" viewBox="0 0 24 24"><path d="M4 6h16M10 10h10M4 14h16M10 18h10"/></symbol>
|
||||
<symbol id="icon-undo" viewBox="0 0 24 24"><path d="m9 7-5 5 5 5"/><path d="M20 17a8 8 0 0 0-8-8H4"/></symbol>
|
||||
<symbol id="icon-redo" viewBox="0 0 24 24"><path d="m15 7 5 5-5 5"/><path d="M4 17a8 8 0 0 1 8-8h8"/></symbol>
|
||||
|
||||
<symbol id="icon-check-circle" viewBox="0 0 24 24"><path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||
<symbol id="icon-circle-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></symbol>
|
||||
<symbol id="icon-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
|
||||
|
||||
<symbol id="icon-printer" viewBox="0 0 24 24"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
|
||||
<symbol id="icon-filter" viewBox="0 0 24 24"><path d="M4 5h16"/><path d="M7 12h10"/><path d="M10 19h4"/></symbol>
|
||||
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></symbol>
|
||||
<symbol id="icon-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="m10 14 11-11"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||
<symbol id="icon-package" viewBox="0 0 24 24"><path d="m12 3 9 5-9 5-9-5Z"/><path d="m3 8 9 5 9-5v8l-9 5-9-5Z"/><path d="M12 13v8"/></symbol>
|
||||
<symbol id="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></symbol>
|
||||
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5 8.5 8.5 0 1 0 20.5 14.2Z"/></symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="__LANG__" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark light"><meta name="pa-rs-version" content="__VERSION__"><meta name="pa-rs-asset" content="__ASSET_ID__"><base href="__BASE_PATH__"><title>PostfixAdmin-RS</title><link rel="stylesheet" href="assets/app.__ASSET_ID__.css"></head><body><div id="app"><div class="boot"><svg><use href="assets/icons.__ASSET_ID__.svg#icon-mail"></use></svg><strong>PostfixAdmin-RS</strong><span>Loading…</span></div></div><div id="toast" class="toast" hidden></div><dialog id="modal"></dialog><script src="assets/app.__ASSET_ID__.js" defer></script></body></html>
|
||||
@@ -0,0 +1,510 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
const app = $('#app');
|
||||
const modal = $('#modal');
|
||||
const toast = $('#toast');
|
||||
|
||||
const S = { me: null, features: {}, csrf: '', page: 'overview', cache: {}, language: 'en', catalog: { messages: {}, patterns: [] }, loading: 0 };
|
||||
const LANGUAGES = { en: 'English', pl: 'Polski' };
|
||||
const ASSET_ID = document.querySelector('meta[name="pa-rs-asset"]')?.content || '';
|
||||
const icon = name => `<svg class="icon" aria-hidden="true"><use href="assets/icons.${ASSET_ID}.svg#icon-${name}"></use></svg>`;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
|
||||
function translateText(value) {
|
||||
const text = String(value ?? '');
|
||||
const exact = S.catalog?.messages?.[text];
|
||||
if (exact != null) return exact;
|
||||
for (const pattern of S.catalog?.patterns || []) {
|
||||
try {
|
||||
const regex = new RegExp(pattern.match, pattern.flags || '');
|
||||
if (regex.test(text)) return text.replace(regex, pattern.replace);
|
||||
} catch {}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function translateDom(root) {
|
||||
if (!root) return;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
for (const node of nodes) {
|
||||
const raw = node.nodeValue || '';
|
||||
const match = raw.match(/^(\s*)(.*?)(\s*)$/s);
|
||||
if (!match || !match[2]) continue;
|
||||
const translated = translateText(match[2]);
|
||||
if (translated !== match[2]) node.nodeValue = `${match[1]}${translated}${match[3]}`;
|
||||
}
|
||||
root.querySelectorAll?.('[title],[aria-label],[placeholder]').forEach(element => {
|
||||
for (const attr of ['title', 'aria-label', 'placeholder']) {
|
||||
if (!element.hasAttribute(attr)) continue;
|
||||
const value = element.getAttribute(attr);
|
||||
const translated = translateText(value);
|
||||
if (translated !== value) element.setAttribute(attr, translated);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLanguage(language) {
|
||||
const normalized = String(language || 'en').toLowerCase().split('-')[0];
|
||||
const selected = LANGUAGES[normalized] ? normalized : 'en';
|
||||
try {
|
||||
const response = await fetch(`assets/i18n/${selected}.json`, { cache: 'no-cache' });
|
||||
if (!response.ok) throw new Error(`language ${selected}: ${response.status}`);
|
||||
const catalog = await response.json();
|
||||
S.catalog = { messages: catalog.messages || {}, patterns: catalog.patterns || [] };
|
||||
S.language = selected;
|
||||
} catch (error) {
|
||||
console.warn('i18n catalog load failed', error);
|
||||
S.catalog = { messages: {}, patterns: [] };
|
||||
S.language = 'en';
|
||||
}
|
||||
document.documentElement.lang = S.language;
|
||||
localStorage.setItem('pa_language', S.language);
|
||||
}
|
||||
|
||||
async function initLanguage() {
|
||||
const configured = document.documentElement.lang || 'en';
|
||||
const selected = localStorage.getItem('pa_language') || configured || 'en';
|
||||
await loadLanguage(selected);
|
||||
}
|
||||
|
||||
async function setLanguage(language) {
|
||||
if (!LANGUAGES[language] || language === S.language) return;
|
||||
await loadLanguage(language);
|
||||
if (S.me) {
|
||||
shell();
|
||||
await go(S.page);
|
||||
} else {
|
||||
loginView();
|
||||
}
|
||||
}
|
||||
|
||||
const languageOptions = () => Object.entries(LANGUAGES)
|
||||
.map(([value, label]) => `<option value="${value}" ${value === S.language ? 'selected' : ''}>${label}</option>`)
|
||||
.join('');
|
||||
|
||||
function showToast(message, error = false) {
|
||||
toast.textContent = translateText(message);
|
||||
toast.classList.toggle('error', error);
|
||||
toast.hidden = false;
|
||||
clearTimeout(showToast.timer);
|
||||
showToast.timer = setTimeout(() => { toast.hidden = true; }, 3500);
|
||||
}
|
||||
|
||||
function updateLoading() {
|
||||
const bar = $('#routeProgress');
|
||||
if (!bar) return;
|
||||
const busy = S.loading > 0;
|
||||
bar.hidden = !busy;
|
||||
document.body.classList.toggle('api-busy', busy);
|
||||
}
|
||||
|
||||
function beginLoading() {
|
||||
S.loading += 1;
|
||||
updateLoading();
|
||||
}
|
||||
|
||||
function endLoading() {
|
||||
S.loading = Math.max(0, S.loading - 1);
|
||||
updateLoading();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (options.body && !(options.body instanceof FormData)) headers.set('content-type', 'application/json');
|
||||
if (options.method && options.method !== 'GET' && S.csrf) headers.set('x-csrf-token', S.csrf);
|
||||
beginLoading();
|
||||
try {
|
||||
const response = await fetch(`api${path}`, { credentials: 'same-origin', ...options, headers });
|
||||
if (response.status === 204) return null;
|
||||
let data = null;
|
||||
try { data = await response.json(); } catch {}
|
||||
if (!response.ok) {
|
||||
const error = new Error(data?.error?.message || `${response.status} ${response.statusText}`);
|
||||
error.code = data?.error?.code;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
endLoading();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function randomPassword(length = 20) {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%_-';
|
||||
const bytes = new Uint32Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, value => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
function h(tag, attrs = {}, ...children) {
|
||||
const element = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'class') element.className = value;
|
||||
else if (key.startsWith('on')) element.addEventListener(key.slice(2), value);
|
||||
else if (value != null) element.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
element.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function shell() {
|
||||
const role = S.me.role.replace('_', ' ');
|
||||
app.innerHTML = `<div class="shell">
|
||||
<header class="topbar">
|
||||
<div id="routeProgress" class="route-progress" hidden><span></span></div>
|
||||
<div class="brand">${icon('mail')}<span>PostfixAdmin-RS</span></div>
|
||||
<div class="search"><span>${icon('search')}</span><input id="globalSearch" placeholder="Filtruj bieżący widok…"></div>
|
||||
<div class="top-actions">
|
||||
${S.runtime?.database_read_only ? '<span class="badge off" title="Połączenie z bazą tylko do odczytu">READ ONLY</span>' : ''}
|
||||
<select id="languageSelect" class="compact-select language-select" title="Język" aria-label="Język">${languageOptions()}</select>
|
||||
<button class="btn theme-toggle" id="themeBtn" title="Zmień motyw" aria-label="Zmień motyw"></button>
|
||||
<div class="who"><strong>${esc(S.me.username)}</strong><span>${esc(role)}</span></div>
|
||||
<button class="btn ghost" id="logoutBtn" title="Wyloguj">${icon('log-out')}</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<aside class="sidebar"><nav class="nav" id="nav"></nav><div class="sidebar-foot">Rust ${esc(window.PA_RS?.version || '')}</div></aside>
|
||||
<main class="content" id="content"></main>
|
||||
</div>
|
||||
</div>`;
|
||||
$('#logoutBtn').onclick = logout;
|
||||
$('#languageSelect').onchange = event => { setLanguage(event.target.value); };
|
||||
const updateThemeButton = () => {
|
||||
const current = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||
const button = $('#themeBtn');
|
||||
const nextLabel = current === 'light' ? 'Przełącz na ciemny motyw' : 'Przełącz na jasny motyw';
|
||||
button.innerHTML = `${icon(current === 'light' ? 'moon' : 'sun')}<span class="theme-fallback" aria-hidden="true">${current === 'light' ? '☾' : '☀'}</span>`;
|
||||
button.title = nextLabel;
|
||||
button.setAttribute('aria-label', nextLabel);
|
||||
};
|
||||
updateThemeButton();
|
||||
$('#themeBtn').onclick = () => {
|
||||
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('pa_theme', next);
|
||||
updateThemeButton();
|
||||
};
|
||||
$('#globalSearch').oninput = event => {
|
||||
const query = event.target.value.toLowerCase();
|
||||
$$('#content tbody tr').forEach(row => { row.hidden = !row.textContent.toLowerCase().includes(query); });
|
||||
};
|
||||
renderNav();
|
||||
translateDom(app);
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = $('#nav');
|
||||
const items = [
|
||||
['overview', 'inbox', 'Przegląd', true],
|
||||
['domains', 'folder', 'Domeny', S.me.kind === 'admin'],
|
||||
['mailboxes', 'mail', 'Skrzynki', S.me.kind === 'admin'],
|
||||
['aliases', 'forward', 'Aliasy', true],
|
||||
['admins', 'user-round', 'Administratorzy', S.me.role === 'global_admin'],
|
||||
['fetchmail', 'download', 'Fetchmail', S.features.fetchmail && S.me.kind === 'admin'],
|
||||
['vacation', 'calendar-days', 'Vacation', S.features.vacation && (S.me.kind === 'admin' || S.features.vacation_control)],
|
||||
['filters', 'filter', 'Filtry', S.me.kind === 'user' && S.runtime?.sieve?.enabled],
|
||||
['dkim', 'shield-alert', 'DKIM', ((S.features.dkim || S.runtime?.rspamd_dkim?.enabled) && S.me.kind === 'admin') || (S.me.kind === 'user' && S.runtime?.dkim?.user_read_only && (S.features.dkim || S.runtime?.rspamd_dkim?.enabled))],
|
||||
['logs', 'file-text', 'Logi', S.features.logging && S.me.kind === 'admin'],
|
||||
['mail', 'send', 'Wyślij / broadcast', S.features.sendmail && S.me.kind === 'admin'],
|
||||
['security', 'settings', 'Bezpieczeństwo', true],
|
||||
['system', 'package', 'System', S.me.role === 'global_admin'],
|
||||
];
|
||||
let group = '';
|
||||
for (const [page, iconName, label, visible] of items) {
|
||||
if (!visible) continue;
|
||||
const nextGroup = ['overview', 'domains', 'mailboxes', 'aliases', 'admins'].includes(page)
|
||||
? 'Zarządzanie'
|
||||
: ['fetchmail', 'vacation', 'filters', 'dkim', 'logs', 'mail'].includes(page)
|
||||
? 'Usługi'
|
||||
: 'Konto i system';
|
||||
if (nextGroup !== group) {
|
||||
nav.insertAdjacentHTML('beforeend', `<div class="nav-section">${nextGroup}</div>`);
|
||||
group = nextGroup;
|
||||
}
|
||||
const button = h('button', { 'data-page': page, onclick: () => go(page) });
|
||||
button.innerHTML = `${icon(iconName)}<span>${label}</span>`;
|
||||
if (page === S.page) button.classList.add('active');
|
||||
nav.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
async function go(page) {
|
||||
S.page = page;
|
||||
$$('#nav button').forEach(button => button.classList.toggle('active', button.dataset.page === page));
|
||||
const content = $('#content');
|
||||
content.innerHTML = '<div class="empty">Ładowanie…</div>';
|
||||
try {
|
||||
await window.PAGES[page]();
|
||||
translateDom(content);
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div class="panel"><div class="empty">${esc(translateText(error.message))}</div></div>`;
|
||||
showToast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function pageHead(title, description, buttons = '') {
|
||||
return `<div class="page-head"><div><h1>${esc(translateText(title))}</h1><p>${esc(translateText(description))}</p></div><div class="actions">${buttons}</div></div>`;
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
return `<div class="panel"><div class="table-wrap"><table><thead><tr>${headers.map(header => `<th>${esc(translateText(header))}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div></div>`;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (modal.open) modal.close();
|
||||
modal.innerHTML = '';
|
||||
}
|
||||
|
||||
function formModal(title, fields, values, onSubmit) {
|
||||
modal.innerHTML = '';
|
||||
const form = h('form');
|
||||
form.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-close>${icon('x')}</button></div><div class="modal-body"></div><div class="modal-foot"><button class="btn" type="button" data-close>Anuluj</button><button class="btn primary" type="submit">Zapisz</button></div>`;
|
||||
const body = $('.modal-body', form);
|
||||
|
||||
for (const field of fields) {
|
||||
const label = h('label', { class: `field ${field.wide ? 'wide' : ''}` });
|
||||
label.innerHTML = `<span>${esc(field.label)}</span>`;
|
||||
let element;
|
||||
if (field.type === 'select') {
|
||||
element = h('select', { name: field.name });
|
||||
for (const [value, optionLabel] of field.options) {
|
||||
const option = h('option', { value }, optionLabel);
|
||||
if (String(values[field.name] ?? '') === String(value)) option.selected = true;
|
||||
element.append(option);
|
||||
}
|
||||
} else if (field.type === 'textarea') {
|
||||
element = h('textarea', { name: field.name });
|
||||
element.value = values[field.name] ?? '';
|
||||
} else if (field.type === 'checkbox') {
|
||||
label.className = `field checkbox-row ${field.wide ? 'wide' : ''}`;
|
||||
element = h('input', { type: 'checkbox', name: field.name });
|
||||
element.checked = Boolean(values[field.name]);
|
||||
label.append(element, h('span', {}, field.help || ''));
|
||||
body.append(label);
|
||||
continue;
|
||||
} else {
|
||||
element = h('input', {
|
||||
type: field.type || 'text',
|
||||
name: field.name,
|
||||
autocomplete: field.autocomplete || 'off',
|
||||
});
|
||||
element.value = values[field.name] ?? '';
|
||||
if (field.min != null) element.min = field.min;
|
||||
if (field.placeholder) element.placeholder = field.placeholder;
|
||||
}
|
||||
if (field.required) element.required = true;
|
||||
if (field.readonly) element.readOnly = true;
|
||||
if (field.generate) {
|
||||
const wrapper = h('div', { class: 'input-action' });
|
||||
const generate = h('button', { type: 'button', class: 'btn mini' });
|
||||
generate.innerHTML = `${icon('refresh-cw')}<span>${translateText('Generuj')}</span>`;
|
||||
generate.onclick = () => {
|
||||
element.value = randomPassword(field.generateLength || 20);
|
||||
element.type = 'text';
|
||||
element.focus();
|
||||
element.select();
|
||||
};
|
||||
wrapper.append(element, generate);
|
||||
label.append(wrapper);
|
||||
} else {
|
||||
label.append(element);
|
||||
}
|
||||
body.append(label);
|
||||
}
|
||||
|
||||
$$('[data-close]', form).forEach(button => { button.onclick = closeModal; });
|
||||
form.onsubmit = async event => {
|
||||
event.preventDefault();
|
||||
const data = {};
|
||||
for (const field of fields) {
|
||||
const element = form.elements[field.name];
|
||||
data[field.name] = field.type === 'checkbox' ? element.checked : element.value;
|
||||
}
|
||||
const submit = $('button[type="submit"]', form);
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const outcome = await onSubmit(data);
|
||||
closeModal();
|
||||
if (outcome?.refresh !== false && S.me) await go(S.page);
|
||||
if (outcome?.reveal) {
|
||||
textModal(outcome.reveal.title || 'Hasło', outcome.reveal.text || '', { copy: true });
|
||||
} else {
|
||||
showToast(outcome?.message || 'Zapisano');
|
||||
}
|
||||
} catch (error) {
|
||||
submit.disabled = false;
|
||||
showToast(error.message, true);
|
||||
}
|
||||
};
|
||||
modal.append(form);
|
||||
translateDom(modal);
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function textModal(title, text, options = {}) {
|
||||
modal.innerHTML = '';
|
||||
const wrapper = h('div');
|
||||
wrapper.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-close>${icon('x')}</button></div>
|
||||
<div class="modal-body modal-body-single"><div class="codebox dns-record">${esc(text || '')}</div></div>
|
||||
<div class="modal-foot">${options.copy === false ? '' : '<button class="btn" type="button" data-copy>Skopiuj</button>'}<button class="btn primary" type="button" data-close>Zamknij</button></div>`;
|
||||
$$('[data-close]', wrapper).forEach(button => { button.onclick = closeModal; });
|
||||
const copy = $('[data-copy]', wrapper);
|
||||
if (copy) copy.onclick = async () => {
|
||||
try { await navigator.clipboard.writeText(String(text || '')); showToast('Skopiowano'); }
|
||||
catch { showToast('Nie udało się skopiować do schowka', true); }
|
||||
};
|
||||
modal.append(wrapper);
|
||||
translateDom(modal);
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
function confirmModal(title, message, options = {}) {
|
||||
return new Promise(resolve => {
|
||||
modal.innerHTML = '';
|
||||
const form = h('form');
|
||||
const confirmLabel = options.confirmLabel || 'Potwierdź';
|
||||
const dangerClass = options.danger === false ? 'primary' : 'danger';
|
||||
form.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
|
||||
<div class="modal-body modal-body-single"><div class="modal-message">${esc(message)}</div></div>
|
||||
<div class="modal-foot"><button class="btn" type="button" data-cancel>Anuluj</button><button class="btn ${dangerClass}" type="submit">${esc(confirmLabel)}</button></div>`;
|
||||
let settled = false;
|
||||
const finish = value => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
closeModal();
|
||||
resolve(value);
|
||||
};
|
||||
$$('[data-cancel]', form).forEach(button => { button.onclick = () => finish(false); });
|
||||
form.onsubmit = event => { event.preventDefault(); finish(true); };
|
||||
modal.addEventListener('cancel', event => { event.preventDefault(); finish(false); }, { once: true });
|
||||
modal.append(form);
|
||||
translateDom(modal);
|
||||
modal.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function promptModal(title, label, value = '', options = {}) {
|
||||
return new Promise(resolve => {
|
||||
modal.innerHTML = '';
|
||||
const form = h('form');
|
||||
form.innerHTML = `<div class="modal-head"><h2>${esc(title)}</h2><button class="btn ghost" type="button" data-cancel>${icon('x')}</button></div>
|
||||
<div class="modal-body modal-body-single"><label class="field"><span>${esc(label)}</span><input name="value" type="${esc(options.type || 'text')}" autocomplete="${esc(options.autocomplete || 'off')}" ${options.required === false ? '' : 'required'}></label>${options.help ? `<div class="muted modal-help">${esc(options.help)}</div>` : ''}</div>
|
||||
<div class="modal-foot"><button class="btn" type="button" data-cancel>Anuluj</button><button class="btn primary" type="submit">${esc(options.submitLabel || 'Dalej')}</button></div>`;
|
||||
const input = $('input[name="value"]', form);
|
||||
input.value = value || '';
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
closeModal();
|
||||
resolve(result);
|
||||
};
|
||||
$$('[data-cancel]', form).forEach(button => { button.onclick = () => finish(null); });
|
||||
form.onsubmit = event => { event.preventDefault(); finish(input.value); };
|
||||
modal.addEventListener('cancel', event => { event.preventDefault(); finish(null); }, { once: true });
|
||||
modal.append(form);
|
||||
translateDom(modal);
|
||||
modal.showModal();
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await api('/auth/logout', { method: 'POST' }); }
|
||||
finally {
|
||||
S.me = null;
|
||||
S.csrf = '';
|
||||
loginView();
|
||||
}
|
||||
}
|
||||
|
||||
function recoveryRequestModal() {
|
||||
formModal('Odzyskiwanie hasła', [
|
||||
{ name: 'username', label: 'Adres konta', type: 'email', required: true },
|
||||
{ name: 'kind', label: 'Typ konta', type: 'select', options: [['user', 'Użytkownik skrzynki'], ['admin', 'Administrator']] },
|
||||
], { kind: 'user' }, async data => {
|
||||
const result = await api('/auth/recovery/request', { method: 'POST', body: JSON.stringify(data) });
|
||||
return { refresh: false, message: result.message || 'Jeśli konto spełnia warunki, kod został wysłany.' };
|
||||
});
|
||||
}
|
||||
|
||||
function recoveryResetModal() {
|
||||
formModal('Ustaw nowe hasło', [
|
||||
{ name: 'username', label: 'Adres konta', type: 'email', required: true },
|
||||
{ name: 'kind', label: 'Typ konta', type: 'select', options: [['user', 'Użytkownik skrzynki'], ['admin', 'Administrator']] },
|
||||
{ name: 'token', label: 'Kod odzyskiwania', required: true, autocomplete: 'one-time-code' },
|
||||
{ name: 'new_password', label: 'Nowe hasło', type: 'password', required: true, autocomplete: 'new-password' },
|
||||
], { kind: 'user' }, async data => {
|
||||
await api('/auth/recovery/reset', { method: 'POST', body: JSON.stringify(data) });
|
||||
return { refresh: false, message: 'Hasło zostało zmienione.' };
|
||||
});
|
||||
}
|
||||
|
||||
function loginView() {
|
||||
app.innerHTML = `<div class="login-view"><section class="login-panel">
|
||||
<div class="login-brand">${icon('mail')}<div><strong>PostfixAdmin-RS</strong><small>Panel administracyjny poczty</small></div></div>
|
||||
<form class="login-form" id="loginForm">
|
||||
<label class="field"><span>Login</span><input name="username" type="email" required autofocus autocomplete="username"></label>
|
||||
<label class="field"><span>Hasło</span><input name="password" type="password" required autocomplete="current-password"></label>
|
||||
<label class="field"><span>Typ konta</span><select name="kind"><option value="admin">Administrator</option><option value="user">Użytkownik skrzynki</option></select></label>
|
||||
<button class="btn primary" type="submit">Zaloguj</button>
|
||||
<div class="actions"><button class="btn ghost" type="button" id="recoverBtn">Odzyskaj hasło</button><button class="btn ghost" type="button" id="resetBtn">Mam kod</button></div>
|
||||
<div id="loginErr" class="muted"></div>
|
||||
</form>
|
||||
<div class="login-footer"><label class="login-language"><span>Język</span><select id="loginLanguage" class="compact-select">${languageOptions()}</select></label><span>PostfixAdmin-RS ${esc(window.PA_RS?.version || '')}</span></div>
|
||||
</section></div>`;
|
||||
|
||||
translateDom(app);
|
||||
$('#loginLanguage').onchange = event => { setLanguage(event.target.value); };
|
||||
$('#recoverBtn').onclick = recoveryRequestModal;
|
||||
$('#resetBtn').onclick = recoveryResetModal;
|
||||
$('#loginForm').onsubmit = async event => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.target);
|
||||
try {
|
||||
const result = await api('/auth/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(form)) });
|
||||
if (result.mfa_required) {
|
||||
const code = await promptModal('Weryfikacja TOTP', 'Kod 6-cyfrowy', '', { autocomplete: 'one-time-code', submitLabel: 'Zweryfikuj' });
|
||||
if (!code) return;
|
||||
const mfa = await api('/auth/mfa', { method: 'POST', body: JSON.stringify({ challenge: result.challenge, code }) });
|
||||
S.csrf = mfa.csrf;
|
||||
} else {
|
||||
S.csrf = result.csrf;
|
||||
}
|
||||
await bootAuthed();
|
||||
} catch (error) {
|
||||
$('#loginErr').textContent = translateText(error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function bootAuthed() {
|
||||
S.me = await api('/auth/me');
|
||||
S.csrf = S.me.csrf;
|
||||
const runtime = await api('/system/features');
|
||||
S.features = runtime.features;
|
||||
S.runtime = runtime;
|
||||
shell();
|
||||
go(S.page);
|
||||
}
|
||||
|
||||
window.UI = {
|
||||
S, api, esc, icon, showToast, go, pageHead, table, formModal, confirmModal, promptModal, textModal, closeModal, loginView, bootAuthed,
|
||||
t: translateText, translateDom, initLanguage, setLanguage,
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
(()=>{'use strict';window.PA_RS={version:document.querySelector('meta[name="pa-rs-version"]')?.content||''};const saved=localStorage.getItem('pa_theme');if(saved)document.documentElement.dataset.theme=saved;async function boot(){await window.UI.initLanguage();try{await window.UI.bootAuthed()}catch{window.UI.loginView()}}boot()})();
|
||||
@@ -0,0 +1,3 @@
|
||||
00-core.js
|
||||
10-pages.js
|
||||
90-boot.js
|
||||
Reference in new issue
Block a user