mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-07 09:19:21 +00:00
[Web] Add archive module classes
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user