mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 10:13:37 +00:00
[Web][Dovecot] Add retention rules: nightly deletion of mail by folder and age for domains, groups and mailboxes
This commit is contained in:
36 files changed
+3013
-16
No files matched your search
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Retention\Service;
|
||||
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
use Mailcow\Retention\Service\Planner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PlannerTest extends TestCase
|
||||
{
|
||||
private static function rule(int $id, array $folders, int $days, array $values = []): Rule
|
||||
{
|
||||
$rule = new Rule();
|
||||
$rule->id = $id;
|
||||
$rule->name = 'rule ' . $id;
|
||||
$rule->folders = $folders;
|
||||
$rule->days = $days;
|
||||
foreach ($values as $property => $value) {
|
||||
$rule->$property = $value;
|
||||
}
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @return string[] folder/match:days:rule id in the order of the plan */
|
||||
private static function plan(array $candidates): array
|
||||
{
|
||||
return array_map(fn(array $action): string => $action['folder'] . '/' . $action['match'] . ':' . $action['days'] . ':' . $action['rule_id'],
|
||||
Planner::actions($candidates));
|
||||
}
|
||||
|
||||
public function testTheMailboxRuleWinsOverGroupAndDomain(): void
|
||||
{
|
||||
$this->assertSame(['trash/folder:90:3', 'trash/subfolders:90:3'], self::plan([
|
||||
['rule' => self::rule(1, ['trash'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['trash'], 10), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(3, ['trash'], 90), 'level' => Planner::LEVEL_MAILBOX],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testAMoreSpecificAssignmentWinsAlsoWithAllFolders(): void
|
||||
{
|
||||
// the group rule for all folders is more specific than the domain rule for the trash, which never wins
|
||||
$this->assertSame(['all/all:365:2'], self::plan([
|
||||
['rule' => self::rule(1, ['trash'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['all'], 365), 'level' => Planner::LEVEL_GROUP],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testNamedFoldersComeBeforeAllFoldersOnTheSameLevel(): void
|
||||
{
|
||||
$this->assertSame(['trash/folder:30:2', 'folder:Newsletter/folder:7:3', 'trash/subfolders:30:2', 'all/all:365:1'], self::plan([
|
||||
['rule' => self::rule(1, ['all'], 365), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['trash'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(3, ['folder:Newsletter'], 7), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testTheFolderItselfBeforePatternsBeforeSubfolders(): void
|
||||
{
|
||||
// INBOX/Newsletter: its own rule; other subfolders of the inbox: INBOX/* before the subfolders of the inbox rule
|
||||
$this->assertSame(['inbox/folder:365:1', 'folder:INBOX/Newsletter/folder:7:3', 'folder:INBOX/*/pattern:30:2', 'inbox/subfolders:365:1'], self::plan([
|
||||
['rule' => self::rule(1, ['inbox'], 365), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['folder:INBOX/*'], 30), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(3, ['folder:INBOX/Newsletter'], 7), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testOverlappingFoldersAreDecidedPerFolder(): void
|
||||
{
|
||||
// * also matches the trash: the trash rule keeps the trash itself, * the other folders
|
||||
$this->assertSame(['trash/folder:365:3', 'folder:*/pattern:7:1', 'trash/subfolders:365:3'], self::plan([
|
||||
['rule' => self::rule(1, ['folder:*'], 7, ['mode' => 'delete']), 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(3, ['trash'], 365), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testOnATieTheLongerPeriodAndThenTheLowerIdWin(): void
|
||||
{
|
||||
$this->assertSame(['junk/folder:60:2', 'trash/folder:30:3', 'junk/subfolders:60:2', 'trash/subfolders:30:3'], self::plan([
|
||||
['rule' => self::rule(1, ['junk'], 30), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(2, ['junk'], 60), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(4, ['trash'], 30), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => self::rule(3, ['trash'], 30), 'level' => Planner::LEVEL_GROUP],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testARuleAssignedTwiceCountsWithItsBestLevel(): void
|
||||
{
|
||||
$rule = self::rule(1, ['folder:Alt'], 30);
|
||||
$this->assertSame(['folder:Alt/folder:30:1', 'all/all:90:2'], self::plan([
|
||||
['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN],
|
||||
['rule' => self::rule(2, ['all'], 90), 'level' => Planner::LEVEL_GROUP],
|
||||
['rule' => $rule, 'level' => Planner::LEVEL_MAILBOX],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testTheWinnerBringsItsModeAndFlaggedMail(): void
|
||||
{
|
||||
$actions = Planner::actions([
|
||||
['rule' => self::rule(1, ['folder:Alt'], 30, ['mode' => 'delete', 'keepFlagged' => 1]), 'level' => Planner::LEVEL_DOMAIN],
|
||||
]);
|
||||
$this->assertSame([['folder' => 'folder:Alt', 'match' => 'folder', 'days' => 30, 'keep_flagged' => 1, 'mode' => 'delete', 'rule_id' => 1,
|
||||
'rule_name' => 'rule 1']], $actions);
|
||||
$this->assertSame([], Planner::actions([]));
|
||||
}
|
||||
|
||||
public function testDescribeJoinsTheSubfoldersOfTheSameRule(): void
|
||||
{
|
||||
$described = fn(array $candidates): array => array_map(fn(array $action): string => $action['folder'] . '/' . $action['match']
|
||||
. ($action['subfolders'] ? '+' : '') . ':' . $action['rule_id'], Planner::describe(Planner::actions($candidates)));
|
||||
$trashAndJunk = ['rule' => self::rule(1, ['trash', 'junk'], 30), 'level' => Planner::LEVEL_GROUP];
|
||||
$junk = ['rule' => self::rule(2, ['junk'], 90), 'level' => Planner::LEVEL_GROUP];
|
||||
|
||||
$this->assertSame(['junk/folder+:2', 'trash/folder+:1'], $described([$trashAndJunk, $junk]));
|
||||
// a name with * between them may take subfolders: they stay apart
|
||||
$this->assertSame(['junk/folder:2', 'trash/folder:1', 'folder:Junk/*/pattern:3', 'junk/subfolders+:2', 'trash/subfolders+:1'],
|
||||
$described([$trashAndJunk, $junk, ['rule' => self::rule(3, ['folder:Junk/*'], 60), 'level' => Planner::LEVEL_GROUP]]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Retention\Service;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Retention\Model\Rule;
|
||||
use Mailcow\Retention\Repository\RuleRepository;
|
||||
use Mailcow\Retention\Service\Planner;
|
||||
use Mailcow\Retention\Service\RuleService;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RuleServiceTest extends TestCase
|
||||
{
|
||||
private RuleRepository&MockObject $repo;
|
||||
/** @var Rule[] saved rules */
|
||||
private array $saved = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(RuleRepository::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(fn(int $id): ?string => [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 (Rule $rule): int {
|
||||
$rule->id ??= 1;
|
||||
$this->saved[] = clone $rule;
|
||||
return $rule->id;
|
||||
});
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): RuleService
|
||||
{
|
||||
return new RuleService($this->repo, $context ?? FakeContext::admin());
|
||||
}
|
||||
|
||||
private function stored(array $values = []): Rule
|
||||
{
|
||||
$rule = new Rule();
|
||||
$rule->id = 3;
|
||||
$rule->domain = 'firma.de';
|
||||
$rule->name = 'Papierkorb';
|
||||
$rule->folders = ['trash', 'folder:Newsletter'];
|
||||
$rule->groups = [7];
|
||||
foreach ($values as $property => $value) {
|
||||
$rule->$property = $value;
|
||||
}
|
||||
return $rule;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$rule = $this->service()->create([
|
||||
'domain' => 'Firma.de', 'name' => 'Aufräumen', 'folders' => ['', 'trash', 'junk', 'trash'], 'own_folders' => "Projekte/*\n\n /Newsletter/ ",
|
||||
'days' => '30', 'keep_flagged' => ['0', '1'], 'all_mailboxes' => ['0'], 'mailboxes' => ['', 'Azubi@firma.de'], 'groups' => ['7'],
|
||||
]);
|
||||
$this->assertSame(['domain' => 'firma.de', 'name' => 'Aufräumen', 'folders' => ['trash', 'junk', 'folder:Projekte/*', 'folder:Newsletter'],
|
||||
'days' => 30, 'keep_flagged' => 1, 'mode' => 'simulate', 'all_mailboxes' => 0, 'active' => 1, 'mailboxes' => ['azubi@firma.de'], 'groups' => [7]],
|
||||
array_intersect_key($rule->toArray(), array_flip(['domain', 'name', 'folders', 'days', 'keep_flagged', 'mode', 'all_mailboxes', 'active', 'mailboxes', 'groups'])));
|
||||
}
|
||||
|
||||
public function testUpdateKeepsMissingValuesAndReplacesOwnFolders(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$this->assertSame(['trash', 'folder:Archiv/2020'], $this->service()->update(3, ['own_folders' => 'Archiv/2020'])->folders);
|
||||
// the API names all folders in folders, the UI sends the own folders apart
|
||||
$this->assertSame(['junk', 'folder:Alt'], $this->service()->update(3, ['folders' => ['junk', 'folder:Alt']])->folders);
|
||||
$this->assertSame(['sent', 'folder:Neu'], $this->service()->update(3, ['folders' => ['sent'], 'own_folders' => 'Neu'])->folders);
|
||||
// a list keeps commas in names, INBOX is written like Dovecot does
|
||||
$this->assertSame(['sent', 'folder:Kunden, alt', 'folder:INBOX/Alt', 'folder:Inboxen'],
|
||||
$this->service()->update(3, ['own_folders' => ['Kunden, alt', 'inbox/Alt', 'Inboxen']])->folders);
|
||||
$this->assertSame('delete', $this->service()->update(3, ['mode' => 'delete'])->mode);
|
||||
}
|
||||
|
||||
public static function invalidInput(): array
|
||||
{
|
||||
return [
|
||||
'no folder' => [['folders' => '', 'own_folders' => ''], 'retention_folders_invalid'],
|
||||
'unknown folder' => [['folders' => ['spam']], ['retention_folder_invalid', 'spam']],
|
||||
'folder with control character' => [['own_folders' => "a\tb"], ['retention_folder_invalid', "folder:a\tb"]],
|
||||
'folder list with a list' => [['own_folders' => [['a']]], ['retention_folder_invalid', '']],
|
||||
'no days' => [['days' => '0'], ['retention_days_invalid', 36500]],
|
||||
'too many days' => [['days' => '36501'], ['retention_days_invalid', 36500]],
|
||||
'mode' => [['mode' => 'now'], 'retention_mode_invalid'],
|
||||
'name' => [['name' => ''], 'retention_name_invalid'],
|
||||
'mailbox of another domain' => [['mailboxes' => 'andere@tochter.de'], ['retention_member_invalid', 'andere@tochter.de']],
|
||||
'group of another domain' => [['groups' => ['9']], ['retention_member_invalid', '9']],
|
||||
'too many mailboxes' => [['mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))], ['retention_entries_exceeded', 500]],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidInput')]
|
||||
public function testInvalidInput(array $input, string|array $error): void
|
||||
{
|
||||
$this->expectUserError($error, fn() => $this->service()->create($input + ['domain' => 'firma.de', 'name' => 'Test', 'folders' => ['trash'], 'days' => 30]));
|
||||
$this->assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1', 'mailbox_groups' => '1']);
|
||||
$create = fn(string $domain): array => ['domain' => $domain, 'name' => 'Test', 'folders' => ['trash'], 'days' => 30];
|
||||
|
||||
$this->assertSame('Test', $this->service($da)->create($create('firma.de'))->name);
|
||||
$this->assertNotNull($this->service($da)->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->create($create('tochter.de')));
|
||||
$this->assertNull($this->service(FakeContext::domainAdmin([], ['tochter.de'], ['retention' => '1']))->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']))->create($create('firma.de')));
|
||||
$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());
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1']);
|
||||
|
||||
$this->assertSame([], $this->service($da)->groupChoices(['firma.de']));
|
||||
// the form sends no group field: the assignment stays; sending it back unchanged is accepted
|
||||
$this->assertSame([7], $this->service($da)->update(3, ['days' => 60])->groups);
|
||||
$this->assertSame([7], $this->service($da)->update(3, ['groups' => ['7']])->groups);
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']]));
|
||||
$this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de']));
|
||||
}
|
||||
|
||||
public function testEffectiveRulesOfAMailbox(): void
|
||||
{
|
||||
$rule = $this->stored(['folders' => ['trash'], 'days' => 30]);
|
||||
$this->repo->method('candidates')->willReturn(['azubi@firma.de' => [['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN]]]);
|
||||
|
||||
$this->assertSame('trash', $this->service(FakeContext::user('Azubi@firma.de'))->effective('azubi@firma.de')[0]['folder']);
|
||||
// users only see their own mailbox
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('chef@firma.de'))->effective('azubi@firma.de'));
|
||||
$this->assertCount(2, $this->service()->effective('azubi@firma.de'));
|
||||
// domain admins need the ACL as well
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(['azubi@firma.de'], ['firma.de'], []))->effective('azubi@firma.de'));
|
||||
$this->assertCount(2, $this->service(FakeContext::domainAdmin(['azubi@firma.de'], ['firma.de'], ['retention' => '1']))->effective('azubi@firma.de'));
|
||||
}
|
||||
|
||||
public function testThePlanComesInPages(): void
|
||||
{
|
||||
$rule = $this->stored(['folders' => ['all'], 'days' => 30]);
|
||||
$this->repo->method('mailboxesWithRules')->willReturnCallback(fn(string $after, int $limit): array =>
|
||||
array_slice(array_values(array_filter(['a@firma.de', 'b@firma.de', 'c@firma.de'], fn(string $u): bool => $u > $after)), 0, $limit));
|
||||
$this->repo->method('candidates')->willReturnCallback(fn(array $usernames): array =>
|
||||
array_fill_keys(array_diff($usernames, ['b@firma.de']), [['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN]]));
|
||||
|
||||
$first = $this->service(Context::system())->plan('', 2);
|
||||
// b has no rule of its own: not in the plan, but the page goes on after it
|
||||
$this->assertSame(['a@firma.de'], array_keys($first['mailboxes']));
|
||||
$this->assertSame('b@firma.de', $first['next']);
|
||||
$last = $this->service(Context::system())->plan('b@firma.de', 2);
|
||||
$this->assertSame(['c@firma.de'], array_keys($last['mailboxes']));
|
||||
$this->assertNull($last['next']);
|
||||
}
|
||||
|
||||
public function testReportDropsBrokenEntries(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('addLog')->with(
|
||||
[['username' => 'azubi@firma.de', 'rule_id' => 3, 'folder' => 'Trash', 'matched' => 2]],
|
||||
[['rule_id' => 3, 'mailboxes' => 1]],
|
||||
['mailboxes' => 1]
|
||||
)->willReturn(3);
|
||||
$this->assertSame(3, $this->service(Context::system())->report(
|
||||
[['username' => 'azubi@firma.de', 'rule_id' => 3, 'folder' => 'Trash', 'matched' => 2], ['username' => ['x']], 'text'],
|
||||
[['rule_id' => 3, 'mailboxes' => 1], ['rule_id' => 'x']],
|
||||
['mailboxes' => 1]
|
||||
));
|
||||
// the runner sends parts of 200
|
||||
$this->expectUserError(['retention_entries_exceeded', 1000], fn() => $this->service(Context::system())->report(array_fill(0, 1001, []), []));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user