Files
PostfixAdminRS/src/limits.rs
T
2026-11-04 08:55:53 +01:00

285 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}