` entries name groups the editor may access. */ class PolicyService { private const MAX_ENTRIES = 500; /** The reject text goes into the SMTP reply: printable ASCII */ private const MAX_REJECT_TEXT = 200; private const LISTS = ['send_allow' => 'sendAllow', 'send_block' => 'sendBlock', 'receive_allow' => 'receiveAllow', 'receive_block' => 'receiveBlock']; public function __construct(private PolicyRepository $repo, private PolicyEngine $engine, private Context $context) { } public function canRead(): bool { return $this->context->hasAdminRights() || ($this->context->isDomainAdmin() && $this->context->hasAcl('mail_policies')); } /** @throws AccessDenied unless the caller may edit policies at all */ public function requireEditor(): void { if (!$this->canRead() || $this->context->isReadOnlyApi()) { throw new AccessDenied(); } } public function get(int $id): ?Policy { $policy = $this->canRead() ? $this->repo->find($id) : null; return ($policy !== null && $this->visible($policy)) ? $policy : null; } /** @return int[] */ public function visibleIds(array $domains = []): array { return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : []; } public function canEdit(Policy $policy): bool { return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($policy); } public function toArray(Policy $policy): array { return $policy->toArray() + ['can_edit' => (int)$this->canEdit($policy)]; } /** @throws UserError */ public function create(array $input): Policy { $this->requireEditor(); $policy = new Policy(); $policy->domain = $this->domainFor($input['domain'] ?? null); $this->apply($policy, $input); $this->repo->save($policy); return $policy; } /** Attributes missing in $input keep their value @throws UserError */ public function update(int $id, array $input): Policy { $policy = $this->editable($id); $this->apply($policy, $input); $this->repo->save($policy); return $policy; } /** @throws UserError */ public function delete(int $id): Policy { $policy = $this->editable($id); $this->repo->delete($id); return $policy; } /** @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->repo->groupsIn($domains) : []; } /** * "Test a rule": would the mail be accepted? A sender that is a mailbox counts as logged in when $loggedIn. * * @return array{allowed: int, side: string, policy: ?string, internal_alias: int, text: string} * @throws UserError */ public function test(mixed $sender, mixed $recipient, bool $loggedIn): array { if (!$this->canRead()) { throw new AccessDenied(); } $sender = strtolower((string)Input::text($sender)); $recipient = strtolower((string)Input::text($recipient)); if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) { throw new UserError('mail_policy_test_invalid'); } // domain admins test mail of their domains if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($sender)) && !$this->context->canAccessDomain(self::domainOf($recipient))) { throw new AccessDenied(); } $login = ($loggedIn && $this->repo->mailboxDomain($sender) !== null) ? $sender : ''; $decision = $this->engine->check($sender, $recipient, $login); $policy = $decision->policy; return [ 'allowed' => (int)$decision->allowed, 'side' => $decision->side, 'policy' => ($policy !== null && $policy->id !== null && $this->visible($policy)) ? $policy->name : null, 'internal_alias' => (int)($policy !== null && $policy->id === null), 'logged_in' => (int)($login !== ''), 'text' => $decision->allowed ? '' : $decision->text(), ]; } private function visible(Policy $policy): bool { return $this->context->hasAdminRights() || $this->context->canAccessDomain($policy->domain); } /** @throws UserError */ private function editable(int $id): Policy { $this->requireEditor(); $policy = $this->get($id); if ($policy === null || !$this->canEdit($policy)) { throw new AccessDenied(); } return $policy; } /** @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(Policy $policy, array $input): void { if (array_key_exists('name', $input) || $policy->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('mail_policy_name_invalid'); } if ($this->repo->nameExists($policy->domain, $name, $policy->id)) { throw new UserError('object_exists', [htmlspecialchars($name)]); } $policy->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('mail_policy_description_invalid'); } $policy->description = $description; } foreach (['send_mode' => 'sendMode', 'receive_mode' => 'receiveMode'] as $key => $property) { if (array_key_exists($key, $input)) { $mode = Input::text($input[$key]); $policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid'); } } foreach (self::LISTS as $key => $property) { // the UI sends the groups of a list separately (_groups); the API may name them as group: if (array_key_exists($key, $input) || array_key_exists($key . '_groups', $input)) { $entries = array_key_exists($key, $input) ? self::values($input[$key]) : array_filter($policy->$property, fn(string $entry): bool => !str_starts_with($entry, 'group:')); if (array_key_exists($key . '_groups', $input)) { $entries = array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:')); $entries = array_merge($entries, array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups']))); } $policy->$property = $this->entries(array_values($entries)); } } if (array_key_exists('reject_text', $input)) { $text = Input::text($input['reject_text']); if ($text === null || strlen($text) > self::MAX_REJECT_TEXT || preg_match('/[^\x20-\x7e]/', $text)) { throw new UserError('mail_policy_reject_text_invalid'); } $policy->rejectText = $text; } if (array_key_exists('active', $input)) { $policy->active = (int)self::flag($input['active']); } if (array_key_exists('mailboxes', $input)) { $policy->mailboxes = []; foreach (self::values($input['mailboxes']) as $username) { $username = strtolower($username); if ($this->repo->mailboxDomain($username) !== $policy->domain) { throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]); } $policy->mailboxes[$username] = $username; } $policy->mailboxes = array_values($policy->mailboxes); } if (array_key_exists('groups', $input)) { $policy->groups = []; foreach (self::values($input['groups']) as $value) { $id = Input::intInRange($value, 1, PHP_INT_MAX); if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) { throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]); } $policy->groups[$id] = $id; } $policy->groups = array_values($policy->groups); } } /** * Entries of a list: addresses, domains (a leading @ is dropped) and group:, lower case, without repeats. * * @return string[] * @throws UserError */ private function entries(mixed $value): array { $entries = []; foreach (self::values($value) as $entry) { $entry = strtolower($entry); if (str_starts_with($entry, 'group:')) { $id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX); $domain = ($id === null) ? null : $this->repo->groupDomain($id); if ($domain === null || (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain))) { throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); } $entry = 'group:' . $id; } elseif (str_contains(ltrim($entry, '@'), '@')) { if (!filter_var($entry, FILTER_VALIDATE_EMAIL)) { throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); } } else { $entry = ltrim($entry, '@'); if (!str_contains($entry, '.') || !filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); } } $entries[$entry] = $entry; } if (count($entries) > self::MAX_ENTRIES) { throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]); } return array_values($entries); } /** * Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces. * * @return string[] */ private static function values(mixed $value): array { $values = is_array($value) ? $value : preg_split('/[\s,;]+/', (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); } private static function domainOf(string $address): string { $at = strrpos($address, '@'); return ($at === false) ? '' : substr($address, $at + 1); } }