v0.1.0
This commit is contained in:
1 parent
cdca612a59
commit
eb25f8de4a
56 files changed
+7848
-4345
No files matched your search
+284
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user