Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
888e6bf
move delete_inactive_users to new implementation
hpk42 Sep 11, 2025
f863600
do all expunging in python
hpk42 Sep 11, 2025
31ac558
add summary reporting, rework expiry logic
hpk42 Sep 11, 2025
64a46d6
refactor and write tests for overall expiry/report runs
hpk42 Sep 14, 2025
8d9371e
add basic command line parsing for expire + some streamlining
hpk42 Sep 14, 2025
4be2beb
add argument parsing for reporting
hpk42 Sep 14, 2025
0dc3196
fix comment
hpk42 Sep 14, 2025
e77215c
strike superflous code
hpk42 Sep 15, 2025
a034986
more streamline
hpk42 Sep 15, 2025
f32c5c5
don't globally collect files anymore to avoid using growing-with-numb…
hpk42 Sep 15, 2025
01a2a87
no reporting by default, and adding a summary line
hpk42 Sep 15, 2025
a18c582
further reduce code
hpk42 Sep 15, 2025
c59f6c7
remove superflous Stats class
hpk42 Sep 15, 2025
5956c51
some renaming
hpk42 Sep 15, 2025
101ffca
fix lint issues
hpk42 Sep 15, 2025
d1941f0
during fsreport (reporting) don't store all mailbxoes but categorize …
hpk42 Sep 15, 2025
21efffa
remove superflous totalsize attribute
hpk42 Sep 16, 2025
c3ed0ee
prefix new commands
hpk42 Oct 20, 2025
fcea25c
address four review comments from link2xt
hpk42 Oct 20, 2025
6e26a62
cosmetic: refine summary and fix typo
hpk42 Oct 20, 2025
aeec17c
unify chatmail-fsreport and chatmail-expire to both just require a ch…
hpk42 Oct 20, 2025
3fd56ce
fix another invocation
hpk42 Oct 20, 2025
b159b60
always use "H" for printing numbers, and make "chatmail.ini" file opt…
hpk42 Oct 20, 2025
39adf4a
unify K output
hpk42 Oct 20, 2025
0e4899e
use systemd timer instead of cron-job for expiry (tested by hand on c2)
hpk42 Oct 20, 2025
b834c43
also run fsreport
hpk42 Oct 20, 2025
a5fa8cb
simplify and beautify formatting and sizes
hpk42 Oct 20, 2025
97ddeb3
Apply suggestions from code review
hpk42 Oct 20, 2025
4d69543
replace expunge mentioning in architecture
hpk42 Oct 20, 2025
aa0aa7c
try fix CI
hpk42 Oct 21, 2025
5e01aa3
make sure fsreport can run on empty mailbox dir
hpk42 Oct 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ graph LR;
/var/lib/acme`")] --> nginx-internal;
cron --- chatmail-metrics;
cron --- acmetool;
cron --- expunge;
chatmail-metrics --- website;
acmetool --> certs[("`TLS certs
/var/lib/acme`")];
Expand All @@ -35,7 +34,8 @@ graph LR;
dovecot --- users;
dovecot --- |metadata.socket|chatmail-metadata;
doveauth --- users;
expunge --- users;
chatmail-expire-daily --- users;
chatmail-fsreport-daily --- users;
chatmail-metadata --- iroh-relay;
certs-nginx --> postfix;
certs-nginx --> dovecot;
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
([#661](https://github.com/chatmail/relay/pull/661))

- Rework expiry of message files and mailboxes in Python
to only do a single iteration over sometimes millions of messages
instead of doing "find" commands that iterate 9 times over the messages.
Provide an "fsreport" CLI for more fine grained analysis of message files.
([#637](https://github.com/chatmail/relay/pull/632))


## 1.7.0 2025-09-11

- Make www upload path configurable
Expand Down
3 changes: 2 additions & 1 deletion chatmaild/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main"
chatmail-expire = "chatmaild.expire:main"
chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"

Expand Down
31 changes: 0 additions & 31 deletions chatmaild/src/chatmaild/delete_inactive_users.py

This file was deleted.

177 changes: 177 additions & 0 deletions chatmaild/src/chatmaild/expire.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Expire old messages and addresses.

"""

import os
import shutil
import sys
import time
from argparse import ArgumentParser
from collections import namedtuple
from datetime import datetime
from stat import S_ISREG

from chatmaild.config import read_config

FileEntry = namedtuple("FileEntry", ("relpath", "mtime", "size"))


def iter_mailboxes(basedir, maxnum):
if not os.path.exists(basedir):
print_info(f"no mailboxes found at: {basedir}")
return

for name in os.listdir(basedir)[:maxnum]:
if "@" in name:
yield MailboxStat(basedir + "/" + name)


class MailboxStat:
last_login = None

def __init__(self, basedir):
self.basedir = str(basedir)
# all detected messages in cur/new/tmp folders
self.messages = []

# all detected files in mailbox top dir
self.extrafiles = []

# scan all relevant files (without recursion)
old_cwd = os.getcwd()
os.chdir(self.basedir)
for name in os.listdir("."):
if name in ("cur", "new", "tmp"):
for msg_name in os.listdir(name):
relpath = name + "/" + msg_name
st = os.stat(relpath)
self.messages.append(FileEntry(relpath, st.st_mtime, st.st_size))
else:
st = os.stat(name)
if S_ISREG(st.st_mode):
self.extrafiles.append(FileEntry(name, st.st_mtime, st.st_size))
if name == "password":
self.last_login = st.st_mtime
self.extrafiles.sort(key=lambda x: -x.size)
os.chdir(old_cwd)


def print_info(msg):
print(msg, file=sys.stderr)


class Expiry:
def __init__(self, config, dry, now, verbose):
self.config = config
self.dry = dry
self.now = now
self.verbose = verbose
self.del_mboxes = 0
self.all_mboxes = 0
self.del_files = 0
self.all_files = 0
self.start = time.time()

def remove_mailbox(self, mboxdir):
if self.verbose:
print_info(f"removing {mboxdir}")
if not self.dry:
shutil.rmtree(mboxdir)
self.del_mboxes += 1

def remove_file(self, path):
if self.verbose:
print_info(f"removing {path}")
if not self.dry:
try:
os.unlink(path)
except FileNotFoundError:
print_info(f"file not found/vanished {path}")
self.del_files += 1

def process_mailbox_stat(self, mbox):
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

# all to-be-removed files are relative to the mailbox basedir
os.chdir(mbox.basedir)
self.all_files += len(mbox.messages)
for message in mbox.messages:
if message.mtime < cutoff_mails:
self.remove_file(message.relpath)
elif message.size > 200000 and message.mtime < cutoff_large_mails:
self.remove_file(message.relpath)
else:
continue
changed = True
if changed:
self.remove_file("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 main(args=None):
"""Expire mailboxes and messages according to chatmail config"""
parser = ArgumentParser(description=main.__doc__)
ini = "/usr/local/lib/chatmaild/chatmail.ini"
parser.add_argument(
"chatmail_ini",
action="store",
nargs="?",
help=f"path pointing to chatmail.ini file, default: {ini}",
default=ini,
)
parser.add_argument(
"--days", action="store", help="assume date to be days older than now"
)

parser.add_argument(
"--maxnum",
default=None,
action="store",
help="maximum number of mailboxes to iterate on",
)
parser.add_argument(
"-v",
dest="verbose",
action="store_true",
help="print out removed files and mailboxes",
)

parser.add_argument(
"--remove",
dest="remove",
action="store_true",
help="actually remove all expired files and dirs",
)
args = parser.parse_args(args)

config = read_config(args.chatmail_ini)
now = datetime.utcnow().timestamp()
if args.days:
now = now - 86400 * int(args.days)

maxnum = int(args.maxnum) if args.maxnum else None
exp = Expiry(config, dry=not args.remove, now=now, verbose=args.verbose)
for mailbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
exp.process_mailbox_stat(mailbox)
print(exp.get_summary())


if __name__ == "__main__":
main(sys.argv[1:])
Loading