cache = []; $recipient = self::address($recipient); if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '' || $recipient === self::POSTMASTER) { return Decision::allow(); } if (self::isRouted($recipient)) { return Decision::routedAddress(); } $recipient = $this->stored($recipient); $sender = $this->stored(self::address($sender)); $login = self::address($login); $serviceLogin = self::domainOf($login) === self::SERVICE_LOGIN_DOMAIN; $account = null; if ($login !== '' && !$serviceLogin) { $account = $this->cached('mailbox', $login); } elseif ($serviceLogin || $port === self::SERVICE_PORT) { if ($port === self::SERVICE_PORT && !$serviceLogin && strtolower($helo) === self::QUOTA_HELO) { return Decision::allow(); } // without a mailbox sender the sending mailbox is unknown $account = $this->senderMailbox($sender); if ($account === null && !$this->cached('isLocalDomain', self::domainOf($recipient)) && $this->lookup->hasSendRules()) { return Decision::deny('send'); } } foreach ($this->sendingPolicies($account, $sender) as $policy) { if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain), fn(string $entry): bool => $this->recipientMatches($entry, $recipient))) { return Decision::deny('send', $policy); } } foreach ($this->receivingPolicies($recipient) as $policy) { if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock, $account !== null && $account['domain'] === $policy->domain, fn(string $entry, bool $block): bool => $this->senderMatches($entry, $sender, $account, $block))) { return Decision::deny('receive', $policy); } } return Decision::allow(); } /** Blocked entries first; then the mode decides. $matches($entry, $block) */ private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool { foreach ($block as $entry) { if ($matches($entry, true)) { return false; } } return match ($mode) { 'internal' => $internal || $this->any($allow, $matches), 'list' => $this->any($allow, $matches), default => true, }; } private function any(array $entries, callable $matches): bool { foreach ($entries as $entry) { if ($matches($entry, false)) { return true; } } return false; } /** @return array{username: string, domain: string}|null mailbox of the sender address */ private function senderMailbox(string $sender): ?array { return str_contains($sender, '@') ? $this->cached('mailbox', $this->resolve(self::untagged($sender))) : null; } /** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */ private function sendingPolicies(?array $account, string $sender): array { if ($account === null) { return []; } $policies = $this->lookup->policiesForMailbox($account['username']); $sentAs = $this->senderMailbox($sender); if ($sentAs !== null && $sentAs['username'] !== $account['username']) { $policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username'])); } return self::unique($policies); } /** @return Policy[] of what Postfix delivers the recipient to, see the class comment */ private function receivingPolicies(string $recipient): array { // the address, then without +tag; an alias domain maps to the mailbox in its target domain and Postfix // looks up the result again with the +tag [$address, $target] = $this->aliasTarget([$recipient, self::untagged($recipient)]); $resolved = $this->resolve($recipient); if ($target === null && $resolved !== $recipient) { [$address, $target] = $this->aliasTarget([$resolved, self::untagged($resolved)]); } $address ??= self::untagged($resolved); if (($target['kind'] ?? '') === 'temporary') { $address = $target['owner']; } $policies = ($this->cached('mailbox', $address) !== null) ? $this->lookup->policiesForMailbox($address) : []; $group = $this->lookup->groupByAddress($address); if ($group !== null) { $policies = array_merge($policies, $this->lookup->policiesForGroup($group)); } // the `internal` flag of an alias or group: only senders of its domain if (!empty($target['internal'])) { $policies[] = $this->internalRule($target['domain']); } foreach (array_unique([self::domainOf($recipient), $this->resolveDomain(self::domainOf($recipient))]) as $domain) { if ($this->lookup->internalCatchAll($domain)) { $policies[] = $this->internalRule($domain); } } return self::unique($policies); } /** @return array{0: ?string, 1: ?array} the first key Postfix' virtual alias maps find, with what they find */ private function aliasTarget(array $keys): array { foreach (array_unique($keys) as $key) { $target = $this->lookup->aliasTarget($key); if ($target !== null) { return [$key, $target]; } } return [null, null]; } /** Receive rule of the `internal` flag */ private function internalRule(string $domain): Policy { $policy = new Policy(); $policy->domain = $this->resolveDomain($domain); $policy->receiveMode = 'internal'; return $policy; } private function isInternalRecipient(string $recipient, string $domain): bool { return $this->resolveDomain(self::domainOf($recipient)) === $domain; } private function recipientMatches(string $entry, string $recipient): bool { $address = $this->resolve(self::untagged($recipient)); if (str_starts_with($entry, 'group:')) { $id = (int)substr($entry, 6); return $this->cached('groupAddress', $id) === $address || ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address)); } if (str_contains($entry, '@')) { return $this->resolve($entry) === $address; } return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry); } /** $block: a block entry, which also matches the envelope sender (refusing a forged sender is no risk) */ private function senderMatches(string $entry, string $sender, ?array $account, bool $block): bool { $address = $this->resolve(self::untagged($sender)); // the sender address of a local entry only counts when logged in: Postfix checked that the login may use it $useAddress = $account !== null || $block; if (str_starts_with($entry, 'group:')) { $id = (int)substr($entry, 6); return ($account !== null && $this->cached('isGroupMember', $id, $account['username'])) || ($useAddress && ($this->cached('groupAddress', $id) === $address || ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address)))); } if (str_contains($entry, '@')) { return $this->cached('isLocalDomain', self::domainOf($entry)) ? ($account !== null && $account['username'] === $this->resolve($entry)) || ($useAddress && $address === $this->resolve($entry)) : self::untagged($sender) === $entry; } return $this->cached('isLocalDomain', $entry) ? ($account !== null && $account['domain'] === $this->resolveDomain($entry)) || ($useAddress && $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry)) : self::domainOf($sender) === $entry; } /** * Stored spelling of an address of this mailcow: Postfix finds chéf@ as chef@ (MySQL ignores accents). Only * addresses with non-ASCII characters can differ, as they are already in lower case. */ private function stored(string $address): string { if (!preg_match('/[\x80-\xff]/', $address) || !str_contains($address, '@')) { return $address; } foreach (array_unique([$address, self::untagged($address)]) as $key) { $stored = $this->cached('storedAddress', $key); if ($stored !== null) { return $stored; } } $domain = $this->cached('storedDomain', self::domainOf($address)); return ($domain === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $domain; } /** Address in an alias domain as the address in its target domain */ private function resolve(string $address): string { $domain = self::domainOf($address); $target = ($domain !== '') ? $this->cached('aliasDomainTarget', $domain) : null; return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target; } private function resolveDomain(string $domain): string { return ($domain === '') ? '' : ($this->cached('aliasDomainTarget', $domain) ?? $domain); } /** A lookup, done once per check: long lists ask the same domains and groups again */ private function cached(string $method, int|string ...$args): mixed { $key = $method . "\0" . implode("\0", $args); if (!array_key_exists($key, $this->cache)) { $this->cache[$key] = $this->lookup->$method(...$args); } return $this->cache[$key]; } /** @param Policy[] $policies @return Policy[] without repeated policies */ private static function unique(array $policies): array { $unique = []; foreach ($policies as $policy) { $unique[$policy->id ?? 'internal:' . $policy->domain] = $policy; } return array_values($unique); } /** Lower case, without angle brackets, quotes and trailing dot of the domain, as Postfix resolves it; '' = null sender */ private static function address(string $value): string { $value = strtolower(trim($value, " \t\r\n<>")); $at = strrpos($value, '@'); return ($at === false) ? $value : str_replace('"', '', substr($value, 0, $at)) . '@' . rtrim(substr($value, $at + 1), '.'); } /** * Postfix would deliver the recipient elsewhere: no domain, or routing through a local destination * (localhost, the IP of the server) with %, ! or a second @ in the local part */ private static function isRouted(string $address): bool { $at = strrpos($address, '@'); if ($at === false || $at === 0 || $at === strlen($address) - 1) { return true; } $domain = substr($address, $at + 1); return (in_array($domain, self::LOCAL_DESTINATIONS, true) || str_starts_with($domain, '[')) && strpbrk(substr($address, 0, $at), '@%!') !== false; } private static function untagged(string $address): string { $at = strrpos($address, '@'); $pos = ($at === false) ? false : strpos(substr($address, 0, $at), '+'); return ($pos === false || $pos === 0) ? $address : substr($address, 0, $pos) . substr($address, $at); } private static function domainOf(string $address): string { $at = strrpos($address, '@'); return ($at === false) ? '' : substr($address, $at + 1); } }