feat: add quota-triggered per-user mailbox cleanup

Inflate the Dovecot-visible quota to 140% of the configured
max_mailbox_size so that Delta Chat clients (which warn at
80% of IMAP-reported quota) never show quota warnings.
A quota_warning at 72% of the inflated limit triggers
chatmail-quota-expire, which trims the mailbox to 80% of the
configured limit.  Existing over-quota mailboxes start
receiving mail again immediately after deploy
without any manual operator action needed.
This commit is contained in:
holger krekel
2026-04-18 17:08:21 +02:00
parent 0aa08b7413
commit 4512d1e735
10 changed files with 249 additions and 7 deletions

View File

@@ -22,6 +22,7 @@ where = ['src']
doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main"
chatmail-expire = "chatmaild.expire:main"
chatmail-quota-expire = "chatmaild.quota_expire:main"
chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"

View File

@@ -95,6 +95,11 @@ class Config:
# old unused option (except for first migration from sqlite to maildir store)
self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite"))
@property
def max_mailbox_size_mb(self):
"""Return max_mailbox_size as an integer in megabytes."""
return parse_size_mb(self.max_mailbox_size)
def _getbytefile(self):
return open(self._inipath, "rb")
@@ -108,6 +113,16 @@ class Config:
return User(maildir, addr, password_path, uid="vmail", gid="vmail")
def parse_size_mb(limit):
"""Parse a size string like ``500M`` or ``2G`` and return megabytes."""
value = limit.strip().upper().rstrip("B")
if value.endswith("G"):
return int(value[:-1]) * 1024
if value.endswith("M"):
return int(value[:-1])
return int(value)
def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides."""
content = get_default_config_content(mail_domain, **overrides)

View File

@@ -0,0 +1,90 @@
"""
Quota-triggered per-user mailbox cleanup.
Dovecot calls this script via ``quota_warning``
when a user crosses the quota threshold.
The script removes oldest messages first
to keep the mailbox under a specified target size.
Usage::
chatmail-quota-expire <target_mb> <mailbox_path>
"""
import sys
from argparse import ArgumentParser
from pathlib import Path
from chatmaild.expire import get_file_entry, os_listdir_if_exists
def scan_mailbox_messages(mailbox_dir):
"""Collect FileEntry items from top-level cur/new/tmp only."""
mbox = Path(mailbox_dir)
messages = []
for sub in ("cur", "new", "tmp"):
for name in os_listdir_if_exists(mbox / sub):
if entry := get_file_entry(str(mbox / sub / name)):
messages.append(entry)
return messages
def expire_to_target(mailbox_dir, target_bytes):
"""Remove oldest files until total size <= *target_bytes*.
Returns ``(removed_count, cache_bytes)`` where *cache_bytes*
is the size of the deleted ``dovecot.index.cache`` file
(0 when the file did not exist).
"""
mbox = Path(mailbox_dir)
messages = scan_mailbox_messages(mbox)
total_size = sum(m.size for m in messages)
removed = 0
for count, entry in enumerate(sorted(messages, key=lambda m: m.mtime), 1):
if total_size <= target_bytes:
break
Path(entry.path).unlink(missing_ok=True)
total_size -= entry.size
removed = count
(mbox / "maildirsize").unlink(missing_ok=True)
cache = mbox / "dovecot.index.cache"
try:
cache_bytes = cache.stat().st_size
except FileNotFoundError:
cache_bytes = 0
cache.unlink(missing_ok=True)
return removed, cache_bytes
def main(args=None):
"""Remove mailbox messages to stay within a megabyte target."""
parser = ArgumentParser(description=main.__doc__)
parser.add_argument(
"target_mb",
type=int,
help="target mailbox size in megabytes",
)
parser.add_argument(
"mailbox_path",
help="path to a user mailbox",
)
args = parser.parse_args(args)
target_bytes = args.target_mb * 1024 * 1024
removed_count, cache_bytes = expire_to_target(args.mailbox_path, target_bytes)
if removed_count:
user = Path(args.mailbox_path).name
cache_mb = cache_bytes / 1024 / 1024
print(
f"quota-expire: removed {removed_count} message(s) from {user}"
f" cache={cache_mb:.1f}MB",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,6 +1,6 @@
import pytest
from chatmaild.config import read_config
from chatmaild.config import parse_size_mb, read_config
def test_read_config_basic(example_config):
@@ -121,3 +121,17 @@ def test_config_tls_external_bad_format(make_config):
"tls_external_cert_and_key": "/only/one/path.pem",
},
)
def test_parse_size_mb():
assert parse_size_mb("500M") == 500
assert parse_size_mb("2G") == 2048
assert parse_size_mb(" 1g ") == 1024
assert parse_size_mb("100MB") == 100
assert parse_size_mb("256") == 256
def test_max_mailbox_size_mb(make_config):
config = make_config("chat.example.org")
assert config.max_mailbox_size == "500M"
assert config.max_mailbox_size_mb == 500

View File

@@ -0,0 +1,73 @@
import os
import time
from chatmaild.quota_expire import expire_to_target, main, scan_mailbox_messages
MB = 1024 * 1024
def _create_message(basedir, relpath, size, days_old=0):
path = basedir / relpath
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x" * size)
mtime = time.time() - days_old * 86400
os.utime(path, (mtime, mtime))
return path
def test_scan_cur_new_tmp(tmp_path):
_create_message(tmp_path, "cur/msg1", 100)
_create_message(tmp_path, "new/msg2", 200)
_create_message(tmp_path, "tmp/msg3", 300)
assert len(scan_mailbox_messages(str(tmp_path))) == 3
def test_scan_ignores_subfolders(tmp_path):
_create_message(tmp_path, "cur/a", 10)
_create_message(tmp_path, ".DeltaChat/cur/b", 20)
assert len(scan_mailbox_messages(str(tmp_path))) == 1
def test_removes_to_target(tmp_path):
for i in range(15):
_create_message(tmp_path, f"cur/msg{i:02d}", MB, days_old=i + 1)
removed, _ = expire_to_target(str(tmp_path), 10 * MB)
assert removed == 5
assert len(scan_mailbox_messages(str(tmp_path))) == 10
def test_removes_oldest_first(tmp_path):
_create_message(tmp_path, "cur/old_small", MB, days_old=30)
_create_message(tmp_path, "cur/new_huge", 10 * MB, days_old=1)
# the 10MB file is kept, the 1MB file is removed because it's older
removed, _ = expire_to_target(str(tmp_path), 10 * MB)
assert removed == 1
assert not (tmp_path / "cur/old_small").exists()
assert (tmp_path / "cur/new_huge").exists()
def test_exact_limit(tmp_path):
_create_message(tmp_path, "cur/msg1", 5 * MB)
removed, _ = expire_to_target(str(tmp_path), 5 * MB)
assert removed == 0
def test_removes_stale_caches(tmp_path):
_create_message(tmp_path, "cur/msg1", 2 * MB, days_old=5)
(tmp_path / "maildirsize").write_text("x")
(tmp_path / "dovecot.index.cache").write_bytes(b"y" * 4096)
removed, cache_bytes = expire_to_target(str(tmp_path), MB)
assert removed == 1
assert cache_bytes == 4096
assert not (tmp_path / "maildirsize").exists()
assert not (tmp_path / "dovecot.index.cache").exists()
def test_logging_output_is_mtail_compatible(tmp_path, capsys):
mbox = tmp_path / "user@example.org"
_create_message(mbox, "cur/msg1", 2 * MB, days_old=5)
(mbox / "dovecot.index.cache").write_bytes(b"c" * 2 * MB)
main([str(1), str(mbox)])
_, err = capsys.readouterr()
assert "quota-expire: removed 1 message(s) from user@example.org" in err
assert "cache=2.0MB" in err