#!/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()