mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-09 10:21:45 +00:00
Merge branch 'feat/mail-policy' into nightly
This commit is contained in:
38 files changed
+3161
-45
No files matched your search
@@ -74,6 +74,7 @@ $globalVariables = [
|
||||
'lang_danger' => json_encode($lang['danger']),
|
||||
'lang_syncjobs' => json_encode($lang['syncjobs']),
|
||||
'lang_groups' => json_encode($lang['groups']),
|
||||
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
|
||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||
|
||||
@@ -2439,6 +2439,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
if ($address != $is_now['address']) {
|
||||
// Send-as grants of groups follow the address
|
||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
||||
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
||||
}
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -3312,7 +3313,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'mailbox_group_imap_grant' => ['owner'],
|
||||
'mailbox_group_sogo_grant' => ['owner'],
|
||||
'mailbox_group_sogo_acl' => ['c_uid'],
|
||||
'mailbox_group_subscription' => ['username', 'owner']
|
||||
'mailbox_group_subscription' => ['username', 'owner'],
|
||||
'mail_policy_member' => ['username']
|
||||
];
|
||||
foreach ($tables as $table => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
@@ -3328,6 +3330,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username);
|
||||
// Group memberships and send-as grants follow, group aliases are rebuilt
|
||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username);
|
||||
// Address entries of mail policies follow
|
||||
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($old_username, $new_username);
|
||||
|
||||
// Update c_uid, c_name and mail in _sogo_static_view table
|
||||
$pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username")
|
||||
|
||||
@@ -492,6 +492,86 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// Mail policies: whom the assigned mailboxes and groups may send to and receive from (Mailcow\MailPolicy)
|
||||
"mail_policy" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"name" => "VARCHAR(100) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
// all, internal or list; the entries are JSON lists of addresses, domains and group:<id>
|
||||
"send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||
"send_allow" => "MEDIUMTEXT NOT NULL",
|
||||
"send_block" => "MEDIUMTEXT NOT NULL",
|
||||
"receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||
"receive_allow" => "MEDIUMTEXT NOT NULL",
|
||||
"receive_block" => "MEDIUMTEXT NOT NULL",
|
||||
"reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"domain_name" => array("domain", "name")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_mail_policy_domain" => array(
|
||||
"col" => "domain",
|
||||
"ref" => "domain.domain",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// a mailbox or a group (its members and its address) a policy is assigned to
|
||||
"mail_policy_member" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"policy_id" => "INT NOT NULL",
|
||||
"username" => "VARCHAR(255) NULL DEFAULT NULL",
|
||||
"group_id" => "INT NULL DEFAULT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"policy_mailbox" => array("policy_id", "username"),
|
||||
"policy_group" => array("policy_id", "group_id")
|
||||
),
|
||||
"key" => array(
|
||||
"username" => array("username"),
|
||||
"group_id" => array("group_id")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_mail_policy_member_policy" => array(
|
||||
"col" => "policy_id",
|
||||
"ref" => "mail_policy.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
),
|
||||
"fk_mail_policy_member_mailbox" => array(
|
||||
"col" => "username",
|
||||
"ref" => "mailbox.username",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
),
|
||||
"fk_mail_policy_member_group" => array(
|
||||
"col" => "group_id",
|
||||
"ref" => "mailbox_group.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"mailbox_group_imap_grant" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
@@ -704,7 +784,8 @@ function init_db_schema()
|
||||
"group_id" => array("group_id")
|
||||
),
|
||||
"key" => array(
|
||||
"domain" => array("domain")
|
||||
"domain" => array("domain"),
|
||||
"internal" => array("internal", "active")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_alias_mailbox_group" => array(
|
||||
@@ -1055,7 +1136,8 @@ function init_db_schema()
|
||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||
use Mailcow\MailPolicy\Service\PolicyService;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php and edit.php (routes `mail-policy`), and the answer to the Postfix policy
|
||||
* service (data/conf/rspamd/dynmaps/mailpolicy.php). Messages are logged as ['mail_policy', action, 'policy', data].
|
||||
*/
|
||||
class MailPolicyApiController
|
||||
{
|
||||
public function __construct(private PolicyService $policies, private PolicyEngine $engine, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/mail-policy */
|
||||
public function addPolicy(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', $attr, function (array $log) use ($attr): bool {
|
||||
$policy = $this->policies->create($attr);
|
||||
return $this->response->success(['mail_policy_added', htmlspecialchars($policy->name)], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/mail-policy: one message per id */
|
||||
public function editPolicies(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||
$this->policies->requireEditor();
|
||||
return $this->batch((array)($data['id'] ?? []), $log,
|
||||
fn(int $id): array => ['mail_policy_modified', htmlspecialchars($this->policies->update($id, $attr)->name)]);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/mail-policy: one message per id */
|
||||
public function deletePolicies(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->policies->requireEditor();
|
||||
return $this->batch($items, $log, fn(int $id): array => ['mail_policy_removed', htmlspecialchars($this->policies->delete($id)->name)]);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET mail-policy/all[/<domain,domain>] */
|
||||
public function getPolicies(?string $domains = null): array
|
||||
{
|
||||
$list = array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen'));
|
||||
return array_values(array_filter(array_map(fn(int $id): array => $this->getPolicy($id), $this->policies->visibleIds($list))));
|
||||
}
|
||||
|
||||
/** GET mail-policy/<id>, also edit.php */
|
||||
public function getPolicy(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$policy = ($id === null) ? null : $this->policies->get($id);
|
||||
return ($policy === null) ? [] : $this->policies->toArray($policy);
|
||||
}
|
||||
|
||||
/** GET mail-policy/test/<sender>/<recipient>[/login]: would the mail be accepted? Sender "-" = null sender; [] when not allowed to ask */
|
||||
public function test(string $sender, string $recipient, bool $loggedIn): array
|
||||
{
|
||||
try {
|
||||
return $this->policies->test(($sender === '-') ? '' : $sender, $recipient, $loggedIn);
|
||||
} catch (UserError) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** functions.mailbox.inc.php: a mailbox or alias was renamed */
|
||||
public function addressRenamed(string $old, string $new): void
|
||||
{
|
||||
$this->policies->addressRenamed($old, $new);
|
||||
}
|
||||
|
||||
/** Answer for the Postfix policy service: DUNNO or the rejection */
|
||||
public function check(string $sender, string $recipient, string $login, int $port, string $helo = ''): string
|
||||
{
|
||||
return $this->engine->check($sender, $recipient, $login, $port, $helo)->action();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['mail_policy', $action, 'policy', $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;
|
||||
}
|
||||
|
||||
/** One message per id: the success message returned by $work(id), or the UserError */
|
||||
private function batch(array $ids, array $log, callable $work): bool
|
||||
{
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
$this->response->success($work($this->requireId($id)), $log);
|
||||
} catch (UserError $e) {
|
||||
$this->response->error($e, $log);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Integer id from request input ("12", 12); null for anything else */
|
||||
private function toId(mixed $id): ?int
|
||||
{
|
||||
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
|
||||
}
|
||||
|
||||
/** @throws AccessDenied for ids that cannot exist */
|
||||
private function requireId(mixed $id): int
|
||||
{
|
||||
return $this->toId($id) ?? throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy;
|
||||
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\MailPolicy\Controller\MailPolicyApiController;
|
||||
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||
use Mailcow\MailPolicy\Service\PolicyService;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the mail policies: whom mailboxes and groups may send to and receive from, checked by
|
||||
* Postfix at RCPT TO through the policy service (mailcow-policy in master.cf, data/conf/rspamd/dynmaps/mailpolicy.php).
|
||||
*
|
||||
* process_get_return(MailPolicyModule::fromGlobals()->api->getPolicies());
|
||||
* echo MailPolicyModule::create($pdo, Context::system())->api->check($sender, $recipient, $login, $port); // policy service
|
||||
*/
|
||||
class MailPolicyModule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly PolicyService $policies,
|
||||
public readonly PolicyEngine $engine,
|
||||
public readonly MailPolicyApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context): self
|
||||
{
|
||||
$engine = new PolicyEngine(new LookupRepository($pdo));
|
||||
$policies = new PolicyService(new PolicyRepository($pdo), $engine, $context);
|
||||
return new self($policies, $engine, new MailPolicyApiController($policies, $engine, new ApiResponse()));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Model;
|
||||
|
||||
/**
|
||||
* Result of the policy check for one recipient: allowed, refused by the send or receive part of a policy, or refused
|
||||
* because the recipient is written in a form the check cannot follow (routing through another address).
|
||||
*/
|
||||
class Decision
|
||||
{
|
||||
private const TEXT = [
|
||||
'send' => 'Sending to this recipient is not allowed by the mail policy of the sender',
|
||||
'receive' => 'This recipient does not accept mail from this sender',
|
||||
'address' => 'This form of the recipient address is not allowed by the mail policy',
|
||||
];
|
||||
|
||||
private function __construct(public readonly bool $allowed, public readonly string $side = '', public readonly ?Policy $policy = null)
|
||||
{
|
||||
}
|
||||
|
||||
public static function allow(): self
|
||||
{
|
||||
return new self(true);
|
||||
}
|
||||
|
||||
/** $side send or receive; without $policy the default text is used */
|
||||
public static function deny(string $side, ?Policy $policy = null): self
|
||||
{
|
||||
return new self(false, $side, $policy);
|
||||
}
|
||||
|
||||
/** The recipient routes through another address (user%domain, domain!user, a second @) */
|
||||
public static function routedAddress(): self
|
||||
{
|
||||
return new self(false, 'address');
|
||||
}
|
||||
|
||||
/** Text of the rejection: the text of the policy or the default */
|
||||
public function text(): string
|
||||
{
|
||||
return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : self::defaultText($this->side);
|
||||
}
|
||||
|
||||
public static function defaultText(string $side): string
|
||||
{
|
||||
return self::TEXT[$side] ?? '';
|
||||
}
|
||||
|
||||
/** Answer for the Postfix policy protocol */
|
||||
public function action(): string
|
||||
{
|
||||
return $this->allowed ? 'DUNNO' : '550 5.7.1 ' . $this->text();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Model;
|
||||
|
||||
/**
|
||||
* A mail policy (table `mail_policy`): whom its mailboxes and groups may send to and receive from.
|
||||
*
|
||||
* Each direction has a mode: `all` (only the blocked entries are refused), `internal` (the own domain and its
|
||||
* alias domains, plus the allowed entries) or `list` (only the allowed entries). Blocked entries are always
|
||||
* refused. Entries are addresses, domains or `group:<id>`. A sender counts as internal only when it is logged in
|
||||
* to this mailcow (SASL) with a mailbox of the domain, never because of its address.
|
||||
*/
|
||||
class Policy
|
||||
{
|
||||
public const MODES = ['all', 'internal', 'list'];
|
||||
|
||||
public ?int $id = null;
|
||||
public string $domain = '';
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
public string $sendMode = 'all';
|
||||
/** @var string[] */
|
||||
public array $sendAllow = [];
|
||||
/** @var string[] */
|
||||
public array $sendBlock = [];
|
||||
public string $receiveMode = 'all';
|
||||
/** @var string[] */
|
||||
public array $receiveAllow = [];
|
||||
/** @var string[] */
|
||||
public array $receiveBlock = [];
|
||||
/** Text of the rejection, '' = default text */
|
||||
public string $rejectText = '';
|
||||
public int $active = 1;
|
||||
/** @var string[] usernames the policy is assigned to */
|
||||
public array $mailboxes = [];
|
||||
/** @var int[] groups the policy is assigned to: their members and their address */
|
||||
public array $groups = [];
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
|
||||
/** Row of `mail_policy`; assignments are set by the repository */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$policy = new self();
|
||||
$policy->id = (int)$row['id'];
|
||||
$policy->domain = (string)$row['domain'];
|
||||
$policy->name = (string)$row['name'];
|
||||
$policy->description = (string)($row['description'] ?? '');
|
||||
$policy->sendMode = (string)$row['send_mode'];
|
||||
$policy->sendAllow = self::list($row['send_allow'] ?? null);
|
||||
$policy->sendBlock = self::list($row['send_block'] ?? null);
|
||||
$policy->receiveMode = (string)$row['receive_mode'];
|
||||
$policy->receiveAllow = self::list($row['receive_allow'] ?? null);
|
||||
$policy->receiveBlock = self::list($row['receive_block'] ?? null);
|
||||
$policy->rejectText = (string)($row['reject_text'] ?? '');
|
||||
$policy->active = (int)$row['active'];
|
||||
$policy->created = $row['created'] ?? null;
|
||||
$policy->modified = $row['modified'] ?? null;
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** Column values for `mail_policy` */
|
||||
public function toRow(): array
|
||||
{
|
||||
return [
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'send_mode' => $this->sendMode,
|
||||
'send_allow' => json_encode($this->sendAllow),
|
||||
'send_block' => json_encode($this->sendBlock),
|
||||
'receive_mode' => $this->receiveMode,
|
||||
'receive_allow' => json_encode($this->receiveAllow),
|
||||
'receive_block' => json_encode($this->receiveBlock),
|
||||
'reject_text' => $this->rejectText,
|
||||
'active' => $this->active,
|
||||
];
|
||||
}
|
||||
|
||||
/** Entry of the API and the edit page */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'send_mode' => $this->sendMode,
|
||||
'send_allow' => $this->sendAllow,
|
||||
'send_block' => $this->sendBlock,
|
||||
'receive_mode' => $this->receiveMode,
|
||||
'receive_allow' => $this->receiveAllow,
|
||||
'receive_block' => $this->receiveBlock,
|
||||
'reject_text' => $this->rejectText,
|
||||
'active' => $this->active,
|
||||
'mailboxes' => $this->mailboxes,
|
||||
'groups' => $this->groups,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return string[] entries of a JSON list column */
|
||||
private static function list(mixed $json): array
|
||||
{
|
||||
$list = is_string($json) ? json_decode($json, true) : null;
|
||||
return is_array($list) ? array_values(array_filter($list, 'is_string')) : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
|
||||
/**
|
||||
* Read-only lookups of the policy check: mailboxes, aliases, alias domains, groups and the active policies of a
|
||||
* mailbox or group. Addresses are passed in lower case.
|
||||
*/
|
||||
class LookupRepository extends Repository
|
||||
{
|
||||
/** @return array{username: string, domain: string}|null a regular mailbox that is not disabled */
|
||||
public function mailbox(string $username): ?array
|
||||
{
|
||||
$row = $this->query("SELECT `username`, `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = '' AND `active` != '0'",
|
||||
['username' => $username])->fetch();
|
||||
return is_array($row) ? ['username' => strtolower((string)$row['username']), 'domain' => strtolower((string)$row['domain'])] : null;
|
||||
}
|
||||
|
||||
/** Target domain of an active alias domain */
|
||||
public function aliasDomainTarget(string $domain): ?string
|
||||
{
|
||||
$target = $this->query("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'",
|
||||
['domain' => $domain])->fetchColumn();
|
||||
return is_string($target) ? strtolower($target) : null;
|
||||
}
|
||||
|
||||
/** A domain or alias domain Postfix delivers itself (as mysql_virtual_domains_maps.cf: active, no backup MX) */
|
||||
public function isLocalDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d1 AND `active` = '1'
|
||||
UNION SELECT 1 FROM `domain` WHERE `domain` = :d2 AND `active` = '1' AND `backupmx` = '0'",
|
||||
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored spelling of a mailbox, alias or temporary alias address. MySQL compares without case and accents, so
|
||||
* Postfix delivers mail for chéf@ to chef@.
|
||||
*/
|
||||
public function storedAddress(string $address): ?string
|
||||
{
|
||||
$stored = $this->query("SELECT `username` FROM `mailbox` WHERE `username` = :a1 UNION SELECT `address` FROM `alias` WHERE `address` = :a2
|
||||
UNION SELECT `address` FROM `spamalias` WHERE `address` = :a3 LIMIT 1", ['a1' => $address, 'a2' => $address, 'a3' => $address])->fetchColumn();
|
||||
return is_string($stored) ? strtolower($stored) : null;
|
||||
}
|
||||
|
||||
/** Stored spelling of a domain or alias domain, see storedAddress() */
|
||||
public function storedDomain(string $domain): ?string
|
||||
{
|
||||
$stored = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :d1 UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :d2 LIMIT 1",
|
||||
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
return is_string($stored) ? strtolower($stored) : null;
|
||||
}
|
||||
|
||||
/** Active group with this distribution address */
|
||||
public function groupByAddress(string $address): ?int
|
||||
{
|
||||
$id = $this->query("SELECT `id` FROM `mailbox_group` WHERE `address` = :address AND `active` = '1'", ['address' => $address])->fetchColumn();
|
||||
return ($id === false) ? null : (int)$id;
|
||||
}
|
||||
|
||||
public function groupAddress(int $id): ?string
|
||||
{
|
||||
$address = $this->query("SELECT `address` FROM `mailbox_group` WHERE `id` = :id AND `active` = '1'", ['id' => $id])->fetchColumn();
|
||||
return is_string($address) && $address !== '' ? strtolower($address) : null;
|
||||
}
|
||||
|
||||
/** Member of an active group */
|
||||
public function isGroupMember(int $groupId, string $username): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
|
||||
WHERE `gm`.`group_id` = :id AND `gm`.`username` = :username AND `g`.`active` = '1'",
|
||||
['id' => $groupId, 'username' => $username])->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* What Postfix' virtual alias maps find for this key, in their order: an alias (also the own alias of a mailbox
|
||||
* and the alias of a group address) or a temporary alias.
|
||||
*
|
||||
* @return array{kind: string, domain?: string, internal?: bool, owner?: string}|null
|
||||
*/
|
||||
public function aliasTarget(string $address): ?array
|
||||
{
|
||||
$alias = $this->query("SELECT `domain`, `internal` FROM `alias` WHERE `address` = :address AND `active` IN ('1', '2')",
|
||||
['address' => $address])->fetch();
|
||||
if (is_array($alias)) {
|
||||
return ['kind' => 'alias', 'domain' => strtolower((string)$alias['domain']), 'internal' => $alias['internal'] == '1'];
|
||||
}
|
||||
$owner = $this->query("SELECT `goto` FROM `spamalias` WHERE `address` = :address AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0)",
|
||||
['address' => $address])->fetchColumn();
|
||||
return is_string($owner) ? ['kind' => 'temporary', 'owner' => strtolower($owner)] : null;
|
||||
}
|
||||
|
||||
/** An active catch-all alias of the domain has the internal flag (applies to the whole domain) */
|
||||
public function internalCatchAll(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` IN ('1', '2')",
|
||||
['address' => '@' . $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** An active policy with members restricts sending */
|
||||
public function hasSendRules(): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mail_policy` `p` JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||
WHERE `p`.`active` = '1' AND (`p`.`send_mode` != 'all' OR `p`.`send_block` != '[]') LIMIT 1")->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of; only
|
||||
* policies of its own domain, so a group with members of other domains cannot restrict them
|
||||
*/
|
||||
public function policiesForMailbox(string $username): array
|
||||
{
|
||||
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
|
||||
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||
JOIN `mailbox` `m` ON `m`.`username` = :u1 AND `m`.`domain` = `p`.`domain`
|
||||
WHERE `p`.`active` = '1' AND (`pm`.`username` = :u2 OR `pm`.`group_id` IN (
|
||||
SELECT `gm`.`group_id` FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
|
||||
WHERE `gm`.`username` = :u3 AND `g`.`active` = '1'))
|
||||
ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username, 'u3' => $username]);
|
||||
}
|
||||
|
||||
/** @return Policy[] active policies assigned to the group */
|
||||
public function policiesForGroup(int $groupId): array
|
||||
{
|
||||
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
|
||||
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||
WHERE `p`.`active` = '1' AND `pm`.`group_id` = :id
|
||||
ORDER BY `p`.`id`", ['id' => $groupId]);
|
||||
}
|
||||
|
||||
/** @return Policy[] */
|
||||
private function policies(string $sql, array $params): array
|
||||
{
|
||||
return array_map(fn(array $row): Policy => Policy::fromRow($row), $this->query($sql, $params)->fetchAll());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Repository;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Tables `mail_policy` and `mail_policy_member` (the mailboxes and groups a policy is assigned to), plus the
|
||||
* lookups the editing needs. Assignments go with their mailbox, group or domain (foreign keys, ON DELETE CASCADE).
|
||||
*/
|
||||
class PolicyRepository extends Repository
|
||||
{
|
||||
public function find(int $id): ?Policy
|
||||
{
|
||||
$row = $this->query("SELECT * FROM `mail_policy` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||
return is_array($row) ? $this->withMembers(Policy::fromRow($row)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $domains limit to these domains, [] = all visible
|
||||
* @return int[] ids of the policies visible to the caller, ordered by domain and name
|
||||
*/
|
||||
public function visibleIds(Context $context, array $domains = []): array
|
||||
{
|
||||
if ($context->hasAdminRights()) {
|
||||
[$visible, $params] = ['1=1', []];
|
||||
} elseif ($context->isDomainAdmin()) {
|
||||
[$visible, $params] = ["`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)",
|
||||
['vis_da' => $context->username]];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
$sql = "SELECT `id` FROM `mail_policy` WHERE $visible";
|
||||
if (!empty($domains)) {
|
||||
$names = [];
|
||||
foreach (array_values($domains) as $i => $domain) {
|
||||
$names[] = ':dom' . $i;
|
||||
$params['dom' . $i] = $domain;
|
||||
}
|
||||
$sql .= " AND `domain` IN (" . implode(', ', $names) . ")";
|
||||
}
|
||||
return array_map('intval', $this->query($sql . " ORDER BY `domain`, `name`", $params)->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
public function nameExists(string $domain, string $name, ?int $exceptId = null): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mail_policy` WHERE `domain` = :domain AND `name` = :name AND `id` != :id",
|
||||
['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Inserts or updates the policy with its assignments; returns its id */
|
||||
public function save(Policy $policy): int
|
||||
{
|
||||
return $this->transaction(function () use ($policy): int {
|
||||
if ($policy->id === null) {
|
||||
$policy->id = $this->insertRow('mail_policy', $policy->toRow());
|
||||
} else {
|
||||
$this->updateRow('mail_policy', $policy->id, $policy->toRow());
|
||||
$this->query("DELETE FROM `mail_policy_member` WHERE `policy_id` = :id", ['id' => $policy->id]);
|
||||
}
|
||||
foreach ($policy->mailboxes as $username) {
|
||||
$this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'username' => $username]);
|
||||
}
|
||||
foreach ($policy->groups as $groupId) {
|
||||
$this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'group_id' => $groupId]);
|
||||
}
|
||||
return $policy->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `mail_policy` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/** Address entries of all lists follow a renamed mailbox or alias */
|
||||
public function renameAddress(string $old, string $new): void
|
||||
{
|
||||
$this->transaction(function () use ($old, $new): void {
|
||||
$columns = ['send_allow', 'send_block', 'receive_allow', 'receive_block'];
|
||||
foreach ($this->query("SELECT `id`, `" . implode('`, `', $columns) . "` FROM `mail_policy`")->fetchAll() as $row) {
|
||||
$changed = [];
|
||||
foreach ($columns as $column) {
|
||||
$entries = json_decode((string)$row[$column], true);
|
||||
if (is_array($entries) && in_array($old, $entries, true)) {
|
||||
$changed[$column] = json_encode(array_values(array_unique(array_map(fn(string $entry): string => ($entry === $old) ? $new : $entry, $entries))));
|
||||
}
|
||||
}
|
||||
if ($changed !== []) {
|
||||
$this->updateRow('mail_policy', (int)$row['id'], $changed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A primary domain (policies never belong to alias domains) */
|
||||
public function isDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Domain of a regular mailbox, null if there is none */
|
||||
public function mailboxDomain(string $username): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = ''", ['username' => $username])->fetchColumn();
|
||||
return is_string($domain) ? $domain : null;
|
||||
}
|
||||
|
||||
public function groupDomain(int $id): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox_group` WHERE `id` = :id", ['id' => $id])->fetchColumn();
|
||||
return is_string($domain) ? $domain : null;
|
||||
}
|
||||
|
||||
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||
public function groupsIn(array $domains): array
|
||||
{
|
||||
if (empty($domains)) {
|
||||
return [];
|
||||
}
|
||||
$names = [];
|
||||
$params = [];
|
||||
foreach (array_values($domains) as $i => $domain) {
|
||||
$names[] = ':dom' . $i;
|
||||
$params['dom' . $i] = $domain;
|
||||
}
|
||||
return $this->query("SELECT `id`, `name`, `domain`, `address` FROM `mailbox_group` WHERE `domain` IN (" . implode(', ', $names) . ")
|
||||
ORDER BY `domain`, `name`", $params)->fetchAll();
|
||||
}
|
||||
|
||||
private function withMembers(Policy $policy): Policy
|
||||
{
|
||||
foreach ($this->query("SELECT `username`, `group_id` FROM `mail_policy_member` WHERE `policy_id` = :id ORDER BY `username`, `group_id`",
|
||||
['id' => $policy->id])->fetchAll() as $row) {
|
||||
if ($row['username'] !== null) {
|
||||
$policy->mailboxes[] = (string)$row['username'];
|
||||
} elseif ($row['group_id'] !== null) {
|
||||
$policy->groups[] = (int)$row['group_id'];
|
||||
}
|
||||
}
|
||||
return $policy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Service;
|
||||
|
||||
use Mailcow\MailPolicy\Model\Decision;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Policy check of one recipient, asked by Postfix at RCPT TO (data/conf/rspamd/dynmaps/mailpolicy.php).
|
||||
*
|
||||
* Send part: the policies of the logged-in mailbox (assigned to it or to its groups) and of the mailbox the
|
||||
* sender address belongs to. Receive part: what Postfix delivers the recipient to, looked up in the order of its
|
||||
* virtual alias maps (address, address without +tag, mailbox of an alias domain): the policies of the mailbox, of
|
||||
* the group address or of the owner of a temporary alias, and the `internal` flag of the alias (a catch-all alias
|
||||
* with the flag covers its whole domain). Mail sent to an alias or group reaches its members without their own
|
||||
* policies. Every policy must allow the mail.
|
||||
*
|
||||
* Internal: a recipient in the domain of the policy or one of its alias domains; a sender logged in with a
|
||||
* mailbox of that domain. Entries of a local domain or address count for logged-in senders only, so they cannot
|
||||
* be forged; block entries also match the envelope sender. External entries are compared with the envelope sender.
|
||||
* Addresses are compared the way Postfix finds them: lower case, without trailing dot, in their stored spelling
|
||||
* (MySQL ignores accents). Recipients routed through localhost (user%domain@localhost, domain!user@localhost,
|
||||
* user@domain@localhost) are refused, Postfix would deliver them to the routed address.
|
||||
*
|
||||
* Dovecot submits Sieve redirects, vacation replies and notifications on port 588 without login, SOGo sends
|
||||
* reminders with a service login: mail with the address of a mailbox counts as sent by that mailbox. Other mail of
|
||||
* these services (bounces redirected with the null sender, Sieve rejects) has no known sender: it is checked like
|
||||
* mail from outside and does not leave the mailcow while any policy restricts sending. Quota warnings pass.
|
||||
*/
|
||||
class PolicyEngine
|
||||
{
|
||||
/** Ports of mailcow's own deliveries: quarantine release (590), BCC and archive copies (591) */
|
||||
private const INTERNAL_PORTS = [590, 591];
|
||||
/** Submission of SOGo and Dovecot, without login reachable from mailcow's own network only */
|
||||
private const SERVICE_PORT = 588;
|
||||
/** Domain of the Dovecot master login of mailcow's services (SOGo reminders) */
|
||||
private const SERVICE_LOGIN_DOMAIN = 'mailcow.local';
|
||||
/** HELO of the quota warning copies of Dovecot (quota_notify.py), configured by admins */
|
||||
private const QUOTA_HELO = 'quotanotification';
|
||||
/** Recipient without domain every server accepts (RFC 5321) */
|
||||
private const POSTMASTER = 'postmaster';
|
||||
/** Postfix delivers these destinations itself (mydestination), user%domain and domain!user route from there */
|
||||
private const LOCAL_DESTINATIONS = ['localhost', 'localhost.localdomain'];
|
||||
|
||||
/** Lookups of the current check */
|
||||
private array $cache = [];
|
||||
|
||||
public function __construct(private LookupRepository $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
/** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown; $helo: HELO name */
|
||||
public function check(string $sender, string $recipient, string $login, int $port = 0, string $helo = ''): Decision
|
||||
{
|
||||
$this->cache = [];
|
||||
$recipient = self::address($recipient);
|
||||
if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '' || $recipient === self::POSTMASTER) {
|
||||
return Decision::allow();
|
||||
}
|
||||
if (self::isRouted($recipient)) {
|
||||
return Decision::routedAddress();
|
||||
}
|
||||
$recipient = $this->stored($recipient);
|
||||
$sender = $this->stored(self::address($sender));
|
||||
$login = self::address($login);
|
||||
$serviceLogin = self::domainOf($login) === self::SERVICE_LOGIN_DOMAIN;
|
||||
$account = null;
|
||||
if ($login !== '' && !$serviceLogin) {
|
||||
$account = $this->cached('mailbox', $login);
|
||||
} elseif ($serviceLogin || $port === self::SERVICE_PORT) {
|
||||
if ($port === self::SERVICE_PORT && !$serviceLogin && strtolower($helo) === self::QUOTA_HELO) {
|
||||
return Decision::allow();
|
||||
}
|
||||
// without a mailbox sender the sending mailbox is unknown
|
||||
$account = $this->senderMailbox($sender);
|
||||
if ($account === null && !$this->cached('isLocalDomain', self::domainOf($recipient)) && $this->lookup->hasSendRules()) {
|
||||
return Decision::deny('send');
|
||||
}
|
||||
}
|
||||
foreach ($this->sendingPolicies($account, $sender) as $policy) {
|
||||
if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain),
|
||||
fn(string $entry): bool => $this->recipientMatches($entry, $recipient))) {
|
||||
return Decision::deny('send', $policy);
|
||||
}
|
||||
}
|
||||
foreach ($this->receivingPolicies($recipient) as $policy) {
|
||||
if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock,
|
||||
$account !== null && $account['domain'] === $policy->domain,
|
||||
fn(string $entry, bool $block): bool => $this->senderMatches($entry, $sender, $account, $block))) {
|
||||
return Decision::deny('receive', $policy);
|
||||
}
|
||||
}
|
||||
return Decision::allow();
|
||||
}
|
||||
|
||||
/** Blocked entries first; then the mode decides. $matches($entry, $block) */
|
||||
private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool
|
||||
{
|
||||
foreach ($block as $entry) {
|
||||
if ($matches($entry, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return match ($mode) {
|
||||
'all' => true,
|
||||
'internal' => $internal || $this->any($allow, $matches),
|
||||
'list' => $this->any($allow, $matches),
|
||||
// an unknown mode lets nothing through
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function any(array $entries, callable $matches): bool
|
||||
{
|
||||
foreach ($entries as $entry) {
|
||||
if ($matches($entry, false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array{username: string, domain: string}|null mailbox of the sender address */
|
||||
private function senderMailbox(string $sender): ?array
|
||||
{
|
||||
return str_contains($sender, '@') ? $this->cached('mailbox', $this->resolve(self::untagged($sender))) : null;
|
||||
}
|
||||
|
||||
/** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */
|
||||
private function sendingPolicies(?array $account, string $sender): array
|
||||
{
|
||||
if ($account === null) {
|
||||
return [];
|
||||
}
|
||||
$policies = $this->lookup->policiesForMailbox($account['username']);
|
||||
$sentAs = $this->senderMailbox($sender);
|
||||
if ($sentAs !== null && $sentAs['username'] !== $account['username']) {
|
||||
$policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username']));
|
||||
}
|
||||
return self::unique($policies);
|
||||
}
|
||||
|
||||
/** @return Policy[] of what Postfix delivers the recipient to, see the class comment */
|
||||
private function receivingPolicies(string $recipient): array
|
||||
{
|
||||
// the address, then without +tag; an alias domain maps to the mailbox in its target domain and Postfix
|
||||
// looks up the result again with the +tag
|
||||
[$address, $target] = $this->aliasTarget([$recipient, self::untagged($recipient)]);
|
||||
$resolved = $this->resolve($recipient);
|
||||
if ($target === null && $resolved !== $recipient) {
|
||||
[$address, $target] = $this->aliasTarget([$resolved, self::untagged($resolved)]);
|
||||
}
|
||||
$address ??= self::untagged($resolved);
|
||||
if (($target['kind'] ?? '') === 'temporary') {
|
||||
$address = $target['owner'];
|
||||
}
|
||||
$policies = ($this->cached('mailbox', $address) !== null) ? $this->lookup->policiesForMailbox($address) : [];
|
||||
$group = $this->lookup->groupByAddress($address);
|
||||
if ($group !== null) {
|
||||
$policies = array_merge($policies, $this->lookup->policiesForGroup($group));
|
||||
}
|
||||
// the `internal` flag of an alias or group: only senders of its domain
|
||||
if (!empty($target['internal'])) {
|
||||
$policies[] = $this->internalRule($target['domain']);
|
||||
}
|
||||
foreach (array_unique([self::domainOf($recipient), $this->resolveDomain(self::domainOf($recipient))]) as $domain) {
|
||||
if ($this->lookup->internalCatchAll($domain)) {
|
||||
$policies[] = $this->internalRule($domain);
|
||||
}
|
||||
}
|
||||
return self::unique($policies);
|
||||
}
|
||||
|
||||
/** @return array{0: ?string, 1: ?array} the first key Postfix' virtual alias maps find, with what they find */
|
||||
private function aliasTarget(array $keys): array
|
||||
{
|
||||
foreach (array_unique($keys) as $key) {
|
||||
$target = $this->lookup->aliasTarget($key);
|
||||
if ($target !== null) {
|
||||
return [$key, $target];
|
||||
}
|
||||
}
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
/** Receive rule of the `internal` flag */
|
||||
private function internalRule(string $domain): Policy
|
||||
{
|
||||
$policy = new Policy();
|
||||
$policy->domain = $this->resolveDomain($domain);
|
||||
$policy->receiveMode = 'internal';
|
||||
return $policy;
|
||||
}
|
||||
|
||||
private function isInternalRecipient(string $recipient, string $domain): bool
|
||||
{
|
||||
return $this->resolveDomain(self::domainOf($recipient)) === $domain;
|
||||
}
|
||||
|
||||
private function recipientMatches(string $entry, string $recipient): bool
|
||||
{
|
||||
$address = $this->resolve(self::untagged($recipient));
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = (int)substr($entry, 6);
|
||||
return $this->cached('groupAddress', $id) === $address
|
||||
|| ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address));
|
||||
}
|
||||
if (str_contains($entry, '@')) {
|
||||
return $this->resolve($entry) === $address;
|
||||
}
|
||||
return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry);
|
||||
}
|
||||
|
||||
/** $block: a block entry, which also matches the envelope sender (refusing a forged sender is no risk) */
|
||||
private function senderMatches(string $entry, string $sender, ?array $account, bool $block): bool
|
||||
{
|
||||
$address = $this->resolve(self::untagged($sender));
|
||||
// the sender address of a local entry only counts when logged in: Postfix checked that the login may use it
|
||||
$useAddress = $account !== null || $block;
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = (int)substr($entry, 6);
|
||||
return ($account !== null && $this->cached('isGroupMember', $id, $account['username']))
|
||||
|| ($useAddress && ($this->cached('groupAddress', $id) === $address
|
||||
|| ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address))));
|
||||
}
|
||||
if (str_contains($entry, '@')) {
|
||||
return $this->cached('isLocalDomain', self::domainOf($entry))
|
||||
? ($account !== null && $account['username'] === $this->resolve($entry)) || ($useAddress && $address === $this->resolve($entry))
|
||||
: self::untagged($sender) === $entry;
|
||||
}
|
||||
return $this->cached('isLocalDomain', $entry)
|
||||
? ($account !== null && $account['domain'] === $this->resolveDomain($entry))
|
||||
|| ($useAddress && $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry))
|
||||
: self::domainOf($sender) === $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored spelling of an address of this mailcow: Postfix finds chéf@ as chef@ (MySQL ignores accents). Only
|
||||
* addresses with non-ASCII characters can differ, as they are already in lower case.
|
||||
*/
|
||||
private function stored(string $address): string
|
||||
{
|
||||
if (!preg_match('/[\x80-\xff]/', $address) || !str_contains($address, '@')) {
|
||||
return $address;
|
||||
}
|
||||
foreach (array_unique([$address, self::untagged($address)]) as $key) {
|
||||
$stored = $this->cached('storedAddress', $key);
|
||||
if ($stored !== null) {
|
||||
return $stored;
|
||||
}
|
||||
}
|
||||
$domain = $this->cached('storedDomain', self::domainOf($address));
|
||||
return ($domain === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $domain;
|
||||
}
|
||||
|
||||
/** Address in an alias domain as the address in its target domain */
|
||||
private function resolve(string $address): string
|
||||
{
|
||||
$domain = self::domainOf($address);
|
||||
$target = ($domain !== '') ? $this->cached('aliasDomainTarget', $domain) : null;
|
||||
return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target;
|
||||
}
|
||||
|
||||
private function resolveDomain(string $domain): string
|
||||
{
|
||||
return ($domain === '') ? '' : ($this->cached('aliasDomainTarget', $domain) ?? $domain);
|
||||
}
|
||||
|
||||
/** A lookup, done once per check: long lists ask the same domains and groups again */
|
||||
private function cached(string $method, int|string ...$args): mixed
|
||||
{
|
||||
$key = $method . "\0" . implode("\0", $args);
|
||||
if (!array_key_exists($key, $this->cache)) {
|
||||
$this->cache[$key] = $this->lookup->$method(...$args);
|
||||
}
|
||||
return $this->cache[$key];
|
||||
}
|
||||
|
||||
/** @param Policy[] $policies @return Policy[] without repeated policies */
|
||||
private static function unique(array $policies): array
|
||||
{
|
||||
$unique = [];
|
||||
foreach ($policies as $policy) {
|
||||
$unique[$policy->id ?? 'internal:' . $policy->domain] = $policy;
|
||||
}
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
/** Lower case, without angle brackets, quotes and trailing dot of the domain, as Postfix resolves it; '' = null sender */
|
||||
private static function address(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value, " \t\r\n<>"));
|
||||
$at = strrpos($value, '@');
|
||||
return ($at === false) ? $value : str_replace('"', '', substr($value, 0, $at)) . '@' . rtrim(substr($value, $at + 1), '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Postfix would deliver the recipient elsewhere: no domain, or routing through a local destination
|
||||
* (localhost, the IP of the server) with %, ! or a second @ in the local part
|
||||
*/
|
||||
private static function isRouted(string $address): bool
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
if ($at === false || $at === 0 || $at === strlen($address) - 1) {
|
||||
return true;
|
||||
}
|
||||
$domain = substr($address, $at + 1);
|
||||
return (in_array($domain, self::LOCAL_DESTINATIONS, true) || str_starts_with($domain, '['))
|
||||
&& strpbrk(substr($address, 0, $at), '@%!') !== false;
|
||||
}
|
||||
|
||||
private static function untagged(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
$pos = ($at === false) ? false : strpos(substr($address, 0, $at), '+');
|
||||
return ($pos === false || $pos === 0) ? $address : substr($address, 0, $pos) . substr($address, $at);
|
||||
}
|
||||
|
||||
private static function domainOf(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
return ($at === false) ? '' : substr($address, $at + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailPolicy\Model\Decision;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||
|
||||
/**
|
||||
* Use cases of mail policies.
|
||||
*
|
||||
* Read: admins all policies (also read-only API keys); domain admins with the `mail_policies` ACL the policies
|
||||
* of their domains.
|
||||
* Edit: admins; domain admins with the ACL for policies of their domains. A policy is assigned to mailboxes and
|
||||
* groups of its own domain; `group:<id>` entries name groups of that domain. Groups need the `mailbox_groups`
|
||||
* ACL as well: without it a domain admin sees no groups and keeps the group entries and assignments.
|
||||
*/
|
||||
class PolicyService
|
||||
{
|
||||
private const MAX_ENTRIES = 500;
|
||||
/** The reject text goes into the SMTP reply: printable ASCII */
|
||||
private const MAX_REJECT_TEXT = 200;
|
||||
private const LISTS = ['send_allow' => 'sendAllow', 'send_block' => 'sendBlock', 'receive_allow' => 'receiveAllow', 'receive_block' => 'receiveBlock'];
|
||||
|
||||
public function __construct(private PolicyRepository $repo, private PolicyEngine $engine, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights()
|
||||
|| ($this->context->isDomainAdmin() && $this->context->hasAcl('mail_policies'));
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless the caller may edit policies at all */
|
||||
public function requireEditor(): void
|
||||
{
|
||||
if (!$this->canRead() || $this->context->isReadOnlyApi()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(int $id): ?Policy
|
||||
{
|
||||
$policy = $this->canRead() ? $this->repo->find($id) : null;
|
||||
return ($policy !== null && $this->visible($policy)) ? $policy : null;
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
public function visibleIds(array $domains = []): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : [];
|
||||
}
|
||||
|
||||
public function canEdit(Policy $policy): bool
|
||||
{
|
||||
return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($policy);
|
||||
}
|
||||
|
||||
public function toArray(Policy $policy): array
|
||||
{
|
||||
return $policy->toArray() + ['can_edit' => (int)$this->canEdit($policy)];
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function create(array $input): Policy
|
||||
{
|
||||
$this->requireEditor();
|
||||
$policy = new Policy();
|
||||
$policy->domain = $this->domainFor($input['domain'] ?? null);
|
||||
$this->apply($policy, $input);
|
||||
$this->repo->save($policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** Attributes missing in $input keep their value @throws UserError */
|
||||
public function update(int $id, array $input): Policy
|
||||
{
|
||||
$policy = $this->editable($id);
|
||||
$this->apply($policy, $input);
|
||||
$this->repo->save($policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function delete(int $id): Policy
|
||||
{
|
||||
$policy = $this->editable($id);
|
||||
$this->repo->delete($id);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** Address entries of the lists follow a renamed mailbox or alias; called after the rename, without permission check */
|
||||
public function addressRenamed(string $old, string $new): void
|
||||
{
|
||||
$this->repo->renameAddress(strtolower($old), strtolower($new));
|
||||
}
|
||||
|
||||
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||
public function groupChoices(array $domains): array
|
||||
{
|
||||
return ($this->canRead() && $this->canUseGroups()) ? $this->repo->groupsIn($domains) : [];
|
||||
}
|
||||
|
||||
private function canUseGroups(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups');
|
||||
}
|
||||
|
||||
/**
|
||||
* "Test a rule": would the mail be accepted? A sender that is a mailbox counts as logged in when $loggedIn.
|
||||
*
|
||||
* @return array{allowed: int, side: string, policy: ?string, internal_alias: int, text: string}
|
||||
* @throws UserError
|
||||
*/
|
||||
public function test(mixed $sender, mixed $recipient, bool $loggedIn): array
|
||||
{
|
||||
if (!$this->canRead()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$sender = strtolower((string)Input::text($sender));
|
||||
$recipient = strtolower((string)Input::text($recipient));
|
||||
if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new UserError('mail_policy_test_invalid');
|
||||
}
|
||||
// domain admins test mail of their domains; a sender of another domain counts as not logged in
|
||||
$ownSender = $this->context->hasAdminRights() || $this->context->canAccessDomain(self::domainOf($sender));
|
||||
if (!$ownSender && !$this->context->canAccessDomain(self::domainOf($recipient))) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$login = ($loggedIn && $ownSender && $this->repo->mailboxDomain($sender) !== null) ? $sender : '';
|
||||
$decision = $this->engine->check($sender, $recipient, $login);
|
||||
$policy = $decision->policy;
|
||||
$hidden = $policy !== null && $policy->id !== null && !$this->visible($policy);
|
||||
return [
|
||||
'allowed' => (int)$decision->allowed,
|
||||
'side' => $decision->side,
|
||||
'policy' => ($policy !== null && $policy->id !== null && !$hidden) ? $policy->name : null,
|
||||
'internal_alias' => (int)($policy !== null && $policy->id === null),
|
||||
'logged_in' => (int)($login !== ''),
|
||||
// the text of a policy of another domain stays hidden
|
||||
'text' => $decision->allowed ? '' : ($hidden ? Decision::defaultText($decision->side) : $decision->text()),
|
||||
];
|
||||
}
|
||||
|
||||
private function visible(Policy $policy): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() || $this->context->canAccessDomain($policy->domain);
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function editable(int $id): Policy
|
||||
{
|
||||
$this->requireEditor();
|
||||
$policy = $this->get($id);
|
||||
if ($policy === null || !$this->canEdit($policy)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function domainFor(mixed $value): string
|
||||
{
|
||||
$domain = strtolower((string)Input::text($value));
|
||||
if ($domain === '' || !$this->repo->isDomain($domain)) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||
}
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function apply(Policy $policy, array $input): void
|
||||
{
|
||||
if (array_key_exists('name', $input) || $policy->id === null) {
|
||||
$name = Input::text($input['name'] ?? null);
|
||||
if ($name === null || $name === '' || mb_strlen($name) > 100 || preg_match('/[\x00-\x1f\x7f]/', $name)) {
|
||||
throw new UserError('mail_policy_name_invalid');
|
||||
}
|
||||
if ($this->repo->nameExists($policy->domain, $name, $policy->id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||
}
|
||||
$policy->name = $name;
|
||||
}
|
||||
if (array_key_exists('description', $input)) {
|
||||
$description = Input::text($input['description']);
|
||||
if ($description === null || mb_strlen($description) > 255 || preg_match('/[\x00-\x1f\x7f]/', $description)) {
|
||||
throw new UserError('mail_policy_description_invalid');
|
||||
}
|
||||
$policy->description = $description;
|
||||
}
|
||||
foreach (['send_mode' => 'sendMode', 'receive_mode' => 'receiveMode'] as $key => $property) {
|
||||
if (array_key_exists($key, $input)) {
|
||||
$mode = Input::text($input[$key]);
|
||||
$policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid');
|
||||
}
|
||||
}
|
||||
$groupAccess = $this->canUseGroups();
|
||||
foreach (self::LISTS as $key => $property) {
|
||||
// the UI sends the groups of a list separately (<list>_groups); the API may name them as group:<id>
|
||||
if (!array_key_exists($key, $input) && !array_key_exists($key . '_groups', $input)) {
|
||||
continue;
|
||||
}
|
||||
$entries = array_key_exists($key, $input) ? self::values($input[$key]) : self::withoutGroups($policy->$property);
|
||||
$kept = [];
|
||||
if (!$groupAccess) {
|
||||
// without access to groups the group entries stay (also when sent back unchanged); the form shows no group choices
|
||||
$kept = array_values(array_diff($policy->$property, self::withoutGroups($policy->$property)));
|
||||
$entries = array_values(array_diff(array_map('strtolower', $entries), $kept));
|
||||
} elseif (array_key_exists($key . '_groups', $input)) {
|
||||
$entries = array_merge(self::withoutGroups($entries), array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups'])));
|
||||
}
|
||||
$entries = array_values(array_unique(array_merge($this->entries($entries, $policy->domain, $groupAccess), $kept)));
|
||||
if (count($entries) > self::MAX_ENTRIES) {
|
||||
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||
}
|
||||
$policy->$property = $entries;
|
||||
}
|
||||
if (array_key_exists('reject_text', $input)) {
|
||||
$text = Input::text($input['reject_text']);
|
||||
if ($text === null || strlen($text) > self::MAX_REJECT_TEXT || preg_match('/[^\x20-\x7e]/', $text)) {
|
||||
throw new UserError('mail_policy_reject_text_invalid');
|
||||
}
|
||||
$policy->rejectText = $text;
|
||||
}
|
||||
if (array_key_exists('active', $input)) {
|
||||
$policy->active = (int)self::flag($input['active']);
|
||||
}
|
||||
if (array_key_exists('mailboxes', $input)) {
|
||||
$policy->mailboxes = [];
|
||||
foreach (self::limited($input['mailboxes']) as $username) {
|
||||
$username = strtolower($username);
|
||||
if ($this->repo->mailboxDomain($username) !== $policy->domain) {
|
||||
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]);
|
||||
}
|
||||
$policy->mailboxes[$username] = $username;
|
||||
}
|
||||
$policy->mailboxes = array_values($policy->mailboxes);
|
||||
}
|
||||
// without access to groups the assignments stay; only sending them back unchanged is accepted
|
||||
if (array_key_exists('groups', $input) && !$groupAccess && self::values($input['groups']) !== []) {
|
||||
$sent = array_map('intval', self::values($input['groups']));
|
||||
$stored = $policy->groups;
|
||||
sort($sent);
|
||||
sort($stored);
|
||||
if (array_values(array_unique($sent)) !== $stored) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
if (array_key_exists('groups', $input) && $groupAccess) {
|
||||
$policy->groups = [];
|
||||
foreach (self::limited($input['groups']) as $value) {
|
||||
$id = Input::intInRange($value, 1, PHP_INT_MAX);
|
||||
if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) {
|
||||
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]);
|
||||
}
|
||||
$policy->groups[$id] = $id;
|
||||
}
|
||||
$policy->groups = array_values($policy->groups);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries of a list: addresses, domains (a leading @ is dropped) and group:<id> of a group of the policy's
|
||||
* domain, lower case, without repeats.
|
||||
*
|
||||
* @param string[] $values
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private function entries(array $values, string $domain, bool $groupAccess): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach (self::limited(array_map('strtolower', $values)) as $entry) {
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX);
|
||||
if (!$groupAccess || $id === null || $this->repo->groupDomain($id) !== $domain) {
|
||||
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||
}
|
||||
$entry = 'group:' . $id;
|
||||
} elseif (str_contains(ltrim($entry, '@'), '@')) {
|
||||
// in the form the engine compares: domain without the final dot, never a +tag or an IP literal
|
||||
$at = strrpos($entry, '@');
|
||||
$local = substr($entry, 0, $at);
|
||||
$entry = $local . '@' . rtrim(substr($entry, $at + 1), '.');
|
||||
if (!filter_var($entry, FILTER_VALIDATE_EMAIL) || str_contains(substr($local, 1), '+') || str_contains($entry, '@[')) {
|
||||
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||
}
|
||||
} else {
|
||||
$entry = rtrim(ltrim($entry, '@'), '.');
|
||||
if (!str_contains($entry, '.') || !filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
|
||||
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||
}
|
||||
}
|
||||
$entries[$entry] = $entry;
|
||||
}
|
||||
return array_values($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field without repeats, at most MAX_ENTRIES (checked before anything is looked up)
|
||||
*
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private static function limited(mixed $value): array
|
||||
{
|
||||
$values = array_values(array_unique(self::values($value)));
|
||||
if (count($values) > self::MAX_ENTRIES) {
|
||||
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @return string[] entries without group:<id> */
|
||||
private static function withoutGroups(array $entries): array
|
||||
{
|
||||
return array_values(array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function values(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value));
|
||||
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
private static function domainOf(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
return ($at === false) ? '' : substr($address, $at + 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user