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 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, 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:, 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); } }