//! 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> { 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::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 { 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()); } }