mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 18:13:37 +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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user