first commit

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

No files matched your search

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

+1
View File
@@ -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>
+510
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[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
+1
View File
@@ -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()})();
+3
View File
@@ -0,0 +1,3 @@
00-core.js
10-pages.js
90-boot.js