Merge branch 'feat/mail-policy' into nightly

This commit is contained in:
FreddleSpl0it committed 2026-11-09 03:16:30 +01:00
commit 2afb03dd6a
38 files changed
+3161 -45

No files matched your search

@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailPolicy\Service;
use Mailcow\MailPolicy\Model\Policy;
use Mailcow\MailPolicy\Service\PolicyEngine;
use Mailcow\Tests\Support\FakeMailPolicyLookup;
use PHPUnit\Framework\TestCase;
class PolicyEngineTest extends TestCase
{
private FakeMailPolicyLookup $lookup;
private PolicyEngine $engine;
protected function setUp(): void
{
$this->lookup = new FakeMailPolicyLookup();
$this->lookup->domains = ['firma.de', 'tochter.de'];
$this->lookup->aliasDomains = ['firma.com' => 'firma.de'];
$this->lookup->mailboxes = [
'azubi@firma.de' => 'firma.de',
'kollege@firma.de' => 'firma.de',
'chef@firma.de' => 'firma.de',
'info@firma.de' => 'firma.de',
'andere@tochter.de' => 'tochter.de',
];
$this->lookup->groups = [
7 => ['address' => 'azubis@firma.de', 'members' => ['azubi@firma.de']],
8 => ['address' => 'vorstand@firma.de', 'members' => ['chef@firma.de']],
];
$this->engine = new PolicyEngine($this->lookup);
}
private static function policy(int $id, array $values): Policy
{
$policy = new Policy();
$policy->id = $id;
$policy->domain = 'firma.de';
$policy->name = 'policy ' . $id;
foreach ($values as $property => $value) {
$policy->$property = $value;
}
return $policy;
}
/** [allowed, side] of a check */
private function check(string $sender, string $recipient, string $login = '', int $port = 587, string $helo = ''): array
{
$decision = $this->engine->check($sender, $recipient, $login, $port, $helo);
return [$decision->allowed, $decision->side];
}
public function testWithoutPoliciesEverythingPasses(): void
{
$this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de'));
$this->assertSame('DUNNO', $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de')->action());
}
public function testUnknownModeLetsNothingThrough(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(1, ['sendMode' => 'nobody', 'receiveMode' => ''])];
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de'));
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'azubi@firma.de', 'kollege@firma.de'));
}
public function testSendOnlyInternalWithAllowedAndBlockedEntries(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(1, [
'sendMode' => 'internal', 'sendAllow' => ['partner.de', 'kunde@extern.example'], 'sendBlock' => ['chef@firma.de'],
])];
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de'));
// alias domain and sub-address are internal
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'Kollege+x@firma.com', 'azubi@firma.de'));
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de'));
// another domain of the same mailcow is not internal
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'andere@tochter.de', 'azubi@firma.de'));
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'jemand@partner.de', 'azubi@firma.de'));
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'kunde@extern.example', 'azubi@firma.de'));
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
// the rules belong to the login: without it nothing is sent as azubi
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example'));
}
public function testSendOnlyListedRecipientsThroughAGroup(): void
{
$this->lookup->groupPolicies[7] = [self::policy(2, ['sendMode' => 'list', 'sendAllow' => ['group:8']])];
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'vorstand@firma.de', 'azubi@firma.de'));
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de'));
// not a member: no rule
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'x@extern.example', 'kollege@firma.de'));
}
public function testSendingAsAnotherMailboxAppliesItsPolicyToo(): void
{
$this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(3, ['sendMode' => 'internal'])];
$this->assertSame([false, 'send'], $this->check('info@firma.de', 'x@extern.example', 'chef@firma.de'));
$this->assertSame([true, ''], $this->check('chef@firma.de', 'x@extern.example', 'chef@firma.de'));
}
public function testReceiveOnlyInternalCannotBeForged(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(4, ['receiveMode' => 'internal', 'receiveAllow' => ['partner.de'],
'receiveBlock' => ['chef@firma.de']])];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'azubi@firma.de', '', 25));
// a forged internal sender is external
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'azubi@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'azubi+news@firma.com', 'kollege@firma.de'));
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'azubi@firma.de', 'andere@tochter.de'));
// an external domain on the list is compared with the envelope sender
$this->assertSame([true, ''], $this->check('jemand@partner.de', 'azubi@firma.de', '', 25));
$this->assertSame([false, 'receive'], $this->check('chef@firma.de', 'azubi@firma.de', 'chef@firma.de'));
// null sender (bounces) is external
$this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 25));
}
public function testListedLocalSendersMustBeLoggedIn(): void
{
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(5, ['receiveMode' => 'list',
'receiveAllow' => ['kollege@firma.de', 'group:7', 'tochter.de']])];
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'chef@firma.de', 'kollege@firma.de'));
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('andere@tochter.de', 'chef@firma.de', 'andere@tochter.de'));
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25));
}
public function testGroupAddressAndMembersAsRecipients(): void
{
$this->lookup->groupPolicies[8] = [self::policy(6, ['receiveMode' => 'internal', 'receiveBlock' => ['group:7']])];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'vorstand@firma.de', '', 25));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'vorstand@firma.de', 'kollege@firma.de'));
// the policy of a group applies to its members as well
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef@firma.de', '', 25));
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
}
public function testInternalFlagOfAliasesAndGroups(): void
{
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25));
// the old rspamd rule let forged senders of the domain pass
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.com', '', 25));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', 'kollege@firma.de'));
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'team@firma.de', 'andere@tochter.de'));
}
public function testTheStrictestPolicyWins(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(9, ['sendMode' => 'all'])];
$this->lookup->groupPolicies[7] = [self::policy(10, ['sendMode' => 'internal', 'rejectText' => 'Azubis senden nur intern'])];
$decision = $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de', 587);
$this->assertSame(['send', 10, '550 5.7.1 Azubis senden nur intern'], [$decision->side, $decision->policy->id, $decision->action()]);
}
public function testDefaultTextsAndOwnDeliveries(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(11, ['sendMode' => 'internal', 'receiveMode' => 'internal'])];
$this->assertSame('550 5.7.1 Sending to this recipient is not allowed by the mail policy of the sender',
$this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de', 587)->action());
$this->assertSame('550 5.7.1 This recipient does not accept mail from this sender',
$this->engine->check('x@extern.example', 'azubi@firma.de', '', 25)->action());
// quarantine release (590) and BCC/archive copies (591) are not checked
$this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de', '', 590));
$this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de', '', 591));
}
public function testDovecotSubmissionsCountAsTheMailbox(): void
{
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])];
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])];
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
// 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([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588));
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588));
// other senders are checked like mail from outside
$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
$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));
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailPolicy\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\MailPolicy\Model\Policy;
use Mailcow\MailPolicy\Repository\PolicyRepository;
use Mailcow\MailPolicy\Service\PolicyEngine;
use Mailcow\MailPolicy\Service\PolicyService;
use Mailcow\Tests\Support\FakeContext;
use Mailcow\Tests\Support\FakeMailPolicyLookup;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class PolicyServiceTest extends TestCase
{
private PolicyRepository&MockObject $repo;
private FakeMailPolicyLookup $lookup;
/** @var Policy[] saved policies */
private array $saved = [];
private int $groupLookups = 0;
protected function setUp(): void
{
$this->repo = $this->createMock(PolicyRepository::class);
$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 =>
['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? 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 {
$policy->id ??= 1;
$this->saved[] = clone $policy;
return $policy->id;
});
$this->lookup = new FakeMailPolicyLookup();
$this->lookup->domains = ['firma.de', 'tochter.de'];
$this->lookup->mailboxes = ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de'];
}
private function service(?Context $context = null): PolicyService
{
return new PolicyService($this->repo, new PolicyEngine($this->lookup), $context ?? FakeContext::admin());
}
private function stored(array $values = []): Policy
{
$policy = new Policy();
$policy->id = 3;
$policy->domain = 'firma.de';
$policy->name = 'Azubis';
$policy->sendAllow = ['partner.de', 'group:7'];
foreach ($values as $property => $value) {
$policy->$property = $value;
}
return $policy;
}
private function expectUserError(string|array $msg, callable $call): void
{
try {
$call();
$this->fail('UserError ' . json_encode($msg) . ' expected');
} catch (UserError $e) {
$this->assertSame($msg, $e->msg());
}
}
public function testCreateNormalizesTheInput(): void
{
$policy = $this->service()->create([
'domain' => 'Firma.de', 'name' => 'Azubis', 'description' => 'Nur intern', 'active' => ['0', '1'],
'send_mode' => 'internal', 'send_allow' => "@Partner.de\nkunde@extern.example, partner.de", 'send_allow_groups' => ['', '7'],
'send_block' => '', 'receive_mode' => 'list', 'receive_allow' => ['group:7'], 'reject_text' => 'Nur intern erlaubt',
'mailboxes' => ['', 'Azubi@firma.de'], 'groups' => ['7'],
]);
$this->assertSame([
'domain' => 'firma.de', 'name' => 'Azubis', 'description' => 'Nur intern', 'send_mode' => 'internal',
'send_allow' => ['partner.de', 'kunde@extern.example', 'group:7'], 'send_block' => [], 'receive_mode' => 'list',
'receive_allow' => ['group:7'], 'receive_block' => [], 'reject_text' => 'Nur intern erlaubt', 'active' => 1,
'mailboxes' => ['azubi@firma.de'], 'groups' => [7],
], array_intersect_key($policy->toArray(), array_flip(['domain', 'name', 'description', 'send_mode', 'send_allow', 'send_block',
'receive_mode', 'receive_allow', 'receive_block', 'reject_text', 'active', 'mailboxes', 'groups'])));
$this->assertCount(1, $this->saved);
}
public function testUpdateKeepsMissingValuesAndReplacesListGroups(): void
{
$this->repo->method('find')->willReturn($this->stored());
$policy = $this->service()->update(3, ['description' => 'neu']);
$this->assertSame(['neu', ['partner.de', 'group:7']], [$policy->description, $policy->sendAllow]);
// the UI sends the groups apart: they replace the group entries, the other entries stay
$this->repo->method('find')->willReturn($this->stored());
$this->assertSame(['partner.de'], $this->service()->update(3, ['send_allow_groups' => ''])->sendAllow);
}
public static function invalidInput(): array
{
return [
'empty name' => [['name' => ''], 'mail_policy_name_invalid'],
'long name' => [['name' => str_repeat('a', 101)], 'mail_policy_name_invalid'],
'mode' => [['send_mode' => 'nobody'], 'mail_policy_mode_invalid'],
'domain without dot' => [['send_allow' => 'localhost'], ['mail_policy_entry_invalid', 'localhost']],
'address' => [['receive_block' => 'a@@b.de'], ['mail_policy_entry_invalid', 'a@@b.de']],
'address with +tag' => [['receive_block' => 'news+promo@vendor.example'], ['mail_policy_entry_invalid', 'news+promo@vendor.example']],
'IP literal' => [['send_allow' => 'u@[127.0.0.1]'], ['mail_policy_entry_invalid', 'u@[127.0.0.1]']],
'top level domain' => [['send_block' => 'com.'], ['mail_policy_entry_invalid', 'com']],
'unknown group' => [['receive_block_groups' => ['8']], ['mail_policy_entry_invalid', 'group:8']],
'reject text with line break' => [['reject_text' => "a\r\nb"], 'mail_policy_reject_text_invalid'],
'reject text not ASCII' => [['reject_text' => 'Nur für Azubis'], 'mail_policy_reject_text_invalid'],
'mailbox of another domain' => [['mailboxes' => 'andere@tochter.de'], ['mail_policy_member_invalid', 'andere@tochter.de']],
'group of another domain' => [['groups' => ['9']], ['mail_policy_member_invalid', '9']],
'unknown domain' => [['domain' => 'fremd.de'], ['domain_not_found', 'fremd.de']],
];
}
public function testEntriesInTheFormTheEngineCompares(): void
{
$policy = $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'receive_block' => "Spam.Example.\n@news.example\nBoss@Ext.Example.\n+x@ext.example"]);
$this->assertSame(['spam.example', 'news.example', 'boss@ext.example', '+x@ext.example'], $policy->receiveBlock);
}
#[DataProvider('invalidInput')]
public function testInvalidInput(array $input, string|array $error): void
{
$this->expectUserError($error, fn() => $this->service()->create($input + ['domain' => 'firma.de', 'name' => 'Test']));
$this->assertSame([], $this->saved);
}
public function testNamesAreUniquePerDomain(): void
{
$this->repo->method('nameExists')->willReturn(true);
$this->expectUserError(['object_exists', 'Azubis'], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Azubis']));
}
public function testPermissions(): void
{
$this->repo->method('find')->willReturn($this->stored());
$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->assertNotNull($this->service($da)->get(3));
// other domains, no ACL, users and read-only keys
$this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'tochter.de', 'name' => 'Test']));
$this->expectUserError(['mail_policy_entry_invalid', 'group:9'],
fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block_groups' => ['9']]));
$this->assertNull($this->service(FakeContext::domainAdmin([], ['tochter.de'], ['mail_policies' => '1']))->get(3));
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin([], ['firma.de'], []))->create(['domain' => 'firma.de', 'name' => 'Test']));
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('azubi@firma.de'))->update(3, []));
$readOnly = new Context('admin', 'API', [], false, 'ro');
$this->assertNotNull($this->service($readOnly)->get(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
{
$policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]);
$this->lookup->mailboxPolicies['azubi@firma.de'] = [$policy];
$this->assertSame(['allowed' => 0, 'side' => 'send', 'policy' => 'Azubis', 'internal_alias' => 0, 'logged_in' => 1,
'text' => 'Sending to this recipient is not allowed by the mail policy of the sender'],
$this->service()->test('azubi@firma.de', 'x@extern.example', true));
// not logged in: the send rules of the mailbox do not apply
$this->assertSame(1, $this->service()->test('azubi@firma.de', 'x@extern.example', false)['allowed']);
// an external sender is never logged in
$this->assertSame(0, $this->service()->test('x@extern.example', 'chef@firma.de', true)['logged_in']);
$this->expectUserError('mail_policy_test_invalid', fn() => $this->service()->test('azubi@firma.de', 'kein-empfaenger', true));
// domain admins test mail of their domains
$da = FakeContext::domainAdmin([], ['tochter.de'], ['mail_policies' => '1']);
$this->expectUserError('access_denied', fn() => $this->service($da)->test('azubi@firma.de', 'x@extern.example', true));
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Support;
use Mailcow\MailPolicy\Model\Policy;
use Mailcow\MailPolicy\Repository\LookupRepository;
/**
* Address tables and policy assignments in memory, with the conditions of the SQL already applied
* (only active rows are listed).
*/
class FakeMailPolicyLookup extends LookupRepository
{
/** username => domain */
public array $mailboxes = [];
/** alias domain => target domain */
public array $aliasDomains = [];
/** @var string[] domains of the mailcow (without alias domains) */
public array $domains = [];
/** id => ['address' => ?string, 'members' => string[]] */
public array $groups = [];
/** alias address => ['domain' => string, 'internal' => bool] (active aliases, also own aliases of mailboxes) */
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 */
public array $mailboxPolicies = [];
/** group id => Policy[] */
public array $groupPolicies = [];
public function __construct()
{
}
public function mailbox(string $username): ?array
{
return isset($this->mailboxes[$username]) ? ['username' => $username, 'domain' => $this->mailboxes[$username]] : null;
}
public function aliasDomainTarget(string $domain): ?string
{
return $this->aliasDomains[$domain] ?? null;
}
public function isLocalDomain(string $domain): bool
{
return in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain]);
}
public function groupByAddress(string $address): ?int
{
foreach ($this->groups as $id => $group) {
if (($group['address'] ?? null) === $address) {
return $id;
}
}
return null;
}
public function groupAddress(int $id): ?string
{
return $this->groups[$id]['address'] ?? null;
}
public function isGroupMember(int $groupId, string $username): bool
{
return in_array($username, $this->groups[$groupId]['members'] ?? [], true);
}
public function aliasTarget(string $address): ?array
{
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
{
$policies = $this->mailboxPolicies[$username] ?? [];
foreach ($this->groups as $id => $group) {
if (in_array($username, $group['members'] ?? [], true)) {
$policies = array_merge($policies, $this->groupPolicies[$id] ?? []);
}
}
return $policies;
}
public function policiesForGroup(int $groupId): array
{
return $this->groupPolicies[$groupId] ?? [];
}
}