[Web][Postfix][Dovecot] Mail policies: compare addresses like Postfix, close bypasses via alias forms, temporary aliases, Sieve and cross-domain groups

This commit is contained in:
FreddleSpl0it committed 2026-11-07 17:51:27 +01:00
1 parent 4484cfcd42
commit 46c9ec90b0
22 files changed
+614 -141

No files matched your search

+2 -1
View File
@@ -22,7 +22,8 @@ def check(request):
if nginx is None: if nginx is None:
nginx = socket.gethostbyname('nginx') nginx = socket.gethostbyname('nginx')
# http.client sets TCP_NODELAY, so the body is not held back until the headers are acknowledged # http.client sets TCP_NODELAY, so the body is not held back until the headers are acknowledged
conn = http.client.HTTPConnection(nginx, 8081, timeout=10) # below the timeout of Postfix (main.cf), so an answer always arrives
conn = http.client.HTTPConnection(nginx, 8081, timeout=8)
try: try:
conn.request('POST', '/mailpolicy.php', body, {'Host': 'nginx', 'Content-Type': 'application/x-www-form-urlencoded'}) conn.request('POST', '/mailpolicy.php', body, {'Host': 'nginx', 'Content-Type': 'application/x-www-form-urlencoded'})
response = conn.getresponse() response = conn.getresponse()
+4 -5
View File
@@ -246,11 +246,10 @@ user = ${DBUSER}
password = ${DBPASS} password = ${DBPASS}
hosts = unix:/var/run/mysqld/mysqld.sock hosts = unix:/var/run/mysqld/mysqld.sock
dbname = ${DBNAME} dbname = ${DBNAME}
query = SELECT 'mailcow_mail_policy' FROM mail_policy_member query = SELECT 'mailcow_mail_policy' FROM DUAL WHERE '%s' != ''
JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id AND (EXISTS (SELECT 1 FROM mail_policy_member JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id
WHERE mail_policy.active = '1' AND '%s' != '' WHERE mail_policy.active = '1')
UNION SELECT 'mailcow_mail_policy' FROM alias WHERE internal = '1' AND active = '1' OR EXISTS (SELECT 1 FROM alias WHERE internal = '1' AND active IN ('1', '2')));
LIMIT 1;
EOF EOF
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf
+2
View File
@@ -199,6 +199,8 @@ plugin {
sieve_plugins = sieve_imapsieve sieve_extprograms sieve_plugins = sieve_imapsieve sieve_extprograms
sieve_vacation_send_from_recipient = yes sieve_vacation_send_from_recipient = yes
sieve_redirect_envelope_from = recipient sieve_redirect_envelope_from = recipient
# Notifications are sent with the address of the mailbox (not :from or postmaster), so mail policies apply
sieve_notify_mailto_envelope_from = recipient
# From elsewhere to Spam folder # From elsewhere to Spam folder
imapsieve_mailbox1_name = Junk imapsieve_mailbox1_name = Junk
imapsieve_mailbox1_causes = COPY imapsieve_mailbox1_causes = COPY
+1 -1
View File
@@ -175,7 +175,7 @@ lmtp_destination_recipient_limit=1
# Mail policies: while policies or internal aliases exist, mysql_mail_policy_maps.cf returns this class and smtpd # Mail policies: while policies or internal aliases exist, mysql_mail_policy_maps.cf returns this class and smtpd
# asks the policy service for every recipient (mailcow-policy in master.cf); no answer defers the recipient # asks the policy service for every recipient (mailcow-policy in master.cf); no answer defers the recipient
smtpd_restriction_classes = mailcow_mail_policy smtpd_restriction_classes = mailcow_mail_policy
mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, default_action=DEFER_IF_PERMIT } mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, try_limit=1, default_action=DEFER_IF_PERMIT }
mailcow-policy_time_limit = 3600s mailcow-policy_time_limit = 3600s
# DO NOT EDIT ANYTHING BELOW # # DO NOT EDIT ANYTHING BELOW #
+1 -1
View File
@@ -30,7 +30,7 @@ $value = fn(string $key): string => is_string($_POST[$key] ?? null) ? $_POST[$ke
try { try {
$policy = \Mailcow\MailPolicy\MailPolicyModule::create($pdo, \Mailcow\Core\Context::system()); $policy = \Mailcow\MailPolicy\MailPolicyModule::create($pdo, \Mailcow\Core\Context::system());
echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port')); echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port'), $value('helo_name'));
} }
catch (Throwable $e) { catch (Throwable $e) {
// the policy service answers with a temporary error // the policy service answers with a temporary error
+12 -10
View File
@@ -1459,7 +1459,7 @@ paths:
description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only
type: string type: string
send_allow: send_allow:
description: addresses, domains or group:<id> (array or list separated by commas, spaces or newlines), max. 500 description: addresses, domains or group:<id> of a group of the policy's domain (array or list separated by commas, spaces or newlines), max. 500
type: array type: array
send_block: send_block:
description: recipients that are always refused, entries as send_allow description: recipients that are always refused, entries as send_allow
@@ -1477,10 +1477,10 @@ paths:
description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text
type: string type: string
mailboxes: mailboxes:
description: mailboxes of the domain the policy applies to (replaces the list) description: mailboxes of the domain the policy applies to (replaces the list), max. 500
type: array type: array
groups: groups:
description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list) description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged
type: array type: array
active: active:
type: boolean type: boolean
@@ -4731,7 +4731,7 @@ paths:
description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only
type: string type: string
send_allow: send_allow:
description: addresses, domains or group:<id> (array or list separated by commas, spaces or newlines), max. 500 description: addresses, domains or group:<id> of a group of the policy's domain (array or list separated by commas, spaces or newlines), max. 500
type: array type: array
send_block: send_block:
description: recipients that are always refused, entries as send_allow description: recipients that are always refused, entries as send_allow
@@ -4749,10 +4749,10 @@ paths:
description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text
type: string type: string
mailboxes: mailboxes:
description: mailboxes of the domain the policy applies to (replaces the list) description: mailboxes of the domain the policy applies to (replaces the list), max. 500
type: array type: array
groups: groups:
description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list) description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged
type: array type: array
active: active:
type: boolean type: boolean
@@ -6812,10 +6812,12 @@ paths:
tags: tags:
- Mail policies - Mail policies
description: >- description: >-
Would Postfix accept the mail for this recipient? side is "send" or Would Postfix accept the mail for this recipient? side is "send",
"receive", policy is empty for a policy of another domain, "receive" or "address" (the recipient routes through another address),
internal_alias = refused by the internal flag of an alias or group. policy is empty for a policy of another domain (its text is then the
Domain admins test addresses of their domains. Empty answer for invalid default text), internal_alias = refused by the internal flag of an
alias or group. Domain admins test addresses of their domains; a sender
of another domain is tested as not logged in. Empty answer for invalid
input. input.
operationId: Test mail policies operationId: Test mail policies
summary: Test mail policies summary: Test mail policies
+5 -1
View File
@@ -2432,6 +2432,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
if ($address != $is_now['address']) { if ($address != $is_now['address']) {
// Send-as grants of groups follow the address // Send-as grants of groups follow the address
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address); \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
} }
} }
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
@@ -3295,7 +3296,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
'mailbox_group_imap_grant' => ['owner'], 'mailbox_group_imap_grant' => ['owner'],
'mailbox_group_sogo_grant' => ['owner'], 'mailbox_group_sogo_grant' => ['owner'],
'mailbox_group_sogo_acl' => ['c_uid'], '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 ($tables as $table => $columns) {
foreach ($columns as $column) { foreach ($columns as $column) {
@@ -3311,6 +3313,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username); \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username);
// Group memberships and send-as grants follow, group aliases are rebuilt // Group memberships and send-as grants follow, group aliases are rebuilt
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username); \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 // 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") $pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username")
+6 -5
View File
@@ -500,11 +500,11 @@ function init_db_schema()
"description" => "VARCHAR(255) NOT NULL DEFAULT ''", "description" => "VARCHAR(255) NOT NULL DEFAULT ''",
// all, internal or list; the entries are JSON lists of addresses, domains and group:<id> // all, internal or list; the entries are JSON lists of addresses, domains and group:<id>
"send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'", "send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
"send_allow" => "TEXT NOT NULL", "send_allow" => "MEDIUMTEXT NOT NULL",
"send_block" => "TEXT NOT NULL", "send_block" => "MEDIUMTEXT NOT NULL",
"receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'", "receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
"receive_allow" => "TEXT NOT NULL", "receive_allow" => "MEDIUMTEXT NOT NULL",
"receive_block" => "TEXT NOT NULL", "receive_block" => "MEDIUMTEXT NOT NULL",
"reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''", "reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''",
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
@@ -783,7 +783,8 @@ function init_db_schema()
"group_id" => array("group_id") "group_id" => array("group_id")
), ),
"key" => array( "key" => array(
"domain" => array("domain") "domain" => array("domain"),
"internal" => array("internal", "active")
), ),
"fkey" => array( "fkey" => array(
"fk_alias_mailbox_group" => array( "fk_alias_mailbox_group" => array(
@@ -75,10 +75,16 @@ class MailPolicyApiController
} }
} }
/** Answer for the Postfix policy service: DUNNO or the rejection */ /** functions.mailbox.inc.php: a mailbox or alias was renamed */
public function check(string $sender, string $recipient, string $login, int $port): string public function addressRenamed(string $old, string $new): void
{ {
return $this->engine->check($sender, $recipient, $login, $port)->action(); $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();
} }
/** /**
@@ -5,13 +5,15 @@ declare(strict_types=1);
namespace Mailcow\MailPolicy\Model; namespace Mailcow\MailPolicy\Model;
/** /**
* Result of the policy check for one recipient: allowed, or refused by the send or receive part of a policy. * 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 class Decision
{ {
private const TEXT = [ private const TEXT = [
'send' => 'Sending to this recipient is not allowed by the mail policy of the sender', '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', '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) private function __construct(public readonly bool $allowed, public readonly string $side = '', public readonly ?Policy $policy = null)
@@ -23,16 +25,27 @@ class Decision
return new self(true); return new self(true);
} }
/** $side send or receive */ /** $side send or receive; without $policy the default text is used */
public static function deny(string $side, Policy $policy): self public static function deny(string $side, ?Policy $policy = null): self
{ {
return new self(false, $side, $policy); 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 */ /** Text of the rejection: the text of the policy or the default */
public function text(): string public function text(): string
{ {
return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : (self::TEXT[$this->side] ?? ''); 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 */ /** Answer for the Postfix policy protocol */
@@ -8,8 +8,8 @@ use Mailcow\Core\Repository;
use Mailcow\MailPolicy\Model\Policy; use Mailcow\MailPolicy\Model\Policy;
/** /**
* Read-only lookups of the policy check: mailboxes, alias domains, groups and the active policies of a mailbox * Read-only lookups of the policy check: mailboxes, aliases, alias domains, groups and the active policies of a
* or group. Addresses are passed in lower case. * mailbox or group. Addresses are passed in lower case.
*/ */
class LookupRepository extends Repository class LookupRepository extends Repository
{ {
@@ -29,13 +29,33 @@ class LookupRepository extends Repository
return is_string($target) ? strtolower($target) : null; return is_string($target) ? strtolower($target) : null;
} }
/** A domain or alias domain of this mailcow */ /** A domain or alias domain Postfix delivers itself (as mysql_virtual_domains_maps.cf: active, no backup MX) */
public function isLocalDomain(string $domain): bool public function isLocalDomain(string $domain): bool
{ {
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :d1 UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2", 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(); ['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 */ /** Active group with this distribution address */
public function groupByAddress(string $address): ?int public function groupByAddress(string $address): ?int
{ {
@@ -49,29 +69,59 @@ class LookupRepository extends Repository
return is_string($address) && $address !== '' ? strtolower($address) : null; return is_string($address) && $address !== '' ? strtolower($address) : null;
} }
/** Member of an active group */
public function isGroupMember(int $groupId, string $username): bool public function isGroupMember(int $groupId, string $username): bool
{ {
return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` WHERE `group_id` = :id AND `username` = :username", 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(); ['id' => $groupId, 'username' => $username])->fetchColumn();
} }
/** Domain of an active alias that only accepts mail from its own domain (alias and group flag `internal`) */ /**
public function internalAlias(string $address): ?string * 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
{ {
$domain = $this->query("SELECT `domain` FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` = '1'", $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(); ['address' => $address])->fetchColumn();
return is_string($domain) ? strtolower($domain) : null; return is_string($owner) ? ['kind' => 'temporary', 'owner' => strtolower($owner)] : null;
} }
/** @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of */ /** 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 public function policiesForMailbox(string $username): array
{ {
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p` return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id` JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
WHERE `p`.`active` = '1' AND (`pm`.`username` = :u1 OR `pm`.`group_id` IN ( 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` SELECT `gm`.`group_id` FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
WHERE `gm`.`username` = :u2 AND `g`.`active` = '1')) WHERE `gm`.`username` = :u3 AND `g`.`active` = '1'))
ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username]); ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username, 'u3' => $username]);
} }
/** @return Policy[] active policies assigned to the group */ /** @return Policy[] active policies assigned to the group */
@@ -78,6 +78,26 @@ class PolicyRepository extends Repository
$this->query("DELETE FROM `mail_policy` WHERE `id` = :id", ['id' => $id]); $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) */ /** A primary domain (policies never belong to alias domains) */
public function isDomain(string $domain): bool public function isDomain(string $domain): bool
{ {
@@ -12,14 +12,23 @@ use Mailcow\MailPolicy\Repository\LookupRepository;
* Policy check of one recipient, asked by Postfix at RCPT TO (data/conf/rspamd/dynmaps/mailpolicy.php). * 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 * 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: the policies of the addressed mailbox (+tag and alias domains * sender address belongs to. Receive part: what Postfix delivers the recipient to, looked up in the order of its
* resolved), of the addressed group and the `internal` flag of the addressed alias or group. Mail sent to an * virtual alias maps (address, address without +tag, mailbox of an alias domain): the policies of the mailbox, of
* alias or group reaches its members without their own policies. Every policy must allow the mail. * 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 * 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 * mailbox of that domain. Entries of a local domain or address count for logged-in senders only, so they cannot
* be forged; external entries are compared with the envelope sender. Dovecot submits Sieve redirects and * be forged; block entries also match the envelope sender. External entries are compared with the envelope sender.
* vacation replies of a mailbox without login on port 588: they count as sent by that mailbox. * 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 class PolicyEngine
{ {
@@ -27,27 +36,49 @@ class PolicyEngine
private const INTERNAL_PORTS = [590, 591]; private const INTERNAL_PORTS = [590, 591];
/** Submission of SOGo and Dovecot, without login reachable from mailcow's own network only */ /** Submission of SOGo and Dovecot, without login reachable from mailcow's own network only */
private const SERVICE_PORT = 588; 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) public function __construct(private LookupRepository $lookup)
{ {
} }
/** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown */ /** $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): Decision public function check(string $sender, string $recipient, string $login, int $port = 0, string $helo = ''): Decision
{ {
$sender = self::address($sender); $this->cache = [];
$recipient = self::address($recipient); $recipient = self::address($recipient);
$login = self::address($login); if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '' || $recipient === self::POSTMASTER) {
if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '') {
return Decision::allow(); return Decision::allow();
} }
$account = ($login !== '') ? $this->lookup->mailbox($login) : null; if (self::isRouted($recipient)) {
if ($login === '' && $port === self::SERVICE_PORT) { return Decision::routedAddress();
// Sieve redirects and vacation replies use the address of the mailbox, other mail of mailcow passes }
$account = $this->lookup->mailbox($this->resolve(self::untagged($sender))); $recipient = $this->stored($recipient);
if ($account === null) { $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(); 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) { foreach ($this->sendingPolicies($account, $sender) as $policy) {
if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain), if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain),
@@ -58,18 +89,18 @@ class PolicyEngine
foreach ($this->receivingPolicies($recipient) as $policy) { foreach ($this->receivingPolicies($recipient) as $policy) {
if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock, if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock,
$account !== null && $account['domain'] === $policy->domain, $account !== null && $account['domain'] === $policy->domain,
fn(string $entry): bool => $this->senderMatches($entry, $sender, $account))) { fn(string $entry, bool $block): bool => $this->senderMatches($entry, $sender, $account, $block))) {
return Decision::deny('receive', $policy); return Decision::deny('receive', $policy);
} }
} }
return Decision::allow(); return Decision::allow();
} }
/** Blocked entries first; then the mode decides */ /** Blocked entries first; then the mode decides. $matches($entry, $block) */
private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool
{ {
foreach ($block as $entry) { foreach ($block as $entry) {
if ($matches($entry)) { if ($matches($entry, true)) {
return false; return false;
} }
} }
@@ -83,13 +114,19 @@ class PolicyEngine
private function any(array $entries, callable $matches): bool private function any(array $entries, callable $matches): bool
{ {
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($matches($entry)) { if ($matches($entry, false)) {
return true; return true;
} }
} }
return false; 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 */ /** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */
private function sendingPolicies(?array $account, string $sender): array private function sendingPolicies(?array $account, string $sender): array
{ {
@@ -97,36 +134,65 @@ class PolicyEngine
return []; return [];
} }
$policies = $this->lookup->policiesForMailbox($account['username']); $policies = $this->lookup->policiesForMailbox($account['username']);
$sentAs = $this->lookup->mailbox($this->resolve(self::untagged($sender))); $sentAs = $this->senderMailbox($sender);
if ($sentAs !== null && $sentAs['username'] !== $account['username']) { if ($sentAs !== null && $sentAs['username'] !== $account['username']) {
$policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username'])); $policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username']));
} }
return self::unique($policies); return self::unique($policies);
} }
/** @return Policy[] of the addressed mailbox, group and internal alias */ /** @return Policy[] of what Postfix delivers the recipient to, see the class comment */
private function receivingPolicies(string $recipient): array private function receivingPolicies(string $recipient): array
{ {
$address = $this->resolve(self::untagged($recipient)); // the address, then without +tag; an alias domain maps to the mailbox in its target domain and Postfix
$policies = []; // looks up the result again with the +tag
if ($this->lookup->mailbox($address) !== null) { [$address, $target] = $this->aliasTarget([$recipient, self::untagged($recipient)]);
$policies = $this->lookup->policiesForMailbox($address); $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); $group = $this->lookup->groupByAddress($address);
if ($group !== null) { if ($group !== null) {
$policies = array_merge($policies, $this->lookup->policiesForGroup($group)); $policies = array_merge($policies, $this->lookup->policiesForGroup($group));
} }
$aliasDomain = $this->lookup->internalAlias($address); // the `internal` flag of an alias or group: only senders of its domain
if ($aliasDomain !== null) { if (!empty($target['internal'])) {
// the `internal` flag of an alias or group: only senders of its domain $policies[] = $this->internalRule($target['domain']);
$internal = new Policy(); }
$internal->domain = $aliasDomain; foreach (array_unique([self::domainOf($recipient), $this->resolveDomain(self::domainOf($recipient))]) as $domain) {
$internal->receiveMode = 'internal'; if ($this->lookup->internalCatchAll($domain)) {
$policies[] = $internal; $policies[] = $this->internalRule($domain);
}
} }
return self::unique($policies); 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 private function isInternalRecipient(string $recipient, string $domain): bool
{ {
return $this->resolveDomain(self::domainOf($recipient)) === $domain; return $this->resolveDomain(self::domainOf($recipient)) === $domain;
@@ -137,8 +203,8 @@ class PolicyEngine
$address = $this->resolve(self::untagged($recipient)); $address = $this->resolve(self::untagged($recipient));
if (str_starts_with($entry, 'group:')) { if (str_starts_with($entry, 'group:')) {
$id = (int)substr($entry, 6); $id = (int)substr($entry, 6);
return $this->lookup->groupAddress($id) === $address return $this->cached('groupAddress', $id) === $address
|| ($this->lookup->mailbox($address) !== null && $this->lookup->isGroupMember($id, $address)); || ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address));
} }
if (str_contains($entry, '@')) { if (str_contains($entry, '@')) {
return $this->resolve($entry) === $address; return $this->resolve($entry) === $address;
@@ -146,35 +212,69 @@ class PolicyEngine
return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry); return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry);
} }
private function senderMatches(string $entry, string $sender, ?array $account): bool /** $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)); $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:')) { if (str_starts_with($entry, 'group:')) {
$id = (int)substr($entry, 6); $id = (int)substr($entry, 6);
return $account !== null && ($this->lookup->isGroupMember($id, $account['username']) || $this->lookup->groupAddress($id) === $address); 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, '@')) { if (str_contains($entry, '@')) {
// a local address only for a logged-in sender (Postfix checked that the login may use it) return $this->cached('isLocalDomain', self::domainOf($entry))
return $this->lookup->isLocalDomain(self::domainOf($entry)) ? ($account !== null && $account['username'] === $this->resolve($entry)) || ($useAddress && $address === $this->resolve($entry))
? $account !== null && ($account['username'] === $this->resolve($entry) || $address === $this->resolve($entry))
: self::untagged($sender) === $entry; : self::untagged($sender) === $entry;
} }
return $this->lookup->isLocalDomain($entry) return $this->cached('isLocalDomain', $entry)
? $account !== null && ($account['domain'] === $this->resolveDomain($entry) || $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry)) ? ($account !== null && $account['domain'] === $this->resolveDomain($entry))
|| ($useAddress && $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry))
: self::domainOf($sender) === $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 */ /** Address in an alias domain as the address in its target domain */
private function resolve(string $address): string private function resolve(string $address): string
{ {
$domain = self::domainOf($address); $domain = self::domainOf($address);
$target = ($domain !== '') ? $this->lookup->aliasDomainTarget($domain) : null; $target = ($domain !== '') ? $this->cached('aliasDomainTarget', $domain) : null;
return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target; return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target;
} }
private function resolveDomain(string $domain): string private function resolveDomain(string $domain): string
{ {
return ($domain === '') ? '' : ($this->lookup->aliasDomainTarget($domain) ?? $domain); 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 */ /** @param Policy[] $policies @return Policy[] without repeated policies */
@@ -182,16 +282,32 @@ class PolicyEngine
{ {
$unique = []; $unique = [];
foreach ($policies as $policy) { foreach ($policies as $policy) {
$unique[$policy->id ?? spl_object_id($policy)] = $policy; $unique[$policy->id ?? 'internal:' . $policy->domain] = $policy;
} }
return array_values($unique); return array_values($unique);
} }
/** Lower case address without angle brackets; '' unless local@domain */ /** 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 private static function address(string $value): string
{ {
$value = strtolower(trim($value, " \t\r\n<>")); $value = strtolower(trim($value, " \t\r\n<>"));
return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : ''; $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 private static function untagged(string $address): string
@@ -8,6 +8,7 @@ use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context; use Mailcow\Core\Context;
use Mailcow\Core\Input; use Mailcow\Core\Input;
use Mailcow\Core\UserError; use Mailcow\Core\UserError;
use Mailcow\MailPolicy\Model\Decision;
use Mailcow\MailPolicy\Model\Policy; use Mailcow\MailPolicy\Model\Policy;
use Mailcow\MailPolicy\Repository\PolicyRepository; use Mailcow\MailPolicy\Repository\PolicyRepository;
@@ -17,7 +18,8 @@ use Mailcow\MailPolicy\Repository\PolicyRepository;
* Read: admins all policies (also read-only API keys); domain admins with the `mail_policies` ACL the policies * Read: admins all policies (also read-only API keys); domain admins with the `mail_policies` ACL the policies
* of their domains. * of their domains.
* Edit: admins; domain admins with the ACL for policies of their domains. A policy is assigned to mailboxes and * 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 the editor may access. * 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 class PolicyService
{ {
@@ -94,10 +96,21 @@ class PolicyService
return $policy; 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) */ /** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
public function groupChoices(array $domains): array public function groupChoices(array $domains): array
{ {
return $this->canRead() ? $this->repo->groupsIn($domains) : []; return ($this->canRead() && $this->canUseGroups()) ? $this->repo->groupsIn($domains) : [];
}
private function canUseGroups(): bool
{
return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups');
} }
/** /**
@@ -116,21 +129,23 @@ class PolicyService
if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) { if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
throw new UserError('mail_policy_test_invalid'); throw new UserError('mail_policy_test_invalid');
} }
// domain admins test mail of their domains // domain admins test mail of their domains; a sender of another domain counts as not logged in
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($sender)) $ownSender = $this->context->hasAdminRights() || $this->context->canAccessDomain(self::domainOf($sender));
&& !$this->context->canAccessDomain(self::domainOf($recipient))) { if (!$ownSender && !$this->context->canAccessDomain(self::domainOf($recipient))) {
throw new AccessDenied(); throw new AccessDenied();
} }
$login = ($loggedIn && $this->repo->mailboxDomain($sender) !== null) ? $sender : ''; $login = ($loggedIn && $ownSender && $this->repo->mailboxDomain($sender) !== null) ? $sender : '';
$decision = $this->engine->check($sender, $recipient, $login); $decision = $this->engine->check($sender, $recipient, $login);
$policy = $decision->policy; $policy = $decision->policy;
$hidden = $policy !== null && $policy->id !== null && !$this->visible($policy);
return [ return [
'allowed' => (int)$decision->allowed, 'allowed' => (int)$decision->allowed,
'side' => $decision->side, 'side' => $decision->side,
'policy' => ($policy !== null && $policy->id !== null && $this->visible($policy)) ? $policy->name : null, 'policy' => ($policy !== null && $policy->id !== null && !$hidden) ? $policy->name : null,
'internal_alias' => (int)($policy !== null && $policy->id === null), 'internal_alias' => (int)($policy !== null && $policy->id === null),
'logged_in' => (int)($login !== ''), 'logged_in' => (int)($login !== ''),
'text' => $decision->allowed ? '' : $decision->text(), // the text of a policy of another domain stays hidden
'text' => $decision->allowed ? '' : ($hidden ? Decision::defaultText($decision->side) : $decision->text()),
]; ];
} }
@@ -189,17 +204,26 @@ class PolicyService
$policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid'); $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) { 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> // 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)) { if (!array_key_exists($key, $input) && !array_key_exists($key . '_groups', $input)) {
$entries = array_key_exists($key, $input) ? self::values($input[$key]) continue;
: array_filter($policy->$property, fn(string $entry): bool => !str_starts_with($entry, 'group:'));
if (array_key_exists($key . '_groups', $input)) {
$entries = array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:'));
$entries = array_merge($entries, array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups'])));
}
$policy->$property = $this->entries(array_values($entries));
} }
$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)) { if (array_key_exists('reject_text', $input)) {
$text = Input::text($input['reject_text']); $text = Input::text($input['reject_text']);
@@ -213,7 +237,7 @@ class PolicyService
} }
if (array_key_exists('mailboxes', $input)) { if (array_key_exists('mailboxes', $input)) {
$policy->mailboxes = []; $policy->mailboxes = [];
foreach (self::values($input['mailboxes']) as $username) { foreach (self::limited($input['mailboxes']) as $username) {
$username = strtolower($username); $username = strtolower($username);
if ($this->repo->mailboxDomain($username) !== $policy->domain) { if ($this->repo->mailboxDomain($username) !== $policy->domain) {
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]); throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]);
@@ -222,9 +246,19 @@ class PolicyService
} }
$policy->mailboxes = array_values($policy->mailboxes); $policy->mailboxes = array_values($policy->mailboxes);
} }
if (array_key_exists('groups', $input)) { // 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 = []; $policy->groups = [];
foreach (self::values($input['groups']) as $value) { foreach (self::limited($input['groups']) as $value) {
$id = Input::intInRange($value, 1, PHP_INT_MAX); $id = Input::intInRange($value, 1, PHP_INT_MAX);
if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) { if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) {
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]); throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]);
@@ -236,20 +270,20 @@ class PolicyService
} }
/** /**
* Entries of a list: addresses, domains (a leading @ is dropped) and group:<id>, lower case, without repeats. * 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[] * @return string[]
* @throws UserError * @throws UserError
*/ */
private function entries(mixed $value): array private function entries(array $values, string $domain, bool $groupAccess): array
{ {
$entries = []; $entries = [];
foreach (self::values($value) as $entry) { foreach (self::limited(array_map('strtolower', $values)) as $entry) {
$entry = strtolower($entry);
if (str_starts_with($entry, 'group:')) { if (str_starts_with($entry, 'group:')) {
$id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX); $id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX);
$domain = ($id === null) ? null : $this->repo->groupDomain($id); if (!$groupAccess || $id === null || $this->repo->groupDomain($id) !== $domain) {
if ($domain === null || (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain))) {
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
} }
$entry = 'group:' . $id; $entry = 'group:' . $id;
@@ -265,10 +299,28 @@ class PolicyService
} }
$entries[$entry] = $entry; $entries[$entry] = $entry;
} }
if (count($entries) > self::MAX_ENTRIES) { 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]); throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
} }
return array_values($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:')));
} }
/** /**
+1 -1
View File
@@ -130,7 +130,7 @@ var mailcowMailPolicy = (function($) {
} else if (r.allowed == 1) { } else if (r.allowed == 1) {
html = '<div class="alert alert-success py-2">' + esc(L('test_allowed')) + '</div>'; html = '<div class="alert alert-success py-2">' + esc(L('test_allowed')) + '</div>';
} else { } else {
var reason = r.internal_alias == 1 ? L('test_denied_internal') : var reason = r.internal_alias == 1 ? L('test_denied_internal') : r.side === 'address' ? L('test_denied_address') :
(r.policy ? L('test_denied_' + r.side).replace('%s', r.policy) : L('test_denied_other')); (r.policy ? L('test_denied_' + r.side).replace('%s', r.policy) : L('test_denied_other'));
html = '<div class="alert alert-danger py-2">' + esc(reason) + '<div class="small mt-1"><code>550 5.7.1 ' + esc(r.text) + '</code></div></div>'; html = '<div class="alert alert-danger py-2">' + esc(reason) + '<div class="small mt-1"><code>550 5.7.1 ' + esc(r.text) + '</code></div></div>';
} }
+6 -4
View File
@@ -584,7 +584,7 @@
"last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.", "last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.",
"login_failed": "Anmeldung fehlgeschlagen", "login_failed": "Anmeldung fehlgeschlagen",
"mail_policy_description_invalid": "Ungültige Beschreibung", "mail_policy_description_invalid": "Ungültige Beschreibung",
"mail_policy_entries_exceeded": "Eine Liste darf höchstens %s Einträge enthalten", "mail_policy_entries_exceeded": "Höchstens %s Einträge pro Liste oder Auswahl möglich",
"mail_policy_entry_invalid": "Ungültige Adresse, Domain oder Gruppe in der Liste: %s", "mail_policy_entry_invalid": "Ungültige Adresse, Domain oder Gruppe in der Liste: %s",
"mail_policy_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Richtlinie", "mail_policy_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Richtlinie",
"mail_policy_mode_invalid": "Ungültiger Modus", "mail_policy_mode_invalid": "Ungültiger Modus",
@@ -1050,13 +1050,14 @@
"allow_receive": "Erlaubte Absender", "allow_receive": "Erlaubte Absender",
"allow_send": "Erlaubte Empfänger", "allow_send": "Erlaubte Empfänger",
"assign": "Zugewiesen an", "assign": "Zugewiesen an",
"assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.", "assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen aus der Domain der Richtlinie, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.",
"assigned_none": "Nicht zugewiesen", "assigned_none": "Nicht zugewiesen",
"block_receive": "Gesperrte Absender", "block_receive": "Gesperrte Absender",
"block_send": "Gesperrte Empfänger", "block_send": "Gesperrte Empfänger",
"description": "Beschreibung", "description": "Beschreibung",
"domain": "Domain", "domain": "Domain",
"entries_info": "Adressen oder Domains, eine pro Zeile, und Gruppen. Adressen und Domains dieser mailcow gelten nur für angemeldete Absender.", "entries_info_receive": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain. Erlaubte Adressen und Domains dieser mailcow gelten nur für angemeldete Absender, gesperrte auch für die Absenderadresse im Umschlag. Externe Absender werden an der Absenderadresse im Umschlag erkannt.",
"entries_info_send": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain.",
"groups": "Gruppen", "groups": "Gruppen",
"info": "Mail-Richtlinien legen fest, an wen Mailboxen und Gruppen senden und von wem sie empfangen dürfen, z. B. nur intern oder nur mit bestimmten Domains. Postfix prüft sie für jeden Empfänger und lehnt nicht erlaubte Empfänger mit einem 550-Fehler ab. Eingehende E-Mails erreichen die übrigen Empfänger trotzdem, SOGo sendet eine E-Mail mit einem abgelehnten Empfänger dagegen gar nicht.", "info": "Mail-Richtlinien legen fest, an wen Mailboxen und Gruppen senden und von wem sie empfangen dürfen, z. B. nur intern oder nur mit bestimmten Domains. Postfix prüft sie für jeden Empfänger und lehnt nicht erlaubte Empfänger mit einem 550-Fehler ab. Eingehende E-Mails erreichen die übrigen Empfänger trotzdem, SOGo sendet eine E-Mail mit einem abgelehnten Empfänger dagegen gar nicht.",
"internal_info": "Intern: Empfänger in der Domain der Richtlinie und ihren Alias-Domains; Absender, die mit einer Mailbox dieser Domain angemeldet sind.", "internal_info": "Intern: Empfänger in der Domain der Richtlinie und ihren Alias-Domains; Absender, die mit einer Mailbox dieser Domain angemeldet sind.",
@@ -1085,6 +1086,7 @@
"summary_list": "Nur erlaubte", "summary_list": "Nur erlaubte",
"test": "Regel testen", "test": "Regel testen",
"test_allowed": "Angenommen: Keine Richtlinie lehnt diese E-Mail ab.", "test_allowed": "Angenommen: Keine Richtlinie lehnt diese E-Mail ab.",
"test_denied_address": "Abgelehnt: Diese Schreibweise leitet über eine andere Adresse um und ist nicht erlaubt.",
"test_denied_internal": "Abgelehnt: Die Adresse nimmt nur E-Mails von angemeldeten Absendern ihrer Domain an (interner Alias oder interne Gruppe).", "test_denied_internal": "Abgelehnt: Die Adresse nimmt nur E-Mails von angemeldeten Absendern ihrer Domain an (interner Alias oder interne Gruppe).",
"test_denied_other": "Abgelehnt durch eine Richtlinie einer anderen Domain.", "test_denied_other": "Abgelehnt durch eine Richtlinie einer anderen Domain.",
"test_denied_receive": "Abgelehnt durch den Empfangen-Teil der Richtlinie „%s“.", "test_denied_receive": "Abgelehnt durch den Empfangen-Teil der Richtlinie „%s“.",
@@ -1092,7 +1094,7 @@
"test_info": "Prüft, ob Postfix die E-Mail für diesen Empfänger annehmen würde. Für Bounces den Absender leer lassen.", "test_info": "Prüft, ob Postfix die E-Mail für diesen Empfänger annehmen würde. Für Bounces den Absender leer lassen.",
"test_invalid": "Geben Sie einen gültigen Empfänger (und Absender) Ihrer Domains ein.", "test_invalid": "Geben Sie einen gültigen Empfänger (und Absender) Ihrer Domains ein.",
"test_logged_in": "Absender ist angemeldet", "test_logged_in": "Absender ist angemeldet",
"test_not_a_mailbox": "Der Absender ist keine Mailbox dieser mailcow und wurde als nicht angemeldet geprüft.", "test_not_a_mailbox": "Der Absender ist keine Mailbox Ihrer Domains und wurde als nicht angemeldet geprüft.",
"test_recipient": "Empfänger", "test_recipient": "Empfänger",
"test_run": "Testen", "test_run": "Testen",
"test_sender": "Absender", "test_sender": "Absender",
+6 -4
View File
@@ -520,7 +520,7 @@
"iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_mode_invalid": "Invalid sync mode",
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", "iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
"mail_policy_description_invalid": "Invalid description", "mail_policy_description_invalid": "Invalid description",
"mail_policy_entries_exceeded": "A list may contain at most %s entries", "mail_policy_entries_exceeded": "At most %s entries per list or selection are possible",
"mail_policy_entry_invalid": "Invalid address, domain or group in the list: %s", "mail_policy_entry_invalid": "Invalid address, domain or group in the list: %s",
"mail_policy_member_invalid": "Mailbox or group %s does not belong to the domain of the policy", "mail_policy_member_invalid": "Mailbox or group %s does not belong to the domain of the policy",
"mail_policy_mode_invalid": "Invalid mode", "mail_policy_mode_invalid": "Invalid mode",
@@ -1050,13 +1050,14 @@
"allow_receive": "Allowed senders", "allow_receive": "Allowed senders",
"allow_send": "Allowed recipients", "allow_send": "Allowed recipients",
"assign": "Assigned to", "assign": "Assigned to",
"assign_info": "The policy applies to these mailboxes and to the members of these groups; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.", "assign_info": "The policy applies to these mailboxes and to the members of these groups in the policy's domain; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.",
"assigned_none": "Not assigned", "assigned_none": "Not assigned",
"block_receive": "Blocked senders", "block_receive": "Blocked senders",
"block_send": "Blocked recipients", "block_send": "Blocked recipients",
"description": "Description", "description": "Description",
"domain": "Domain", "domain": "Domain",
"entries_info": "Addresses or domains, one per line, and groups. Addresses and domains of this mailcow count for logged-in senders only.", "entries_info_receive": "Addresses or domains, one per line, and groups of the domain. Allowed addresses and domains of this mailcow count for logged-in senders only, blocked ones also for the envelope sender address. External senders are recognised by the envelope sender address.",
"entries_info_send": "Addresses or domains, one per line, and groups of the domain.",
"groups": "Groups", "groups": "Groups",
"info": "Mail policies define whom mailboxes and groups may send to and receive from, e.g. only internally or only with certain domains. Postfix checks them for every recipient and refuses recipients that are not allowed with a 550 error. Incoming mail still reaches the other recipients, whereas SOGo does not send a mail with a refused recipient at all.", "info": "Mail policies define whom mailboxes and groups may send to and receive from, e.g. only internally or only with certain domains. Postfix checks them for every recipient and refuses recipients that are not allowed with a 550 error. Incoming mail still reaches the other recipients, whereas SOGo does not send a mail with a refused recipient at all.",
"internal_info": "Internal: recipients in the domain of the policy and its alias domains; senders logged in with a mailbox of this domain.", "internal_info": "Internal: recipients in the domain of the policy and its alias domains; senders logged in with a mailbox of this domain.",
@@ -1085,6 +1086,7 @@
"summary_list": "Allowed only", "summary_list": "Allowed only",
"test": "Test a rule", "test": "Test a rule",
"test_allowed": "Accepted: no policy refuses this mail.", "test_allowed": "Accepted: no policy refuses this mail.",
"test_denied_address": "Refused: this form of the address routes through another address and is not allowed.",
"test_denied_internal": "Refused: the address only accepts mail from logged-in senders of its domain (internal alias or group).", "test_denied_internal": "Refused: the address only accepts mail from logged-in senders of its domain (internal alias or group).",
"test_denied_other": "Refused by a policy of another domain.", "test_denied_other": "Refused by a policy of another domain.",
"test_denied_receive": "Refused by the receiving part of the policy \"%s\".", "test_denied_receive": "Refused by the receiving part of the policy \"%s\".",
@@ -1092,7 +1094,7 @@
"test_info": "Checks whether Postfix would accept the mail for this recipient. Leave the sender empty for bounces.", "test_info": "Checks whether Postfix would accept the mail for this recipient. Leave the sender empty for bounces.",
"test_invalid": "Enter a valid recipient (and sender) of your domains.", "test_invalid": "Enter a valid recipient (and sender) of your domains.",
"test_logged_in": "Sender is logged in", "test_logged_in": "Sender is logged in",
"test_not_a_mailbox": "The sender is no mailbox of this mailcow and was tested as not logged in.", "test_not_a_mailbox": "The sender is no mailbox of your domains and was tested as not logged in.",
"test_recipient": "Recipient", "test_recipient": "Recipient",
"test_run": "Test", "test_run": "Test",
"test_sender": "Sender", "test_sender": "Sender",
@@ -1,5 +1,6 @@
{# Fields of a mail policy (add modal, edit page): policy, id (prefix of the element ids), groups (choices: id, name, {# Fields of a mail policy (add modal, edit page): policy, id (prefix of the element ids), groups (choices: id, name,
domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as <list>_groups. #} domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as <list>_groups; without
group choices (none in the domain, no access to groups) the group fields are left out and stay unchanged. #}
{% set entries = {} %} {% set entries = {} %}
{% set listed = {} %} {% set listed = {} %}
{% for key in ['send_allow', 'send_block', 'receive_allow', 'receive_block'] %} {% for key in ['send_allow', 'send_block', 'receive_allow', 'receive_block'] %}
@@ -35,13 +36,15 @@
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_{{ key }}">{{ attribute(lang.mail_policy, list ~ '_' ~ side) }}</label> <label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_{{ key }}">{{ attribute(lang.mail_policy, list ~ '_' ~ side) }}</label>
<div class="col-sm-10"> <div class="col-sm-10">
<textarea class="form-control" rows="3" name="{{ key }}" id="{{ id }}_{{ key }}" placeholder="partner.example, user@example.org"{% if ro %} disabled{% endif %}>{{ attribute(entries, key) }}</textarea> <textarea class="form-control" rows="3" name="{{ key }}" id="{{ id }}_{{ key }}" placeholder="partner.example, user@example.org"{% if ro %} disabled{% endif %}>{{ attribute(entries, key) }}</textarea>
<small class="text-muted d-block mb-1">{{ lang.mail_policy.entries_info }}</small> <small class="text-muted d-block mb-1">{{ attribute(lang.mail_policy, 'entries_info_' ~ side) }}</small>
{% if groups %}
{% if not ro %}<input type="hidden" name="{{ key }}_groups" value="">{% endif %} {% if not ro %}<input type="hidden" name="{{ key }}_groups" value="">{% endif %}
<select data-live-search="true" data-width="100%" name="{{ key }}_groups" class="form-control mail-policy-groups" multiple title="{{ lang.mail_policy.groups }}"{% if ro %} disabled{% endif %}> <select data-live-search="true" data-width="100%" name="{{ key }}_groups" class="form-control mail-policy-groups" multiple title="{{ lang.mail_policy.groups }}"{% if ro %} disabled{% endif %}>
{% for group in groups %} {% for group in groups %}
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id ~ '' in attribute(listed, key) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option> <option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id ~ '' in attribute(listed, key) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -66,6 +69,7 @@
</select> </select>
</div> </div>
</div> </div>
{% if groups %}
<div class="row mb-2"> <div class="row mb-2">
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_groups">{{ lang.mail_policy.groups }}</label> <label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_groups">{{ lang.mail_policy.groups }}</label>
<div class="col-sm-10"> <div class="col-sm-10">
@@ -77,3 +81,4 @@
</select> </select>
</div> </div>
</div> </div>
{% endif %}
+1 -1
View File
@@ -1,5 +1,5 @@
# Version of the database schema # Version of the database schema
x-db-version: &db_version DB_VERSION=07112026_1200 x-db-version: &db_version DB_VERSION=07112026_1800
services: services:
@@ -46,9 +46,9 @@ class PolicyEngineTest extends TestCase
} }
/** [allowed, side] of a check */ /** [allowed, side] of a check */
private function check(string $sender, string $recipient, string $login = '', int $port = 587): array private function check(string $sender, string $recipient, string $login = '', int $port = 587, string $helo = ''): array
{ {
$decision = $this->engine->check($sender, $recipient, $login, $port); $decision = $this->engine->check($sender, $recipient, $login, $port, $helo);
return [$decision->allowed, $decision->side]; return [$decision->allowed, $decision->side];
} }
@@ -139,7 +139,7 @@ class PolicyEngineTest extends TestCase
public function testInternalFlagOfAliasesAndGroups(): void public function testInternalFlagOfAliasesAndGroups(): void
{ {
$this->lookup->internalAliases['team@firma.de'] = 'firma.de'; $this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25)); $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25));
// the old rspamd rule let forged senders of the domain pass // the old rspamd rule let forged senders of the domain pass
@@ -174,15 +174,123 @@ class PolicyEngineTest extends TestCase
{ {
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])]; $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])];
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])]; $this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])];
$this->lookup->internalAliases['team@firma.de'] = 'firma.de'; $this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
// a Sieve redirect of azubi to an external address follows its send rules // a Sieve redirect of azubi to an external address follows its send rules
$this->assertSame([false, 'send'], $this->check('azubi+x@firma.de', 'x@extern.example', '', 588)); $this->assertSame([false, 'send'], $this->check('azubi+x@firma.de', 'x@extern.example', '', 588));
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588)); $this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588)); $this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588));
// notifications of mailcow (e.g. quota warnings) are no mailbox // other senders are checked like mail from outside
$this->assertSame([true, ''], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588)); $this->assertSame([false, 'receive'], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588));
// the quota warning copies of Dovecot carry the address of the mailbox over its quota
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example', '', 588, 'quotanotification'));
// on other ports the sender address is no login // on other ports the sender address is no login
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.de', '', 25)); $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.de', '', 25));
} }
public function testDovecotMailWithoutMailboxSender(): void
{
// bounces redirected with the null sender, notifications of other senders: local recipients only while
// a policy restricts sending, as the sending mailbox is unknown
$this->assertSame([true, ''], $this->check('', 'x@extern.example', '', 588));
$this->lookup->sendRules = true;
$this->assertSame([false, 'send'], $this->check('', 'x@extern.example', '', 588));
$this->assertSame([false, 'send'], $this->check('irgendwer@nirgends.example', 'x@extern.example', '', 588));
$this->assertSame([true, ''], $this->check('', 'kollege@firma.com', '', 588));
// local recipients: receive rules as for mail from outside
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(14, ['sendMode' => 'internal', 'receiveMode' => 'internal'])];
$this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 588));
// SOGo reminders log in with the Dovecot master user
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'abc@mailcow.local', 588));
$this->assertSame([false, 'send'], $this->check('', 'x@extern.example', 'abc@mailcow.local', 588));
}
public function testRecipientsAreComparedAsPostfixDeliversThem(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(15, ['receiveMode' => 'internal'])];
// trailing dot and quotes are dropped by Postfix
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'Azubi@Firma.de.', '', 25));
$this->assertSame([false, 'receive'], $this->check('x@extern.example', '"azubi+a b"@firma.com', '', 25));
// routes through another address are refused, Postfix would deliver them to the routed address
foreach (['azubi@firma.de@localhost', 'azubi%firma.de@localhost', 'firma.de!azubi@localhost.localdomain', 'x@extern.example@localhost',
'azubi%firma.de@[192.0.2.1]', 'abuse'] as $recipient) {
$this->assertSame([false, 'address'], $this->check('x@extern.example', $recipient, '', 25), $recipient);
}
// in other domains %, ! and @ in the local part are ordinary characters
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'a!b@extern.example', 'kollege@firma.de'));
$this->assertSame([true, ''], $this->check('x@extern.example', 'sales%x@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('x@extern.example', 'postmaster', '', 25));
$this->assertSame('550 5.7.1 This form of the recipient address is not allowed by the mail policy',
$this->engine->check('azubi@firma.de', 'x%extern.example@localhost', 'azubi@firma.de', 587)->action());
}
public function testSendersAreComparedAsPostfixResolvesThem(): void
{
$this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(16, ['sendBlock' => ['konkurrenz.example']])];
$this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(17, ['sendMode' => 'internal'])];
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@konkurrenz.example.', 'kollege@firma.de'));
// sending as info@ with a trailing dot keeps the policy of info@
$this->assertSame([false, 'send'], $this->check('Info@firma.de.', 'x@extern.example', 'chef@firma.de'));
}
public function testTemporaryAliasesFollowTheirOwner(): void
{
$this->lookup->temporaryAliases['einmal.xyz@firma.de'] = 'azubi@firma.de';
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(18, ['receiveMode' => 'internal'])];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'einmal.xyz@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'einmal.xyz@firma.de', 'kollege@firma.de'));
}
public function testInternalFlagFollowsThePostfixLookup(): void
{
// an alias of its own in an alias domain; senders of the target domain are internal
$this->lookup->aliases['team2@firma.com'] = ['domain' => 'firma.com', 'internal' => true];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team2@firma.com', '', 25));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team2@firma.com', 'kollege@firma.de'));
// the full address first, then without +tag
$this->lookup->aliases['team+ext@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => false];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team+ext@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('x@extern.example', 'team+other@firma.de', '', 25));
// an internal catch-all alias covers the whole domain, also through its alias domain
$this->lookup->internalCatchAlls = ['tochter.de'];
$this->lookup->aliasDomains['tochter.com'] = 'tochter.de';
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'andere@tochter.de', '', 25));
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'unbekannt@tochter.com', '', 25));
$this->assertSame([true, ''], $this->check('andere@tochter.de', 'unbekannt@tochter.de', 'andere@tochter.de'));
}
public function testAddressesAreComparedInTheirStoredSpelling(): void
{
// MySQL ignores accents: Postfix delivers chéf@ to chef@ and töchter.de to tochter.de
$this->lookup->spellings = ['chéf@firma.de' => 'chef@firma.de', 'töchter.de' => 'tochter.de', 'köllege@firma.de' => 'kollege@firma.de'];
$this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(20, ['sendBlock' => ['chef@firma.de', 'tochter.de']])];
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(21, ['receiveBlock' => ['kollege@firma.de']])];
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'chéf@firma.de', 'kollege@firma.de'));
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@töchter.de', 'kollege@firma.de'));
$this->assertSame([false, 'receive'], $this->check('köllege@firma.de', 'chef@firma.de', '', 25));
}
public function testTaggedAliasThroughAnAliasDomain(): void
{
// Postfix maps chef@firma.com to chef@firma.de and looks up chef+intern@firma.de again
$this->lookup->aliases['chef+intern@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef+intern@firma.com', '', 25));
$this->assertSame([true, ''], $this->check('x@extern.example', 'chef+other@firma.com', '', 25));
}
public function testBlockEntriesAlsoMatchTheEnvelopeSender(): void
{
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(19, ['receiveBlock' => ['kollege@firma.de', 'group:7', 'tochter.de']])];
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25));
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25));
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('info@firma.de', 'chef@firma.de', '', 25));
}
} }
@@ -23,6 +23,7 @@ class PolicyServiceTest extends TestCase
private FakeMailPolicyLookup $lookup; private FakeMailPolicyLookup $lookup;
/** @var Policy[] saved policies */ /** @var Policy[] saved policies */
private array $saved = []; private array $saved = [];
private int $groupLookups = 0;
protected function setUp(): void protected function setUp(): void
{ {
@@ -30,7 +31,11 @@ class PolicyServiceTest extends TestCase
$this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true)); $this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true));
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => $this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string =>
['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null); ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null);
$this->repo->method('groupDomain')->willReturnCallback(fn(int $id): ?string => [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null); $this->repo->method('groupDomain')->willReturnCallback(function (int $id): ?string {
$this->groupLookups++;
return [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null;
});
$this->repo->method('groupsIn')->willReturn([['id' => 7, 'name' => 'Azubis', 'domain' => 'firma.de', 'address' => null]]);
$this->repo->method('save')->willReturnCallback(function (Policy $policy): int { $this->repo->method('save')->willReturnCallback(function (Policy $policy): int {
$policy->id ??= 1; $policy->id ??= 1;
$this->saved[] = clone $policy; $this->saved[] = clone $policy;
@@ -133,7 +138,7 @@ class PolicyServiceTest extends TestCase
public function testPermissions(): void public function testPermissions(): void
{ {
$this->repo->method('find')->willReturn($this->stored()); $this->repo->method('find')->willReturn($this->stored());
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']); $da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']);
$this->assertSame('Test', $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test'])->name); $this->assertSame('Test', $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test'])->name);
$this->assertNotNull($this->service($da)->get(3)); $this->assertNotNull($this->service($da)->get(3));
@@ -149,6 +154,58 @@ class PolicyServiceTest extends TestCase
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3)); $this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3));
} }
public function testGroupsNeedTheGroupAcl(): void
{
$this->repo->method('find')->willReturn($this->stored(['groups' => [7]]));
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
$this->assertSame([], $this->service($da)->groupChoices(['firma.de']));
// the form sends empty group fields: the group entries and assignments stay
$policy = $this->service($da)->update(3, ['send_allow' => 'kunde.example', 'send_allow_groups' => '', 'groups' => '']);
$this->assertSame([['kunde.example', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]);
// the stored values sent back unchanged, as returned by the API
$policy = $this->service($da)->update(3, ['send_allow' => ['partner.de', 'group:7'], 'groups' => ['7']]);
$this->assertSame([['partner.de', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]);
$this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']]));
$this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'groups' => ['7']]));
$this->expectUserError(['mail_policy_entry_invalid', 'group:7'],
fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => 'group:7']));
$this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de']));
}
public function testGroupEntriesBelongToThePolicyDomain(): void
{
// also for admins: the policy of firma.de cannot name a group of tochter.de
$this->expectUserError(['mail_policy_entry_invalid', 'group:9'],
fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'receive_block' => 'group:9']));
}
public function testListsAreLimitedBeforeAnythingIsLookedUp(): void
{
$groups = array_map(fn(int $i): string => 'group:' . $i, range(1, 501));
$this->expectUserError(['mail_policy_entries_exceeded', 500], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => $groups]));
$this->assertSame(0, $this->groupLookups);
// repeats count once
$this->assertSame(['group:7'], $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => array_fill(0, 600, 'group:7')])->sendBlock);
$this->expectUserError(['mail_policy_entries_exceeded', 500],
fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))]));
}
public function testRuleTestShowsNothingOfOtherDomains(): void
{
$this->lookup->mailboxes['andere@tochter.de'] = 'tochter.de';
$this->lookup->mailboxPolicies['andere@tochter.de'] = [$this->stored(['id' => 5, 'domain' => 'tochter.de', 'name' => 'Geheim',
'sendAllow' => [], 'receiveMode' => 'internal', 'rejectText' => 'Interner Text'])];
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => $this->lookup->mailboxes[$username] ?? null);
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
// a policy of another domain: neither name nor text
$this->assertSame(['allowed' => 0, 'side' => 'receive', 'policy' => null, 'internal_alias' => 0, 'logged_in' => 1,
'text' => 'This recipient does not accept mail from this sender'], $this->service($da)->test('azubi@firma.de', 'andere@tochter.de', true));
// a sender of another domain is not tested as logged in, so its mailbox and its rules stay hidden
$this->assertSame(0, $this->service($da)->test('andere@tochter.de', 'chef@firma.de', true)['logged_in']);
}
public function testRuleTest(): void public function testRuleTest(): void
{ {
$policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]); $policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]);
+37 -4
View File
@@ -21,8 +21,16 @@ class FakeMailPolicyLookup extends LookupRepository
public array $domains = []; public array $domains = [];
/** id => ['address' => ?string, 'members' => string[]] */ /** id => ['address' => ?string, 'members' => string[]] */
public array $groups = []; public array $groups = [];
/** alias address => domain of aliases with the internal flag */ /** alias address => ['domain' => string, 'internal' => bool] (active aliases, also own aliases of mailboxes) */
public array $internalAliases = []; public array $aliases = [];
/** temporary alias => owner mailbox */
public array $temporaryAliases = [];
/** @var string[] domains with an internal catch-all alias */
public array $internalCatchAlls = [];
/** an active policy restricts sending */
public bool $sendRules = false;
/** other spelling (MySQL ignores accents) => stored address or domain */
public array $spellings = [];
/** username => Policy[] assigned directly */ /** username => Policy[] assigned directly */
public array $mailboxPolicies = []; public array $mailboxPolicies = [];
/** group id => Policy[] */ /** group id => Policy[] */
@@ -67,9 +75,34 @@ class FakeMailPolicyLookup extends LookupRepository
return in_array($username, $this->groups[$groupId]['members'] ?? [], true); return in_array($username, $this->groups[$groupId]['members'] ?? [], true);
} }
public function internalAlias(string $address): ?string public function aliasTarget(string $address): ?array
{ {
return $this->internalAliases[$address] ?? null; if (isset($this->aliases[$address])) {
return ['kind' => 'alias', 'domain' => $this->aliases[$address]['domain'], 'internal' => $this->aliases[$address]['internal']];
}
return isset($this->temporaryAliases[$address]) ? ['kind' => 'temporary', 'owner' => $this->temporaryAliases[$address]] : null;
}
public function internalCatchAll(string $domain): bool
{
return in_array($domain, $this->internalCatchAlls, true);
}
public function hasSendRules(): bool
{
return $this->sendRules;
}
public function storedAddress(string $address): ?string
{
$address = $this->spellings[$address] ?? $address;
return (isset($this->mailboxes[$address]) || isset($this->aliases[$address]) || isset($this->temporaryAliases[$address])) ? $address : null;
}
public function storedDomain(string $domain): ?string
{
$domain = $this->spellings[$domain] ?? $domain;
return (in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain])) ? $domain : null;
} }
public function policiesForMailbox(string $username): array public function policiesForMailbox(string $username): array