mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 16:04:37 +00:00
Compare commits
1 Commits
three-tier
...
quota_expi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb80f23cfd |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,5 +1,24 @@
|
||||
# Changelog for chatmail deployment
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Automated per-user quota-keeping.
|
||||
Replace daily timer-based message expire script
|
||||
with Dovecot quota-warning-triggered cleanup (`chatmail-quota-expire`).
|
||||
When a user reaches 90% of their mailbox quota
|
||||
Dovecot calls the new script which removes the largest and oldest messages
|
||||
until usage drops below 80%.
|
||||
The daily `chatmail-expire` timer now only handles deletion
|
||||
of inactive user mailboxes.
|
||||
|
||||
After upgrading, run the following once to clean up
|
||||
mailboxes that are already over quota::
|
||||
|
||||
/usr/local/lib/chatmaild/venv/bin/chatmail-quota-expire \
|
||||
400 /home/vmail/mail/YOURDOMAIN --sweep
|
||||
|
||||
## 1.9.0 2025-12-18
|
||||
|
||||
### Documentation
|
||||
|
||||
@@ -21,8 +21,8 @@ where = ['src']
|
||||
[project.scripts]
|
||||
doveauth = "chatmaild.doveauth:main"
|
||||
chatmail-metadata = "chatmaild.metadata:main"
|
||||
chatmail-expire = "chatmaild.expire:daily_expire_main"
|
||||
chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
|
||||
chatmail-expire = "chatmaild.expire_inactive_users:main"
|
||||
chatmail-quota-expire = "chatmaild.quota_expire:main"
|
||||
chatmail-fsreport = "chatmaild.fsreport:main"
|
||||
lastlogin = "chatmaild.lastlogin:main"
|
||||
turnserver = "chatmaild.turnserver:main"
|
||||
|
||||
@@ -25,8 +25,6 @@ class Config:
|
||||
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
|
||||
self.max_mailbox_size = params["max_mailbox_size"]
|
||||
self.max_message_size = int(params.get("max_message_size", "31457280"))
|
||||
self.delete_mails_after = params["delete_mails_after"]
|
||||
self.delete_large_after = params["delete_large_after"]
|
||||
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
|
||||
self.username_min_length = int(params["username_min_length"])
|
||||
self.username_max_length = int(params["username_max_length"])
|
||||
@@ -115,7 +113,7 @@ class Config:
|
||||
|
||||
def parse_size_mb(limit):
|
||||
"""Parse a size string like ``500M`` or ``2G`` and return megabytes."""
|
||||
value = limit.strip().upper().removesuffix("B")
|
||||
value = limit.strip().upper().rstrip("B")
|
||||
if value.endswith("G"):
|
||||
return int(value[:-1]) * 1024
|
||||
if value.endswith("M"):
|
||||
|
||||
@@ -4,26 +4,17 @@ Expire old messages and addresses.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from argparse import ArgumentParser
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from stat import S_ISREG
|
||||
|
||||
from chatmaild.config import read_config
|
||||
|
||||
FileEntry = namedtuple("FileEntry", ("path", "mtime", "size"))
|
||||
QuotaFileEntry = namedtuple("QuotaFileEntry", ("mtime", "quota_size", "path"))
|
||||
|
||||
# Quota cleanup factor of max_mailbox_size. The mailbox is reset to this size.
|
||||
QUOTA_CLEANUP_FACTOR = 0.7
|
||||
|
||||
# e.g. "cur/1775324677.M448978P3029757.exam,S=3235,W=3305:2,S"
|
||||
_dovecot_fn_rex = re.compile(r".+/(\d+)\..+,S=(\d+)")
|
||||
|
||||
|
||||
def iter_mailboxes(basedir, maxnum):
|
||||
@@ -83,54 +74,6 @@ class MailboxStat:
|
||||
self.extrafiles.sort(key=lambda x: -x.size)
|
||||
|
||||
|
||||
def parse_dovecot_filename(relpath):
|
||||
m = _dovecot_fn_rex.match(relpath)
|
||||
if not m:
|
||||
return None
|
||||
return QuotaFileEntry(int(m.group(1)), int(m.group(2)), relpath)
|
||||
|
||||
|
||||
def scan_mailbox_messages(mbox):
|
||||
messages = []
|
||||
for sub in ("cur", "new"):
|
||||
for name in os_listdir_if_exists(mbox / sub):
|
||||
if entry := parse_dovecot_filename(f"{sub}/{name}"):
|
||||
messages.append(entry)
|
||||
return messages
|
||||
|
||||
|
||||
# Within this window, large messages are deleted before small ones.
|
||||
DELETE_LARGE_FIRST_DAYS = 7
|
||||
|
||||
|
||||
def expire_to_target(mbox, target_bytes):
|
||||
cutoff = time.time() - DELETE_LARGE_FIRST_DAYS * 86400
|
||||
messages = scan_mailbox_messages(mbox)
|
||||
|
||||
def sort_key(msg):
|
||||
# prio 0: Older than cutoff -> remove oldest first
|
||||
if msg.mtime < cutoff:
|
||||
return (0, msg.mtime)
|
||||
|
||||
# prio 1: more recent than cutoff, large -> remove largest first
|
||||
if msg.quota_size > 200000:
|
||||
return (1, -msg.quota_size, msg.mtime)
|
||||
|
||||
# prio 2: more recent than cutoff, small -> remove oldest first
|
||||
return (2, msg.mtime)
|
||||
|
||||
total_size = sum(m.quota_size for m in messages)
|
||||
removed = 0
|
||||
for entry in sorted(messages, key=sort_key):
|
||||
if total_size <= target_bytes:
|
||||
break
|
||||
(mbox / entry.path).unlink(missing_ok=True)
|
||||
total_size -= entry.quota_size
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def print_info(msg):
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
@@ -172,11 +115,8 @@ class Expiry:
|
||||
cutoff_without_login = (
|
||||
self.now - int(self.config.delete_inactive_users_after) * 86400
|
||||
)
|
||||
cutoff_mails = self.now - int(self.config.delete_mails_after) * 86400
|
||||
cutoff_large_mails = self.now - int(self.config.delete_large_after) * 86400
|
||||
|
||||
self.all_mboxes += 1
|
||||
changed = False
|
||||
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
||||
self.remove_mailbox(mbox.basedir)
|
||||
return
|
||||
@@ -188,45 +128,17 @@ class Expiry:
|
||||
print_info(f"checking mailbox {date.strftime('%b %d')} {mboxname}")
|
||||
else:
|
||||
print_info(f"checking mailbox (no last_login) {mboxname}")
|
||||
self.all_files += len(mbox.messages)
|
||||
for message in mbox.messages:
|
||||
if message.mtime < cutoff_mails:
|
||||
self.remove_file(message.path, mtime=message.mtime)
|
||||
elif message.size > 200000 and message.mtime < cutoff_large_mails:
|
||||
# we only remove noticed large files (not unnoticed ones in new/)
|
||||
parts = message.path.split("/")
|
||||
if len(parts) >= 2 and parts[-2] == "cur":
|
||||
self.remove_file(message.path, mtime=message.mtime)
|
||||
else:
|
||||
continue
|
||||
changed = True
|
||||
|
||||
target_bytes = (
|
||||
self.config.max_mailbox_size_mb * 1024 * 1024 * QUOTA_CLEANUP_FACTOR
|
||||
)
|
||||
removed = expire_to_target(Path(mbox.basedir), target_bytes)
|
||||
if removed:
|
||||
changed = True
|
||||
self.del_files += removed
|
||||
if self.verbose:
|
||||
print_info(
|
||||
f"quota-expire: removed {removed} message(s) from {mboxname}"
|
||||
)
|
||||
|
||||
if changed:
|
||||
self.remove_file(f"{mbox.basedir}/maildirsize")
|
||||
|
||||
def get_summary(self):
|
||||
return (
|
||||
f"Removed {self.del_mboxes} out of {self.all_mboxes} mailboxes "
|
||||
f"and {self.del_files} out of {self.all_files} files in existing mailboxes "
|
||||
f"in {time.time() - self.start:2.2f} seconds"
|
||||
)
|
||||
|
||||
|
||||
def daily_expire_main(args=None):
|
||||
def main(args=None):
|
||||
"""Expire mailboxes and messages according to chatmail config"""
|
||||
parser = ArgumentParser(description=daily_expire_main.__doc__)
|
||||
parser = ArgumentParser(description=main.__doc__)
|
||||
ini = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||
parser.add_argument(
|
||||
"chatmail_ini",
|
||||
@@ -272,33 +184,5 @@ def daily_expire_main(args=None):
|
||||
print(exp.get_summary())
|
||||
|
||||
|
||||
def quota_expire_main(args=None):
|
||||
"""Remove mailbox messages to stay within a megabyte target.
|
||||
|
||||
This entry point is called by dovecot when a quota threshold is passed.
|
||||
"""
|
||||
|
||||
parser = ArgumentParser(description=quota_expire_main.__doc__)
|
||||
parser.add_argument(
|
||||
"target_mb",
|
||||
type=int,
|
||||
help="target mailbox size in megabytes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"mailbox_path",
|
||||
type=Path,
|
||||
help="path to a user mailbox",
|
||||
)
|
||||
args = parser.parse_args(args)
|
||||
|
||||
target_bytes = args.target_mb * 1024 * 1024
|
||||
|
||||
removed_count = expire_to_target(args.mailbox_path, target_bytes)
|
||||
if removed_count:
|
||||
(args.mailbox_path / "maildirsize").unlink(missing_ok=True)
|
||||
print(
|
||||
f"quota-expire: removed {removed_count} message(s)"
|
||||
f" from {args.mailbox_path.name}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
|
||||
@@ -18,18 +18,11 @@ max_user_send_per_minute = 60
|
||||
max_user_send_burst_size = 10
|
||||
|
||||
# maximum mailbox size of a chatmail address
|
||||
# Oldest messages will be removed automatically, so mailboxes never run full.
|
||||
max_mailbox_size = 500M
|
||||
|
||||
# maximum message size for an e-mail in bytes
|
||||
max_message_size = 31457280
|
||||
|
||||
# days after which mails are unconditionally deleted
|
||||
delete_mails_after = 20
|
||||
|
||||
# days after which large messages (>200k) are unconditionally deleted
|
||||
delete_large_after = 7
|
||||
|
||||
# days after which users without a successful login are deleted (database and mails)
|
||||
delete_inactive_users_after = 90
|
||||
|
||||
|
||||
152
chatmaild/src/chatmaild/quota_expire.py
Normal file
152
chatmaild/src/chatmaild/quota_expire.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Remove messages from a mailbox to meet a size target.
|
||||
|
||||
Dovecot calls this script when a user's quota is near its limit.
|
||||
Files are scored by ``size * age`` so that large, old messages
|
||||
are removed first.
|
||||
|
||||
Usage::
|
||||
|
||||
quota_expire <target_mb> <mailbox_path>
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from argparse import ArgumentParser
|
||||
from collections import namedtuple
|
||||
from stat import S_ISREG
|
||||
|
||||
FileEntry = namedtuple("FileEntry", ("path", "mtime", "size"))
|
||||
|
||||
|
||||
def _get_file_entry(path):
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not S_ISREG(st.st_mode):
|
||||
return None
|
||||
return FileEntry(path, st.st_mtime, st.st_size)
|
||||
|
||||
|
||||
def _listdir(path):
|
||||
try:
|
||||
return os.listdir(path)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
def scan_mailbox_messages(mailbox_dir):
|
||||
messages = []
|
||||
for sub in ("cur", "new", "tmp"):
|
||||
subdir = f"{mailbox_dir}/{sub}"
|
||||
for name in _listdir(subdir):
|
||||
entry = _get_file_entry(f"{subdir}/{name}")
|
||||
if entry is not None:
|
||||
messages.append(entry)
|
||||
return messages
|
||||
|
||||
|
||||
def _remove_stale_caches(mailbox_dir):
|
||||
for name in ("maildirsize", "dovecot.index.cache"):
|
||||
try:
|
||||
os.unlink(f"{mailbox_dir}/{name}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def expire_to_target(mailbox_dir, target_bytes, now=None):
|
||||
"""Remove highest-scored files until total size <= *target_bytes*.
|
||||
|
||||
Returns the list of removed file paths.
|
||||
"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
|
||||
messages = scan_mailbox_messages(mailbox_dir)
|
||||
total_size = sum(m.size for m in messages)
|
||||
|
||||
if total_size <= target_bytes:
|
||||
return []
|
||||
|
||||
# Score: large and old files get the highest score.
|
||||
scored = sorted(
|
||||
messages,
|
||||
key=lambda m: m.size * (now - m.mtime),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
removed = []
|
||||
for entry in scored:
|
||||
if total_size <= target_bytes:
|
||||
break
|
||||
try:
|
||||
os.unlink(entry.path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
total_size -= entry.size
|
||||
removed.append(entry.path)
|
||||
|
||||
if removed:
|
||||
_remove_stale_caches(mailbox_dir)
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
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, or with --sweep the mailboxes directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sweep",
|
||||
action="store_true",
|
||||
help="sweep all mailboxes under mailbox_path",
|
||||
)
|
||||
args = parser.parse_args(args)
|
||||
|
||||
target_bytes = args.target_mb * 1024 * 1024
|
||||
|
||||
if args.sweep:
|
||||
return _sweep(args.mailbox_path, target_bytes)
|
||||
|
||||
removed = expire_to_target(args.mailbox_path, target_bytes)
|
||||
if removed:
|
||||
print(
|
||||
f"removed {len(removed)} file(s) from {args.mailbox_path}"
|
||||
f" to reach {args.target_mb} MB target",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _sweep(mailboxes_dir, target_bytes):
|
||||
try:
|
||||
names = os.listdir(mailboxes_dir)
|
||||
except FileNotFoundError:
|
||||
print(f"directory not found: {mailboxes_dir}", file=sys.stderr)
|
||||
return 1
|
||||
for name in sorted(names):
|
||||
if "@" not in name:
|
||||
continue
|
||||
mbox = f"{mailboxes_dir}/{name}"
|
||||
removed = expire_to_target(mbox, target_bytes)
|
||||
if removed:
|
||||
print(
|
||||
f"removed {len(removed)} file(s) from {name}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from chatmaild.config import parse_size_mb, read_config
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
def test_read_config_basic(example_config):
|
||||
@@ -34,8 +34,6 @@ def test_read_config_testrun(make_config):
|
||||
assert config.postfix_reinject_port == 10025
|
||||
assert config.max_user_send_per_minute == 60
|
||||
assert config.max_mailbox_size == "500M"
|
||||
assert config.delete_mails_after == "20"
|
||||
assert config.delete_large_after == "7"
|
||||
assert config.username_min_length == 9
|
||||
assert config.username_max_length == 9
|
||||
assert config.password_min_length == 9
|
||||
@@ -121,17 +119,3 @@ 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
|
||||
from chatmaild.doveauth import AuthDictProxy
|
||||
from chatmaild.expire import daily_expire_main as main_expire
|
||||
from chatmaild.expire import main as main_expire
|
||||
|
||||
|
||||
def test_login_timestamps(example_config):
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -12,19 +8,13 @@ import pytest
|
||||
from chatmaild.expire import (
|
||||
FileEntry,
|
||||
MailboxStat,
|
||||
expire_to_target,
|
||||
get_file_entry,
|
||||
iter_mailboxes,
|
||||
os_listdir_if_exists,
|
||||
parse_dovecot_filename,
|
||||
quota_expire_main,
|
||||
scan_mailbox_messages,
|
||||
)
|
||||
from chatmaild.expire import daily_expire_main as expiry_main
|
||||
from chatmaild.expire import main as expiry_main
|
||||
from chatmaild.fsreport import main as report_main
|
||||
|
||||
MB = 1024 * 1024
|
||||
|
||||
|
||||
def fill_mbox(folderdir):
|
||||
password = folderdir.joinpath("password")
|
||||
@@ -163,35 +153,6 @@ def test_expiry_cli_basic(example_config, mbox1):
|
||||
expiry_main(args)
|
||||
|
||||
|
||||
def test_expiry_cli_old_files(capsys, example_config, mbox1):
|
||||
relpaths_old = ["cur/msg_old1", "cur/msg_old1"]
|
||||
cutoff_days = int(example_config.delete_mails_after) + 1
|
||||
create_new_messages(mbox1.basedir, relpaths_old, size=1000, days=cutoff_days)
|
||||
|
||||
relpaths_large = ["cur/msg_old_large1", "new/msg_old_large2"]
|
||||
cutoff_days = int(example_config.delete_large_after) + 1
|
||||
create_new_messages(
|
||||
mbox1.basedir, relpaths_large, size=1000 * 300, days=cutoff_days
|
||||
)
|
||||
|
||||
create_new_messages(mbox1.basedir, ["cur/shouldstay"], size=1000 * 300, days=1)
|
||||
|
||||
args = str(example_config._inipath), "--remove", "-v"
|
||||
expiry_main(args)
|
||||
out, err = capsys.readouterr()
|
||||
|
||||
allpaths = relpaths_old + relpaths_large + ["maildirsize"]
|
||||
for path in allpaths:
|
||||
for line in err.split("\n"):
|
||||
if fnmatch(line, f"removing*{path}"):
|
||||
break
|
||||
else:
|
||||
if path != "new/msg_old_large2":
|
||||
pytest.fail(f"failed to remove {path}\n{err}")
|
||||
|
||||
assert "shouldstay" not in err
|
||||
|
||||
|
||||
def test_get_file_entry(tmp_path):
|
||||
assert get_file_entry(str(tmp_path.joinpath("123123"))) is None
|
||||
p = tmp_path.joinpath("x")
|
||||
@@ -205,74 +166,3 @@ def test_os_listdir_if_exists(tmp_path):
|
||||
tmp_path.joinpath("x").write_text("hello")
|
||||
assert len(os_listdir_if_exists(str(tmp_path))) == 1
|
||||
assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0
|
||||
|
||||
|
||||
# --- quota expire tests ---
|
||||
|
||||
_msg_counter = itertools.count(1)
|
||||
|
||||
|
||||
def _create_message(basedir, sub, size, days_old=0, disk_size=None):
|
||||
seq = next(_msg_counter)
|
||||
mtime = int(time.time() - days_old * 86400)
|
||||
name = f"{mtime}.M1P1Q{seq}.hostname,S={size},W={size}:2,S"
|
||||
path = basedir / sub / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x" * (disk_size if disk_size is not None else size))
|
||||
os.utime(path, (mtime, mtime))
|
||||
return path
|
||||
|
||||
|
||||
def test_parse_dovecot_filename():
|
||||
e = parse_dovecot_filename("cur/1775324677.M448978P3029757.exam,S=3235,W=3305:2,S")
|
||||
assert e.path == "cur/1775324677.M448978P3029757.exam,S=3235,W=3305:2,S"
|
||||
assert e.mtime == 1775324677
|
||||
assert e.quota_size == 3235
|
||||
assert parse_dovecot_filename("cur/msg_without_structure") is None
|
||||
|
||||
|
||||
def test_expire_to_target(tmp_path):
|
||||
_create_message(tmp_path, "cur", MB, days_old=10, disk_size=100)
|
||||
_create_message(tmp_path, "new", MB, days_old=5)
|
||||
assert len(scan_mailbox_messages(tmp_path)) == 2
|
||||
# removes oldest first, uses S= size not disk size
|
||||
removed = expire_to_target(tmp_path, MB)
|
||||
assert removed == 1
|
||||
assert len(scan_mailbox_messages(tmp_path)) == 1
|
||||
|
||||
|
||||
def test_expire_to_target_prioritization(tmp_path):
|
||||
def create_messages():
|
||||
for sub in ("cur", "new"):
|
||||
if (tmp_path / sub).exists():
|
||||
shutil.rmtree(tmp_path / sub)
|
||||
# prio 0: older than 7 days
|
||||
_create_message(tmp_path, "cur", 5 * MB, days_old=10)
|
||||
# prio 1: last 7 days, large (>200KB)
|
||||
_create_message(tmp_path, "cur", 5 * MB, days_old=1)
|
||||
# prio 2: last 7 days, small
|
||||
_create_message(tmp_path, "cur", 1000, days_old=2)
|
||||
|
||||
# Shrink to 6MB: only the old message (prio 0) is removed.
|
||||
create_messages()
|
||||
assert expire_to_target(tmp_path, 6 * MB) == 1
|
||||
msgs = scan_mailbox_messages(tmp_path)
|
||||
assert len(msgs) == 2
|
||||
assert all(m.mtime > time.time() - 7 * 86400 for m in msgs)
|
||||
|
||||
# Shrink to 1KB: old and recent-large removed, small survives.
|
||||
create_messages()
|
||||
assert expire_to_target(tmp_path, 1024) == 2
|
||||
msgs = scan_mailbox_messages(tmp_path)
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].quota_size == 1000
|
||||
|
||||
|
||||
def test_quota_expire_main(tmp_path, capsys):
|
||||
mbox = tmp_path / "user@example.org"
|
||||
_create_message(mbox, "cur", 2 * MB, days_old=5)
|
||||
(mbox / "maildirsize").write_text("x")
|
||||
quota_expire_main([str(1), str(mbox)])
|
||||
_, err = capsys.readouterr()
|
||||
assert "quota-expire: removed 1 message(s) from user@example.org" in err
|
||||
assert not (mbox / "maildirsize").exists()
|
||||
|
||||
91
chatmaild/src/chatmaild/tests/test_quota_expire.py
Normal file
91
chatmaild/src/chatmaild/tests/test_quota_expire.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from chatmaild.quota_expire import expire_to_target, 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)
|
||||
messages = scan_mailbox_messages(str(tmp_path))
|
||||
assert len(messages) == 3
|
||||
sizes = sorted(m.size for m in messages)
|
||||
assert sizes == [100, 200, 300]
|
||||
|
||||
|
||||
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_scan_empty(tmp_path):
|
||||
assert scan_mailbox_messages(str(tmp_path)) == []
|
||||
assert scan_mailbox_messages(str(tmp_path / "nope")) == []
|
||||
|
||||
|
||||
def test_noop_under_limit(tmp_path):
|
||||
_create_message(tmp_path, "cur/msg1", MB)
|
||||
assert expire_to_target(str(tmp_path), 2 * MB) == []
|
||||
assert (tmp_path / "cur" / "msg1").exists()
|
||||
|
||||
|
||||
def test_removes_to_target(tmp_path):
|
||||
now = time.time()
|
||||
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, now=now)
|
||||
assert len(removed) == 5
|
||||
assert len(scan_mailbox_messages(str(tmp_path))) == 10
|
||||
|
||||
|
||||
def test_scoring_prefers_large_old(tmp_path):
|
||||
now = time.time()
|
||||
_create_message(tmp_path, "cur/large_old", 2 * MB, days_old=30)
|
||||
_create_message(tmp_path, "cur/small_new", MB, days_old=1)
|
||||
removed = expire_to_target(str(tmp_path), 2 * MB, now=now)
|
||||
assert len(removed) == 1
|
||||
assert "large_old" in removed[0]
|
||||
|
||||
|
||||
def test_scoring_large_new_beats_small_old(tmp_path):
|
||||
now = time.time()
|
||||
_create_message(tmp_path, "cur/big_new", 10 * MB, days_old=1)
|
||||
_create_message(tmp_path, "cur/small_old", MB, days_old=5)
|
||||
# big_new score: 10MB * 1d = 10 vs small_old score: 1MB * 5d = 5
|
||||
removed = expire_to_target(str(tmp_path), 10 * MB, now=now)
|
||||
assert len(removed) == 1
|
||||
assert "big_new" in removed[0]
|
||||
|
||||
|
||||
def test_exact_limit(tmp_path):
|
||||
_create_message(tmp_path, "cur/msg1", 5 * MB)
|
||||
assert expire_to_target(str(tmp_path), 5 * MB) == []
|
||||
|
||||
|
||||
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_text("x")
|
||||
expire_to_target(str(tmp_path), MB)
|
||||
assert not (tmp_path / "maildirsize").exists()
|
||||
assert not (tmp_path / "dovecot.index.cache").exists()
|
||||
|
||||
|
||||
def test_no_cache_removal_when_under_limit(tmp_path):
|
||||
_create_message(tmp_path, "cur/msg1", MB)
|
||||
(tmp_path / "maildirsize").write_text("x")
|
||||
expire_to_target(str(tmp_path), 2 * MB)
|
||||
assert (tmp_path / "maildirsize").exists()
|
||||
@@ -194,6 +194,12 @@ def status_cmd(args, out):
|
||||
|
||||
|
||||
def test_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"--slow",
|
||||
dest="slow",
|
||||
action="store_true",
|
||||
help="also run slow tests",
|
||||
)
|
||||
add_ssh_host_option(parser)
|
||||
|
||||
|
||||
@@ -215,6 +221,8 @@ def test_cmd(args, out):
|
||||
"-v",
|
||||
"--durations=5",
|
||||
]
|
||||
if args.slow:
|
||||
pytest_args.append("--slow")
|
||||
ret = out.run_ret(pytest_args, env=env)
|
||||
return ret
|
||||
|
||||
|
||||
@@ -150,40 +150,18 @@ class UnboundDeployer(Deployer):
|
||||
self.need_restart = False
|
||||
|
||||
def install(self):
|
||||
# Run local DNS resolver `unbound`. `resolvconf` takes care of
|
||||
# setting up /etc/resolv.conf to use 127.0.0.1 as the resolver.
|
||||
|
||||
# On an IPv4-only system, if unbound is started but not configured,
|
||||
# it causes subsequent steps to fail to resolve hosts.
|
||||
with blocked_service_startup():
|
||||
apt.packages(
|
||||
name="Install unbound",
|
||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
||||
packages=["unbound", "unbound-anchor", "dnsutils", "resolvconf"],
|
||||
)
|
||||
|
||||
def configure(self):
|
||||
# Remove dynamic resolver managers that compete for /etc/resolv.conf.
|
||||
apt.packages(
|
||||
name="Purge resolvconf",
|
||||
packages=["resolvconf"],
|
||||
present=False,
|
||||
extra_uninstall_args="--purge",
|
||||
)
|
||||
# systemd-resolved can't be purged due to dependencies; stop and mask.
|
||||
server.shell(
|
||||
name="Stop and mask systemd-resolved",
|
||||
commands=[
|
||||
"systemctl stop systemd-resolved.service || true",
|
||||
"systemctl mask systemd-resolved.service",
|
||||
],
|
||||
)
|
||||
# Configure unbound resolver with Quad9 fallback and a trailing newline
|
||||
# (SolusVM bug).
|
||||
files.put(
|
||||
name="Write static resolv.conf",
|
||||
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
||||
dest="/etc/resolv.conf",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
server.shell(
|
||||
name="Generate root keys for validating DNSSEC",
|
||||
commands=[
|
||||
@@ -590,6 +568,14 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
||||
Deployment().perform_stages([WebsiteDeployer(config)])
|
||||
return
|
||||
|
||||
if host.get_fact(Port, port=53) != "unbound":
|
||||
files.line(
|
||||
name="Add 9.9.9.9 to resolv.conf",
|
||||
path="/etc/resolv.conf",
|
||||
# Guard against resolv.conf missing a trailing newline (SolusVM bug).
|
||||
line="\nnameserver 9.9.9.9",
|
||||
)
|
||||
|
||||
# Check if mtail_address interface is available (if configured)
|
||||
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
|
||||
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
||||
|
||||
@@ -186,7 +186,7 @@ def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]
|
||||
can_modify = not is_in_container()
|
||||
for name in ("max_user_instances", "max_user_watches"):
|
||||
key = f"fs.inotify.{name}"
|
||||
value = host.get_fact(Sysctl).get(key, 0)
|
||||
value = host.get_fact(Sysctl)[key]
|
||||
if value > 65534:
|
||||
continue
|
||||
if not can_modify:
|
||||
|
||||
@@ -149,25 +149,21 @@ plugin {
|
||||
}
|
||||
|
||||
plugin {
|
||||
# for now we define static quota-rules for all users
|
||||
quota = maildir:User quota
|
||||
quota_rule = *:storage={{ config.max_mailbox_size }}
|
||||
quota_max_mail_size={{ config.max_message_size }}
|
||||
quota_grace = 0
|
||||
|
||||
quota_rule = *:storage={{ config.max_mailbox_size_mb }}M
|
||||
|
||||
# Trigger at 75%% of quota, expire oldest messages down to 70%%.
|
||||
# The percentages are chosen to prevent current Delta Chat users
|
||||
# from seeing "quota warnings" which trigger at 80% and 95%.
|
||||
|
||||
quota_warning = storage=75%% quota-warning {{ config.max_mailbox_size_mb * 70 // 100 }} {{ config.mailboxes_dir }}/%u
|
||||
# When a user reaches 90% quota, run chatmail-quota-expire
|
||||
# to remove large/old messages until usage is below 80%.
|
||||
quota_warning = storage=90%% quota-warning {{ config.max_mailbox_size_mb * 80 // 100 }} {{ config.mailboxes_dir }}/%u
|
||||
}
|
||||
|
||||
service quota-warning {
|
||||
executable = script /usr/local/lib/chatmaild/venv/bin/chatmail-quota-expire
|
||||
user = vmail
|
||||
unix_listener quota-warning {
|
||||
user = vmail
|
||||
mode = 0600
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,11 +78,3 @@ counter rejected_unencrypted_mail_count
|
||||
/Rejected unencrypted mail/ {
|
||||
rejected_unencrypted_mail_count++
|
||||
}
|
||||
|
||||
counter quota_expire_runs
|
||||
counter quota_expire_removed_files
|
||||
|
||||
/quota-expire: removed (?P<count>\d+) message\(s\)/ {
|
||||
quota_expire_runs++
|
||||
quota_expire_removed_files += $count
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ def test_rewrite_subject(cmsetup, maildata):
|
||||
assert "Subject: Unencrypted subject" not in rcvd_msg
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
||||
"""Test that the per-account send-mail limit is exceeded."""
|
||||
user1, user2 = cmsetup.gen_users(2)
|
||||
@@ -243,23 +244,6 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
||||
pytest.fail("Rate limit was not exceeded")
|
||||
|
||||
|
||||
def test_expunged(remote, chatmail_config):
|
||||
outdated_days = int(chatmail_config.delete_mails_after) + 1
|
||||
find_cmds = [
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/new/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/tmp/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
|
||||
]
|
||||
outdated_days = int(chatmail_config.delete_large_after) + 1
|
||||
find_cmds.append(
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
|
||||
)
|
||||
for cmd in find_cmds:
|
||||
for line in remote.iter_output(cmd):
|
||||
assert not line
|
||||
|
||||
|
||||
def test_deployed_state(remote):
|
||||
|
||||
@@ -23,6 +23,12 @@ def _is_ip(domain):
|
||||
return False
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--slow", action="store_true", default=False, help="also run slow tests"
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config._benchresults = {}
|
||||
config.addinivalue_line(
|
||||
@@ -30,6 +36,13 @@ def pytest_configure(config):
|
||||
)
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
markers = list(item.iter_markers(name="slow"))
|
||||
if markers:
|
||||
if not item.config.getoption("--slow"):
|
||||
pytest.skip("skipping slow test, use --slow to run")
|
||||
|
||||
|
||||
def _get_chatmail_config():
|
||||
inipath = os.environ.get("CHATMAIL_INI")
|
||||
if inipath:
|
||||
|
||||
@@ -102,12 +102,14 @@ short overview of ``chatmaild`` services:
|
||||
Apple/Google/Huawei.
|
||||
|
||||
- `chatmail-expire <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/expire.py>`_
|
||||
deletes old messages, large messages, and entire mailboxes
|
||||
of users who have not logged in for longer than
|
||||
``delete_inactive_users_after`` days.
|
||||
deletes entire mailboxes of users who have not logged in
|
||||
for longer than ``delete_inactive_users_after`` days.
|
||||
|
||||
- ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism
|
||||
and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``.
|
||||
- `chatmail-quota-expire <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/quota_expire.py>`_
|
||||
is called by Dovecot's ``quota_warning`` mechanism when a
|
||||
user reaches 90% of their mailbox quota.
|
||||
It removes the largest and oldest messages
|
||||
until usage drops below 80% of the quota.
|
||||
|
||||
- `lastlogin <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/lastlogin.py>`_
|
||||
is contacted by Dovecot when a user logs in and stores the date of
|
||||
@@ -143,7 +145,7 @@ Chatmail relay dependency diagram
|
||||
certs-nginx[("`TLS certs
|
||||
/var/lib/acme`")] --> nginx-internal;
|
||||
systemd-timer --- acmetool;
|
||||
systemd-timer --- chatmail-expire-daily;
|
||||
systemd-timer --- chatmail-expire-inactive;
|
||||
systemd-timer --- chatmail-fsreport-daily;
|
||||
acmetool --> certs[("`TLS certs
|
||||
/var/lib/acme`")];
|
||||
@@ -164,7 +166,7 @@ Chatmail relay dependency diagram
|
||||
chatmail-quota-expire --- maildir;
|
||||
lastlogin --- maildir;
|
||||
doveauth --- maildir;
|
||||
chatmail-expire-daily --- maildir;
|
||||
chatmail-expire-inactive --- maildir;
|
||||
chatmail-fsreport-daily --- maildir;
|
||||
chatmail-metadata --- iroh-relay;
|
||||
chatmail-metadata --- |encrypted device token| notifications.delta.chat;
|
||||
|
||||
Reference in New Issue
Block a user