This commit is contained in:
Mateusz Gruszczyński committed 2026-11-04 08:55:53 +01:00
1 parent cdca612a59
commit eb25f8de4a
56 files changed
+7848 -4345

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
//! Domain limits and quota rules, ported from PostfixAdmin's
//! `get_domain_properties()`, `MailboxHandler::check_quota()`,
//! `MailboxHandler::allowed_quota()` and the `create_allowed()` checks of the
//! mailbox and alias handlers.
//!
//! Limit semantics (identical to PostfixAdmin):
//! * `0` – unlimited
//! * `-1` – disabled (nothing may be created / quota disabled)
//! * `n` – at most `n`
use crate::{
db::{Table, row_i64},
error::{AppError, AppResult},
state::AppState,
};
#[derive(Debug, Clone)]
pub struct DomainProps {
pub aliases: i64,
pub mailboxes: i64,
/// Maximum mailbox quota in MB.
pub maxquota: i64,
/// Domain quota in MB.
pub quota: i64,
/// Aliases that do not belong to a mailbox (PostfixAdmin's `alias_count`).
pub alias_count: i64,
pub mailbox_count: i64,
/// Sum of all mailbox quotas of the domain, in MB.
pub total_quota: i64,
}
impl DomainProps {
pub async fn load(state: &AppState, domain: &str) -> AppResult<Option<Self>> {
let d = state.db.table(Table::Domain);
let a = state.db.table(Table::Alias);
let m = state.db.table(Table::Mailbox);
let quota_sum = state.db.cast_i64(&format!(
"(SELECT COALESCE(SUM(CASE WHEN z.quota > 0 THEN z.quota ELSE 0 END),0) FROM {m} z WHERE z.domain=d.domain)"
));
let sql = state.db.q(&format!(
"SELECT d.aliases,d.mailboxes,d.maxquota,d.quota, \
(SELECT COUNT(*) FROM {a} x WHERE x.domain=d.domain) AS all_aliases, \
(SELECT COUNT(*) FROM {m} y WHERE y.domain=d.domain) AS mailbox_count, \
{quota_sum} AS quota_bytes \
FROM {d} d WHERE d.domain=?"
));
let Some(row) = sqlx::query(&sql)
.bind(domain)
.fetch_optional(state.db.pool())
.await?
else {
return Ok(None);
};
let mailbox_count = row_i64(&row, "mailbox_count");
let multiplier = state.config.defaults.quota_multiplier.max(1);
Ok(Some(Self {
aliases: row_i64(&row, "aliases"),
mailboxes: row_i64(&row, "mailboxes"),
maxquota: row_i64(&row, "maxquota"),
quota: row_i64(&row, "quota"),
alias_count: (row_i64(&row, "all_aliases") - mailbox_count).max(0),
mailbox_count,
total_quota: row_i64(&row, "quota_bytes") / multiplier,
}))
}
pub async fn require(state: &AppState, domain: &str) -> AppResult<Self> {
Self::load(state, domain)
.await?
.ok_or_else(|| AppError::not_found(format!("Domain does not exist: {domain}")))
}
/// `MailboxHandler::create_allowed()`
pub fn ensure_mailbox_allowed(&self) -> AppResult<()> {
match limit_state(self.mailboxes, self.mailbox_count) {
LimitState::Ok => Ok(()),
LimitState::Disabled => Err(AppError::forbidden(
"Mailboxes are disabled for this domain",
)),
LimitState::Reached => Err(AppError::conflict(format!(
"The mailbox limit of this domain has been reached ({})",
self.mailboxes
))),
}
}
/// `AliasHandler::create_allowed()` (mailbox aliases are always allowed and
/// never counted).
pub fn ensure_alias_allowed(&self) -> AppResult<()> {
match limit_state(self.aliases, self.alias_count) {
LimitState::Ok => Ok(()),
LimitState::Disabled => {
Err(AppError::forbidden("Aliases are disabled for this domain"))
}
LimitState::Reached => Err(AppError::conflict(format!(
"The alias limit of this domain has been reached ({})",
self.aliases
))),
}
}
}
enum LimitState {
Ok,
Disabled,
Reached,
}
fn limit_state(limit: i64, count: i64) -> LimitState {
if limit == 0 {
LimitState::Ok
} else if limit < 0 {
LimitState::Disabled
} else if count >= limit {
LimitState::Reached
} else {
LimitState::Ok
}
}
/// Validate a requested mailbox quota (in MB) – `MailboxHandler::check_quota()`.
///
/// `others_total` is the sum (MB) of the quotas of all *other* mailboxes of
/// the domain.
pub fn check_quota(
quota_enabled: bool,
domain_quota_enabled: bool,
props: &DomainProps,
quota: i64,
others_total: i64,
) -> AppResult<()> {
if !quota_enabled {
return Ok(());
}
if quota < -1 {
return Err(AppError::bad_request("Quota must be -1 or greater"));
}
let limit = props.maxquota;
if limit < 0 && quota < 0 {
return Ok(());
}
if quota < 0 {
return Err(AppError::bad_request(
"Quota -1 (disabled) is only allowed when the domain maximum quota is disabled",
));
}
if limit > 0 && quota == 0 {
return Err(AppError::bad_request(format!(
"Unlimited quota is not allowed – the maximum mailbox quota of this domain is {limit} MB"
)));
}
if limit != 0 && quota > limit {
return Err(AppError::bad_request(format!(
"The quota exceeds the maximum mailbox quota of this domain ({limit} MB)"
)));
}
if !domain_quota_enabled || props.quota <= 0 {
return Ok(());
}
if quota == 0 {
return Err(AppError::bad_request(
"Unlimited quota is not allowed because the domain has a total quota",
));
}
if quota + others_total > props.quota {
return Err(AppError::bad_request(format!(
"The domain quota would be exceeded ({} MB of {} MB already allocated)",
others_total, props.quota
)));
}
Ok(())
}
/// Maximum quota (MB) a mailbox may get – `MailboxHandler::allowed_quota()`.
/// `0` means unlimited, a negative value means quota is disabled.
pub fn allowed_quota(
quota_enabled: bool,
domain_quota_enabled: bool,
props: &DomainProps,
current_mailbox_quota_mb: i64,
) -> i64 {
if !quota_enabled {
return 0;
}
let mut max = props.maxquota;
if domain_quota_enabled && props.quota != 0 {
let free = props.quota - props.total_quota + current_mailbox_quota_mb.max(0);
if free < max || max == 0 {
max = free;
}
}
max
}
/// Sum (MB) of all mailbox quotas of a domain except `exclude`.
pub async fn others_total_quota(
state: &AppState,
domain: &str,
exclude: Option<&str>,
) -> AppResult<i64> {
let m = state.db.table(Table::Mailbox);
let total = state
.db
.cast_i64("COALESCE(SUM(CASE WHEN quota > 0 THEN quota ELSE 0 END),0)");
let mut sql = format!("SELECT {total} AS total FROM {m} WHERE domain=?");
if exclude.is_some() {
sql.push_str(" AND username<>?");
}
let sql = state.db.q(&sql);
let mut query = sqlx::query(&sql).bind(domain);
if let Some(exclude) = exclude {
query = query.bind(exclude);
}
let row = query.fetch_one(state.db.pool()).await?;
Ok(row_i64(&row, "total") / state.config.defaults.quota_multiplier.max(1))
}
#[cfg(test)]
mod tests {
use super::*;
fn props(maxquota: i64, quota: i64) -> DomainProps {
DomainProps {
aliases: 10,
mailboxes: 10,
maxquota,
quota,
alias_count: 0,
mailbox_count: 0,
total_quota: 0,
}
}
#[test]
fn quota_rules_match_postfixadmin() {
// maxquota limited: unlimited mailbox not allowed, above max not allowed
let p = props(100, 0);
assert!(check_quota(true, true, &p, 0, 0).is_err());
assert!(check_quota(true, true, &p, 101, 0).is_err());
assert!(check_quota(true, true, &p, 100, 0).is_ok());
// unlimited maxquota, unlimited domain quota
let p = props(0, 0);
assert!(check_quota(true, true, &p, 0, 0).is_ok());
assert!(check_quota(true, true, &p, 5000, 0).is_ok());
// domain quota
let p = props(0, 1000);
assert!(check_quota(true, true, &p, 0, 0).is_err());
assert!(check_quota(true, true, &p, 600, 500).is_err());
assert!(check_quota(true, true, &p, 500, 500).is_ok());
assert!(check_quota(true, false, &p, 600, 500).is_ok());
// disabled quota
let p = props(-1, 0);
assert!(check_quota(true, true, &p, -1, 0).is_ok());
assert!(check_quota(false, true, &props(1, 1), 99999, 0).is_ok());
}
#[test]
fn allowed_quota_rules() {
let mut p = props(100, 1000);
p.total_quota = 950;
assert_eq!(allowed_quota(true, true, &p, 0), 50);
assert_eq!(allowed_quota(true, true, &p, 30), 80);
let mut p = props(0, 1000);
p.total_quota = 200;
assert_eq!(allowed_quota(true, true, &p, 0), 800);
assert_eq!(allowed_quota(true, false, &p, 0), 0);
assert_eq!(allowed_quota(false, true, &p, 0), 0);
}
#[test]
fn limits() {
let mut p = props(0, 0);
p.mailboxes = 0;
assert!(p.ensure_mailbox_allowed().is_ok());
p.mailboxes = -1;
assert!(p.ensure_mailbox_allowed().is_err());
p.mailboxes = 2;
p.mailbox_count = 2;
assert!(p.ensure_mailbox_allowed().is_err());
p.aliases = 3;
p.alias_count = 2;
assert!(p.ensure_alias_allowed().is_ok());
}
}