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