285 lines
9.3 KiB
Rust
285 lines
9.3 KiB
Rust
//! 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());
|
||
}
|
||
}
|