diff --git a/data/web/inc/lib/mailcow/Archive/ArchiveModule.php b/data/web/inc/lib/mailcow/Archive/ArchiveModule.php new file mode 100644 index 000000000..a9033d819 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/ArchiveModule.php @@ -0,0 +1,65 @@ +api->getMailboxes()); + * ArchiveModule::create($pdo, Context::system(), $redis)->journal->prepare($request); // rspamd dynmap + * ArchiveModule::fromGlobals()->journal->submitDelivered($message, $from, $rcpts, 'quarantine'); + */ +class ArchiveModule +{ + /** Request keys whose values never reach a log */ + private const SECRET_KEYS = ['password', 'signing_secret']; + + public function __construct( + public readonly SettingsService $settings, + public readonly JournalService $journal, + public readonly StatusService $status, + public readonly MailboxListService $mailboxes, + public readonly ArchiveApiController $api + ) { + } + + /** @param ?object $redis Redis connection; without it nothing is read from or written to Redis */ + public static function create(PDO $pdo, Context $context, ?object $redis = null): self + { + $settingsRepo = new SettingsRepository($pdo); + $lookup = new LookupRepository($pdo); + $state = new ArchiveState($redis); + $smtp = new SmtpClient(); + $settings = new SettingsService($settingsRepo, $lookup, $state, $smtp, $context); + $journal = new JournalService($settingsRepo, new OwnerResolver($lookup), new HeaderBuilder(), $smtp, $state, $context); + $status = new StatusService($settingsRepo, $state, new LegacyQueue(), $context); + $mailboxes = new MailboxListService($lookup, $settingsRepo, $context); + $api = new ArchiveApiController($settings, $status, $journal, $mailboxes, new ApiResponse(self::SECRET_KEYS)); + return new self($settings, $journal, $status, $mailboxes, $api); + } + + /** Legacy entry point for procedural callers: reads $pdo, $redis and $_SESSION; $context overrides the session caller */ + public static function fromGlobals(?Context $context = null): self + { + $redis = $GLOBALS['redis'] ?? null; + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), is_object($redis) ? $redis : null); + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php b/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php new file mode 100644 index 000000000..420fc12df --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php @@ -0,0 +1,103 @@ +read(fn(): array => $this->settings->get()->toArray()); + } + + /** GET archive/status */ + public function getStatus(): array + { + return $this->read(fn(): array => $this->status->status()); + } + + /** GET archive/log */ + public function getLog(): array + { + return $this->read(fn(): array => $this->status->log()); + } + + /** GET archive/mailboxes */ + public function getMailboxes(): array + { + return $this->read(fn(): array => $this->mailboxes->archived()); + } + + /** POST edit/archive: the settings; `action: test` tests the connection, `action: testmail` sends a test copy */ + public function edit(array $attr): bool + { + return match ($attr['action'] ?? null) { + 'test' => $this->handle('edit', 'test', [], fn(array $log): bool => $this->testResult($this->settings->test(), $log)), + 'testmail' => $this->handle('edit', 'testmail', [], + fn(array $log): bool => $this->response->success(['archive_testmail_queued', htmlspecialchars($this->journal->testMail())], $log)), + default => $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool { + $this->settings->save($attr); + return $this->response->success('archive_settings_saved', $log); + }), + }; + } + + /** A missing extension is a warning: the archive works, but may refuse some mails */ + private function testResult(string|array $msg, array $log): bool + { + if (is_array($msg)) { + $this->response->warning($msg, $log); + return true; + } + return $this->response->success($msg, $log); + } + + /** A GET answer, empty when the caller may not read it */ + private function read(callable $work): array + { + try { + return $work(); + } catch (UserError) { + return []; + } + } + + /** + * 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, string $object, mixed $data, callable $work): bool + { + $log = ['archive', $action, $object, $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; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Legacy/LegacyQueue.php b/data/web/inc/lib/mailcow/Archive/Legacy/LegacyQueue.php new file mode 100644 index 000000000..184996434 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Legacy/LegacyQueue.php @@ -0,0 +1,27 @@ + is_array($entry) && ($entry['sender'] ?? '') === $sender)); + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Model/Journal.php b/data/web/inc/lib/mailcow/Archive/Model/Journal.php new file mode 100644 index 000000000..c3c8b45d1 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Model/Journal.php @@ -0,0 +1,32 @@ +archive, $this->domainArchive); + } + + /** Why the mailbox is archived: its own setting or the domain default */ + public function source(): string + { + return ($this->archive === '1') ? 'mailbox' : 'domain'; + } + + /** Entry of GET archive/mailboxes */ + public function toArray(): array + { + return [ + 'username' => $this->username, + 'domain' => $this->domain, + 'name' => $this->name, + 'active' => $this->active, + 'source' => $this->source(), + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Model/Settings.php b/data/web/inc/lib/mailcow/Archive/Model/Settings.php new file mode 100644 index 000000000..50fca3d17 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Model/Settings.php @@ -0,0 +1,72 @@ +address !== '' && ($this->profile !== 'archivecow' || $this->signingKey !== ''); + } + + /** Next hop for Postfix: [host]:port, else the domain of the address (MX lookup), with the port when not 25 */ + public function nexthop(): string + { + if ($this->host !== '') { + $host = filter_var($this->host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6:' . $this->host : $this->host; + return '[' . $host . ']:' . $this->port; + } + $domain = substr($this->address, (int)strrpos($this->address, '@') + 1); + return ($this->port === 25) ? $domain : $domain . ':' . $this->port; + } + + /** API representation (GET archive/settings), without secrets */ + public function toArray(): array + { + return [ + 'enabled' => (int)$this->enabled, + 'address' => $this->address, + 'host' => $this->host, + 'port' => $this->port, + 'tls' => $this->tls, + 'username' => $this->username, + 'has_password' => (int)($this->password !== ''), + 'profile' => $this->profile, + 'has_signing_secret' => (int)($this->signingKey !== ''), + 'header_from' => $this->headerFrom, + 'header_rcpt' => $this->headerRcpt, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Redis/ArchiveState.php b/data/web/inc/lib/mailcow/Archive/Redis/ArchiveState.php new file mode 100644 index 000000000..85ebe4c96 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Redis/ArchiveState.php @@ -0,0 +1,107 @@ +redis?->set(self::ENABLED, $enabled ? '1' : '0'); + } + + /** A mail was not archived: its copy could not be handed to Postfix */ + public function dropped(string $qid, string $reason, string $messageId = ''): void + { + if ($this->redis === null) { + return; + } + $this->redis->incr(self::DROPPED); + $this->redis->lPush(self::DROPPED_LOG, json_encode(['time' => time(), 'qid' => $qid, 'reason' => $reason, 'message_id' => $messageId])); + $this->redis->lTrim(self::DROPPED_LOG, 0, 99); + } + + /** @return array{count: int, recent: array[]} count since ever; recent: time, qid, reason, message_id; newest first */ + public function droppedStats(int $limit = 100): array + { + if ($this->redis === null) { + return ['count' => 0, 'recent' => []]; + } + $recent = []; + foreach ((array)$this->redis->lRange(self::DROPPED_LOG, 0, $limit - 1) as $line) { + $entry = is_string($line) ? json_decode($line, true) : null; + if (is_array($entry)) { + $recent[] = ['time' => (int)($entry['time'] ?? 0), 'qid' => (string)($entry['qid'] ?? ''), + 'reason' => (string)($entry['reason'] ?? ''), 'message_id' => (string)($entry['message_id'] ?? '')]; + } + } + return ['count' => (int)$this->redis->get(self::DROPPED), 'recent' => $recent]; + } + + /** + * Archive log, newest first: deliveries of copies (status sent|deferred|softbounce|bounced, text = answer of the + * archive), expired copies (expired) and mails not archived (dropped, text = reason). + * + * @return array[] time, status, qid (of the copy, of the mail for dropped), message_id, text + */ + public function events(int $limit = 1000): array + { + if ($this->redis === null) { + return []; + } + $events = []; + $messageIds = []; + foreach ((array)$this->redis->lRange(self::MAILLOG, 0, -1) as $line) { + $entry = is_string($line) ? json_decode($line, true) : null; + if (!is_array($entry)) { + continue; + } + $program = (string)($entry['program'] ?? ''); + $message = (string)($entry['message'] ?? ''); + $time = (int)($entry['time'] ?? 0); + if ($program === 'postfix/cleanup' && preg_match('/^([0-9A-Za-z]+): message-id=(\S*)/', $message, $m)) { + $messageIds[$m[1]] = trim($m[2], '<>'); + } + // the archive_discard lines are the dropped notices, not deliveries + elseif ($program === 'postfix/archive/smtp' && preg_match('/^([0-9A-Za-z]+): to=<[^>]*>.*\bstatus=(\w+) \((.*)\)$/', $message, $m)) { + $events[] = ['time' => $time, 'status' => strtolower($m[2]), 'qid' => $m[1], 'message_id' => '', 'text' => $m[3]]; + } + elseif ($program === 'postfix/qmgr' && preg_match('/^([0-9A-Za-z]+): from=<' . preg_quote(Settings::SENDER, '/') . '>, status=expired/', $message, $m)) { + $events[] = ['time' => $time, 'status' => 'expired', 'qid' => $m[1], 'message_id' => '', 'text' => '']; + } + } + foreach ($events as &$event) { + $event['message_id'] = $messageIds[$event['qid']] ?? ''; + } + unset($event); + foreach ($this->droppedStats()['recent'] as $dropped) { + $events[] = ['time' => $dropped['time'], 'status' => 'dropped', 'qid' => $dropped['qid'], + 'message_id' => $dropped['message_id'], 'text' => $dropped['reason']]; + } + usort($events, fn(array $a, array $b): int => $b['time'] <=> $a['time']); + return array_slice($events, 0, $limit); + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Repository/LookupRepository.php b/data/web/inc/lib/mailcow/Archive/Repository/LookupRepository.php new file mode 100644 index 000000000..3aed0d38c --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Repository/LookupRepository.php @@ -0,0 +1,94 @@ +value("SELECT `new_dest` FROM `recipient_maps` WHERE `old_dest` = :key AND `active` = '1' LIMIT 1", $key); + } + + /** Targets of an alias row (aliases, catch-alls '@domain', groups, mailbox self rows) */ + public function aliasGoto(string $key): ?string + { + return $this->value("SELECT `goto` FROM `alias` WHERE `address` = :key AND (`active` = '1' OR `active` = '2') LIMIT 1", $key); + } + + /** Resources (location, thing, group) are discarded by Postfix */ + public function isResource(string $username): bool + { + return $this->value("SELECT `username` FROM `mailbox` WHERE `username` = :key AND `kind` REGEXP 'location|thing|group' LIMIT 1", $username) !== null; + } + + public function spamAliasGoto(string $key): ?string + { + return $this->value("SELECT `goto` FROM `spamalias` WHERE `address` = :key + AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0) LIMIT 1", $key); + } + + /** Mailbox local@target behind local@domain when domain is an alias domain */ + public function aliasDomainMailbox(string $local, string $domain): ?string + { + $username = $this->query("SELECT `mailbox`.`username` FROM `mailbox`, `alias_domain` + WHERE `alias_domain`.`alias_domain` = :domain AND `mailbox`.`username` = CONCAT(:local, '@', `alias_domain`.`target_domain`) + AND (`mailbox`.`active` = '1' OR `mailbox`.`active` = '2') AND `alias_domain`.`active` = '1' LIMIT 1", + ['domain' => $domain, 'local' => $local])->fetchColumn(); + return is_string($username) ? $username : null; + } + + /** BCC destination of an address or '@domain' as rspamd's bcc.php finds it; $type 'rcpt' or 'sender' */ + public function bccDestination(string $key, string $type): ?string + { + $dest = $this->query("SELECT `bcc_dest` FROM `bcc_maps` WHERE `type` = :type AND `local_dest` = :key AND `active` = '1' LIMIT 1", + ['type' => $type, 'key' => $key])->fetchColumn(); + return is_string($dest) ? $dest : null; + } + + /** A domain or alias domain of this mailcow */ + public function isMailcowDomain(string $domain): bool + { + return $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain + UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :alias LIMIT 1", + ['domain' => $domain, 'alias' => $domain])->fetchColumn() !== false; + } + + /** The mailbox when it receives mail */ + public function mailbox(string $username): ?Mailbox + { + $row = $this->query(self::MAILBOX . " AND m.`username` = :username", ['username' => $username])->fetch(); + return is_array($row) ? Mailbox::fromRow($row) : null; + } + + /** @return Mailbox[] mailboxes archived by their setting or their domain (without the global switch) */ + public function archivedMailboxes(): array + { + $rows = $this->query(self::MAILBOX . " AND (JSON_VALUE(m.`attributes`, '$.archive') = '1' + OR (COALESCE(JSON_VALUE(m.`attributes`, '$.archive'), '') NOT IN ('0', '1') AND d.`archive` = '1')) + ORDER BY m.`username`")->fetchAll(); + return array_map(fn(array $row): Mailbox => Mailbox::fromRow($row), $rows); + } + + private function value(string $sql, string $key): ?string + { + $value = $this->query($sql, ['key' => $key])->fetchColumn(); + return is_string($value) ? $value : null; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Repository/SettingsRepository.php b/data/web/inc/lib/mailcow/Archive/Repository/SettingsRepository.php new file mode 100644 index 000000000..1848dcced --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Repository/SettingsRepository.php @@ -0,0 +1,73 @@ + value) and the row the Postfix maps + * read (table `archive_transport`: transport, SASL, TLS policy of the archive_smtp service). + */ +class SettingsRepository extends Repository +{ + /** Stored settings; keys that are not stored yet keep the defaults of the model */ + public function load(): Settings + { + $rows = $this->query("SELECT `name`, `value` FROM `archive_settings`")->fetchAll(PDO::FETCH_KEY_PAIR); + $settings = new Settings(); + $settings->enabled = ($rows['enabled'] ?? '0') === '1'; + $settings->address = (string)($rows['address'] ?? ''); + $settings->host = (string)($rows['host'] ?? ''); + $settings->port = (int)($rows['port'] ?? $settings->port); + $settings->tls = (string)($rows['tls'] ?? $settings->tls); + $settings->username = (string)($rows['username'] ?? ''); + $settings->password = (string)($rows['password'] ?? ''); + $settings->profile = (string)($rows['profile'] ?? $settings->profile); + $settings->signingKey = (string)($rows['signing_secret'] ?? ''); + $settings->headerFrom = (string)($rows['header_from'] ?? $settings->headerFrom); + $settings->headerRcpt = (string)($rows['header_rcpt'] ?? $settings->headerRcpt); + return $settings; + } + + /** + * Saves the settings and the transport row. The row stays while an address is set, also with archiving off, + * so copies still queued in Postfix are delivered. + */ + public function save(Settings $settings): void + { + $values = [ + 'enabled' => $settings->enabled ? '1' : '0', + 'address' => $settings->address, + 'host' => $settings->host, + 'port' => (string)$settings->port, + 'tls' => $settings->tls, + 'username' => $settings->username, + 'password' => $settings->password, + 'profile' => $settings->profile, + 'signing_secret' => $settings->signingKey, + 'header_from' => $settings->headerFrom, + 'header_rcpt' => $settings->headerRcpt, + ]; + $sql = "INSERT INTO `archive_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + $this->transaction(function () use ($sql, $values, $settings): void { + foreach ($values as $name => $value) { + $this->query($sql, ['name' => $name, 'value' => $value]); + } + $this->query("DELETE FROM `archive_transport`"); + if ($settings->address !== '') { + $this->insertRow('archive_transport', [ + 'address' => $settings->address, + 'nexthop' => $settings->nexthop(), + 'username' => $settings->username, + 'password' => $settings->password, + 'tls_policy' => $settings->tls, + 'active' => 1, + ]); + } + }); + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Service/HeaderBuilder.php b/data/web/inc/lib/mailcow/Archive/Service/HeaderBuilder.php new file mode 100644 index 000000000..c79157292 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Service/HeaderBuilder.php @@ -0,0 +1,125 @@ +. The signature is HMAC-SHA256 with the signing key over + * the lines above it, each as ":", joined by "\n". + * The block ends with the signature line, everything after it is the mail as accepted (CRLF line ends). + * - envelope: SMTP sender and recipients under configurable names (default X-Envelope-From / X-Envelope-To). + * - none: no headers. + * + * Empty values are left out. Lists are folded after commas (CRLF + space). + */ +class HeaderBuilder +{ + /** Fold a list before a line would get longer */ + private const FOLD_AT = 78; + + public function build(Settings $settings, Journal $journal): string + { + return match ($settings->profile) { + 'archivecow' => $this->archivecow($settings, $journal), + 'envelope' => $this->envelope($settings, $journal), + default => '', + }; + } + + /** One line of the signed text */ + public static function canonical(string $name, string $value): string + { + return strtolower($name) . ':' . preg_replace('/\s+/', '', $value); + } + + private function archivecow(Settings $settings, Journal $journal): string + { + $values = [ + 'X-Archive-Owner' => $journal->owners, + 'X-Archive-Rcpt' => $journal->rcpts, + 'X-Archive-From' => $journal->from, + 'X-Archive-Queue-ID' => $journal->qid, + 'X-Archive-Received' => self::time($journal->received), + 'X-Archive-Direction' => $journal->direction, + 'X-Archive-Source' => $journal->source, + 'X-Archive-SHA256' => $journal->sha256, + ]; + $lines = []; + $signed = []; + foreach ($values as $name => $value) { + $value = is_array($value) ? self::cleanList($value) : self::clean($value); + if ($value === '' || $value === []) { + continue; + } + $lines[] = self::line($name, $value); + $signed[] = self::canonical($name, is_array($value) ? implode(',', $value) : $value); + } + $lines[] = 'X-Archive-Signature: v=1; a=hmac-sha256; s=' . hash_hmac('sha256', implode("\n", $signed), $settings->signingKey); + return implode("\r\n", $lines) . "\r\n"; + } + + private function envelope(Settings $settings, Journal $journal): string + { + $from = self::clean($journal->from); + $lines = [self::line($settings->headerFrom, ($from === '') ? '<>' : $from)]; + $rcpts = self::cleanList($journal->rcpts); + if ($rcpts !== []) { + $lines[] = self::line($settings->headerRcpt, $rcpts); + } + return implode("\r\n", $lines) . "\r\n"; + } + + /** Header line; a list is folded after commas */ + private static function line(string $name, string|array $value): string + { + if (is_string($value)) { + return $name . ': ' . $value; + } + $out = $name . ': ' . array_shift($value); + $length = strlen($out); + foreach ($value as $item) { + if ($length + strlen($item) + 1 > self::FOLD_AT) { + $out .= ",\r\n " . $item; + $length = strlen($item) + 1; + } else { + $out .= ',' . $item; + $length += strlen($item) + 1; + } + } + return $out; + } + + /** Header value without control characters (no header injection) */ + private static function clean(string $value): string + { + return trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $value)); + } + + /** @return string[] */ + private static function cleanList(array $values): array + { + $list = []; + foreach ($values as $value) { + $value = is_string($value) ? str_replace(',', '', self::clean($value)) : ''; + if ($value !== '') { + $list[] = $value; + } + } + return $list; + } + + /** RFC 3339 in UTC with milliseconds */ + private static function time(float $time): string + { + $date = DateTime::createFromFormat('U.u', sprintf('%.3F', $time)); + return $date ? $date->format('Y-m-d\TH:i:s.v\Z') : ''; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Service/JournalService.php b/data/web/inc/lib/mailcow/Archive/Service/JournalService.php new file mode 100644 index 000000000..eb444e6b8 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Service/JournalService.php @@ -0,0 +1,145 @@ +settings->load(); + $this->state->setEnabled($settings->enabled); + if (!$settings->enabled || !$settings->canDeliver()) { + return ['archive' => false]; + } + $from = self::text($request['from'] ?? ''); + $rcpts = is_array($request['rcpt'] ?? null) ? array_values(array_filter($request['rcpt'], 'is_string')) : []; + $resolved = $this->resolver->resolve($from, $rcpts, self::text($request['user'] ?? ''), ($request['bcc'] ?? false) === true); + if ($resolved['owners'] === []) { + return ['archive' => false]; + } + $sha256 = self::text($request['sha256'] ?? ''); + $journal = new Journal($resolved['owners'], $rcpts, $from, mb_strcut(self::text($request['qid'] ?? ''), 0, 64), + $resolved['direction'], 'smtp', is_numeric($request['received'] ?? null) ? (float)$request['received'] : microtime(true), + preg_match('/^[0-9a-f]{64}$/', $sha256) ? $sha256 : ''); + return ['archive' => true, 'rcpt' => $settings->address, 'sender' => Settings::SENDER, 'headers' => $this->headers->build($settings, $journal)]; + } + + /** + * Sends the copy of a mail that reached its mailbox without rspamd (quarantine release, mass mail) for its + * archived recipients. Errors are logged and counted, never thrown: the mail is delivered already. + * + * @param string[] $rcpts + */ + public function submitDelivered(string $message, string $from, array $rcpts, string $source): bool + { + try { + $settings = $this->settings->load(); + if (!$settings->enabled || !$settings->canDeliver()) { + return false; + } + $resolved = $this->resolver->resolve($from, $rcpts, '', false); + if ($resolved['owners'] === []) { + return false; + } + $this->send($settings, $message, $resolved['owners'], array_values($rcpts), $from, 'in', $source); + return true; + } catch (Throwable $e) { + error_log('ARCHIVE: cannot send the archive copy (' . $source . '): ' . $e->getMessage()); + $this->state->dropped('', $source . ': ' . $e->getMessage(), self::messageId($message)); + return false; + } + } + + /** + * Sends a test copy through Postfix to the archive and returns the Postfix queue id. + * + * @throws UserError + */ + public function testMail(): string + { + $this->context->requireAdmin(); + $settings = $this->settings->load(); + if (!$settings->canDeliver()) { + throw new UserError('archive_incomplete'); + } + $message = implode("\r\n", [ + 'From: mailcow <' . Settings::SENDER . '>', + 'To: <' . $settings->address . '>', + 'Subject: mailcow archive test', + 'Date: ' . date(DATE_RFC2822), + 'Message-ID: <' . bin2hex(random_bytes(12)) . '@archive-test.mailcow>', + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Test copy from mailcow (System - Configuration - Archive).', + '', + ]); + try { + return $this->send($settings, $message, [], [$settings->address], Settings::SENDER, 'in', 'test'); + } catch (SmtpException $e) { + throw new UserError('archive_testmail_failed', [htmlspecialchars($e->describe())]); + } + } + + /** @throws SmtpException */ + private function send(Settings $settings, string $message, array $owners, array $rcpts, string $from, string $direction, string $source): string + { + $message = SmtpClient::crlf($message); + $journal = new Journal($owners, $rcpts, $from, '', $direction, $source, microtime(true), hash('sha256', $message)); + return $this->smtp->submit($this->postfixHost, self::SUBMIT_PORT, Settings::SENDER, $settings->address, + $this->headers->build($settings, $journal) . $message); + } + + /** Message-ID of a mail, for the archive log */ + private static function messageId(string $message): string + { + $headers = explode("\n\n", str_replace("\r\n", "\n", $message), 2)[0]; + return preg_match('/^Message-ID:\s*\s]+)>?/mi', $headers, $m) ? $m[1] : ''; + } + + private static function text(mixed $value): string + { + return is_scalar($value) ? trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', (string)$value)) : ''; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Service/MailboxListService.php b/data/web/inc/lib/mailcow/Archive/Service/MailboxListService.php new file mode 100644 index 000000000..ae4ecf47f --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Service/MailboxListService.php @@ -0,0 +1,38 @@ +context->requireAdmin(false); + $enabled = $this->settings->load()->enabled; + return [ + 'enabled' => (int)$enabled, + 'mailboxes' => $enabled ? array_map(fn(Mailbox $mailbox): array => $mailbox->toArray(), $this->lookup->archivedMailboxes()) : [], + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Service/OwnerResolver.php b/data/web/inc/lib/mailcow/Archive/Service/OwnerResolver.php new file mode 100644 index 000000000..7ae419050 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Service/OwnerResolver.php @@ -0,0 +1,264 @@ +deliver($rcpt, $incoming); + } + } + if ($bcc) { + foreach ($this->bccDestinations($from, $rcpts) as $destination) { + $this->deliver($destination, $incoming); + } + } + $outgoing = $this->senders($from, self::address($user)); + $owners = []; + foreach (array_merge(array_values($incoming), array_values($outgoing)) as $mailbox) { + if ($mailbox->archived()) { + $owners[$mailbox->username] = true; + } + } + $owners = array_keys($owners); + sort($owners); + $direction = empty($outgoing) ? 'in' : (empty($incoming) ? 'out' : 'internal'); + return ['owners' => $owners, 'direction' => $direction]; + } + + /** Adds the local mailboxes a recipient ends in to $mailboxes (username => Mailbox) */ + private function deliver(string $address, array &$mailboxes): void + { + $seen = []; + $this->expand($this->canonical($address), 0, $mailboxes, $seen); + } + + /** recipient_maps: first matching key form, one rewrite */ + private function canonical(string $address): string + { + foreach (self::keys($address) as $key) { + $target = self::target($this->lookup->recipientMap($key)); + if ($target !== '') { + return self::rewrite($address, $key, $target); + } + } + return $address; + } + + private function expand(string $address, int $depth, array &$mailboxes, array &$seen): void + { + if ($depth > self::MAX_DEPTH || count($seen) >= self::MAX_ADDRESSES || isset($seen[$address])) { + return; + } + $seen[$address] = true; + $targets = $this->virtual($address); + if ($targets === null) { + $this->final($address, $mailboxes); + return; + } + foreach ($targets as $target) { + // an address that expands to itself is delivered as it is + if ($target === $address) { + $this->final($address, $mailboxes); + } else { + $this->expand($target, $depth + 1, $mailboxes, $seen); + } + } + } + + /** Targets from the first key form a virtual alias map knows, null if none does */ + private function virtual(string $address): ?array + { + if (self::split($address)[1] === 'localhost') { + // null@, spam@, ham@localhost: local delivery of Postfix, never a mailbox + return []; + } + foreach (self::keys($address) as $key) { + $targets = $this->virtualLookup($key); + if ($targets !== null) { + return array_map(fn(string $target): string => self::rewrite($address, $key, $target), $targets); + } + } + return null; + } + + /** virtual_alias_maps in the order of main.cf: alias, resource, spam alias, alias domain mailbox */ + private function virtualLookup(string $key): ?array + { + $domainKey = str_starts_with($key, '@'); + $targets = self::targets($this->lookup->aliasGoto($key)); + if ($targets === null && !$domainKey && $this->lookup->isResource($key)) { + $targets = ['null@localhost']; + } + $targets ??= self::targets($this->lookup->spamAliasGoto($key)); + if ($targets === null && !$domainKey) { + [$local, $domain] = self::split($key); + $targets = self::targets($this->lookup->aliasDomainMailbox($local, $domain)); + } + return $targets; + } + + /** A final address: a mailbox (as u+ext@d or u@d) or anything else, which is not ours */ + private function final(string $address, array &$mailboxes): void + { + foreach (array_unique([$address, self::untagged($address)]) as $candidate) { + $mailbox = $this->lookup->mailbox($candidate); + if ($mailbox !== null) { + $mailboxes[$mailbox->username] = $mailbox; + return; + } + } + } + + /** BCC destinations of rspamd: rcpt maps for every recipient and its domain, sender maps for MAIL FROM and its domain */ + private function bccDestinations(string $from, array $rcpts): array + { + $destinations = []; + foreach ($rcpts as $rcpt) { + $rcpt = self::address($rcpt); + if ($rcpt !== '') { + $destinations[] = $this->lookup->bccDestination(self::untagged($rcpt), 'rcpt'); + $destinations[] = $this->lookup->bccDestination('@' . self::split($rcpt)[1], 'rcpt'); + } + } + if ($from !== '') { + $destinations[] = $this->lookup->bccDestination(self::untagged($from), 'sender'); + $destinations[] = $this->lookup->bccDestination('@' . self::split($from)[1], 'sender'); + } + return array_values(array_unique(array_filter(array_map(fn(?string $address): string => self::address($address), $destinations)))); + } + + /** Mailboxes on the sending side: the SASL user and the mailbox the user sent as */ + private function senders(string $from, string $user): array + { + $mailbox = ($user !== '') ? $this->lookup->mailbox($user) : null; + if ($mailbox === null) { + return []; + } + $senders = [$mailbox->username => $mailbox]; + $sentAs = ($from !== '') ? $this->sentAs(self::untagged($from)) : null; + if ($sentAs !== null) { + $senders[$sentAs->username] = $sentAs; + } + return $senders; + } + + /** Mailbox a sender address belongs to: the mailbox itself or the mailbox behind an alias domain address, not aliases */ + private function sentAs(string $address): ?Mailbox + { + $mailbox = $this->lookup->mailbox($address); + if ($mailbox === null) { + [$local, $domain] = self::split($address); + $username = $this->lookup->aliasDomainMailbox($local, $domain); + $mailbox = ($username !== null) ? $this->lookup->mailbox(strtolower($username)) : null; + } + return $mailbox; + } + + /** Lookup keys in the order of Postfix: u+ext@d, u@d, @d */ + private static function keys(string $address): array + { + $keys = [$address, self::untagged($address), '@' . self::split($address)[1]]; + return array_values(array_unique($keys)); + } + + /** + * Result of a lookup for $address under $key: '@domain' keeps the local part, a match without the extension + * passes it on (propagate_unmatched_extensions). + */ + private static function rewrite(string $address, string $key, string $target): string + { + [$local] = self::split($address); + if (str_starts_with($target, '@')) { + return $local . $target; + } + $extension = self::extension($local); + if ($key === $address || $extension === '') { + return $target; + } + [$targetLocal, $targetDomain] = self::split($target); + return ($targetDomain === 'localhost' || self::extension($targetLocal) !== '') + ? $target + : $targetLocal . '+' . $extension . '@' . $targetDomain; + } + + /** Comma separated lookup result; null when empty (not found) */ + private static function targets(?string $value): ?array + { + $targets = array_values(array_filter(array_map(fn(string $target): string => self::target($target), explode(',', (string)$value)))); + return empty($targets) ? null : $targets; + } + + /** Address or '@domain' from a lookup result, lower case; '' if invalid */ + private static function target(?string $value): string + { + $value = strtolower(trim((string)$value)); + return preg_match('/^[^@\s,<>]*@[^@\s,<>]+$/', $value) ? $value : ''; + } + + /** Lower case address without angle brackets; '' unless local@domain */ + private static function address(mixed $value): string + { + $value = is_string($value) ? strtolower(trim($value, " \t\r\n<>")) : ''; + return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : ''; + } + + private static function untagged(string $address): string + { + [$local, $domain] = self::split($address); + $pos = strpos($local, '+'); + return ($pos === false || $pos === 0) ? $address : substr($local, 0, $pos) . '@' . $domain; + } + + /** Address extension after the recipient delimiter "+" */ + private static function extension(string $local): string + { + $pos = strpos($local, '+'); + return ($pos === false || $pos === 0) ? '' : substr($local, $pos + 1); + } + + /** [local part, domain] of an address or '@domain' */ + private static function split(string $address): array + { + $at = strrpos($address, '@'); + return ($at === false) ? [$address, ''] : [substr($address, 0, $at), substr($address, $at + 1)]; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Service/SettingsService.php b/data/web/inc/lib/mailcow/Archive/Service/SettingsService.php new file mode 100644 index 000000000..8edc70af7 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Service/SettingsService.php @@ -0,0 +1,166 @@ +context->requireAdmin(false); + return $this->repo->load(); + } + + /** Global switch for internal callers (page templates, mailbox details), no permission check; off while the table is missing */ + public function enabled(): bool + { + try { + return $this->repo->load()->enabled; + } catch (PDOException) { + return false; + } + } + + /** + * Saves the settings: attributes missing in $input keep their value, an empty password or signing key keeps + * the stored one, `password_remove` deletes the password. Switching on needs an address (and a signing key for archivecow). + * + * @throws UserError + */ + public function save(array $input): Settings + { + $this->context->requireAdmin(); + $settings = $this->repo->load(); + if (array_key_exists('address', $input)) { + $address = strtolower((string)Input::text($input['address'])); + if (!is_scalar($input['address']) || ($address !== '' && !filter_var($address, FILTER_VALIDATE_EMAIL))) { + throw new UserError('archive_address_invalid'); + } + // Postfix would treat an own address as local (aliases, unknown users) before the copy reaches the archive + if ($address !== '' && $this->lookup->isMailcowDomain(substr($address, strrpos($address, '@') + 1))) { + throw new UserError('archive_address_local'); + } + $settings->address = $address; + } + if (array_key_exists('host', $input)) { + $host = strtolower(trim((string)Input::text($input['host']), " []")); + if (!is_scalar($input['host']) || ($host !== '' && !filter_var($host, FILTER_VALIDATE_IP) + && !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))) { + throw new UserError('archive_host_invalid'); + } + $settings->host = $host; + } + if (array_key_exists('port', $input)) { + $settings->port = Input::intInRange($input['port'], 1, 65535) ?? throw new UserError('archive_port_invalid'); + } + if (array_key_exists('tls', $input)) { + $settings->tls = in_array($input['tls'], Settings::TLS_MODES, true) ? $input['tls'] : throw new UserError('archive_tls_invalid'); + } + if (array_key_exists('username', $input)) { + $username = Input::text($input['username']); + if ($username === null || strlen($username) > self::MAX_SECRET_LENGTH || preg_match('/[\s:]/', $username)) { + throw new UserError('archive_username_invalid'); + } + $settings->username = $username; + } + if (array_key_exists('password', $input)) { + $settings->password = self::secret($input['password'], $settings->password, 'archive_password_invalid'); + } + if (self::flag($input['password_remove'] ?? '0')) { + $settings->password = ''; + } + if (array_key_exists('profile', $input)) { + $settings->profile = in_array($input['profile'], Settings::PROFILES, true) ? $input['profile'] : throw new UserError('archive_profile_invalid'); + } + if (array_key_exists('signing_secret', $input)) { + $settings->signingKey = self::secret($input['signing_secret'], $settings->signingKey, 'archive_signing_secret_invalid'); + } + foreach (['header_from' => 'headerFrom', 'header_rcpt' => 'headerRcpt'] as $key => $property) { + if (array_key_exists($key, $input)) { + $name = Input::text($input[$key]); + if ($name === null || !preg_match('/^[A-Za-z][A-Za-z0-9-]{0,63}$/', $name)) { + throw new UserError('archive_header_invalid'); + } + $settings->$property = $name; + } + } + if (array_key_exists('enabled', $input)) { + $settings->enabled = self::flag($input['enabled']); + } + if ($settings->enabled && !$settings->canDeliver()) { + throw new UserError('archive_incomplete'); + } + $this->repo->save($settings); + $this->state->setEnabled($settings->enabled); + return $settings; + } + + /** + * Tests the stored target: connection, TLS, AUTH and whether the archive accepts the address (no mail is sent). + * Returns the success message, with the missing extension when the archive lacks 8BITMIME. + * + * @throws UserError + */ + public function test(): string|array + { + $this->context->requireAdmin(); + $settings = $this->repo->load(); + if ($settings->address === '') { + throw new UserError('archive_incomplete'); + } + $host = ($settings->host !== '') ? $settings->host : $this->smtp->mailHost(substr($settings->address, strrpos($settings->address, '@') + 1)); + try { + $extensions = $this->smtp->probe($host, $settings->port, $settings->tls, $settings->username, $settings->password, + Settings::SENDER, $settings->address); + } catch (SmtpException $e) { + throw new UserError('archive_test_failed', [htmlspecialchars($host . ':' . $settings->port . ' ' . $e->describe())]); + } + $missing = array_values(array_diff(self::WANTED_EXTENSIONS, $extensions)); + return empty($missing) ? 'archive_test_ok' : ['archive_test_ok_missing', implode(', ', $missing)]; + } + + /** A new secret, or the stored one when $value is empty */ + private static function secret(mixed $value, string $current, string $error): string + { + $secret = Input::text($value); + if ($secret === null || strlen($secret) > self::MAX_SECRET_LENGTH || preg_match('/[^\x21-\x7e]/', $secret)) { + throw new UserError($error); + } + return ($secret === '') ? $current : $secret; + } + + /** 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); + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Service/StatusService.php b/data/web/inc/lib/mailcow/Archive/Service/StatusService.php new file mode 100644 index 000000000..eb97a26bc --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Service/StatusService.php @@ -0,0 +1,84 @@ +context->requireAdmin(false); + $settings = $this->settings->load(); + $copies = $this->queue->entries(Settings::SENDER); + $reasons = []; + $oldest = null; + $bytes = 0; + foreach ($copies as $copy) { + $arrival = (int)($copy['arrival_time'] ?? 0); + $oldest = ($oldest === null) ? $arrival : min($oldest, $arrival); + $bytes += (int)($copy['message_size'] ?? 0); + foreach ((array)($copy['recipients'] ?? []) as $rcpt) { + if (is_string($rcpt) && preg_match('/^\S+ \((.+)\)$/', $rcpt, $m)) { + $reasons[$m[1]] = true; + } + } + } + $dropped = $this->state->droppedStats(); + $recent = array_values(array_filter($dropped['recent'], fn(array $d): bool => $d['time'] >= time() - self::DROPPED_WINDOW)); + $lastDelivery = null; + foreach ($this->state->events() as $event) { + if ($event['status'] === 'sent') { + $lastDelivery = $event['time']; + break; + } + } + return [ + 'enabled' => (int)$settings->enabled, + 'configured' => (int)$settings->canDeliver(), + 'queued' => count($copies), + 'queued_bytes' => $bytes, + 'oldest_queued' => $oldest, + 'delay_reasons' => array_slice(array_keys($reasons), 0, 5), + 'last_delivery' => $lastDelivery, + 'dropped' => $dropped['count'], + 'dropped_24h' => count($recent), + 'last_dropped' => $recent ? ['time' => $recent[0]['time'], 'reason' => $recent[0]['reason']] : null, + ]; + } + + /** + * GET archive/log: deliveries, expired copies and mails not archived, newest first (admins, read-only keys included). + * + * @throws AccessDenied + */ + public function log(): array + { + $this->context->requireAdmin(false); + return $this->state->events(); + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Smtp/SmtpClient.php b/data/web/inc/lib/mailcow/Archive/Smtp/SmtpClient.php new file mode 100644 index 000000000..2e2116bff --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Smtp/SmtpClient.php @@ -0,0 +1,190 @@ +open($host, $port); + try { + $this->expect('connect', 220); + $this->ehlo(); + $this->command('sender', 'MAIL FROM:<' . $from . '>', 250); + $this->command('recipient', 'RCPT TO:<' . $rcpt . '>', 250); + $this->command('data', 'DATA', 354); + $this->write(self::dotStuff(self::crlf($data)) . ".\r\n"); + $reply = $this->expect('data', 250); + $this->write("QUIT\r\n"); + return preg_match('/queued as ([0-9A-Za-z]+)/', $reply, $m) ? $m[1] : ''; + } finally { + $this->close(); + } + } + + /** + * Connection test against the archive server: STARTTLS (unless $tls is 'may' and it is not offered), AUTH when + * a username is set, then MAIL and RCPT for the archive address, then RSET. + * + * @return string[] extensions the server announced (after STARTTLS) + * @throws SmtpException + */ + public function probe(string $host, int $port, string $tls, string $username, string $password, string $from, string $rcpt): array + { + $this->open($host, $port); + try { + $this->expect('connect', 220); + $extensions = $this->ehlo(); + if ($tls !== 'may' || in_array('STARTTLS', $extensions, true)) { + if (!in_array('STARTTLS', $extensions, true)) { + throw new SmtpException('tls', 'STARTTLS not offered'); + } + $this->command('tls', 'STARTTLS', 220); + $this->startTls($host, $tls === 'verify'); + $extensions = $this->ehlo(); + } + if ($username !== '') { + $this->command('auth', 'AUTH PLAIN ' . base64_encode("\0" . $username . "\0" . $password), 235); + } + $this->command('sender', 'MAIL FROM:<' . $from . '>', 250); + $this->command('recipient', 'RCPT TO:<' . $rcpt . '>', 250); + $this->command('recipient', 'RSET', 250); + $this->write("QUIT\r\n"); + return $extensions; + } finally { + $this->close(); + } + } + + /** Mail server of a domain: the MX with the lowest preference, else the domain itself */ + public function mailHost(string $domain): string + { + if (@getmxrr($domain, $hosts, $weights) && !empty($hosts)) { + array_multisort($weights, SORT_NUMERIC, $hosts); + return rtrim($hosts[0], '.'); + } + return $domain; + } + + /** CRLF line ends, ending with CRLF */ + public static function crlf(string $data): string + { + $data = (string)preg_replace('/\r\n|\r|\n/', "\r\n", $data); + return str_ends_with($data, "\r\n") ? $data : $data . "\r\n"; + } + + /** RFC 5321 4.5.2: a line starting with "." gets another "." */ + public static function dotStuff(string $data): string + { + $data = str_replace("\r\n.", "\r\n..", $data); + return str_starts_with($data, '.') ? '.' . $data : $data; + } + + /** @throws SmtpException */ + protected function open(string $host, int $port): void + { + $target = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $host . ']' : $host; + $socket = @stream_socket_client('tcp://' . $target . ':' . $port, $errno, $error, self::TIMEOUT); + if ($socket === false) { + throw new SmtpException('connect', $error !== '' ? $error : 'connection failed'); + } + stream_set_timeout($socket, self::TIMEOUT); + $this->socket = $socket; + } + + protected function close(): void + { + if (is_resource($this->socket)) { + fclose($this->socket); + } + $this->socket = null; + } + + /** @throws SmtpException */ + protected function write(string $data): void + { + for ($written = 0; $written < strlen($data); $written += $count) { + $count = @fwrite($this->socket, substr($data, $written, 1048576)); + if ($count === false || $count === 0) { + throw new SmtpException('data', 'connection lost while sending'); + } + } + } + + /** One reply line without CRLF, null when the connection ended */ + protected function readLine(): ?string + { + $line = fgets($this->socket, 4096); + return ($line === false) ? null : rtrim($line, "\r\n"); + } + + /** @throws SmtpException */ + protected function startTls(string $host, bool $verify): void + { + stream_context_set_option($this->socket, 'ssl', 'verify_peer', $verify); + stream_context_set_option($this->socket, 'ssl', 'verify_peer_name', $verify); + stream_context_set_option($this->socket, 'ssl', 'allow_self_signed', !$verify); + stream_context_set_option($this->socket, 'ssl', 'peer_name', $host); + if (!@stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new SmtpException('tls', $verify ? 'TLS handshake failed or certificate not valid' : 'TLS handshake failed'); + } + } + + /** @return string[] extensions in upper case */ + private function ehlo(): array + { + $reply = $this->command('connect', 'EHLO ' . (gethostname() ?: 'mailcow'), 250); + $extensions = []; + foreach (array_slice(explode("\n", $reply), 1) as $line) { + $extensions[] = strtoupper((string)strtok(substr($line, 4), ' ')); + } + return $extensions; + } + + /** @throws SmtpException */ + private function command(string $step, string $command, int $code): string + { + $this->write($command . "\r\n"); + return $this->expect($step, $code); + } + + /** + * Reads a (multi-line) reply and returns it, lines joined by "\n". + * + * @throws SmtpException when the code differs + */ + private function expect(string $step, int $code): string + { + $lines = []; + do { + $line = $this->readLine(); + if ($line === null) { + throw new SmtpException($step, 'connection closed'); + } + $lines[] = $line; + } while (strlen($line) > 3 && $line[3] === '-'); + $reply = implode("\n", $lines); + if ((int)substr($reply, 0, 3) !== $code && !($code === 250 && (int)substr($reply, 0, 3) === 251)) { + throw new SmtpException($step, $reply); + } + return $reply; + } +} diff --git a/data/web/inc/lib/mailcow/Archive/Smtp/SmtpException.php b/data/web/inc/lib/mailcow/Archive/Smtp/SmtpException.php new file mode 100644 index 000000000..2a538eb16 --- /dev/null +++ b/data/web/inc/lib/mailcow/Archive/Smtp/SmtpException.php @@ -0,0 +1,27 @@ +: ", without angle brackets (the UI escapes message arguments twice) */ + public function describe(): string + { + $text = trim((string)preg_replace('/\s+/', ' ', $this->step . ': ' . str_replace(['<', '>'], '', $this->reply))); + return mb_substr($text, 0, self::MAX_TEXT_LENGTH); + } +}