[Web][Dovecot] Add retention rules: nightly deletion of mail by folder and age for domains, groups and mailboxes

This commit is contained in:
FreddleSpl0it committed 2026-11-07 21:23:13 +01:00
1 parent 46c9ec90b0
commit a64ee13670
36 files changed
+3013 -16

No files matched your search

@@ -0,0 +1,249 @@
#!/usr/bin/python3
# Nightly run of the retention rules (ofelia, dovecot container): gets the plan page by page from the mailcow UI
# (nginx:8081/retention.php), gives every folder of a mailbox to the first entry of its plan that matches it, counts
# the mail saved in the folders before the period of the rule with doveadm, deletes it unless the rule only
# simulates, and reports the results back.
import fcntl
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
plan_url = 'http://nginx:8081/retention.php'
lock_file = '/tmp/retention_runner.lock'
# folder keys of the rules and the special-use flag of their folders in the Dovecot configuration
special_use = {'trash': '\\Trash', 'junk': '\\Junk', 'sent': '\\Sent', 'drafts': '\\Drafts', 'archive': '\\Archive'}
own_folder = 'folder:'
# mailboxes per page of the plan and results per report, a large instance has many mailboxes
plan_page = 200
report_batch = 200
# a request is tried this often, waiting longer each time
tries = 3
token = hmac.new(os.environ.get('DBPASS', '').encode(), b'retention', hashlib.sha256).hexdigest()
def request(url, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json', 'X-Retention-Token': token},
method='GET' if body is None else 'POST')
with urllib.request.urlopen(req, timeout=300) as response:
return json.load(response)
def retried(url, body=None):
for attempt in range(1, tries + 1):
try:
return request(url, body)
except Exception as e:
if attempt == tries:
raise
print('retention: request failed, trying again: %s' % e, file=sys.stderr)
time.sleep(30 * attempt)
def plan():
# one page after the other, so memory stays small; rule changes apply to the pages fetched after them
after = ''
while after is not None:
page = retried(plan_url + '?' + urllib.parse.urlencode({'after': after, 'limit': plan_page}))
yield from page['mailboxes']
after = page['next']
def post(body):
# False if the report could not be sent
try:
retried(plan_url, body)
return True
except Exception as e:
print('retention: report failed: %s' % e, file=sys.stderr)
return False
class Reports:
# results go back in parts; once a part cannot be sent, the rest waits for the end of the run
def __init__(self):
self.pending, self.failed = [], False
def add(self, result):
self.pending.append(result)
if len(self.pending) >= report_batch and not self.failed:
self.failed = not self.send()
def send(self, summary=None, run=None):
# full parts, at the end all results with the summary and the run in the last part
final = summary is not None
while len(self.pending) >= report_batch or final:
body = {'results': self.pending[:report_batch]}
if final and len(self.pending) <= report_batch:
body.update(summary=summary, run=run)
if not post(body):
return False
del self.pending[:report_batch]
if 'summary' in body:
break
return True
def doveadm(*args, partial=False):
result = subprocess.run(['doveadm', *args], capture_output=True, text=True, timeout=3600)
# mailbox status also fails for single folders that cannot be opened; the others are still listed
if result.returncode != 0 and not (partial and result.stdout):
raise RuntimeError((result.stderr or result.stdout).strip()[:500] or 'doveadm exit %d' % result.returncode)
return result.stdout
def configuration():
# names of the special folders per key, and the prefixes of the shared and public namespaces (not the user's)
names = {key: set() for key in special_use}
prefixes = ['Shared/']
namespace, mailbox = None, None
# as vmail doveconf cannot read every included file (mail_crypt keys) and fails, but prints the namespaces
for line in subprocess.run(['doveconf', '-n'], capture_output=True, text=True).stdout.splitlines():
stripped = line.strip()
if line.startswith('namespace ') and line.endswith('{'):
namespace = {}
elif line == '}':
if namespace and namespace.get('type') in ('shared', 'public') and namespace.get('prefix'):
prefixes.append(namespace['prefix'].split('%')[0])
namespace = None
elif namespace is None:
continue
elif re.match(r'mailbox .+ \{$', stripped):
mailbox = stripped[len('mailbox '):-2].strip().strip('"')
elif stripped == '}':
mailbox = None
elif '=' in stripped:
key, value = (part.strip() for part in stripped.split('=', 1))
if mailbox is not None and key == 'special_use':
for folder, flag in special_use.items():
if flag in value.split():
names[folder].add(mailbox)
elif mailbox is None and key in ('type', 'prefix'):
namespace[key] = value
if not names['trash']:
raise RuntimeError('no special folders in the output of doveconf -n')
return names, [prefix for prefix in prefixes if prefix]
def own_folders(user, prefixes):
# folder name => guid of the user's own folders; listed first, so folders others share are not opened
names = set(name for name in doveadm('-f', 'tab', 'mailbox', 'list', '-u', user).splitlines()[1:]
if name and not any((name + '/').startswith(prefix) for prefix in prefixes))
folders = {}
if not names:
return folders
for line in doveadm('-f', 'tab', 'mailbox', 'status', '-u', user, 'guid', *sorted(names), partial=True).splitlines()[1:]:
name, _, guid = line.rpartition('\t')
if name in names and re.fullmatch(r'[0-9a-f]{32}', guid):
folders[name] = guid
return folders
def canonical(name):
# Dovecot writes INBOX in capitals, also as the parent of folders
return 'INBOX' + name[5:] if name[:5].upper() == 'INBOX' and name[5:6] in ('', '/') else name
def matches(action, name, names):
# match: the folder itself, an own folder name with *, the subfolders of a special folder or the inbox, all folders
key, match = action['folder'], action['match']
if match == 'all':
return True
if key.startswith(own_folder):
# * is the only wildcard of own folder names
pattern = '.*'.join(re.escape(part) for part in canonical(key[len(own_folder):]).split('*'))
return re.fullmatch(pattern, name) is not None
folders = ['INBOX'] if key == 'inbox' else names.get(key, ())
if match == 'subfolders':
return any(name.startswith(folder + '/') for folder in folders)
return name in folders
def decide(actions, folders, names):
# rule id => (its action, the folders it decides about): every folder goes to the first action that matches it
decided = {}
for name in sorted(folders):
for action in actions:
if matches(action, canonical(name), names):
decided.setdefault(action['rule_id'], (action, []))[1].append(name)
break
return decided
def found(user, query):
return len(doveadm('search', '-u', user, *query).splitlines())
def clean(user, action, selected, folders, result):
# name and guid together: doveadm then only opens these folders
query = []
for name in selected:
query += (['OR'] if query else []) + ['(', 'mailbox', name, 'mailbox-guid', folders[name], ')']
query = ['('] + query + [')', 'savedbefore', '%dd' % int(action['days'])]
if action['keep_flagged']:
query += ['NOT', 'FLAGGED']
result['matched'] = found(user, query)
if not result['matched'] or action['mode'] != 'delete':
return
try:
doveadm('expunge', '-u', user, *query)
result['deleted'] = result['matched']
except Exception as e:
# part of the mail may be gone: what is left is counted
result['error'] = str(e)[:1000]
try:
result['deleted'] = max(0, result['matched'] - found(user, query))
except Exception:
pass
def main():
# released by the kernel when the process ends, a crashed run leaves no stale lock
lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
sys.exit("retention: can't lock " + lock_file)
reports = Reports()
run = {'started': int(time.time()), 'mailboxes': 0, 'matched': 0, 'deleted': 0, 'errors': 0, 'error': ''}
summary = {}
try:
names, prefixes = configuration()
for mailbox in plan():
user = mailbox['username']
run['mailboxes'] += 1
try:
folders, error = own_folders(user, prefixes), ''
decided = decide(mailbox['actions'], folders, names)
except Exception as e:
# the folders are unknown: an error for every rule of the mailbox
folders, error, decided = {}, str(e)[:1000], {}
for action in mailbox['actions']:
decided.setdefault(action['rule_id'], (action, []))
for rule_id, (action, selected) in decided.items():
rule = summary.setdefault(rule_id, {'rule_id': rule_id, 'mailboxes': 0, 'matched': 0, 'deleted': 0, 'errors': 0,
'simulated': int(action['mode'] != 'delete')})
result = {'username': user, 'rule_id': rule_id, 'folder': '\n'.join(selected), 'days': action['days'], 'matched': 0,
'deleted': 0, 'simulated': rule['simulated'], 'error': error}
if not error:
try:
clean(user, action, selected, folders, result)
except Exception as e:
result['error'] = str(e)[:1000]
for counter in (rule, run):
counter['matched'] += result['matched']
counter['deleted'] += result['deleted']
counter['errors'] += int(result['error'] != '')
rule['mailboxes'] += 1
# only mailboxes with found mail or an error are logged, every rule gets a summary
if result['matched'] or result['error']:
reports.add(result)
except Exception as e:
run['error'] = str(e)[:1000]
reported = reports.send(list(summary.values()), run)
print('retention: %d mailboxes, %d mails deleted, %d found by simulating rules, %d errors' % (run['mailboxes'],
run['deleted'], sum(rule['matched'] for rule in summary.values() if rule['simulated']), run['errors']))
if run['error']:
print('retention: %s' % run['error'], file=sys.stderr)
if not reported:
print('retention: the results could not be reported', file=sys.stderr)
if run['error'] or not reported:
sys.exit(1)
if __name__ == '__main__':
main()