[Web] Add archive module classes

This commit is contained in:
FreddleSpl0it committed 2026-11-07 00:15:08 +01:00
1 parent 329bca1d46
commit 2ac2edf0ad
17 files changed
+1685

No files matched your search

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive;
use Mailcow\Archive\Controller\ArchiveApiController;
use Mailcow\Archive\Legacy\LegacyQueue;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\LookupRepository;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Archive\Service\HeaderBuilder;
use Mailcow\Archive\Service\JournalService;
use Mailcow\Archive\Service\MailboxListService;
use Mailcow\Archive\Service\OwnerResolver;
use Mailcow\Archive\Service\SettingsService;
use Mailcow\Archive\Service\StatusService;
use Mailcow\Archive\Smtp\SmtpClient;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use PDO;
/**
* Composition root of the archive module (archive copies by SMTP through Postfix).
*
* process_get_return(ArchiveModule::fromGlobals()->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);
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Controller;
use Mailcow\Archive\Service\JournalService;
use Mailcow\Archive\Service\MailboxListService;
use Mailcow\Archive\Service\SettingsService;
use Mailcow\Archive\Service\StatusService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
/**
* HTTP API adapter for json_api.php (routes `archive/*`, `archive`).
*
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
* Messages are logged as ['archive', action, object, data].
*/
class ArchiveApiController
{
public function __construct(private SettingsService $settings, private StatusService $status, private JournalService $journal,
private MailboxListService $mailboxes, private ApiResponse $response)
{
}
/** GET archive/settings */
public function getSettings(): array
{
return $this->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;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Legacy;
/**
* Bridge to the legacy mailq() (Postfix queue through dockerapi) until it is converted.
*/
class LegacyQueue
{
/**
* @return array[] queue entries of $sender: queue_id, queue_name, arrival_time, message_size,
* recipients ("address (delay reason)")
*/
public function entries(string $sender): array
{
if (!function_exists('mailq')) {
return [];
}
$list = json_decode((string)mailq('get'), true);
if (!is_array($list)) {
return [];
}
return array_values(array_filter($list, fn(mixed $entry): bool => is_array($entry) && ($entry['sender'] ?? '') === $sender));
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Model;
/**
* What the archive learns about one mail besides its content: owners, SMTP envelope, origin and content hash.
*/
class Journal
{
public const DIRECTIONS = ['in', 'out', 'internal'];
public const SOURCES = ['smtp', 'quarantine', 'massmail', 'test'];
/**
* @param string[] $owners mailbox usernames the mail belongs to
* @param string[] $rcpts SMTP envelope recipients as received (incl. BCC)
* @param float $received time mailcow accepted the mail (unix time with milliseconds)
* @param string $sha256 hash of the content as sent to the archive (CRLF line ends)
*/
public function __construct(
public readonly array $owners,
public readonly array $rcpts,
public readonly string $from,
public readonly string $qid,
public readonly string $direction,
public readonly string $source,
public readonly float $received,
public readonly string $sha256
) {
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Model;
/**
* A mailbox that receives mail (regular kind, active 1 or 2, in an active domain that is no backup MX)
* with its archive settings: the mailbox attribute `archive` ('1', '0' or 'inherit') and the domain default.
*/
class Mailbox
{
public const INHERIT = 'inherit';
public function __construct(
public readonly string $username,
public readonly string $domain,
public readonly string $archive,
public readonly bool $domainArchive,
public readonly string $name = '',
public readonly int $active = 1
) {
}
public static function fromRow(array $row): self
{
return new self(
strtolower((string)$row['username']),
strtolower((string)$row['domain']),
self::setting($row['archive'] ?? null),
(int)($row['domain_archive'] ?? 0) === 1,
(string)($row['name'] ?? ''),
(int)($row['active'] ?? 1)
);
}
/** Mailbox setting as stored: '1', '0', anything else (also a missing attribute) is 'inherit' */
public static function setting(mixed $value): string
{
$value = is_scalar($value) ? (string)$value : '';
return in_array($value, ['0', '1'], true) ? $value : self::INHERIT;
}
/** Archiving applies by the mailbox setting, 'inherit' follows the domain (without the global switch) */
public static function effective(mixed $archive, mixed $domainArchive): bool
{
$archive = self::setting($archive);
return ($archive === self::INHERIT) ? (int)$domainArchive === 1 : $archive === '1';
}
public function archived(): bool
{
return self::effective($this->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(),
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Model;
/**
* The archive target (table `archive_settings`): archive copies of accepted mails go by SMTP to `address`,
* through the Postfix queue (table `archive_transport`, service archive_smtp in master.cf).
*/
class Settings
{
public const TLS_MODES = ['encrypt', 'verify', 'may'];
/** Metadata headers of a copy: archivecow (X-Archive-*, signed), envelope (sender/recipients), none */
public const PROFILES = ['archivecow', 'envelope', 'none'];
/** Envelope sender of copies; Postfix drops every notice to it (archive_discard), nothing reaches a real sender */
public const SENDER = 'archive-bounce@localhost';
/** rspamd hands accepted mails to the archive */
public bool $enabled = false;
/** Address the archive receives copies at */
public string $address = '';
/** SMTP server of the archive; empty: MX of the address domain */
public string $host = '';
public int $port = 25;
public string $tls = 'encrypt';
/** SMTP AUTH at the archive, optional */
public string $username = '';
/** Never part of toArray() */
public string $password = '';
public string $profile = 'archivecow';
/** Key of X-Archive-Signature (profile archivecow), never part of toArray() */
public string $signingKey = '';
/** Header names of the envelope profile */
public string $headerFrom = 'X-Envelope-From';
public string $headerRcpt = 'X-Envelope-To';
/** Copies can be sent: an address, and a signing key for archivecow */
public function canDeliver(): bool
{
return $this->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,
];
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Redis;
use Mailcow\Archive\Model\Settings;
/**
* Redis side of the archive:
* - ARCHIVE_ENABLED: the switch rspamd reads before it asks for owners, mirrored from the settings.
* - ARCHIVE_DROPPED / ARCHIVE_DROPPED_LOG: copies that could not be handed to Postfix (rspamd.local.lua, release
* hooks), counted and the last 100 listed.
* - POSTFIX_MAILLOG: the Postfix log, read for the archive log: deliveries of the archive_smtp service and expired
* copies, with the Message-ID of the mail (a copy keeps the Message-ID of the original).
*
* The connection is a plain object, so the class loads without the redis extension; without a connection
* nothing is read or written.
*/
class ArchiveState
{
public const ENABLED = 'ARCHIVE_ENABLED';
public const DROPPED = 'ARCHIVE_DROPPED';
public const DROPPED_LOG = 'ARCHIVE_DROPPED_LOG';
private const MAILLOG = 'POSTFIX_MAILLOG';
public function __construct(private ?object $redis)
{
}
public function setEnabled(bool $enabled): void
{
$this->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);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Repository;
use Mailcow\Archive\Model\Mailbox;
use Mailcow\Core\Repository;
/**
* Read-only lookups of the address tables, with the same conditions as the Postfix maps
* (data/Dockerfiles/postfix/postfix.sh), and the mailboxes with their archive settings.
*/
class LookupRepository extends Repository
{
/** Columns of a mailbox that receives mail; the domain must be active and no backup MX */
private const MAILBOX = "SELECT m.`username`, m.`domain`, m.`name`, m.`active`,
JSON_VALUE(m.`attributes`, '$.archive') AS `archive`, d.`archive` AS `domain_archive`
FROM `mailbox` m
JOIN `domain` d ON d.`domain` = m.`domain`
WHERE m.`kind` = '' AND (m.`active` = '1' OR m.`active` = '2') AND d.`active` = '1' AND d.`backupmx` = '0'";
/** recipient_canonical_maps: new destination of an address or '@domain' */
public function recipientMap(string $key): ?string
{
return $this->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;
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Repository;
use Mailcow\Archive\Model\Settings;
use Mailcow\Core\Repository;
use PDO;
/**
* SQL for the archive target: the settings (table `archive_settings`, name => 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,
]);
}
});
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use DateTime;
use Mailcow\Archive\Model\Journal;
use Mailcow\Archive\Model\Settings;
/**
* Metadata header block in front of an archive copy, by profile:
*
* - archivecow: X-Archive-Owner, -Rcpt, -From, -Queue-ID, -Received, -Direction, -Source, -SHA256 and as the last
* line X-Archive-Signature: v=1; a=hmac-sha256; s=<hex>. The signature is HMAC-SHA256 with the signing key over
* the lines above it, each as "<lower case name>:<value without any whitespace>", 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') : '';
}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use Mailcow\Archive\Model\Journal;
use Mailcow\Archive\Model\Settings;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Archive\Smtp\SmtpClient;
use Mailcow\Archive\Smtp\SmtpException;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Throwable;
/**
* Archive copies: a copy of every accepted mail of an archived mailbox goes by SMTP to the archive, through the
* Postfix queue. rspamd sends the copies of mails it scanned (rspamd.local.lua) and asks prepare() whether to send
* one and for its header block; mails that bypass rspamd (quarantine release, mass mail) are sent here.
*
* Copies have the envelope sender Settings::SENDER, Postfix drops every notice to it: an archive copy never causes
* a bounce or delay warning to a real sender.
*/
class JournalService
{
/** Postfix service that takes copies (no milter, mynetworks only) */
private const SUBMIT_PORT = 591;
public function __construct(
private SettingsRepository $settings,
private OwnerResolver $resolver,
private HeaderBuilder $headers,
private SmtpClient $smtp,
private ArchiveState $state,
private Context $context,
private string $postfixHost = 'postfix'
) {
}
/**
* Archive decision for a mail rspamd accepted (dynmap archive.php), no copy while archiving is off or no
* archived mailbox is involved.
*
* @param array $request from, rcpt[], user, bcc, qid, received, sha256 (of the content rspamd sends)
* @return array{archive: bool, rcpt?: string, sender?: string, headers?: string}
*/
public function prepare(array $request): array
{
$settings = $this->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)) : '';
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use Mailcow\Archive\Model\Mailbox;
use Mailcow\Archive\Repository\LookupRepository;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
/**
* The mailboxes archived now (GET archive/mailboxes), the list archivecow keeps its mailboxes in line with.
* Same rule as the owners of a mail: the global switch, then the mailbox setting, 'inherit' follows the domain.
*/
class MailboxListService
{
public function __construct(private LookupRepository $lookup, private SettingsRepository $settings, private Context $context)
{
}
/**
* Admins, read-only API keys included.
*
* @return array{enabled: int, mailboxes: array[]}
* @throws AccessDenied
*/
public function archived(): array
{
$this->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()) : [],
];
}
}
@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use Mailcow\Archive\Model\Mailbox;
use Mailcow\Archive\Repository\LookupRepository;
/**
* Finds the mailboxes a mail belongs to in the archive, following the way Postfix delivers it.
*
* Incoming: each envelope recipient goes through recipient_maps (one rewrite) and the virtual alias expansion:
* alias rows (incl. catch-alls and groups), resources (discarded), spam aliases and mailboxes of alias domains,
* each tried as u+ext@d, u@d and @d, recursively. The local mailboxes at the end receive the mail. BCC map
* destinations count as recipients when rspamd sends the BCC copy (it bypasses rspamd).
*
* Outgoing: the SASL user and the mailbox the user sent as (its address, with +tag or in an alias domain);
* Postfix already checked that the login may use that sender. Without a login the sender never counts, so a
* forged local sender cannot make a mailbox an owner.
*
* Only mailboxes with archiving become owners.
*/
class OwnerResolver
{
/** virtual_alias_recursion_limit and virtual_alias_expansion_limit of Postfix */
private const MAX_DEPTH = 1000;
private const MAX_ADDRESSES = 1000;
public function __construct(private LookupRepository $lookup)
{
}
/**
* @param string[] $rcpts SMTP envelope recipients
* @param bool $bcc rspamd sent the BCC copies of this mail
* @return array{owners: string[], direction: string} sorted usernames; direction in, out or internal
*/
public function resolve(string $from, array $rcpts, string $user, bool $bcc): array
{
$from = self::address($from);
$incoming = [];
foreach ($rcpts as $rcpt) {
$rcpt = self::address($rcpt);
if ($rcpt !== '') {
$this->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)];
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use Mailcow\Archive\Model\Settings;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\LookupRepository;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Archive\Smtp\SmtpClient;
use Mailcow\Archive\Smtp\SmtpException;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use PDOException;
/**
* Admin use cases of the archive target. Read-only API keys may read the settings (never password or key).
*/
class SettingsService
{
private const MAX_SECRET_LENGTH = 255;
/** Extensions the archive should announce: Postfix passes 8-bit mails unchanged (archive_smtp in master.cf) */
private const WANTED_EXTENSIONS = ['8BITMIME'];
public function __construct(private SettingsRepository $repo, private LookupRepository $lookup, private ArchiveState $state,
private SmtpClient $smtp, private Context $context)
{
}
/** @throws AccessDenied */
public function get(): Settings
{
$this->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);
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use Mailcow\Archive\Legacy\LegacyQueue;
use Mailcow\Archive\Model\Settings;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
/**
* State of the archive for the admin: the summary of the archive tab (copies waiting in the Postfix queue with the
* reason Postfix gives, last delivery, mails not archived) and the archive log of the dashboard.
*/
class StatusService
{
/** Mails not archived count as a problem for a day */
private const DROPPED_WINDOW = 86400;
public function __construct(private SettingsRepository $settings, private ArchiveState $state, private LegacyQueue $queue,
private Context $context)
{
}
/**
* GET archive/status (admins, read-only keys included).
*
* @throws AccessDenied
*/
public function status(): array
{
$this->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();
}
}
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Smtp;
/**
* Small SMTP client: hands archive copies to Postfix (port 591: no milter, mynetworks only) and tests the
* connection to the archive server (EHLO, STARTTLS, AUTH, MAIL/RCPT, no mail is sent).
*
* The socket I/O sits in protected methods, so tests can replace it.
*/
class SmtpClient
{
private const TIMEOUT = 20;
/** @var resource|null */
private $socket = null;
/**
* Sends one mail and returns the queue id Postfix reports. $data must end with CRLF; it is received as it is.
*
* @throws SmtpException
*/
public function submit(string $host, int $port, string $from, string $rcpt, string $data): string
{
$this->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;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Smtp;
use RuntimeException;
/**
* An SMTP step failed: connect, tls, auth, sender, recipient or data, with the reply of the server.
*/
class SmtpException extends RuntimeException
{
private const MAX_TEXT_LENGTH = 200;
public function __construct(public readonly string $step, public readonly string $reply)
{
parent::__construct($step . ': ' . $reply);
}
/** Short text for messages: "<step>: <reply>", 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);
}
}