mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 02:07:07 +00:00
[Web][Dovecot] Add retention rules: nightly deletion of mail by folder and age for domains, groups and mailboxes
This commit is contained in:
36 files changed
+3013
-16
No files matched your search
@@ -150,6 +150,7 @@ COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||
COPY imapsync /usr/local/bin/imapsync
|
||||
COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
|
||||
COPY retention_runner.py /usr/local/bin/retention_runner.py
|
||||
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
|
||||
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
|
||||
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
|
||||
|
||||
@@ -281,6 +281,7 @@ chown root:tty /dev/console
|
||||
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
|
||||
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
|
||||
/usr/local/bin/imapsync_runner.py \
|
||||
/usr/local/bin/retention_runner.py \
|
||||
/usr/local/bin/imapsync \
|
||||
/usr/local/bin/trim_logs.sh \
|
||||
/usr/local/bin/sa-rules.sh \
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
// Retention rules for the nightly run in the dovecot container (retention_runner.py)
|
||||
// GET ?after=<username>&limit=<n>: a page of the plan, per mailbox the folders to clean up (JSON)
|
||||
// POST (JSON {"results": [...], "summary": [...], "run": {...}}): the results
|
||||
// Both need the header X-Retention-Token: HMAC-SHA256 of "retention" with the database password as key
|
||||
header('Content-Type: application/json');
|
||||
require_once "vars.inc.php";
|
||||
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||
// Do not show errors, we log to using error_log
|
||||
ini_set('error_reporting', 0);
|
||||
if (empty($database_pass) || !hash_equals(hash_hmac('sha256', 'retention', $database_pass), (string)($_SERVER['HTTP_X_RETENTION_TOKEN'] ?? ''))) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
// Init database
|
||||
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
error_log("RETENTION: " . $e . PHP_EOL);
|
||||
http_response_code(501);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$retention = \Mailcow\Retention\RetentionModule::create($pdo, \Mailcow\Core\Context::system());
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$body = json_decode((string)file_get_contents('php://input'), true);
|
||||
if (!is_array($body) || !is_array($body['results'] ?? null)) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['stored' => $retention->api->report($body['results'], is_array($body['summary'] ?? null) ? $body['summary'] : [],
|
||||
is_array($body['run'] ?? null) ? $body['run'] : null)]);
|
||||
}
|
||||
else {
|
||||
echo json_encode($retention->api->plan((string)($_GET['after'] ?? ''), (int)($_GET['limit'] ?? 0)));
|
||||
}
|
||||
}
|
||||
catch (\Mailcow\Core\UserError $e) {
|
||||
http_response_code(400);
|
||||
}
|
||||
catch (Throwable $e) {
|
||||
error_log("RETENTION: " . $e->getMessage() . PHP_EOL);
|
||||
http_response_code(500);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/groups.js');
|
||||
$js_minifier->add('/web/js/site/mail-policy.js');
|
||||
$js_minifier->add('/web/js/site/retention.js');
|
||||
$js_minifier->add('/web/js/site/mailbox.js');
|
||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
@@ -48,6 +49,7 @@ $template_data = [
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains),
|
||||
'retention_groups' => \Mailcow\Retention\RetentionModule::fromGlobals()->rules->groupChoices($domains),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -1486,6 +1486,117 @@ paths:
|
||||
type: boolean
|
||||
type: object
|
||||
summary: Create mail policy
|
||||
/api/v1/add/retention-rule:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- retention
|
||||
- add
|
||||
- rule
|
||||
- domain: example.org
|
||||
name: Trash
|
||||
folders:
|
||||
- trash
|
||||
days: 30
|
||||
msg:
|
||||
- retention_rule_added
|
||||
- Trash
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- warning
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Retention rules
|
||||
description: >-
|
||||
Create a retention rule (admins; domain admins with the "retention" ACL
|
||||
for their domains). Every night mail is deleted from the folders of the
|
||||
rule once it was saved there more than "days" days ago. If several
|
||||
rules apply to the same folder of a mailbox, the most specific one
|
||||
wins, per folder: mailbox before group before domain, then the folder
|
||||
itself (special folder, inbox, own name) before an own name with *,
|
||||
before the subfolders of a special folder or the inbox, before all
|
||||
folders, then the longer period. New rules simulate by default. Rules
|
||||
of inactive domains do not run.
|
||||
operationId: Create retention rule
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
domain: example.org
|
||||
name: Trash
|
||||
folders:
|
||||
- trash
|
||||
- junk
|
||||
days: 30
|
||||
keep_flagged: "1"
|
||||
mode: simulate
|
||||
all_mailboxes: "1"
|
||||
active: "1"
|
||||
properties:
|
||||
domain:
|
||||
description: domain of the rule (required)
|
||||
type: string
|
||||
name:
|
||||
description: unique per domain, max. 100 characters
|
||||
type: string
|
||||
description:
|
||||
description: max. 255 characters
|
||||
type: string
|
||||
folders:
|
||||
description: trash, junk, sent, drafts, archive, inbox (each with its subfolders), all (folders no other rule decides about) and folder:<name> (exactly this own folder, / for subfolders, * as wildcard), max. 50; replaces the list
|
||||
type: array
|
||||
own_folders:
|
||||
description: own folder names, one per line or as a list, replacing the folder:<name> entries (the UI sends them apart)
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
days:
|
||||
description: delete mail saved in the folder more than this many days ago, 1 to 36500
|
||||
type: number
|
||||
keep_flagged:
|
||||
description: flagged mail stays
|
||||
type: boolean
|
||||
mode:
|
||||
description: simulate (the nightly run only counts, default) or delete
|
||||
type: string
|
||||
all_mailboxes:
|
||||
description: applies to all mailboxes of the domain
|
||||
type: boolean
|
||||
mailboxes:
|
||||
description: mailboxes of the domain the rule applies to (replaces the list), max. 500
|
||||
type: array
|
||||
groups:
|
||||
description: group IDs of the domain (their members of that domain), max. 500; domain admins need the mailbox_groups ACL, without it the groups stay unchanged
|
||||
type: array
|
||||
active:
|
||||
type: boolean
|
||||
type: object
|
||||
summary: Create retention rule
|
||||
/api/v1/add/oauth2-client:
|
||||
post:
|
||||
responses:
|
||||
@@ -3018,6 +3129,63 @@ paths:
|
||||
type: object
|
||||
type: object
|
||||
summary: Delete mail policies
|
||||
/api/v1/delete/retention-rule:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- retention
|
||||
- delete
|
||||
- rule
|
||||
- id:
|
||||
- "3"
|
||||
msg:
|
||||
- retention_rule_removed
|
||||
- Trash
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- warning
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Retention rules
|
||||
description: >-
|
||||
Delete retention rules by ID.
|
||||
operationId: Delete retention rules
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
- "3"
|
||||
properties:
|
||||
items:
|
||||
description: contains list of rule IDs you want to delete
|
||||
type: object
|
||||
type: object
|
||||
summary: Delete retention rules
|
||||
/api/v1/delete/identity-link:
|
||||
post:
|
||||
responses:
|
||||
@@ -4762,6 +4930,105 @@ paths:
|
||||
type: object
|
||||
type: object
|
||||
summary: Update mail policies
|
||||
/api/v1/edit/retention-rule:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- retention
|
||||
- edit
|
||||
- rule
|
||||
- id:
|
||||
- "3"
|
||||
mode: delete
|
||||
msg:
|
||||
- retention_rule_modified
|
||||
- Trash
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- warning
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Retention rules
|
||||
description: >-
|
||||
Update retention rules. Attributes you do not send keep their value.
|
||||
The domain cannot be changed.
|
||||
operationId: Update retention rules
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
items:
|
||||
- "3"
|
||||
attr:
|
||||
mode: delete
|
||||
properties:
|
||||
attr:
|
||||
properties:
|
||||
name:
|
||||
description: unique per domain, max. 100 characters
|
||||
type: string
|
||||
description:
|
||||
description: max. 255 characters
|
||||
type: string
|
||||
folders:
|
||||
description: trash, junk, sent, drafts, archive, inbox (each with its subfolders), all (folders no other rule decides about) and folder:<name> (exactly this own folder, / for subfolders, * as wildcard), max. 50; replaces the list
|
||||
type: array
|
||||
own_folders:
|
||||
description: own folder names, one per line or as a list, replacing the folder:<name> entries (the UI sends them apart)
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
days:
|
||||
description: delete mail saved in the folder more than this many days ago, 1 to 36500
|
||||
type: number
|
||||
keep_flagged:
|
||||
description: flagged mail stays
|
||||
type: boolean
|
||||
mode:
|
||||
description: simulate (the nightly run only counts, default) or delete
|
||||
type: string
|
||||
all_mailboxes:
|
||||
description: applies to all mailboxes of the domain
|
||||
type: boolean
|
||||
mailboxes:
|
||||
description: mailboxes of the domain the rule applies to (replaces the list), max. 500
|
||||
type: array
|
||||
groups:
|
||||
description: group IDs of the domain (their members of that domain), max. 500; domain admins need the mailbox_groups ACL, without it the groups stay unchanged
|
||||
type: array
|
||||
active:
|
||||
type: boolean
|
||||
type: object
|
||||
items:
|
||||
description: contains list of rule IDs you want to update
|
||||
type: object
|
||||
type: object
|
||||
summary: Update retention rules
|
||||
/api/v1/edit/identity-provider-sync:
|
||||
post:
|
||||
responses:
|
||||
@@ -6492,6 +6759,70 @@ paths:
|
||||
the end of the api url.
|
||||
operationId: Get SOGo logs
|
||||
summary: Get SOGo logs
|
||||
"/api/v1/get/logs/retention/{count}":
|
||||
get:
|
||||
parameters:
|
||||
- description: Number of logs to return (default 1000)
|
||||
in: path
|
||||
name: count
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- description: e.g. api-key-string
|
||||
example: api-key-string
|
||||
in: header
|
||||
name: X-API-Key
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- time: "1794368412"
|
||||
domain: example.org
|
||||
username: user@example.org
|
||||
rule_id: 3
|
||||
rule_name: Trash
|
||||
folder: "Trash\nTrash/Old"
|
||||
days: 30
|
||||
mailboxes: 0
|
||||
matched: 12
|
||||
deleted: 12
|
||||
errors: 0
|
||||
simulated: 0
|
||||
error: null
|
||||
- time: "1794368400"
|
||||
domain: ""
|
||||
username: ""
|
||||
rule_id: null
|
||||
rule_name: ""
|
||||
folder: null
|
||||
days: 0
|
||||
mailboxes: 120
|
||||
matched: 4211
|
||||
deleted: 312
|
||||
errors: 0
|
||||
simulated: 0
|
||||
error: null
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Logs
|
||||
description: >-
|
||||
Results of the nightly retention runs of the last 90 days, newest
|
||||
first: mailboxes with found mail or an error, per mailbox and rule
|
||||
(folder: the folder names separated by line breaks); of simulating
|
||||
rules only the last run. Admins see all domains and one row per run
|
||||
(rule_id null, username empty, error if the run failed), domain admins
|
||||
with the "retention" ACL their domains.
|
||||
operationId: Get retention logs
|
||||
summary: Get retention logs
|
||||
"/api/v1/get/logs/watchdog/{count}":
|
||||
get:
|
||||
parameters:
|
||||
@@ -6821,6 +7152,111 @@ paths:
|
||||
input.
|
||||
operationId: Test mail policies
|
||||
summary: Test mail policies
|
||||
"/api/v1/get/retention-rule/{id}":
|
||||
get:
|
||||
parameters:
|
||||
- description: id of the rule, or "all" (optionally followed by /domain1,domain2)
|
||||
example: all
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- id: 3
|
||||
domain: example.org
|
||||
name: Trash
|
||||
description: ""
|
||||
folders:
|
||||
- trash
|
||||
days: 30
|
||||
keep_flagged: 0
|
||||
mode: delete
|
||||
all_mailboxes: 1
|
||||
active: 1
|
||||
mailboxes: []
|
||||
groups: []
|
||||
created: "2026-11-07 12:00:00"
|
||||
modified: null
|
||||
can_edit: 1
|
||||
last_run:
|
||||
created: "2026-11-08 03:30:12"
|
||||
mailboxes: 120
|
||||
matched: 4211
|
||||
deleted: 4211
|
||||
errors: 0
|
||||
simulated: 0
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Retention rules
|
||||
description: >-
|
||||
Retention rules visible to the caller (admins all, domain admins with
|
||||
the "retention" ACL their domains), with the summary of their last
|
||||
nightly run (null = not run yet).
|
||||
operationId: Get retention rules
|
||||
summary: Get retention rules
|
||||
"/api/v1/get/retention-rule/effective/{username}":
|
||||
get:
|
||||
parameters:
|
||||
- description: mailbox
|
||||
example: user@example.org
|
||||
in: path
|
||||
name: username
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- folder: trash
|
||||
match: folder
|
||||
days: 30
|
||||
keep_flagged: 0
|
||||
mode: delete
|
||||
rule_id: 3
|
||||
rule_name: Trash
|
||||
- folder: trash
|
||||
match: subfolders
|
||||
days: 30
|
||||
keep_flagged: 0
|
||||
mode: delete
|
||||
rule_id: 3
|
||||
rule_name: Trash
|
||||
- folder: all
|
||||
match: all
|
||||
days: 365
|
||||
keep_flagged: 1
|
||||
mode: simulate
|
||||
rule_id: 4
|
||||
rule_name: One year
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Retention rules
|
||||
description: >-
|
||||
What the rules delete in a mailbox, in order of "most specific wins":
|
||||
each folder of the mailbox goes to the first entry that matches it.
|
||||
match: folder (the folder itself), pattern (own name with *),
|
||||
subfolders (of a special folder or the inbox), all. Mailbox users ask for their own
|
||||
mailbox, admins and domain admins with the "retention" ACL for
|
||||
mailboxes they manage.
|
||||
operationId: Get effective retention rules
|
||||
summary: Get effective retention rules
|
||||
/api/v1/get/identity-provider/sync-status:
|
||||
get:
|
||||
responses:
|
||||
@@ -8379,6 +8815,8 @@ tags:
|
||||
description: Mailbox groups (distribution lists, send-as rights, templates)
|
||||
- name: Mail policies
|
||||
description: Whom mailboxes and groups may send to and receive from
|
||||
- name: Retention rules
|
||||
description: Delete mail from folders after a number of days
|
||||
- name: Routing
|
||||
description: Define your own email routes
|
||||
- name: Resources
|
||||
|
||||
@@ -12,6 +12,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/groups.js');
|
||||
$js_minifier->add('/web/js/site/mail-policy.js');
|
||||
$js_minifier->add('/web/js/site/retention.js');
|
||||
$js_minifier->add('/web/js/site/mailbox.js');
|
||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
@@ -47,6 +48,7 @@ $template_data = [
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains),
|
||||
'retention_groups' => \Mailcow\Retention\RetentionModule::fromGlobals()->rules->groupChoices($domains),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -126,6 +126,17 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
'mail_policy_groups' => !empty($result) ? $mail_policy->policies->groupChoices([$result['domain']]) : [],
|
||||
];
|
||||
}
|
||||
elseif (isset($_GET['retention-rule']) &&
|
||||
is_numeric($_GET['retention-rule'])) {
|
||||
$retention = \Mailcow\Retention\RetentionModule::fromGlobals();
|
||||
$result = $retention->api->getRule($_GET['retention-rule']);
|
||||
$template = 'edit/retention-rule.twig';
|
||||
// Choices: mailboxes and groups of the domain of the rule
|
||||
$template_data = [
|
||||
'mailboxes' => !empty($result) ? mailbox('get', 'mailboxes', $result['domain']) : [],
|
||||
'retention_groups' => !empty($result) ? $retention->rules->groupChoices([$result['domain']]) : [],
|
||||
];
|
||||
}
|
||||
elseif (isset($_GET['api-key']) &&
|
||||
is_numeric($_GET['api-key'])) {
|
||||
$result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']);
|
||||
|
||||
@@ -75,6 +75,7 @@ $globalVariables = [
|
||||
'lang_syncjobs' => json_encode($lang['syncjobs']),
|
||||
'lang_groups' => json_encode($lang['groups']),
|
||||
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
|
||||
'lang_retention' => json_encode($lang['retention'] ?? []),
|
||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||
|
||||
@@ -3297,7 +3297,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'mailbox_group_sogo_grant' => ['owner'],
|
||||
'mailbox_group_sogo_acl' => ['c_uid'],
|
||||
'mailbox_group_subscription' => ['username', 'owner'],
|
||||
'mail_policy_member' => ['username']
|
||||
'mail_policy_member' => ['username'],
|
||||
'retention_rule_member' => ['username']
|
||||
];
|
||||
foreach ($tables as $table => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
@@ -5352,6 +5353,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
':domain' => $domain,
|
||||
));
|
||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->domainDeleted($domain);
|
||||
\Mailcow\Retention\RetentionModule::fromGlobals()->api->domainDeleted($domain);
|
||||
$stmt = $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
|
||||
@@ -571,6 +571,116 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"retention_rule" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"name" => "VARCHAR(100) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
// JSON list: trash, junk, sent, drafts, archive, inbox, all, folder:<name>
|
||||
"folders" => "TEXT NOT NULL",
|
||||
"days" => "INT NOT NULL DEFAULT '30'",
|
||||
"keep_flagged" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
// simulate (count only) or delete
|
||||
"mode" => "VARCHAR(16) NOT NULL DEFAULT 'simulate'",
|
||||
"all_mailboxes" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"domain_name" => array("domain", "name")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_retention_rule_domain" => array(
|
||||
"col" => "domain",
|
||||
"ref" => "domain.domain",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// a mailbox or a group (its members of the rule's domain) a retention rule is assigned to
|
||||
"retention_rule_member" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"rule_id" => "INT NOT NULL",
|
||||
"username" => "VARCHAR(255) NULL DEFAULT NULL",
|
||||
"group_id" => "INT NULL DEFAULT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"rule_mailbox" => array("rule_id", "username"),
|
||||
"rule_group" => array("rule_id", "group_id")
|
||||
),
|
||||
"key" => array(
|
||||
"username" => array("username"),
|
||||
"group_id" => array("group_id")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_retention_rule_member_rule" => array(
|
||||
"col" => "rule_id",
|
||||
"ref" => "retention_rule.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
),
|
||||
"fk_retention_rule_member_mailbox" => array(
|
||||
"col" => "username",
|
||||
"ref" => "mailbox.username",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
),
|
||||
"fk_retention_rule_member_group" => array(
|
||||
"col" => "group_id",
|
||||
"ref" => "mailbox_group.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// results of the nightly run: per mailbox and rule with found mail or an error (folder: the folder names), per
|
||||
// rule a summary (username '') and the whole run (username '', rule_id NULL); kept 90 days, of simulating
|
||||
// rules only the last run
|
||||
"retention_log" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"username" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"rule_id" => "INT NULL DEFAULT NULL",
|
||||
"rule_name" => "VARCHAR(100) NOT NULL DEFAULT ''",
|
||||
"folder" => "TEXT NULL DEFAULT NULL",
|
||||
"days" => "INT NOT NULL DEFAULT '0'",
|
||||
"mailboxes" => "INT NOT NULL DEFAULT '0'",
|
||||
"matched" => "INT NOT NULL DEFAULT '0'",
|
||||
"deleted" => "INT NOT NULL DEFAULT '0'",
|
||||
"errors" => "INT NOT NULL DEFAULT '0'",
|
||||
"simulated" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"error" => "TEXT NULL DEFAULT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"key" => array(
|
||||
"domain_created" => array("domain", "created"),
|
||||
"rule_created" => array("rule_id", "username", "created"),
|
||||
"created" => array("created")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"mailbox_group_imap_grant" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
@@ -1135,7 +1245,8 @@ function init_db_schema()
|
||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
"mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"retention" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Retention\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Retention\Service\Planner;
|
||||
use Mailcow\Retention\Service\RuleService;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php, edit.php and user.php (routes `retention-rule`, `logs/retention`), and the
|
||||
* plan and results of the nightly run (data/conf/rspamd/dynmaps/retention.php). Messages are logged as
|
||||
* ['retention', action, 'rule', data].
|
||||
*/
|
||||
class RetentionApiController
|
||||
{
|
||||
public function __construct(private RuleService $rules, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/retention-rule */
|
||||
public function addRule(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', $attr, function (array $log) use ($attr): bool {
|
||||
$rule = $this->rules->create($attr);
|
||||
return $this->response->success(['retention_rule_added', htmlspecialchars($rule->name)], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/retention-rule: one message per id */
|
||||
public function editRules(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||
$this->rules->requireEditor();
|
||||
return $this->batch((array)($data['id'] ?? []), $log,
|
||||
fn(int $id): array => ['retention_rule_modified', htmlspecialchars($this->rules->update($id, $attr)->name)]);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/retention-rule: one message per id */
|
||||
public function deleteRules(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->rules->requireEditor();
|
||||
return $this->batch($items, $log, fn(int $id): array => ['retention_rule_removed', htmlspecialchars($this->rules->delete($id)->name)]);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET retention-rule/all[/<domain,domain>]: with the summary of the last nightly run */
|
||||
public function getRules(?string $domains = null): array
|
||||
{
|
||||
$ids = $this->rules->visibleIds(self::domains($domains));
|
||||
$runs = $this->rules->lastRuns($ids);
|
||||
$rules = [];
|
||||
foreach ($ids as $id) {
|
||||
$rule = $this->rules->get($id);
|
||||
if ($rule !== null) {
|
||||
$rules[] = $this->rules->toArray($rule, $runs[$id] ?? null);
|
||||
}
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** GET retention-rule/<id>, also edit.php */
|
||||
public function getRule(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$rule = ($id === null) ? null : $this->rules->get($id);
|
||||
return ($rule === null) ? [] : $this->rules->toArray($rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET retention-rule/effective/<username>: what the rules delete in the mailbox; [] when not allowed to ask.
|
||||
* $describe: for people (user.php), see Planner::describe()
|
||||
*/
|
||||
public function getEffective(mixed $username, bool $describe = false): array
|
||||
{
|
||||
try {
|
||||
$actions = $this->rules->effective($username);
|
||||
return $describe ? Planner::describe($actions) : $actions;
|
||||
} catch (UserError) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** GET logs/retention[/<count>]: mailboxes with found mail or errors in the nightly runs, newest first */
|
||||
public function getLog(int $limit = 0): array
|
||||
{
|
||||
return $this->rules->log($limit);
|
||||
}
|
||||
|
||||
/** A page of the plan of the nightly run (retention.php): mailboxes with their actions, next: after for the next page */
|
||||
public function plan(string $after = '', int $limit = 0): array
|
||||
{
|
||||
$page = $this->rules->plan($after, $limit);
|
||||
$plan = [];
|
||||
foreach ($page['mailboxes'] as $username => $actions) {
|
||||
$plan[] = ['username' => (string)$username, 'actions' => $actions];
|
||||
}
|
||||
return ['mailboxes' => $plan, 'next' => $page['next']];
|
||||
}
|
||||
|
||||
/** Results of the nightly run (retention.php): mailboxes with found mail or errors, a summary per rule, the run */
|
||||
public function report(array $results, array $summary, ?array $run = null): int
|
||||
{
|
||||
return $this->rules->report($results, $summary, $run);
|
||||
}
|
||||
|
||||
/** The domain was deleted (functions.mailbox.inc.php) */
|
||||
public function domainDeleted(string $domain): void
|
||||
{
|
||||
$this->rules->domainDeleted($domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
|
||||
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
|
||||
*/
|
||||
private function handle(string $action, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['retention', $action, 'rule', $data];
|
||||
try {
|
||||
$result = $work($log);
|
||||
} catch (UserError $e) {
|
||||
$result = $this->response->error($e, $log);
|
||||
} catch (PDOException $e) {
|
||||
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
|
||||
}
|
||||
$this->response->flush();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** One message per id: the success message returned by $work(id), or the UserError */
|
||||
private function batch(array $ids, array $log, callable $work): bool
|
||||
{
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
$this->response->success($work($this->requireId($id)), $log);
|
||||
} catch (UserError $e) {
|
||||
$this->response->error($e, $log);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return string[] domains of a comma separated filter */
|
||||
private static function domains(?string $domains): array
|
||||
{
|
||||
return array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen'));
|
||||
}
|
||||
|
||||
/** Integer id from request input ("12", 12); null for anything else */
|
||||
private function toId(mixed $id): ?int
|
||||
{
|
||||
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
|
||||
}
|
||||
|
||||
/** @throws AccessDenied for ids that cannot exist */
|
||||
private function requireId(mixed $id): int
|
||||
{
|
||||
return $this->toId($id) ?? throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Retention\Model;
|
||||
|
||||
/**
|
||||
* A retention rule (table `retention_rule`): mail is deleted from its folders once it was saved there `days` days
|
||||
* ago. Folders are special folders with all their names (trash: Trash, Papierkorb, Deleted Items, ...), the INBOX,
|
||||
* all folders, or own folder names (`folder:<name>`, wildcard *). A rule applies to all mailboxes of its domain or
|
||||
* to mailboxes and groups of it. In mode `simulate` the nightly run only counts what it would delete.
|
||||
*/
|
||||
class Rule
|
||||
{
|
||||
/** Special folders, the INBOX and all folders; own folders are folder:<name> */
|
||||
public const FOLDERS = ['trash', 'junk', 'sent', 'drafts', 'archive', 'inbox', 'all'];
|
||||
public const MODES = ['simulate', 'delete'];
|
||||
public const OWN_FOLDER = 'folder:';
|
||||
|
||||
public ?int $id = null;
|
||||
public string $domain = '';
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
/** @var string[] keys of FOLDERS and folder:<name> */
|
||||
public array $folders = [];
|
||||
public int $days = 30;
|
||||
/** Flagged mail stays */
|
||||
public int $keepFlagged = 0;
|
||||
public string $mode = 'simulate';
|
||||
/** Applies to all mailboxes of the domain */
|
||||
public int $allMailboxes = 0;
|
||||
public int $active = 1;
|
||||
/** @var string[] usernames the rule is assigned to */
|
||||
public array $mailboxes = [];
|
||||
/** @var int[] groups the rule is assigned to (their members of the rule's domain) */
|
||||
public array $groups = [];
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
|
||||
/** Row of `retention_rule`; assignments are set by the repository */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$rule = new self();
|
||||
$rule->id = (int)$row['id'];
|
||||
$rule->domain = (string)$row['domain'];
|
||||
$rule->name = (string)$row['name'];
|
||||
$rule->description = (string)($row['description'] ?? '');
|
||||
$folders = is_string($row['folders'] ?? null) ? json_decode($row['folders'], true) : null;
|
||||
$rule->folders = is_array($folders) ? array_values(array_filter($folders, 'is_string')) : [];
|
||||
$rule->days = (int)$row['days'];
|
||||
$rule->keepFlagged = (int)$row['keep_flagged'];
|
||||
$rule->mode = (string)$row['mode'];
|
||||
$rule->allMailboxes = (int)$row['all_mailboxes'];
|
||||
$rule->active = (int)$row['active'];
|
||||
$rule->created = $row['created'] ?? null;
|
||||
$rule->modified = $row['modified'] ?? null;
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** Column values for `retention_rule` */
|
||||
public function toRow(): array
|
||||
{
|
||||
return [
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'folders' => json_encode($this->folders),
|
||||
'days' => $this->days,
|
||||
'keep_flagged' => $this->keepFlagged,
|
||||
'mode' => $this->mode,
|
||||
'all_mailboxes' => $this->allMailboxes,
|
||||
'active' => $this->active,
|
||||
];
|
||||
}
|
||||
|
||||
/** Entry of the API and the edit page */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'folders' => $this->folders,
|
||||
'days' => $this->days,
|
||||
'keep_flagged' => $this->keepFlagged,
|
||||
'mode' => $this->mode,
|
||||
'all_mailboxes' => $this->allMailboxes,
|
||||
'active' => $this->active,
|
||||
'mailboxes' => $this->mailboxes,
|
||||
'groups' => $this->groups,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Retention\Repository;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
use Mailcow\Retention\Service\Planner;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Tables `retention_rule`, `retention_rule_member` (the mailboxes and groups a rule is assigned to) and
|
||||
* `retention_log` (results of the nightly run), plus the lookups the editing needs. Assignments go with their
|
||||
* mailbox, group or domain (foreign keys, ON DELETE CASCADE).
|
||||
*/
|
||||
class RuleRepository extends Repository
|
||||
{
|
||||
/** Results of the nightly run are kept this long */
|
||||
private const LOG_DAYS = 90;
|
||||
|
||||
public function find(int $id): ?Rule
|
||||
{
|
||||
$row = $this->query("SELECT * FROM `retention_rule` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||
return is_array($row) ? $this->withMembers(Rule::fromRow($row)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $domains limit to these domains, [] = all visible
|
||||
* @return int[] ids of the rules visible to the caller, ordered by domain and name
|
||||
*/
|
||||
public function visibleIds(Context $context, array $domains = []): array
|
||||
{
|
||||
[$where, $params] = $this->visibleDomains($context, $domains);
|
||||
if ($where === null) {
|
||||
return [];
|
||||
}
|
||||
return array_map('intval', $this->query("SELECT `id` FROM `retention_rule` WHERE $where ORDER BY `domain`, `name`", $params)
|
||||
->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
public function nameExists(string $domain, string $name, ?int $exceptId = null): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `retention_rule` WHERE `domain` = :domain AND `name` = :name AND `id` != :id",
|
||||
['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Inserts or updates the rule with its assignments; returns its id */
|
||||
public function save(Rule $rule): int
|
||||
{
|
||||
return $this->transaction(function () use ($rule): int {
|
||||
if ($rule->id === null) {
|
||||
$rule->id = $this->insertRow('retention_rule', $rule->toRow());
|
||||
} else {
|
||||
$this->updateRow('retention_rule', $rule->id, $rule->toRow());
|
||||
$this->query("DELETE FROM `retention_rule_member` WHERE `rule_id` = :id", ['id' => $rule->id]);
|
||||
}
|
||||
foreach ($rule->mailboxes as $username) {
|
||||
$this->insertRow('retention_rule_member', ['rule_id' => $rule->id, 'username' => $username]);
|
||||
}
|
||||
foreach ($rule->groups as $groupId) {
|
||||
$this->insertRow('retention_rule_member', ['rule_id' => $rule->id, 'group_id' => $groupId]);
|
||||
}
|
||||
return $rule->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `retention_rule` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/** A primary domain (rules never belong to alias domains) */
|
||||
public function isDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Domain of a regular mailbox, null if there is none */
|
||||
public function mailboxDomain(string $username): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = ''", ['username' => $username])->fetchColumn();
|
||||
return is_string($domain) ? $domain : null;
|
||||
}
|
||||
|
||||
public function groupDomain(int $id): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox_group` WHERE `id` = :id", ['id' => $id])->fetchColumn();
|
||||
return is_string($domain) ? $domain : null;
|
||||
}
|
||||
|
||||
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||
public function groupsIn(array $domains): array
|
||||
{
|
||||
if (empty($domains)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in('dom', $domains);
|
||||
return $this->query("SELECT `id`, `name`, `domain`, `address` FROM `mailbox_group` WHERE `domain` IN ($in) ORDER BY `domain`, `name`", $params)
|
||||
->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of the mailboxes the nightly run looks at: Dovecot knows them (regular, not disabled), their domain is
|
||||
* active and has active rules.
|
||||
*
|
||||
* @return string[] usernames after $after, in order
|
||||
*/
|
||||
public function mailboxesWithRules(string $after, int $limit): array
|
||||
{
|
||||
return $this->query("SELECT `m`.`username` FROM `mailbox` `m` JOIN `domain` `d` ON `d`.`domain` = `m`.`domain` AND `d`.`active` = '1'
|
||||
WHERE `m`.`kind` = '' AND `m`.`active` IN ('1', '2') AND `m`.`username` > :after
|
||||
AND EXISTS (SELECT 1 FROM `retention_rule` `r` WHERE `r`.`domain` = `m`.`domain` AND `r`.`active` = '1')
|
||||
ORDER BY `m`.`username` LIMIT " . max(1, $limit), ['after' => $after])->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active rules of these mailboxes with the level of their assignment: the whole domain, a group of the mailbox
|
||||
* (members of the rule's domain only) or the mailbox itself. Only mailboxes Dovecot knows (regular, not
|
||||
* disabled) in active domains.
|
||||
*
|
||||
* @param string[] $usernames
|
||||
* @return array<string, array<array{rule: Rule, level: int}>> username => candidates
|
||||
*/
|
||||
public function candidates(array $usernames): array
|
||||
{
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
$params = [];
|
||||
$mailbox = [];
|
||||
foreach ([1, 2, 3] as $part) {
|
||||
[$in, $inParams] = self::in('u' . $part . '_', $usernames);
|
||||
$params += $inParams;
|
||||
$mailbox[$part] = "`m`.`kind` = '' AND `m`.`active` IN ('1', '2') AND `m`.`username` IN ($in)
|
||||
AND `m`.`domain` IN (SELECT `domain` FROM `domain` WHERE `active` = '1')";
|
||||
}
|
||||
$rows = $this->query("SELECT `r`.*, `m`.`username` AS `member`, " . Planner::LEVEL_DOMAIN . " AS `level` FROM `retention_rule` `r`
|
||||
JOIN `mailbox` `m` ON `m`.`domain` = `r`.`domain` AND {$mailbox[1]}
|
||||
WHERE `r`.`active` = '1' AND `r`.`all_mailboxes` = '1'
|
||||
UNION ALL SELECT `r`.*, `m`.`username`, " . Planner::LEVEL_GROUP . " FROM `retention_rule` `r`
|
||||
JOIN `retention_rule_member` `rm` ON `rm`.`rule_id` = `r`.`id`
|
||||
JOIN `mailbox_group` `g` ON `g`.`id` = `rm`.`group_id` AND `g`.`active` = '1'
|
||||
JOIN `mailbox_group_member` `gm` ON `gm`.`group_id` = `g`.`id`
|
||||
JOIN `mailbox` `m` ON `m`.`username` = `gm`.`username` AND `m`.`domain` = `r`.`domain` AND {$mailbox[2]}
|
||||
WHERE `r`.`active` = '1'
|
||||
UNION ALL SELECT `r`.*, `m`.`username`, " . Planner::LEVEL_MAILBOX . " FROM `retention_rule` `r`
|
||||
JOIN `retention_rule_member` `rm` ON `rm`.`rule_id` = `r`.`id`
|
||||
JOIN `mailbox` `m` ON `m`.`username` = `rm`.`username` AND `m`.`domain` = `r`.`domain` AND {$mailbox[3]}
|
||||
WHERE `r`.`active` = '1'
|
||||
ORDER BY `member`, `id`", $params)->fetchAll();
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$candidates[strtolower((string)$row['member'])][] = ['rule' => Rule::fromRow($row), 'level' => (int)$row['level']];
|
||||
}
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores results of the nightly run. Domain and rule name come from the database, not from the runner; results
|
||||
* of a rule from another domain than the mailbox are dropped. With the summary of a rule, the results its
|
||||
* earlier runs found while simulating are dropped: only the last simulation is kept. After a run without error,
|
||||
* the active rules that decided about no folder get an empty summary.
|
||||
*
|
||||
* @param array[] $results username, rule_id, folder (names), days, matched, deleted, simulated, error: mailboxes
|
||||
* with found mail or an error
|
||||
* @param array[] $summary rule_id, mailboxes, matched, deleted, errors, simulated: one per rule and run
|
||||
* @param array|null $run started (unix time), mailboxes, matched, deleted, errors, error: the whole run, sent at its end
|
||||
*/
|
||||
public function addLog(array $results, array $summary, ?array $run = null): int
|
||||
{
|
||||
$stored = $this->transaction(function () use ($results, $summary, $run): int {
|
||||
$stored = 0;
|
||||
foreach ($results as $result) {
|
||||
$domain = $this->mailboxDomain((string)$result['username']);
|
||||
$rule = $this->ruleName((int)$result['rule_id']);
|
||||
if ($domain === null || $rule === null || $rule['domain'] !== $domain) {
|
||||
continue;
|
||||
}
|
||||
$this->insertRow('retention_log', [
|
||||
'domain' => $domain,
|
||||
'username' => (string)$result['username'],
|
||||
'rule_id' => (int)$result['rule_id'],
|
||||
'rule_name' => $rule['name'],
|
||||
'folder' => mb_substr((string)$result['folder'], 0, 10000),
|
||||
'days' => (int)($result['days'] ?? 0),
|
||||
'matched' => (int)($result['matched'] ?? 0),
|
||||
'deleted' => (int)($result['deleted'] ?? 0),
|
||||
'errors' => (int)(($result['error'] ?? '') !== ''),
|
||||
'simulated' => (int)(bool)($result['simulated'] ?? false),
|
||||
'error' => ($result['error'] ?? '') === '' ? null : mb_substr((string)$result['error'], 0, 1000),
|
||||
]);
|
||||
$stored++;
|
||||
}
|
||||
$summarized = [];
|
||||
foreach ($summary as $runOfRule) {
|
||||
$rule = $this->ruleName((int)$runOfRule['rule_id']);
|
||||
if ($rule === null || isset($summarized[(int)$runOfRule['rule_id']])) {
|
||||
continue;
|
||||
}
|
||||
$summarized[(int)$runOfRule['rule_id']] = true;
|
||||
$this->addSummary((int)$runOfRule['rule_id'], $rule, $runOfRule);
|
||||
$stored++;
|
||||
}
|
||||
if ($run !== null && ($run['error'] ?? '') === '') {
|
||||
// rules created during the run may have missed its first pages
|
||||
foreach ($this->query("SELECT `r`.`id`, `r`.`domain`, `r`.`name`, `r`.`mode` FROM `retention_rule` `r`
|
||||
JOIN `domain` `d` ON `d`.`domain` = `r`.`domain` AND `d`.`active` = '1'
|
||||
WHERE `r`.`active` = '1' AND `r`.`created` < FROM_UNIXTIME(:started)", ['started' => (int)($run['started'] ?? 0)])->fetchAll() as $row) {
|
||||
if (!isset($summarized[(int)$row['id']])) {
|
||||
$this->addSummary((int)$row['id'], ['domain' => (string)$row['domain'], 'name' => (string)$row['name']],
|
||||
['simulated' => $row['mode'] !== 'delete']);
|
||||
$stored++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($run !== null) {
|
||||
$this->insertRow('retention_log', [
|
||||
'domain' => '',
|
||||
'mailboxes' => (int)($run['mailboxes'] ?? 0),
|
||||
'matched' => (int)($run['matched'] ?? 0),
|
||||
'deleted' => (int)($run['deleted'] ?? 0),
|
||||
'errors' => (int)($run['errors'] ?? 0),
|
||||
'error' => ($run['error'] ?? '') === '' ? null : mb_substr((string)$run['error'], 0, 1000),
|
||||
]);
|
||||
$stored++;
|
||||
}
|
||||
return $stored;
|
||||
});
|
||||
if ($run !== null) {
|
||||
$this->prune();
|
||||
}
|
||||
return $stored;
|
||||
}
|
||||
|
||||
/** Summary of a rule's run; drops what its earlier runs found while simulating */
|
||||
private function addSummary(int $ruleId, array $rule, array $values): void
|
||||
{
|
||||
$previous = $this->query("SELECT MAX(`id`) FROM `retention_log` WHERE `rule_id` = :id AND `username` = ''", ['id' => $ruleId])->fetchColumn();
|
||||
if ($previous !== null && $previous !== false) {
|
||||
$this->query("DELETE FROM `retention_log` WHERE `rule_id` = :id AND `username` != '' AND `simulated` = '1' AND `id` < :previous",
|
||||
['id' => $ruleId, 'previous' => (int)$previous]);
|
||||
}
|
||||
$this->insertRow('retention_log', [
|
||||
'domain' => $rule['domain'],
|
||||
'rule_id' => $ruleId,
|
||||
'rule_name' => $rule['name'],
|
||||
'mailboxes' => (int)($values['mailboxes'] ?? 0),
|
||||
'matched' => (int)($values['matched'] ?? 0),
|
||||
'deleted' => (int)($values['deleted'] ?? 0),
|
||||
'errors' => (int)($values['errors'] ?? 0),
|
||||
'simulated' => (int)(bool)($values['simulated'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Drops the results of the runs in a domain (the domain was deleted) */
|
||||
public function deleteLog(string $domain): void
|
||||
{
|
||||
$this->query("DELETE FROM `retention_log` WHERE `domain` = :domain", ['domain' => $domain]);
|
||||
}
|
||||
|
||||
/** @return array<int, array> rule id => the summary of its last run (created, mailboxes, matched, deleted, errors, simulated) */
|
||||
public function lastRuns(array $ruleIds): array
|
||||
{
|
||||
if (empty($ruleIds)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in('rule', array_map('intval', $ruleIds));
|
||||
$runs = [];
|
||||
foreach ($this->query("SELECT `l`.`rule_id`, `l`.`created`, `l`.`mailboxes`, `l`.`matched`, `l`.`deleted`, `l`.`errors`, `l`.`simulated`
|
||||
FROM `retention_log` `l` WHERE `l`.`username` = '' AND `l`.`rule_id` IN ($in) AND `l`.`id` = (SELECT MAX(`id`) FROM `retention_log`
|
||||
WHERE `rule_id` = `l`.`rule_id` AND `username` = '')", $params)->fetchAll() as $row) {
|
||||
$runs[(int)$row['rule_id']] = $row;
|
||||
}
|
||||
return $runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[] mailboxes with found mail or errors in the nightly runs visible to the caller, and for admins the
|
||||
* whole runs (username '', rule_id null), newest first
|
||||
*/
|
||||
public function log(Context $context, array $domains, int $limit): array
|
||||
{
|
||||
[$where, $params] = $this->visibleDomains($context, $domains);
|
||||
if ($where === null) {
|
||||
return [];
|
||||
}
|
||||
return $this->query("SELECT UNIX_TIMESTAMP(`created`) AS `time`, `domain`, `username`, `rule_id`, `rule_name`, `folder`, `days`, `mailboxes`, `matched`, `deleted`, `errors`, `simulated`, `error`
|
||||
FROM `retention_log` WHERE (`username` != '' OR `rule_id` IS NULL) AND $where ORDER BY `created` DESC, `id` DESC LIMIT " . max(1, $limit), $params)->fetchAll();
|
||||
}
|
||||
|
||||
/** Drops results older than LOG_DAYS, in parts so a large log does not hold the table */
|
||||
private function prune(): void
|
||||
{
|
||||
do {
|
||||
$deleted = $this->query("DELETE FROM `retention_log` WHERE `created` < NOW() - INTERVAL " . self::LOG_DAYS . " DAY LIMIT 10000")->rowCount();
|
||||
} while ($deleted >= 10000);
|
||||
}
|
||||
|
||||
/** @return array{domain: string, name: string}|null */
|
||||
private function ruleName(int $id): ?array
|
||||
{
|
||||
$row = $this->query("SELECT `domain`, `name` FROM `retention_rule` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||
return is_array($row) ? ['domain' => (string)$row['domain'], 'name' => (string)$row['name']] : null;
|
||||
}
|
||||
|
||||
/** @return array{0: ?string, 1: array} condition on `domain` for the caller and the domain filter, null = nothing visible */
|
||||
private function visibleDomains(Context $context, array $domains): array
|
||||
{
|
||||
if ($context->hasAdminRights()) {
|
||||
[$where, $params] = ['1=1', []];
|
||||
} elseif ($context->isDomainAdmin()) {
|
||||
[$where, $params] = ["`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)",
|
||||
['vis_da' => $context->username]];
|
||||
} else {
|
||||
return [null, []];
|
||||
}
|
||||
if (!empty($domains)) {
|
||||
[$in, $inParams] = self::in('dom', $domains);
|
||||
$where .= " AND `domain` IN ($in)";
|
||||
$params += $inParams;
|
||||
}
|
||||
return [$where, $params];
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: array} placeholders and parameters of an IN list */
|
||||
private static function in(string $prefix, array $values): array
|
||||
{
|
||||
$names = [];
|
||||
$params = [];
|
||||
foreach (array_values($values) as $i => $value) {
|
||||
$names[] = ':' . $prefix . $i;
|
||||
$params[$prefix . $i] = $value;
|
||||
}
|
||||
return [implode(', ', $names), $params];
|
||||
}
|
||||
|
||||
private function withMembers(Rule $rule): Rule
|
||||
{
|
||||
foreach ($this->query("SELECT `username`, `group_id` FROM `retention_rule_member` WHERE `rule_id` = :id ORDER BY `username`, `group_id`",
|
||||
['id' => $rule->id])->fetchAll() as $row) {
|
||||
if ($row['username'] !== null) {
|
||||
$rule->mailboxes[] = strtolower((string)$row['username']);
|
||||
} elseif ($row['group_id'] !== null) {
|
||||
$rule->groups[] = (int)$row['group_id'];
|
||||
}
|
||||
}
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Retention;
|
||||
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Retention\Controller\RetentionApiController;
|
||||
use Mailcow\Retention\Repository\RuleRepository;
|
||||
use Mailcow\Retention\Service\RuleService;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the retention rules: which mail is deleted from which folders after how many days. The
|
||||
* nightly run in the dovecot container (retention_runner.py) gets its plan from data/conf/rspamd/dynmaps/retention.php.
|
||||
*
|
||||
* process_get_return(RetentionModule::fromGlobals()->api->getRules());
|
||||
* echo json_encode(RetentionModule::create($pdo, Context::system())->api->plan()); // nightly run
|
||||
*/
|
||||
class RetentionModule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly RuleService $rules,
|
||||
public readonly RetentionApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context): self
|
||||
{
|
||||
$rules = new RuleService(new RuleRepository($pdo), $context);
|
||||
return new self($rules, new RetentionApiController($rules, new ApiResponse()));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Retention\Service;
|
||||
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
|
||||
/**
|
||||
* What the rules of one mailbox delete: the most specific rule wins, per folder of the mailbox. The folders of the
|
||||
* rules become entries in order: a rule assigned to the mailbox before one of its groups before one of the whole
|
||||
* domain; then by what an entry matches (MATCHES, the folder itself first); then the longer period, then the lower
|
||||
* id. The runner gives every folder to the first entry that matches it.
|
||||
*/
|
||||
class Planner
|
||||
{
|
||||
public const LEVEL_DOMAIN = 1;
|
||||
public const LEVEL_GROUP = 2;
|
||||
public const LEVEL_MAILBOX = 3;
|
||||
|
||||
/**
|
||||
* What an entry matches, most specific first: the folder itself (a special folder, the inbox, an own folder
|
||||
* without *), an own folder name with *, the subfolders of a special folder or the inbox, all folders
|
||||
*/
|
||||
public const MATCHES = ['folder' => 3, 'pattern' => 2, 'subfolders' => 1, 'all' => 0];
|
||||
|
||||
/**
|
||||
* @param array<array{rule: Rule, level: int}> $candidates active rules of one mailbox with their assignment level
|
||||
* @return array[] in order: folder (key), match, days, keep_flagged, mode, rule_id, rule_name. Entries that never
|
||||
* win are left out: one matching the same folders as an entry before and everything after all folders.
|
||||
*/
|
||||
public static function actions(array $candidates): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
foreach ($candidate['rule']->folders as $folder) {
|
||||
foreach (self::matches($folder) as $match) {
|
||||
// the lower id decides between equal rules, so the result does not depend on the order
|
||||
$entries[] = [
|
||||
'score' => [$candidate['level'], self::MATCHES[$match], $candidate['rule']->days, -(int)$candidate['rule']->id],
|
||||
'folder' => $folder,
|
||||
'match' => $match,
|
||||
'rule' => $candidate['rule'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
usort($entries, fn(array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||
$actions = [];
|
||||
foreach ($entries as $entry) {
|
||||
if (isset($actions[$entry['folder'] . "\n" . $entry['match']])) {
|
||||
continue;
|
||||
}
|
||||
$actions[$entry['folder'] . "\n" . $entry['match']] = self::action($entry['folder'], $entry['match'], $entry['rule']);
|
||||
if ($entry['match'] === 'all') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return array_values($actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan for people: the subfolders of a special folder or the inbox go with the folder (subfolders 1) when the
|
||||
* same rule decides both and no entry with * comes between them.
|
||||
*
|
||||
* @return array[] the entries of actions() with subfolders 0 or 1
|
||||
*/
|
||||
public static function describe(array $actions): array
|
||||
{
|
||||
$described = [];
|
||||
$folders = [];
|
||||
foreach ($actions as $action) {
|
||||
$key = $action['folder'] . "\n" . $action['rule_id'];
|
||||
if ($action['match'] === 'subfolders' && isset($folders[$key])) {
|
||||
$described[$folders[$key]]['subfolders'] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($action['match'] === 'pattern') {
|
||||
$folders = [];
|
||||
} elseif ($action['match'] === 'folder') {
|
||||
$folders[$key] = count($described);
|
||||
}
|
||||
$described[] = $action + ['subfolders' => (int)($action['match'] === 'subfolders')];
|
||||
}
|
||||
return $described;
|
||||
}
|
||||
|
||||
/** @return string[] what the folder key of a rule matches */
|
||||
private static function matches(string $folder): array
|
||||
{
|
||||
if ($folder === 'all') {
|
||||
return ['all'];
|
||||
}
|
||||
if (str_starts_with($folder, Rule::OWN_FOLDER)) {
|
||||
return [str_contains($folder, '*') ? 'pattern' : 'folder'];
|
||||
}
|
||||
return ['folder', 'subfolders'];
|
||||
}
|
||||
|
||||
private static function action(string $folder, string $match, Rule $rule): array
|
||||
{
|
||||
return [
|
||||
'folder' => $folder,
|
||||
'match' => $match,
|
||||
'days' => $rule->days,
|
||||
'keep_flagged' => $rule->keepFlagged,
|
||||
'mode' => $rule->mode,
|
||||
'rule_id' => $rule->id,
|
||||
'rule_name' => $rule->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Retention\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
use Mailcow\Retention\Repository\RuleRepository;
|
||||
|
||||
/**
|
||||
* Use cases of retention rules.
|
||||
*
|
||||
* Read: admins all rules (also read-only API keys); domain admins with the `retention` ACL the rules of their
|
||||
* domains. Mailbox users see the rules that apply to their mailbox (effective()).
|
||||
* Edit: admins; domain admins with the ACL for rules of their domains. A rule is assigned to all mailboxes of its
|
||||
* domain or to mailboxes and groups of it. Groups need the `mailbox_groups` ACL as well: without it a domain
|
||||
* admin sees no groups and keeps the group assignments.
|
||||
*/
|
||||
class RuleService
|
||||
{
|
||||
private const MAX_FOLDERS = 50;
|
||||
private const MAX_MEMBERS = 500;
|
||||
private const MAX_DAYS = 36500;
|
||||
private const LOG_LIMIT = 1000;
|
||||
/** Mailboxes per page of the plan, and results per report of the runner */
|
||||
private const PLAN_PAGE = 500;
|
||||
private const MAX_RESULTS = 1000;
|
||||
|
||||
public function __construct(private RuleRepository $repo, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights()
|
||||
|| ($this->context->isDomainAdmin() && $this->context->hasAcl('retention'));
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless the caller may edit rules at all */
|
||||
public function requireEditor(): void
|
||||
{
|
||||
if (!$this->canRead() || $this->context->isReadOnlyApi()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(int $id): ?Rule
|
||||
{
|
||||
$rule = $this->canRead() ? $this->repo->find($id) : null;
|
||||
return ($rule !== null && $this->visible($rule)) ? $rule : null;
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
public function visibleIds(array $domains = []): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : [];
|
||||
}
|
||||
|
||||
public function canEdit(Rule $rule): bool
|
||||
{
|
||||
return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($rule);
|
||||
}
|
||||
|
||||
/** $lastRun: summary of the last nightly run, null = not run yet */
|
||||
public function toArray(Rule $rule, ?array $lastRun = null): array
|
||||
{
|
||||
return $rule->toArray() + ['can_edit' => (int)$this->canEdit($rule), 'last_run' => $lastRun];
|
||||
}
|
||||
|
||||
/** @return array<int, array> rule id => summary of its last nightly run */
|
||||
public function lastRuns(array $ruleIds): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->lastRuns($ruleIds) : [];
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function create(array $input): Rule
|
||||
{
|
||||
$this->requireEditor();
|
||||
$rule = new Rule();
|
||||
$rule->domain = $this->domainFor($input['domain'] ?? null);
|
||||
$this->apply($rule, $input);
|
||||
$this->repo->save($rule);
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** Attributes missing in $input keep their value @throws UserError */
|
||||
public function update(int $id, array $input): Rule
|
||||
{
|
||||
$rule = $this->editable($id);
|
||||
$this->apply($rule, $input);
|
||||
$this->repo->save($rule);
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function delete(int $id): Rule
|
||||
{
|
||||
$rule = $this->editable($id);
|
||||
$this->repo->delete($id);
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||
public function groupChoices(array $domains): array
|
||||
{
|
||||
return ($this->canRead() && $this->canUseGroups()) ? $this->repo->groupsIn($domains) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the rules delete in a mailbox, per folder (see Planner): for the mailbox user, or for those who may read
|
||||
* the rules and access the mailbox.
|
||||
*
|
||||
* @return array[] actions of Planner::actions()
|
||||
* @throws AccessDenied
|
||||
*/
|
||||
public function effective(mixed $username): array
|
||||
{
|
||||
$username = strtolower((string)Input::text($username));
|
||||
$allowed = $this->context->isUser() ? $username === strtolower((string)$this->context->username)
|
||||
: ($this->canRead() && ($this->context->hasAdminRights() || $this->context->canAccessMailbox($username)));
|
||||
if ($username === '' || !$allowed) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return Planner::actions($this->repo->candidates([$username])[$username] ?? []);
|
||||
}
|
||||
|
||||
/** @return array[] results of the nightly runs in the caller's domains, newest first; $limit 0 = LOG_LIMIT */
|
||||
public function log(int $limit = 0): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->log($this->context, [], ($limit > 0) ? min($limit, self::LOG_LIMIT * 10) : self::LOG_LIMIT) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of the plan of the nightly run (system context), mailboxes after $after in order
|
||||
*
|
||||
* @return array{mailboxes: array<string, array[]>, next: ?string} username => actions of Planner::actions() for
|
||||
* the mailboxes with rules; next: the $after of the next page, null after the last
|
||||
*/
|
||||
public function plan(string $after = '', int $limit = 0): array
|
||||
{
|
||||
$limit = ($limit > 0) ? min($limit, self::PLAN_PAGE) : self::PLAN_PAGE;
|
||||
$usernames = $this->repo->mailboxesWithRules($after, $limit);
|
||||
$plan = array_filter(array_map(fn(array $candidates): array => Planner::actions($candidates), $this->repo->candidates($usernames)));
|
||||
return ['mailboxes' => $plan, 'next' => (count($usernames) < $limit) ? null : (string)end($usernames)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Results of the nightly run (system context): mailboxes with found mail or errors, a summary per rule and at
|
||||
* the end the whole run
|
||||
*
|
||||
* @throws UserError for more than MAX_RESULTS results
|
||||
*/
|
||||
public function report(array $results, array $summary, ?array $run = null): int
|
||||
{
|
||||
if (count($results) > self::MAX_RESULTS || count($summary) > self::MAX_RESULTS * 10) {
|
||||
throw new UserError('retention_entries_exceeded', [self::MAX_RESULTS]);
|
||||
}
|
||||
$results = array_filter($results, fn(mixed $result): bool => is_array($result) && is_string($result['username'] ?? null)
|
||||
&& is_numeric($result['rule_id'] ?? null) && is_string($result['folder'] ?? null));
|
||||
$summary = array_filter($summary, fn(mixed $runOfRule): bool => is_array($runOfRule) && is_numeric($runOfRule['rule_id'] ?? null));
|
||||
return $this->repo->addLog(array_values($results), array_values($summary), $run);
|
||||
}
|
||||
|
||||
/** The domain was deleted: its rules went with it (foreign key), the results of its runs go now */
|
||||
public function domainDeleted(string $domain): void
|
||||
{
|
||||
$this->repo->deleteLog($domain);
|
||||
}
|
||||
|
||||
private function visible(Rule $rule): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() || $this->context->canAccessDomain($rule->domain);
|
||||
}
|
||||
|
||||
private function canUseGroups(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups');
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function editable(int $id): Rule
|
||||
{
|
||||
$this->requireEditor();
|
||||
$rule = $this->get($id);
|
||||
if ($rule === null || !$this->canEdit($rule)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function domainFor(mixed $value): string
|
||||
{
|
||||
$domain = strtolower((string)Input::text($value));
|
||||
if ($domain === '' || !$this->repo->isDomain($domain)) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||
}
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function apply(Rule $rule, array $input): void
|
||||
{
|
||||
if (array_key_exists('name', $input) || $rule->id === null) {
|
||||
$name = Input::text($input['name'] ?? null);
|
||||
if ($name === null || $name === '' || mb_strlen($name) > 100 || preg_match('/[\x00-\x1f\x7f]/', $name)) {
|
||||
throw new UserError('retention_name_invalid');
|
||||
}
|
||||
if ($this->repo->nameExists($rule->domain, $name, $rule->id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||
}
|
||||
$rule->name = $name;
|
||||
}
|
||||
if (array_key_exists('description', $input)) {
|
||||
$description = Input::text($input['description']);
|
||||
if ($description === null || mb_strlen($description) > 255 || preg_match('/[\x00-\x1f\x7f]/', $description)) {
|
||||
throw new UserError('retention_description_invalid');
|
||||
}
|
||||
$rule->description = $description;
|
||||
}
|
||||
// the UI sends the special folders and the own folder names apart (own_folders, one per line or a list)
|
||||
if (array_key_exists('folders', $input) || array_key_exists('own_folders', $input)) {
|
||||
$folders = array_key_exists('folders', $input) ? self::values($input['folders'])
|
||||
: array_filter($rule->folders, fn(string $folder): bool => in_array($folder, Rule::FOLDERS, true));
|
||||
if (array_key_exists('own_folders', $input)) {
|
||||
$folders = array_filter($folders, fn(string $folder): bool => !str_starts_with($folder, Rule::OWN_FOLDER));
|
||||
$names = is_array($input['own_folders']) ? $input['own_folders'] : preg_split('/\r?\n/', (string)Input::text($input['own_folders']));
|
||||
foreach ($names as $name) {
|
||||
if (!is_string($name)) {
|
||||
throw new UserError('retention_folder_invalid', ['']);
|
||||
}
|
||||
if (trim($name) !== '') {
|
||||
$folders[] = Rule::OWN_FOLDER . trim($name);
|
||||
}
|
||||
}
|
||||
}
|
||||
$rule->folders = self::folders($folders);
|
||||
}
|
||||
if ($rule->folders === []) {
|
||||
throw new UserError('retention_folders_invalid');
|
||||
}
|
||||
if (array_key_exists('days', $input) || $rule->id === null) {
|
||||
$days = Input::intInRange($input['days'] ?? null, 1, self::MAX_DAYS);
|
||||
$rule->days = $days ?? throw new UserError('retention_days_invalid', [self::MAX_DAYS]);
|
||||
}
|
||||
if (array_key_exists('mode', $input)) {
|
||||
$mode = Input::text($input['mode']);
|
||||
$rule->mode = in_array($mode, Rule::MODES, true) ? $mode : throw new UserError('retention_mode_invalid');
|
||||
}
|
||||
foreach (['keep_flagged' => 'keepFlagged', 'all_mailboxes' => 'allMailboxes', 'active' => 'active'] as $key => $property) {
|
||||
if (array_key_exists($key, $input)) {
|
||||
$rule->$property = (int)self::flag($input[$key]);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('mailboxes', $input)) {
|
||||
$rule->mailboxes = [];
|
||||
foreach (self::limited($input['mailboxes'], self::MAX_MEMBERS) as $username) {
|
||||
$username = strtolower($username);
|
||||
if ($this->repo->mailboxDomain($username) !== $rule->domain) {
|
||||
throw new UserError('retention_member_invalid', [htmlspecialchars($username)]);
|
||||
}
|
||||
$rule->mailboxes[$username] = $username;
|
||||
}
|
||||
$rule->mailboxes = array_values($rule->mailboxes);
|
||||
}
|
||||
// without access to groups the assignments stay; only sending them back unchanged is accepted
|
||||
$groupAccess = $this->canUseGroups();
|
||||
if (array_key_exists('groups', $input) && !$groupAccess && self::values($input['groups']) !== []) {
|
||||
$sent = array_values(array_unique(array_map('intval', self::values($input['groups']))));
|
||||
$stored = $rule->groups;
|
||||
sort($sent);
|
||||
sort($stored);
|
||||
if ($sent !== $stored) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
if (array_key_exists('groups', $input) && $groupAccess) {
|
||||
$rule->groups = [];
|
||||
foreach (self::limited($input['groups'], self::MAX_MEMBERS) as $value) {
|
||||
$id = Input::intInRange($value, 1, PHP_INT_MAX);
|
||||
if ($id === null || $this->repo->groupDomain($id) !== $rule->domain) {
|
||||
throw new UserError('retention_member_invalid', [htmlspecialchars($value)]);
|
||||
}
|
||||
$rule->groups[$id] = $id;
|
||||
}
|
||||
$rule->groups = array_values($rule->groups);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders of a rule: keys of Rule::FOLDERS and folder:<name>, without repeats. Names are Dovecot folder names
|
||||
* (separator /, wildcard *), printable and at most 255 characters; INBOX is written in capitals like Dovecot does.
|
||||
*
|
||||
* @param string[] $values
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private static function folders(array $values): array
|
||||
{
|
||||
$folders = [];
|
||||
foreach (self::limited($values, self::MAX_FOLDERS) as $folder) {
|
||||
if (str_starts_with($folder, Rule::OWN_FOLDER)) {
|
||||
$name = trim(substr($folder, strlen(Rule::OWN_FOLDER)), " \t/");
|
||||
if ($name === '' || mb_strlen($name) > 255 || preg_match('/[\x00-\x1f\x7f]/u', $name) || !mb_check_encoding($name, 'UTF-8')) {
|
||||
throw new UserError('retention_folder_invalid', [htmlspecialchars($folder)]);
|
||||
}
|
||||
$name = preg_replace('~^inbox(?=/|$)~i', 'INBOX', $name);
|
||||
$folder = Rule::OWN_FOLDER . $name;
|
||||
} elseif (!in_array($folder, Rule::FOLDERS, true)) {
|
||||
throw new UserError('retention_folder_invalid', [htmlspecialchars($folder)]);
|
||||
}
|
||||
$folders[$folder] = $folder;
|
||||
}
|
||||
return array_values($folders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field without repeats, at most $max (checked before anything is looked up)
|
||||
*
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private static function limited(mixed $value, int $max): array
|
||||
{
|
||||
$values = array_values(array_unique(self::values($value)));
|
||||
if (count($values) > $max) {
|
||||
throw new UserError('retention_entries_exceeded', [$max]);
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks or commas.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function values(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\r\n,;]+/', (string)Input::text($value));
|
||||
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
|
||||
}
|
||||
|
||||
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
|
||||
private static function flag(mixed $value): bool
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = end($value);
|
||||
}
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
}
|
||||
@@ -875,6 +875,93 @@ jQuery(function($){
|
||||
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
|
||||
});
|
||||
}
|
||||
// results of the nightly retention runs: mailboxes with found mail or errors, and the runs (username '', no rule)
|
||||
function draw_retention_logs() {
|
||||
// just recalc width if instance already exists
|
||||
if ($.fn.DataTable.isDataTable('#retention_log') ) {
|
||||
$('#retention_log').DataTable().columns.adjust().responsive.recalc();
|
||||
return;
|
||||
}
|
||||
var L = function(key) { return (lang.retention && lang.retention[key]) || ''; };
|
||||
$('#retention_log').DataTable({
|
||||
responsive: true,
|
||||
processing: true,
|
||||
serverSide: false,
|
||||
stateSave: true,
|
||||
pageLength: log_pagination_size,
|
||||
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" +
|
||||
"tr" +
|
||||
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
language: lang_datatables,
|
||||
order: [[0, 'desc']],
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/logs/retention",
|
||||
dataSrc: function(data){
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
title: lang.time,
|
||||
data: 'time',
|
||||
defaultContent: '',
|
||||
createdCell: function(td, cellData) {
|
||||
createSortableDate(td, cellData)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: L('mailbox'),
|
||||
data: 'username',
|
||||
defaultContent: '',
|
||||
render: function(data, type, row) {
|
||||
return row.rule_id === null ? '<i>' + escapeHtml(L('run')) + '</i>' : escapeHtml(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: L('rule'),
|
||||
data: 'rule_name',
|
||||
defaultContent: '',
|
||||
render: function(data) {
|
||||
return escapeHtml(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: L('folder'),
|
||||
data: 'folder',
|
||||
defaultContent: '',
|
||||
className: 'text-break',
|
||||
render: function(data, type, row) {
|
||||
if (row.rule_id === null) return escapeHtml(L('run_mailboxes').replace('%s', row.mailboxes));
|
||||
return (data || '').split('\n').map(escapeHtml).join('<br>') + ' <span class="text-muted small">(' + escapeHtml(L('days_short').replace('%s', row.days)) + ')</span>';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: L('found'),
|
||||
data: 'matched',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: L('deleted'),
|
||||
data: 'deleted',
|
||||
defaultContent: '',
|
||||
render: function(data, type, row) {
|
||||
return row.simulated == 1 ? '<span class="badge bg-secondary">' + escapeHtml(L('simulated')) + '</span>' : escapeHtml(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: L('error'),
|
||||
data: 'error',
|
||||
defaultContent: '',
|
||||
className: 'dtr-col-md text-break',
|
||||
render: function(data, type, row) {
|
||||
if (!data && row.rule_id === null && row.errors > 0) data = L('errors').replace('%s', row.errors);
|
||||
return data ? '<span class="text-danger">' + escapeHtml(data) + '</span>' : '';
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function rspamd_pie_graph() {
|
||||
$.ajax({
|
||||
url: '/api/v1/get/rspamd/actions',
|
||||
@@ -1295,6 +1382,7 @@ jQuery(function($){
|
||||
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
|
||||
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
|
||||
onVisible("[id^=cron_log]", () => draw_cron_logs());
|
||||
onVisible("[id^=retention_log]", () => draw_retention_logs());
|
||||
onVisible("[id^=rspamd_history]", () => draw_rspamd_history());
|
||||
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
|
||||
|
||||
|
||||
@@ -102,16 +102,18 @@ var mailcowMailPolicy = (function($) {
|
||||
});
|
||||
}
|
||||
|
||||
// add modal: mailboxes and groups of the chosen domain only
|
||||
// add modal: mailboxes and groups of the chosen domain only. The options are rebuilt and bootstrap-select is
|
||||
// built anew: its refresh repeats the list and still shows hidden options
|
||||
function filterDomain($form) {
|
||||
var domain = $form.find('select.mail-policy-domain-select').val();
|
||||
$form.find('select.mail-policy-mailboxes, select.mail-policy-groups').each(function() {
|
||||
$(this).find('option').each(function() {
|
||||
var other = $(this).data('domain') !== domain;
|
||||
$(this).prop('hidden', other);
|
||||
if (other) $(this).prop('selected', false);
|
||||
});
|
||||
$(this).selectpicker('refresh');
|
||||
var $select = $(this);
|
||||
if (!$select.data('all-options')) $select.data('all-options', $select.children('option').clone());
|
||||
$select.selectpicker('destroy');
|
||||
$select.empty().append($select.data('all-options').filter(function() {
|
||||
return $(this).data('domain') === domain;
|
||||
}).clone().prop('selected', false));
|
||||
$select.selectpicker({'styleBase': 'btn btn-xs-lg', 'noneSelectedText': lang_footer.nothing_selected});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2494,6 +2494,7 @@ jQuery(function($){
|
||||
onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1));
|
||||
onVisible("[id^=mailbox_group_table]", () => mailcowGroups.drawTable());
|
||||
onVisible("[id^=mail_policy_table]", () => mailcowMailPolicy.drawTable());
|
||||
onVisible("[id^=retention_table]", () => mailcowRetention.drawTable());
|
||||
onVisible("[id^=filter_table]", () => draw_filter_table());
|
||||
onVisible("[id^=bcc_table]", () => draw_bcc_table());
|
||||
onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table());
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Retention rules (admin/domainadmin mailbox page): list with the last nightly run, domain choices of the add modal.
|
||||
// The log of the runs is on the dashboard (draw_retention_logs). Loaded via $js_minifier.
|
||||
var mailcowRetention = (function($) {
|
||||
'use strict';
|
||||
|
||||
// lang.retention of the mailbox page, '' if missing
|
||||
function L(key) {
|
||||
var src = (typeof lang !== 'undefined' && lang && lang.retention) ? lang.retention : {};
|
||||
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
|
||||
}
|
||||
function esc(s) {
|
||||
return escapeHtml(s === null || s === undefined ? '' : s);
|
||||
}
|
||||
// replaces the %s of a text one after the other
|
||||
function fill(text) {
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
return text.replace(/%s/g, function() { return args.length ? args.shift() : ''; });
|
||||
}
|
||||
|
||||
function folderLabel(folder) {
|
||||
return folder.indexOf('folder:') === 0 ? folder.substr(7) : (L('folder_' + folder) || folder);
|
||||
}
|
||||
|
||||
function foldersCell(item) {
|
||||
return (item.folders || []).map(function(folder) { return esc(folderLabel(folder)); }).join('<br>');
|
||||
}
|
||||
|
||||
function periodCell(item) {
|
||||
return esc(fill(L('days_short'), item.days)) + (item.keep_flagged == 1 ? '<div class="small text-muted">' + esc(L('keep_flagged')) + '</div>' : '');
|
||||
}
|
||||
|
||||
function modeCell(mode) {
|
||||
return mode === 'delete' ? '<span class="badge bg-danger">' + esc(L('mode_delete')) + '</span>'
|
||||
: '<span class="badge bg-secondary">' + esc(L('simulated')) + '</span>';
|
||||
}
|
||||
|
||||
function assignedCell(item) {
|
||||
var list = [];
|
||||
if (item.all_mailboxes == 1) list.push(esc(L('all_mailboxes')));
|
||||
(item.mailboxes || []).slice(0, 3).forEach(function(mailbox) { list.push(esc(mailbox)); });
|
||||
if ((item.mailboxes || []).length > 3) list.push('<span class="text-muted">+' + (item.mailboxes.length - 3) + '</span>');
|
||||
if ((item.groups || []).length) list.push('<i class="bi bi-people"></i> ' + esc(L('groups')) + ': ' + item.groups.length);
|
||||
return list.length ? list.join('<br>') : '<span class="text-muted">' + esc(L('assigned_none')) + '</span>';
|
||||
}
|
||||
|
||||
function lastRunCell(run) {
|
||||
if (!run) return '<span class="text-muted">' + esc(L('never_run')) + '</span>';
|
||||
var text = run.simulated == 1 ? fill(L('run_simulated'), run.mailboxes, run.matched) : fill(L('run_deleted'), run.mailboxes, run.deleted);
|
||||
return esc(run.created) + '<div class="small text-muted">' + esc(text) + '</div>'
|
||||
+ (run.errors > 0 ? '<div class="small text-danger">' + esc(fill(L('errors'), run.errors)) + '</div>' : '');
|
||||
}
|
||||
|
||||
function drawTable() {
|
||||
if ($.fn.DataTable.isDataTable('#retention_table')) {
|
||||
$('#retention_table').DataTable().columns.adjust().responsive.recalc();
|
||||
return;
|
||||
}
|
||||
$('#retention_table').DataTable({
|
||||
responsive: true,
|
||||
processing: true,
|
||||
serverSide: false,
|
||||
stateSave: true,
|
||||
pageLength: pagination_size,
|
||||
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" +
|
||||
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
language: lang_datatables,
|
||||
order: [[3, 'asc']],
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/retention-rule/all",
|
||||
dataSrc: function(json) {
|
||||
if (!Array.isArray(json)) return [];
|
||||
$.each(json, function(i, item) {
|
||||
item.chkbox = item.can_edit ?
|
||||
'<input type="checkbox" class="form-check-input" data-id="retention_rule" name="multi_select" value="' + encodeURIComponent(item.id) + '" />' :
|
||||
'<input type="checkbox" class="form-check-input" disabled />';
|
||||
item.name_display = esc(item.name) + (item.description ? '<div class="small text-muted">' + esc(item.description) + '</div>' : '');
|
||||
item.folders_display = foldersCell(item);
|
||||
item.period_display = periodCell(item);
|
||||
item.mode_display = modeCell(item.mode);
|
||||
item.assigned_display = assignedCell(item);
|
||||
item.last_run_display = lastRunCell(item.last_run);
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a href="/edit/retention-rule/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + esc(item.can_edit ? lang.edit : L('view')) + '</a>' +
|
||||
(item.can_edit ? '<a href="#" data-action="delete_selected" data-id="single-retention_rule" data-api-url="delete/retention-rule" data-item="' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + esc(lang.remove) + '</a>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
return json;
|
||||
},
|
||||
error: function() {
|
||||
mailcow_alert_box(L('list_error'), 'danger');
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
// placeholder, so checkbox will not block child row toggle
|
||||
title: '',
|
||||
data: null,
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 1
|
||||
},
|
||||
{ title: '', data: 'chkbox', searchable: false, orderable: false, defaultContent: '', responsivePriority: 2 },
|
||||
{ title: 'ID', data: 'id', defaultContent: '', responsivePriority: 7 },
|
||||
{ title: L('name'), data: 'name_display', defaultContent: '', responsivePriority: 2 },
|
||||
{ title: L('domain'), data: 'domain', defaultContent: '', responsivePriority: 5 },
|
||||
{ title: L('folders'), data: 'folders_display', defaultContent: '', responsivePriority: 3 },
|
||||
{ title: L('days'), data: 'period_display', defaultContent: '', responsivePriority: 3 },
|
||||
{ title: L('mode'), data: 'mode_display', defaultContent: '', responsivePriority: 4 },
|
||||
{ title: L('assign'), data: 'assigned_display', defaultContent: '', responsivePriority: 5 },
|
||||
{ title: L('last_run'), data: 'last_run_display', defaultContent: '', responsivePriority: 4 },
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
defaultContent: '',
|
||||
responsivePriority: 6,
|
||||
render: function(d) { return d == 1 ? '<i class="bi bi-check-lg"></i>' : '<i class="bi bi-x-lg"></i>'; }
|
||||
},
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
orderable: false,
|
||||
className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right',
|
||||
responsivePriority: 3,
|
||||
defaultContent: ''
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// add modal: mailboxes and groups of the chosen domain only. The options are rebuilt and bootstrap-select is
|
||||
// built anew: its refresh repeats the list and still shows hidden options
|
||||
function filterDomain($form) {
|
||||
var domain = $form.find('select.retention-domain-select').val();
|
||||
$form.find('select.retention-mailboxes, select.retention-groups').each(function() {
|
||||
var $select = $(this);
|
||||
if (!$select.data('all-options')) $select.data('all-options', $select.children('option').clone());
|
||||
$select.selectpicker('destroy');
|
||||
$select.empty().append($select.data('all-options').filter(function() {
|
||||
return $(this).data('domain') === domain;
|
||||
}).clone().prop('selected', false));
|
||||
$select.selectpicker({'styleBase': 'btn btn-xs-lg', 'noneSelectedText': lang_footer.nothing_selected});
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$('select.retention-domain-select').on('change', function() {
|
||||
filterDomain($(this).closest('form'));
|
||||
});
|
||||
$('#addRetentionModal').on('show.bs.modal', function() {
|
||||
filterDomain($(this).find('form'));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
drawTable: drawTable
|
||||
};
|
||||
})(jQuery);
|
||||
@@ -283,6 +283,9 @@ if (isset($_GET['query'])) {
|
||||
case "mail-policy":
|
||||
process_add_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addPolicy($attr));
|
||||
break;
|
||||
case "retention-rule":
|
||||
process_add_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->addRule($attr));
|
||||
break;
|
||||
case "domain":
|
||||
switch ($object) {
|
||||
case "template":
|
||||
@@ -878,6 +881,23 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "retention-rule":
|
||||
switch ($object) {
|
||||
case "all":
|
||||
process_get_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->getRules($extra));
|
||||
break;
|
||||
|
||||
// effective/<username>: what the rules delete in the mailbox
|
||||
case "effective":
|
||||
process_get_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->getEffective($extra));
|
||||
break;
|
||||
|
||||
default:
|
||||
process_get_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->getRule($object));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
switch ($object) {
|
||||
case "dovecot":
|
||||
@@ -913,6 +933,11 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||
break;
|
||||
case "retention":
|
||||
// results of the nightly retention runs: mailboxes with found mail or errors, newest first
|
||||
$logs = \Mailcow\Retention\RetentionModule::fromGlobals()->api->getLog(isset($extra) ? intval(preg_replace('/[^\d]/', '', $extra)) : 0);
|
||||
echo (!empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||
break;
|
||||
case "cron":
|
||||
// 0 is first record, so empty is fine
|
||||
if (isset($extra)) {
|
||||
@@ -1802,6 +1827,9 @@ if (isset($_GET['query'])) {
|
||||
case "mail-policy":
|
||||
process_delete_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->deletePolicies($items));
|
||||
break;
|
||||
case "retention-rule":
|
||||
process_delete_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->deleteRules($items));
|
||||
break;
|
||||
case "app-passwd":
|
||||
process_delete_return(app_passwd('delete', array('id' => $items)));
|
||||
break;
|
||||
@@ -1979,6 +2007,9 @@ if (isset($_GET['query'])) {
|
||||
case "mail-policy":
|
||||
process_edit_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->editPolicies($items, $attr));
|
||||
break;
|
||||
case "retention-rule":
|
||||
process_edit_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->editRules($items, $attr));
|
||||
break;
|
||||
case "recipient_map":
|
||||
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"quarantine_notification": "Ändern der Quarantäne-Benachrichtigung",
|
||||
"ratelimit": "Rate limit",
|
||||
"recipient_maps": "Empfängerumschreibungen",
|
||||
"retention": "Aufbewahrungsregeln",
|
||||
"smtp_ip_access": "Verwalten der erlaubten Hosts für SMTP",
|
||||
"sogo_access": "Verwalten des SOGo-Zugriffsrechts erlauben",
|
||||
"sogo_profile_reset": "SOGo-Profil zurücksetzen",
|
||||
@@ -636,6 +637,14 @@
|
||||
"reset_f2b_regex": "Regex-Filter konnten nicht in vorgegebener Zeit zurückgesetzt werden, bitte erneut versuchen oder die Webseite neu laden.",
|
||||
"reset_token_limit_exceeded": "Das Limit für Rücksetz-Tokens wurde überschritten. Bitte versuchen Sie es später erneut.",
|
||||
"resource_invalid": "Ressourcenname %s ist ungültig",
|
||||
"retention_days_invalid": "Ungültige Frist: 1 bis %s Tage",
|
||||
"retention_description_invalid": "Ungültige Beschreibung",
|
||||
"retention_entries_exceeded": "Höchstens %s Einträge pro Auswahl möglich",
|
||||
"retention_folder_invalid": "Ungültiger Ordner: %s",
|
||||
"retention_folders_invalid": "Wählen Sie mindestens einen Ordner",
|
||||
"retention_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Regel",
|
||||
"retention_mode_invalid": "Ungültiger Modus",
|
||||
"retention_name_invalid": "Ungültiger Name (1 bis 100 Zeichen)",
|
||||
"rl_timeframe": "Ratelimit-Zeitraum ist inkorrekt",
|
||||
"rspamd_ui_pw_length": "Rspamd UI-Passwort muss mindestens 6 Zeichen lang sein",
|
||||
"script_empty": "Script darf nicht leer sein",
|
||||
@@ -1200,6 +1209,7 @@
|
||||
"owner": "Besitzer",
|
||||
"policies": "Richtlinien",
|
||||
"policies_mail": "Mail",
|
||||
"policies_retention": "Aufbewahrung",
|
||||
"policies_tls": "TLS",
|
||||
"private_comment": "Privater Kommentar",
|
||||
"public_comment": "Öffentlicher Kommentar",
|
||||
@@ -1351,6 +1361,63 @@
|
||||
"unhold_mail": "Freigeben",
|
||||
"unhold_mail_legend": "Gibt ausgewählte Mails zur Auslieferung frei. (Erfordert vorheriges Zurückhalten)"
|
||||
},
|
||||
"retention": {
|
||||
"add": "Aufbewahrungsregel hinzufügen",
|
||||
"all_mailboxes": "Alle Mailboxen der Domain",
|
||||
"assign": "Gilt für",
|
||||
"assign_info": "Gelten mehrere Regeln für denselben Ordner, gewinnt die genaueste: eine Regel für die Mailbox vor einer für ihre Gruppe vor einer für die ganze Domain, danach eine Regel, die den Ordner selbst nennt, vor einem Namen mit *, vor den Unterordnern eines Spezialordners oder des Posteingangs, vor allen Ordnern, danach die längere Frist. Gruppen gelten für ihre Mitglieder aus der Domain der Regel.",
|
||||
"assigned_none": "Nicht zugewiesen",
|
||||
"days": "Löschen nach",
|
||||
"days_info": "Tage, gezählt ab dem Speichern der E-Mail im Ordner: im Papierkorb ab dem Löschen, im Posteingang ab dem Empfang.",
|
||||
"days_short": "%s Tage",
|
||||
"deleted": "Gelöscht",
|
||||
"description": "Beschreibung",
|
||||
"domain": "Domain",
|
||||
"error": "Fehler",
|
||||
"errors": "%s Fehler",
|
||||
"folder": "Ordner",
|
||||
"folder_all": "Alle Ordner",
|
||||
"folder_archive": "Archiv",
|
||||
"folder_drafts": "Entwürfe",
|
||||
"folder_inbox": "Posteingang",
|
||||
"folder_junk": "Junk",
|
||||
"folder_sent": "Gesendet",
|
||||
"folder_trash": "Papierkorb",
|
||||
"folders": "Ordner",
|
||||
"folders_info": "Spezialordner und der Posteingang gelten mit ihren Unterordnern, Spezialordner unter allen Namen, die mailcow für sie kennt (z. B. Papierkorb, Trash, Deleted Items). „Alle Ordner“ umfasst die Ordner, für die keine andere Regel gilt.",
|
||||
"found": "Gefunden",
|
||||
"groups": "Gruppen",
|
||||
"info": "Aufbewahrungsregeln löschen E-Mails automatisch, sobald sie länger als die eingestellte Zahl an Tagen in einem Ordner liegen. Sie laufen jede Nacht. Neue Regeln zählen zunächst nur (Simulation); gelöscht wird erst, wenn der Modus auf „Löschen“ steht.",
|
||||
"keep_flagged": "Markierte E-Mails behalten",
|
||||
"last_run": "Letzter Lauf",
|
||||
"list_error": "Die Aufbewahrungsregeln konnten nicht geladen werden",
|
||||
"log_info": "Mailboxen, in denen der nächtliche Lauf E-Mails gefunden hat oder ein Fehler auftrat, und die Läufe selbst, der letzten 90 Tage. Von Regeln in der Simulation steht nur der letzte Lauf hier.",
|
||||
"mailbox": "Mailbox",
|
||||
"mailboxes": "Mailboxen",
|
||||
"mode": "Modus",
|
||||
"mode_delete": "Löschen",
|
||||
"mode_info": "In der Simulation zählt der nächtliche Lauf nur, was er löschen würde. Das Ergebnis steht unter „Letzter Lauf“ und im Protokoll.",
|
||||
"mode_simulate": "Simulation (nur zählen)",
|
||||
"name": "Name",
|
||||
"never_run": "Noch nicht gelaufen",
|
||||
"own_folders": "Eigene Ordner",
|
||||
"own_folders_info": "Ordnernamen, einer pro Zeile, Unterordner mit / (z. B. Projekte/Alt). Ein Name gilt nur für genau diesen Ordner, * steht für beliebige Zeichen, z. B. Projekte/* für alle Unterordner von Projekte.",
|
||||
"rule": "Regel",
|
||||
"rules": "Aufbewahrungsregeln",
|
||||
"run": "Nächtlicher Lauf",
|
||||
"run_deleted": "%s Mailboxen, %s E-Mails gelöscht",
|
||||
"run_mailboxes": "%s Mailboxen",
|
||||
"run_simulated": "%s Mailboxen, %s E-Mails würden gelöscht",
|
||||
"simulated": "Simulation",
|
||||
"user_all_other": "Alle übrigen Ordner",
|
||||
"user_info": "Diese Regeln löschen E-Mails in Ihrer Mailbox automatisch, sobald sie die angegebene Zeit im Ordner liegen:",
|
||||
"user_keep_flagged": "markierte E-Mails bleiben",
|
||||
"user_none": "Für Ihre Mailbox gelten keine Aufbewahrungsregeln.",
|
||||
"user_simulated": "nur Simulation, es wird nichts gelöscht",
|
||||
"user_subfolders_of": "Unterordner von %s",
|
||||
"user_with_subfolders": "mit Unterordnern",
|
||||
"view": "Ansehen"
|
||||
},
|
||||
"start": {
|
||||
"help": "Hilfe ein-/ausblenden",
|
||||
"imap_smtp_server_auth_info": "Bitte verwenden Sie Ihre vollständige E-Mail-Adresse sowie das PLAIN-Authentifizierungsverfahren.<br>\r\nIhre Anmeldedaten werden durch die obligatorische Verschlüsselung entgegen des Begriffes \"PLAIN\" nicht unverschlüsselt übertragen."
|
||||
@@ -1438,6 +1505,9 @@
|
||||
"resource_added": "Ressource %s wurde angelegt",
|
||||
"resource_modified": "Änderungen an Ressource %s wurden gespeichert",
|
||||
"resource_removed": "Ressource %s wurde entfernt",
|
||||
"retention_rule_added": "Aufbewahrungsregel %s hinzugefügt",
|
||||
"retention_rule_modified": "Aufbewahrungsregel %s gespeichert",
|
||||
"retention_rule_removed": "Aufbewahrungsregel %s entfernt",
|
||||
"rl_saved": "Ratelimit für Objekt %s wurde gesetzt",
|
||||
"rspamd_ui_pw_set": "Rspamd-UI-Passwort wurde gesetzt",
|
||||
"saved_settings": "Regel wurde gespeichert",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"quarantine_notification": "Change quarantine notifications",
|
||||
"ratelimit": "Rate limit",
|
||||
"recipient_maps": "Recipient maps",
|
||||
"retention": "Retention rules",
|
||||
"smtp_ip_access": "Change allowed hosts for SMTP",
|
||||
"sogo_access": "Allow management of SOGo access",
|
||||
"sogo_profile_reset": "Reset SOGo profile",
|
||||
@@ -527,6 +528,14 @@
|
||||
"mail_policy_name_invalid": "Invalid name (1 to 100 characters)",
|
||||
"mail_policy_reject_text_invalid": "Invalid rejection text: printable ASCII, at most 200 characters",
|
||||
"mail_policy_test_invalid": "Invalid sender or recipient",
|
||||
"retention_days_invalid": "Invalid period: 1 to %s days",
|
||||
"retention_description_invalid": "Invalid description",
|
||||
"retention_entries_exceeded": "At most %s entries per selection are possible",
|
||||
"retention_folder_invalid": "Invalid folder: %s",
|
||||
"retention_folders_invalid": "Choose at least one folder",
|
||||
"retention_member_invalid": "Mailbox or group %s does not belong to the domain of the rule",
|
||||
"retention_mode_invalid": "Invalid mode",
|
||||
"retention_name_invalid": "Invalid name (1 to 100 characters)",
|
||||
"tfa_login_locked": "Too many failed two-factor logins, please try again later",
|
||||
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
|
||||
"alias_domain_invalid": "Alias domain %s is invalid",
|
||||
@@ -1207,6 +1216,7 @@
|
||||
"owner": "Owner",
|
||||
"policies": "Policies",
|
||||
"policies_mail": "Mail",
|
||||
"policies_retention": "Retention",
|
||||
"policies_tls": "TLS",
|
||||
"private_comment": "Private comment",
|
||||
"public_comment": "Public comment",
|
||||
@@ -1358,6 +1368,63 @@
|
||||
"hour": "msgs / hour",
|
||||
"day": "msgs / day"
|
||||
},
|
||||
"retention": {
|
||||
"add": "Add retention rule",
|
||||
"all_mailboxes": "All mailboxes of the domain",
|
||||
"assign": "Applies to",
|
||||
"assign_info": "If several rules apply to the same folder, the most specific one wins: a rule for the mailbox before one for its group before one for the whole domain, then a rule naming the folder itself before a name with *, before the subfolders of a special folder or the inbox, before all folders, then the longer period. Groups apply to their members of the rule's domain.",
|
||||
"assigned_none": "Not assigned",
|
||||
"days": "Delete after",
|
||||
"days_info": "Days, counted from the time the mail was saved in the folder: in the trash from its deletion, in the inbox from its receipt.",
|
||||
"days_short": "%s days",
|
||||
"deleted": "Deleted",
|
||||
"description": "Description",
|
||||
"domain": "Domain",
|
||||
"error": "Error",
|
||||
"errors": "%s errors",
|
||||
"folder": "Folder",
|
||||
"folder_all": "All folders",
|
||||
"folder_archive": "Archive",
|
||||
"folder_drafts": "Drafts",
|
||||
"folder_inbox": "Inbox",
|
||||
"folder_junk": "Junk",
|
||||
"folder_sent": "Sent",
|
||||
"folder_trash": "Trash",
|
||||
"folders": "Folders",
|
||||
"folders_info": "Special folders and the inbox apply with their subfolders, special folders under all names mailcow knows for them (e.g. Trash, Papierkorb, Deleted Items). \"All folders\" covers the folders no other rule applies to.",
|
||||
"found": "Found",
|
||||
"groups": "Groups",
|
||||
"info": "Retention rules delete mail automatically once it has been in a folder for longer than the set number of days. They run every night. New rules only count at first (simulation); mail is deleted once the mode is set to \"Delete\".",
|
||||
"keep_flagged": "Keep flagged mail",
|
||||
"last_run": "Last run",
|
||||
"list_error": "The retention rules could not be loaded",
|
||||
"log_info": "Mailboxes in which the nightly run found mail or an error occurred, and the runs themselves, of the last 90 days. Of rules in simulation only the last run is shown here.",
|
||||
"mailbox": "Mailbox",
|
||||
"mailboxes": "Mailboxes",
|
||||
"mode": "Mode",
|
||||
"mode_delete": "Delete",
|
||||
"mode_info": "In simulation the nightly run only counts what it would delete. The result is shown under \"Last run\" and in the log.",
|
||||
"mode_simulate": "Simulation (count only)",
|
||||
"name": "Name",
|
||||
"never_run": "Not run yet",
|
||||
"own_folders": "Own folders",
|
||||
"own_folders_info": "Folder names, one per line, subfolders with / (e.g. Projects/Old). A name applies to exactly this folder, * stands for any characters, e.g. Projects/* for all subfolders of Projects.",
|
||||
"rule": "Rule",
|
||||
"rules": "Retention rules",
|
||||
"run": "Nightly run",
|
||||
"run_deleted": "%s mailboxes, %s mails deleted",
|
||||
"run_mailboxes": "%s mailboxes",
|
||||
"run_simulated": "%s mailboxes, %s mails would be deleted",
|
||||
"simulated": "Simulation",
|
||||
"user_all_other": "All other folders",
|
||||
"user_info": "These rules delete mail in your mailbox automatically once it has been in the folder for the given time:",
|
||||
"user_keep_flagged": "flagged mail stays",
|
||||
"user_none": "No retention rules apply to your mailbox.",
|
||||
"user_simulated": "simulation only, nothing is deleted",
|
||||
"user_subfolders_of": "Subfolders of %s",
|
||||
"user_with_subfolders": "with subfolders",
|
||||
"view": "View"
|
||||
},
|
||||
"start": {
|
||||
"help": "Show/Hide help panel",
|
||||
"imap_smtp_server_auth_info": "Please use your full email address and the PLAIN authentication mechanism.<br>\r\nYour login data will be encrypted by the server-side mandatory encryption."
|
||||
@@ -1445,6 +1512,9 @@
|
||||
"resource_added": "Resource %s has been added",
|
||||
"resource_modified": "Changes to mailbox %s have been saved",
|
||||
"resource_removed": "Resource %s has been removed",
|
||||
"retention_rule_added": "Retention rule %s added",
|
||||
"retention_rule_modified": "Retention rule %s saved",
|
||||
"retention_rule_removed": "Retention rule %s removed",
|
||||
"rl_saved": "Rate limit for object %s saved",
|
||||
"rspamd_ui_pw_set": "Rspamd UI password successfully set",
|
||||
"saved_settings": "Saved settings",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-logs" aria-selected="false" aria-controls="tab-api-logs" role="tab" data-bs-toggle="tab">API</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-rl" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Ratelimits</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-cron-logs" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Crontasks</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-retention-logs" aria-selected="false" aria-controls="tab-retention-logs" role="tab" data-bs-toggle="tab">{{ lang.mailbox.policies_retention }}</button></li>
|
||||
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.external_logs }}</span></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-rspamd-history" aria-selected="false" aria-controls="tab-rspamd-history" role="tab" data-bs-toggle="tab">Rspamd</button></li>
|
||||
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.static_logs }}</span></li>
|
||||
@@ -547,6 +548,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" class="tab-pane" id="tab-retention-logs">
|
||||
<div class="debug-log-info">{{ lang.retention.log_info }}</div>
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center fs-5">
|
||||
<span class="mt-2 ms-2">{{ lang.retention.rules }}</span>
|
||||
<div class="btn-group ms-auto">
|
||||
<button class="btn btn-sm btn-secondary refresh_table" data-table="retention_log">{{ lang.admin.refresh }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="retention_log" class="table table-striped dt-responsive w-100"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /tab-content -->
|
||||
</div> <!-- /col-md-12 -->
|
||||
</div> <!-- /row -->
|
||||
@@ -554,6 +570,7 @@
|
||||
<script type='text/javascript'>
|
||||
var lang = {{ lang_admin|raw }};
|
||||
var lang_debug = {{ lang_debug|raw }};
|
||||
lang.retention = {{ lang_retention|raw }};
|
||||
var lang_datatables = {{ lang_datatables|raw }};
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
var log_pagination_size = Math.trunc('{{ log_pagination_size }}');
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{# Fields of a retention rule (add modal, edit page): rule, id (prefix of the element ids), groups (choices: id, name,
|
||||
domain, address), mailboxes (choices), ro, label_class. Own folders are sent apart as own_folders, one per line;
|
||||
without group choices (none in the domain, no access to groups) the group field is left out and stays unchanged. #}
|
||||
{% set special = [] %}
|
||||
{% set own = [] %}
|
||||
{% for folder in rule.folders|default([]) %}
|
||||
{% if folder starts with 'folder:' %}
|
||||
{% set own = own|merge([folder|slice(7)]) %}
|
||||
{% else %}
|
||||
{% set special = special|merge([folder]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_folders">{{ lang.retention.folders }}</label>
|
||||
<div class="col-sm-10">
|
||||
{% if not ro %}<input type="hidden" name="folders" value="">{% endif %}
|
||||
<select data-width="100%" name="folders" id="{{ id }}_folders" class="form-control" multiple title="{{ lang.retention.folders }}"{% if ro %} disabled{% endif %}>
|
||||
{% for folder in ['trash', 'junk', 'sent', 'drafts', 'archive', 'inbox', 'all'] %}
|
||||
<option value="{{ folder }}"{% if folder in special %} selected{% endif %}>{{ attribute(lang.retention, 'folder_' ~ folder) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.retention.folders_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_own_folders">{{ lang.retention.own_folders }}</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="2" name="own_folders" id="{{ id }}_own_folders" placeholder="Newsletter Projekte/*"{% if ro %} disabled{% endif %}>{{ own|join("\n") }}</textarea>
|
||||
<small class="text-muted d-block">{{ lang.retention.own_folders_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_days">{{ lang.retention.days }}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group" style="max-width: 16rem">
|
||||
<input type="number" class="form-control" name="days" id="{{ id }}_days" min="1" max="36500" value="{{ rule.days|default(30) }}" required{% if ro %} disabled{% endif %}>
|
||||
<span class="input-group-text">{{ lang.retention.days_short|replace({'%s ': ''}) }}</span>
|
||||
</div>
|
||||
<small class="text-muted d-block">{{ lang.retention.days_info }}</small>
|
||||
<div class="form-check mt-1">
|
||||
{% if not ro %}<input type="hidden" value="0" name="keep_flagged">{% endif %}
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="keep_flagged"{% if rule.keep_flagged|default(0) == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.retention.keep_flagged }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_mode">{{ lang.retention.mode }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="mode" id="{{ id }}_mode"{% if ro %} disabled{% endif %}>
|
||||
{% for mode in ['simulate', 'delete'] %}
|
||||
<option value="{{ mode }}"{% if rule.mode|default('simulate') == mode %} selected{% endif %}>{{ attribute(lang.retention, 'mode_' ~ mode) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.retention.mode_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<legend class="fs-5 mt-4"><i class="bi bi-people"></i> {{ lang.retention.assign }}</legend><hr>
|
||||
<p class="text-muted small">{{ lang.retention.assign_info }}</p>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
{% if not ro %}<input type="hidden" value="0" name="all_mailboxes">{% endif %}
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="all_mailboxes"{% if rule.all_mailboxes|default(0) == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.retention.all_mailboxes }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_mailboxes">{{ lang.retention.mailboxes }}</label>
|
||||
<div class="col-sm-10">
|
||||
{% if not ro %}<input type="hidden" name="mailboxes" value="">{% endif %}
|
||||
<select data-live-search="true" data-width="100%" name="mailboxes" id="{{ id }}_mailboxes" class="form-control retention-mailboxes" multiple title="{{ lang.retention.mailboxes }}"{% if ro %} disabled{% endif %}>
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox }}" data-domain="{{ mailbox|split('@')|last }}"{% if mailbox in rule.mailboxes|default([]) %} selected{% endif %}>{{ mailbox }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if groups %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_groups">{{ lang.retention.groups }}</label>
|
||||
<div class="col-sm-10">
|
||||
{% if not ro %}<input type="hidden" name="groups" value="">{% endif %}
|
||||
<select data-live-search="true" data-width="100%" name="groups" id="{{ id }}_groups" class="form-control retention-groups" multiple title="{{ lang.retention.groups }}"{% if ro %} disabled{% endif %}>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id in rule.groups|default([]) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends 'edit.twig' %}
|
||||
|
||||
{% block inner_content %}
|
||||
{% if result %}
|
||||
{% set ro = not result.can_edit %}
|
||||
<h4>{{ lang.retention.rule }}: {{ result.name }}</h4>
|
||||
<form data-id="edit_retention_rule" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="retention_rule_domain">{{ lang.retention.domain }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="retention_rule_domain" value="{{ result.domain }}" disabled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="retention_rule_name">{{ lang.retention.name }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="name" id="retention_rule_name" value="{{ result.name }}" maxlength="100" required{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="retention_rule_description">{{ lang.retention.description }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="description" id="retention_rule_description" value="{{ result.description }}" maxlength="255"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'edit/retention-rule-fields.twig' with {'rule': result, 'id': 'retention_rule', 'groups': retention_groups, 'mailboxes': mailboxes, 'ro': ro, 'label_class': ''} %}
|
||||
<div class="row mb-4">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.mailbox.active }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not ro %}
|
||||
<div class="row">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_retention_rule" data-item="{{ result.id }}" data-api-url='edit/retention-rule' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
{{ parent() }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -36,16 +36,22 @@
|
||||
{% if acl.mailbox_groups == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-groups" role="tab" data-bs-toggle="tab" data-bs-target="#tab-groups">{{ lang.groups.groups }}</button></li>
|
||||
{% endif %}
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
{# policies: TLS (admins), mail policies and retention rules (ACLs); a single one is a plain tab #}
|
||||
{% set policy_tabs = [] %}
|
||||
{% if mailcow_cc_role == 'admin' %}{% set policy_tabs = policy_tabs|merge([{'id': 'tab-tls-policy', 'short': lang.mailbox.policies_tls, 'long': lang.mailbox.tls_policy_maps}]) %}{% endif %}
|
||||
{% if acl.mail_policies == 1 %}{% set policy_tabs = policy_tabs|merge([{'id': 'tab-mail-policies', 'short': lang.mailbox.policies_mail, 'long': lang.mail_policy.mail_policies}]) %}{% endif %}
|
||||
{% if acl.retention == 1 %}{% set policy_tabs = policy_tabs|merge([{'id': 'tab-retention', 'short': lang.mailbox.policies_retention, 'long': lang.retention.rules}]) %}{% endif %}
|
||||
{% if policy_tabs|length > 1 %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.policies }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="tab-tls-policy" role="tab" data-bs-toggle="tab" data-bs-target="#tab-tls-policy">{{ lang.mailbox.policies_tls }}</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="tab-mail-policies" role="tab" data-bs-toggle="tab" data-bs-target="#tab-mail-policies">{{ lang.mailbox.policies_mail }}</button></li>
|
||||
{% for tab in policy_tabs %}
|
||||
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="{{ tab.id }}" role="tab" data-bs-toggle="tab" data-bs-target="#{{ tab.id }}">{{ tab.short }}</button></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% elseif acl.mail_policies == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-mail-policies" role="tab" data-bs-toggle="tab" data-bs-target="#tab-mail-policies">{{ lang.mail_policy.mail_policies }}</button></li>
|
||||
{% elseif policy_tabs|length == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="{{ policy_tabs[0].id }}" role="tab" data-bs-toggle="tab" data-bs-target="#{{ policy_tabs[0].id }}">{{ policy_tabs[0].long }}</button></li>
|
||||
{% endif %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-filters" role="tab" data-bs-toggle="tab" data-bs-target="#tab-filters">{{ lang.mailbox.filters }}</button></li>
|
||||
@@ -72,6 +78,9 @@
|
||||
{% if acl.mail_policies == 1 %}
|
||||
{% include 'mailbox/tab-mail-policies.twig' %}
|
||||
{% endif %}
|
||||
{% if acl.retention == 1 %}
|
||||
{% include 'mailbox/tab-retention.twig' %}
|
||||
{% endif %}
|
||||
{% include 'mailbox/tab-syncjobs.twig' %}
|
||||
{% include 'mailbox/tab-filters.twig' %}
|
||||
{% include 'mailbox/tab-bcc.twig' %}
|
||||
@@ -89,6 +98,7 @@
|
||||
lang.syncjobs = {{ lang_syncjobs|raw }};
|
||||
lang.groups = {{ lang_groups|raw }};
|
||||
lang.mail_policy = {{ lang_mail_policy|raw }};
|
||||
lang.retention = {{ lang_retention|raw }};
|
||||
var lang_rl = {{ lang_rl|raw }};
|
||||
var lang_edit = {{ lang_edit|raw }};
|
||||
var lang_datatables = {{ lang_datatables|raw }};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<div class="tab-pane fade" id="tab-retention" role="tabpanel" aria-labelledby="tab-retention">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-retention" data-bs-toggle="collapse" aria-controls="collapse-tab-retention">
|
||||
{{ lang.retention.rules }} <span class="badge bg-info table-lines"></span>
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.retention.rules }} <span class="badge bg-info table-lines"></span></span>
|
||||
<div class="btn-group ms-auto d-flex">
|
||||
<button class="btn btn-xs btn-secondary refresh_table" data-table="retention_table">{{ lang.admin.refresh }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collapse-tab-retention" class="card-body collapse" data-bs-parent="#mail-content">
|
||||
<p class="text-muted">{{ lang.retention.info }}</p>
|
||||
<table id="retention_table" class="table table-striped dt-responsive w-100"></table>
|
||||
<div class="mass-actions-mailbox mt-4">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" id="toggle_multi_select_all" data-id="retention_rule" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
|
||||
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="retention_rule" data-api-url='edit/retention-rule' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="retention_rule" data-api-url='edit/retention-rule' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="retention_rule" data-api-url='edit/retention-rule' data-api-attr='{"mode":"simulate"}' href="#">{{ lang.retention.mode }}: {{ lang.retention.mode_simulate }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" data-action="delete_selected" data-id="retention_rule" data-api-url='delete/retention-rule' href="#">{{ lang.mailbox.remove }}</a></li>
|
||||
</ul>
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addRetentionModal"><i class="bi bi-plus-lg"></i> {{ lang.retention.add }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1365,3 +1365,6 @@
|
||||
{% if acl.mail_policies == 1 %}
|
||||
{% include "modals/mail_policy.twig" %}
|
||||
{% endif %}
|
||||
{% if acl.retention == 1 %}
|
||||
{% include "modals/retention.twig" %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="modal fade" id="addRetentionModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ lang.retention.add }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal" role="form" data-id="add_retention_rule" autocapitalize="none" autocorrect="off">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="add_retention_rule_domain">{{ lang.retention.domain }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select data-live-search="true" name="domain" id="add_retention_rule_domain" class="form-control retention-domain-select" required>
|
||||
{% for domain in domains %}
|
||||
<option>{{ domain }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="add_retention_rule_name">{{ lang.retention.name }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="name" id="add_retention_rule_name" maxlength="100" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="add_retention_rule_description">{{ lang.retention.description }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="description" id="add_retention_rule_description" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
{% include 'edit/retention-rule-fields.twig' with {'rule': {}, 'id': 'add_retention_rule', 'groups': retention_groups, 'mailboxes': mailboxes, 'ro': false, 'label_class': ' text-sm-end'} %}
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.mailbox.active }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_retention_rule" data-api-url='add/retention-rule' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,6 +73,21 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4"><i class="bi bi-hourglass-split"></i> {{ lang.mailbox.policies_retention }}:
|
||||
<p class="small">{{ lang.retention.user_info }}</p>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-9 col-12">
|
||||
<p>
|
||||
{% for action in retention_rules %}
|
||||
{% if action.folder starts with 'folder:' %}{{ action.folder|slice(7) }}{% elseif action.folder == 'all' and not loop.first %}{{ lang.retention.user_all_other }}{% elseif action.match == 'subfolders' %}{{ lang.retention.user_subfolders_of|format(attribute(lang.retention, 'folder_' ~ action.folder)) }}{% else %}{{ attribute(lang.retention, 'folder_' ~ action.folder) }}{% if action.subfolders %} {{ lang.retention.user_with_subfolders }}{% endif %}{% endif %}:
|
||||
{{ lang.retention.days_short|format(action.days) }}{% if action.keep_flagged %}, {{ lang.retention.user_keep_flagged }}{% endif %}{% if action.mode != 'delete' %} <span class="text-muted">({{ lang.retention.user_simulated }})</span>{% endif %}{% if not loop.last %}<br>{% endif %}
|
||||
{% else %}
|
||||
{{ lang.retention.user_none }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4">{{ lang.user.is_catch_all }}:</div>
|
||||
<div class="col-sm-8 col-md-9 col-12">
|
||||
|
||||
@@ -65,6 +65,7 @@ $template_data = [
|
||||
'clientconfigstr' => $clientconfigstr,
|
||||
'user_get_alias_details' => $user_get_alias_details,
|
||||
'mailbox_groups' => \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getMemberships($username),
|
||||
'retention_rules' => \Mailcow\Retention\RetentionModule::fromGlobals()->api->getEffective($username, true),
|
||||
'get_tagging_options' => mailbox('get', 'delimiter_action', $username),
|
||||
'get_tls_policy' => mailbox('get', 'tls_policy', $username),
|
||||
'quarantine_notification' => mailbox('get', 'quarantine_notification', $username),
|
||||
|
||||
+5
-2
@@ -1,5 +1,5 @@
|
||||
# Version of the database schema
|
||||
x-db-version: &db_version DB_VERSION=07112026_1800
|
||||
x-db-version: &db_version DB_VERSION=07112026_2330
|
||||
|
||||
services:
|
||||
|
||||
@@ -259,7 +259,7 @@ services:
|
||||
- sogo
|
||||
|
||||
dovecot-mailcow:
|
||||
image: ghcr.io/mailcow/dovecot:2.3.21.1-4
|
||||
image: ghcr.io/mailcow/dovecot:2.3.21.1-5
|
||||
depends_on:
|
||||
- mysql-mailcow
|
||||
- netfilter-mailcow
|
||||
@@ -322,6 +322,9 @@ services:
|
||||
ofelia.job-exec.dovecot_imapsync_runner.schedule: "0 * * * * *"
|
||||
ofelia.job-exec.dovecot_imapsync_runner.no-overlap: "true"
|
||||
ofelia.job-exec.dovecot_imapsync_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu nobody /usr/local/bin/imapsync_runner.py || exit 0\""
|
||||
ofelia.job-exec.dovecot_retention.schedule: "0 30 3 * * *"
|
||||
ofelia.job-exec.dovecot_retention.no-overlap: "true"
|
||||
ofelia.job-exec.dovecot_retention.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/retention_runner.py || exit 0\""
|
||||
ofelia.job-exec.dovecot_trim_logs.schedule: "0 * * * * *"
|
||||
ofelia.job-exec.dovecot_trim_logs.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/trim_logs.sh || exit 0\""
|
||||
ofelia.job-exec.dovecot_quarantine.schedule: "0 */20 * * * *"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Retention\Service;
|
||||
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
use Mailcow\Retention\Service\Planner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PlannerTest extends TestCase
|
||||
{
|
||||
private static function rule(int $id, array $folders, int $days, array $values = []): Rule
|
||||
{
|
||||
$rule = new Rule();
|
||||
$rule->id = $id;
|
||||
$rule->name = 'rule ' . $id;
|
||||
$rule->folders = $folders;
|
||||
$rule->days = $days;
|
||||
foreach ($values as $property => $value) {
|
||||
$rule->$property = $value;
|
||||
}
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @return string[] folder/match:days:rule id in the order of the plan */
|
||||
private static function plan(array $candidates): array
|
||||
{
|
||||
return array_map(fn(array $action): string => $action['folder'] . '/' . $action['match'] . ':' . $action['days'] . ':' . $action['rule_id'],
|
||||
Planner::actions($candidates));
|
||||
}
|
||||
|
||||
public function testTheMailboxRuleWinsOverGroupAndDomain(): void
|
||||
{
|
||||
$this->assertSame(['trash/folder:90:3', 'trash/subfolders:90:3'], self::plan([
|
||||
['rule' => self::rule(1, ['trash'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['trash'], 10), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(3, ['trash'], 90), 'level' => Planner::LEVEL_MAILBOX],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testAMoreSpecificAssignmentWinsAlsoWithAllFolders(): void
|
||||
{
|
||||
// the group rule for all folders is more specific than the domain rule for the trash, which never wins
|
||||
$this->assertSame(['all/all:365:2'], self::plan([
|
||||
['rule' => self::rule(1, ['trash'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['all'], 365), 'level' => Planner::LEVEL_GROUP],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testNamedFoldersComeBeforeAllFoldersOnTheSameLevel(): void
|
||||
{
|
||||
$this->assertSame(['trash/folder:30:2', 'folder:Newsletter/folder:7:3', 'trash/subfolders:30:2', 'all/all:365:1'], self::plan([
|
||||
['rule' => self::rule(1, ['all'], 365), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['trash'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(3, ['folder:Newsletter'], 7), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testTheFolderItselfBeforePatternsBeforeSubfolders(): void
|
||||
{
|
||||
// INBOX/Newsletter: its own rule; other subfolders of the inbox: INBOX/* before the subfolders of the inbox rule
|
||||
$this->assertSame(['inbox/folder:365:1', 'folder:INBOX/Newsletter/folder:7:3', 'folder:INBOX/*/pattern:30:2', 'inbox/subfolders:365:1'], self::plan([
|
||||
['rule' => self::rule(1, ['inbox'], 365), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['folder:INBOX/*'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(3, ['folder:INBOX/Newsletter'], 7), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testOverlappingFoldersAreDecidedPerFolder(): void
|
||||
{
|
||||
// * also matches the trash: the trash rule keeps the trash itself, * the other folders
|
||||
$this->assertSame(['trash/folder:365:3', 'folder:*/pattern:7:1', 'trash/subfolders:365:3'], self::plan([
|
||||
['rule' => self::rule(1, ['folder:*'], 7, ['mode' => 'delete']), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(3, ['trash'], 365), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testOnATieTheLongerPeriodAndThenTheLowerIdWin(): void
|
||||
{
|
||||
$this->assertSame(['junk/folder:60:2', 'trash/folder:30:3', 'junk/subfolders:60:2', 'trash/subfolders:30:3'], self::plan([
|
||||
['rule' => self::rule(1, ['junk'], 30), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(2, ['junk'], 60), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(4, ['trash'], 30), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(3, ['trash'], 30), 'level' => Planner::LEVEL_GROUP],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testARuleAssignedTwiceCountsWithItsBestLevel(): void
|
||||
{
|
||||
$rule = self::rule(1, ['folder:Alt'], 30);
|
||||
$this->assertSame(['folder:Alt/folder:30:1', 'all/all:90:2'], self::plan([
|
||||
['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['all'], 90), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => $rule, 'level' => Planner::LEVEL_MAILBOX],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testTheWinnerBringsItsModeAndFlaggedMail(): void
|
||||
{
|
||||
$actions = Planner::actions([
|
||||
['rule' => self::rule(1, ['folder:Alt'], 30, ['mode' => 'delete', 'keepFlagged' => 1]), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]);
|
||||
$this->assertSame([['folder' => 'folder:Alt', 'match' => 'folder', 'days' => 30, 'keep_flagged' => 1, 'mode' => 'delete', 'rule_id' => 1,
|
||||
'rule_name' => 'rule 1']], $actions);
|
||||
$this->assertSame([], Planner::actions([]));
|
||||
}
|
||||
|
||||
public function testDescribeJoinsTheSubfoldersOfTheSameRule(): void
|
||||
{
|
||||
$described = fn(array $candidates): array => array_map(fn(array $action): string => $action['folder'] . '/' . $action['match']
|
||||
. ($action['subfolders'] ? '+' : '') . ':' . $action['rule_id'], Planner::describe(Planner::actions($candidates)));
|
||||
$trashAndJunk = ['rule' => self::rule(1, ['trash', 'junk'], 30), 'level' => Planner::LEVEL_GROUP];
|
||||
$junk = ['rule' => self::rule(2, ['junk'], 90), 'level' => Planner::LEVEL_GROUP];
|
||||
|
||||
$this->assertSame(['junk/folder+:2', 'trash/folder+:1'], $described([$trashAndJunk, $junk]));
|
||||
// a name with * between them may take subfolders: they stay apart
|
||||
$this->assertSame(['junk/folder:2', 'trash/folder:1', 'folder:Junk/*/pattern:3', 'junk/subfolders+:2', 'trash/subfolders+:1'],
|
||||
$described([$trashAndJunk, $junk, ['rule' => self::rule(3, ['folder:Junk/*'], 60), 'level' => Planner::LEVEL_GROUP]]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Retention\Service;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
use Mailcow\Retention\Repository\RuleRepository;
|
||||
use Mailcow\Retention\Service\Planner;
|
||||
use Mailcow\Retention\Service\RuleService;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RuleServiceTest extends TestCase
|
||||
{
|
||||
private RuleRepository&MockObject $repo;
|
||||
/** @var Rule[] saved rules */
|
||||
private array $saved = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(RuleRepository::class);
|
||||
$this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true));
|
||||
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string =>
|
||||
['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null);
|
||||
$this->repo->method('groupDomain')->willReturnCallback(fn(int $id): ?string => [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null);
|
||||
$this->repo->method('groupsIn')->willReturn([['id' => 7, 'name' => 'Azubis', 'domain' => 'firma.de', 'address' => null]]);
|
||||
$this->repo->method('save')->willReturnCallback(function (Rule $rule): int {
|
||||
$rule->id ??= 1;
|
||||
$this->saved[] = clone $rule;
|
||||
return $rule->id;
|
||||
});
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): RuleService
|
||||
{
|
||||
return new RuleService($this->repo, $context ?? FakeContext::admin());
|
||||
}
|
||||
|
||||
private function stored(array $values = []): Rule
|
||||
{
|
||||
$rule = new Rule();
|
||||
$rule->id = 3;
|
||||
$rule->domain = 'firma.de';
|
||||
$rule->name = 'Papierkorb';
|
||||
$rule->folders = ['trash', 'folder:Newsletter'];
|
||||
$rule->groups = [7];
|
||||
foreach ($values as $property => $value) {
|
||||
$rule->$property = $value;
|
||||
}
|
||||
return $rule;
|
||||
}
|
||||
|
||||
private function expectUserError(string|array $msg, callable $call): void
|
||||
{
|
||||
try {
|
||||
$call();
|
||||
$this->fail('UserError ' . json_encode($msg) . ' expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
|
||||
public function testCreateNormalizesTheInput(): void
|
||||
{
|
||||
$rule = $this->service()->create([
|
||||
'domain' => 'Firma.de', 'name' => 'Aufräumen', 'folders' => ['', 'trash', 'junk', 'trash'], 'own_folders' => "Projekte/*\n\n /Newsletter/ ",
|
||||
'days' => '30', 'keep_flagged' => ['0', '1'], 'all_mailboxes' => ['0'], 'mailboxes' => ['', 'Azubi@firma.de'], 'groups' => ['7'],
|
||||
]);
|
||||
$this->assertSame(['domain' => 'firma.de', 'name' => 'Aufräumen', 'folders' => ['trash', 'junk', 'folder:Projekte/*', 'folder:Newsletter'],
|
||||
'days' => 30, 'keep_flagged' => 1, 'mode' => 'simulate', 'all_mailboxes' => 0, 'active' => 1, 'mailboxes' => ['azubi@firma.de'], 'groups' => [7]],
|
||||
array_intersect_key($rule->toArray(), array_flip(['domain', 'name', 'folders', 'days', 'keep_flagged', 'mode', 'all_mailboxes', 'active', 'mailboxes', 'groups'])));
|
||||
}
|
||||
|
||||
public function testUpdateKeepsMissingValuesAndReplacesOwnFolders(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$this->assertSame(['trash', 'folder:Archiv/2020'], $this->service()->update(3, ['own_folders' => 'Archiv/2020'])->folders);
|
||||
// the API names all folders in folders, the UI sends the own folders apart
|
||||
$this->assertSame(['junk', 'folder:Alt'], $this->service()->update(3, ['folders' => ['junk', 'folder:Alt']])->folders);
|
||||
$this->assertSame(['sent', 'folder:Neu'], $this->service()->update(3, ['folders' => ['sent'], 'own_folders' => 'Neu'])->folders);
|
||||
// a list keeps commas in names, INBOX is written like Dovecot does
|
||||
$this->assertSame(['sent', 'folder:Kunden, alt', 'folder:INBOX/Alt', 'folder:Inboxen'],
|
||||
$this->service()->update(3, ['own_folders' => ['Kunden, alt', 'inbox/Alt', 'Inboxen']])->folders);
|
||||
$this->assertSame('delete', $this->service()->update(3, ['mode' => 'delete'])->mode);
|
||||
}
|
||||
|
||||
public static function invalidInput(): array
|
||||
{
|
||||
return [
|
||||
'no folder' => [['folders' => '', 'own_folders' => ''], 'retention_folders_invalid'],
|
||||
'unknown folder' => [['folders' => ['spam']], ['retention_folder_invalid', 'spam']],
|
||||
'folder with control character' => [['own_folders' => "a\tb"], ['retention_folder_invalid', "folder:a\tb"]],
|
||||
'folder list with a list' => [['own_folders' => [['a']]], ['retention_folder_invalid', '']],
|
||||
'no days' => [['days' => '0'], ['retention_days_invalid', 36500]],
|
||||
'too many days' => [['days' => '36501'], ['retention_days_invalid', 36500]],
|
||||
'mode' => [['mode' => 'now'], 'retention_mode_invalid'],
|
||||
'name' => [['name' => ''], 'retention_name_invalid'],
|
||||
'mailbox of another domain' => [['mailboxes' => 'andere@tochter.de'], ['retention_member_invalid', 'andere@tochter.de']],
|
||||
'group of another domain' => [['groups' => ['9']], ['retention_member_invalid', '9']],
|
||||
'too many mailboxes' => [['mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))], ['retention_entries_exceeded', 500]],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidInput')]
|
||||
public function testInvalidInput(array $input, string|array $error): void
|
||||
{
|
||||
$this->expectUserError($error, fn() => $this->service()->create($input + ['domain' => 'firma.de', 'name' => 'Test', 'folders' => ['trash'], 'days' => 30]));
|
||||
$this->assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1', 'mailbox_groups' => '1']);
|
||||
$create = fn(string $domain): array => ['domain' => $domain, 'name' => 'Test', 'folders' => ['trash'], 'days' => 30];
|
||||
|
||||
$this->assertSame('Test', $this->service($da)->create($create('firma.de'))->name);
|
||||
$this->assertNotNull($this->service($da)->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->create($create('tochter.de')));
|
||||
$this->assertNull($this->service(FakeContext::domainAdmin([], ['tochter.de'], ['retention' => '1']))->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']))->create($create('firma.de')));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('azubi@firma.de'))->update(3, []));
|
||||
$readOnly = new Context('admin', 'API', [], false, 'ro');
|
||||
$this->assertNotNull($this->service($readOnly)->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3));
|
||||
}
|
||||
|
||||
public function testGroupsNeedTheGroupAcl(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1']);
|
||||
|
||||
$this->assertSame([], $this->service($da)->groupChoices(['firma.de']));
|
||||
// the form sends no group field: the assignment stays; sending it back unchanged is accepted
|
||||
$this->assertSame([7], $this->service($da)->update(3, ['days' => 60])->groups);
|
||||
$this->assertSame([7], $this->service($da)->update(3, ['groups' => ['7']])->groups);
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']]));
|
||||
$this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de']));
|
||||
}
|
||||
|
||||
public function testEffectiveRulesOfAMailbox(): void
|
||||
{
|
||||
$rule = $this->stored(['folders' => ['trash'], 'days' => 30]);
|
||||
$this->repo->method('candidates')->willReturn(['azubi@firma.de' => [['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN]]]);
|
||||
|
||||
$this->assertSame('trash', $this->service(FakeContext::user('Azubi@firma.de'))->effective('azubi@firma.de')[0]['folder']);
|
||||
// users only see their own mailbox
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('chef@firma.de'))->effective('azubi@firma.de'));
|
||||
$this->assertCount(2, $this->service()->effective('azubi@firma.de'));
|
||||
// domain admins need the ACL as well
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(['azubi@firma.de'], ['firma.de'], []))->effective('azubi@firma.de'));
|
||||
$this->assertCount(2, $this->service(FakeContext::domainAdmin(['azubi@firma.de'], ['firma.de'], ['retention' => '1']))->effective('azubi@firma.de'));
|
||||
}
|
||||
|
||||
public function testThePlanComesInPages(): void
|
||||
{
|
||||
$rule = $this->stored(['folders' => ['all'], 'days' => 30]);
|
||||
$this->repo->method('mailboxesWithRules')->willReturnCallback(fn(string $after, int $limit): array =>
|
||||
array_slice(array_values(array_filter(['a@firma.de', 'b@firma.de', 'c@firma.de'], fn(string $u): bool => $u > $after)), 0, $limit));
|
||||
$this->repo->method('candidates')->willReturnCallback(fn(array $usernames): array =>
|
||||
array_fill_keys(array_diff($usernames, ['b@firma.de']), [['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN]]));
|
||||
|
||||
$first = $this->service(Context::system())->plan('', 2);
|
||||
// b has no rule of its own: not in the plan, but the page goes on after it
|
||||
$this->assertSame(['a@firma.de'], array_keys($first['mailboxes']));
|
||||
$this->assertSame('b@firma.de', $first['next']);
|
||||
$last = $this->service(Context::system())->plan('b@firma.de', 2);
|
||||
$this->assertSame(['c@firma.de'], array_keys($last['mailboxes']));
|
||||
$this->assertNull($last['next']);
|
||||
}
|
||||
|
||||
public function testReportDropsBrokenEntries(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('addLog')->with(
|
||||
[['username' => 'azubi@firma.de', 'rule_id' => 3, 'folder' => 'Trash', 'matched' => 2]],
|
||||
[['rule_id' => 3, 'mailboxes' => 1]],
|
||||
['mailboxes' => 1]
|
||||
)->willReturn(3);
|
||||
$this->assertSame(3, $this->service(Context::system())->report(
|
||||
[['username' => 'azubi@firma.de', 'rule_id' => 3, 'folder' => 'Trash', 'matched' => 2], ['username' => ['x']], 'text'],
|
||||
[['rule_id' => 3, 'mailboxes' => 1], ['rule_id' => 'x']],
|
||||
['mailboxes' => 1]
|
||||
));
|
||||
// the runner sends parts of 200
|
||||
$this->expectUserError(['retention_entries_exceeded', 1000], fn() => $this->service(Context::system())->report(array_fill(0, 1001, []), []));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user