Files
mailcow-dockerized/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php
T

1002 lines
48 KiB
PHP

<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\RestoreRun;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\RestoreRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\Sogo\Service\SogoService;
use PDOException;
/**
* Merges the settings of a mailbox from a snapshot's export into the mailbox as it is now (the runner of a restore job
* posts one mailbox at a time to backup.php); a mailbox deleted since the snapshot is created first. The aliases of a
* domain restore come in parts before the mailboxes (mergeAliases()).
*
* Only what is missing is added, nothing is deleted; `settings` and `password` overwrite. Components and limits come
* from the job in the database, not from the request: the job must be running and hold the mailbox. A domain admin's
* restore keeps to his ACL at the time of the request, to his rights now for groups and to the job's domain (and its
* alias domains). A named lock per job and mailbox makes a retried request wait for the first one.
*
* The components run in one transaction per mailbox, each in a savepoint: a database error drops only that component.
* Group memberships go through the group module afterwards (its own transaction and effects, no group templates),
* then the SOGo view and the Dovecot auth cache of the touched mailboxes are refreshed.
*/
class RestoreService
{
/** Components merged here, in this order */
public const COMPONENTS = ['aliases', 'sender_acl', 'mail_policies', 'retention_rules', 'filters', 'spam', 'bcc', 'syncjobs', 'app_passwords',
'tfa', 'settings', 'password', 'groups'];
/** Aliases per restore_aliases request */
public const MAX_ALIASES = 500;
private const SPECIAL_GOTOS = ['null@localhost', 'spam@localhost', 'ham@localhost'];
/** Attributes `settings` leaves alone: the password component's, the storage format, the IdP's */
private const KEEP_ATTRIBUTES = ['passwd_update', 'mailbox_format', 'attribute_hash'];
/** Attributes a domain admin needs an ACL for */
private const ATTRIBUTE_ACL = ['imap_access' => 'protocol_access', 'pop3_access' => 'protocol_access', 'smtp_access' => 'protocol_access',
'sieve_access' => 'protocol_access', 'eas_access' => 'protocol_access', 'dav_access' => 'protocol_access',
'relayhost' => 'mailbox_relayhost', 'sogo_access' => 'sogo_access', 'tls_enforce_in' => 'tls_policy', 'tls_enforce_out' => 'tls_policy',
'quarantine_notification' => 'quarantine_notification', 'quarantine_category' => 'quarantine_category'];
/** Columns a restored row never takes from the backup */
private const NEW_ROW = ['id', 'created', 'modified'];
/** @var array<string, int> primary domain => aliases it may still get, counted once per request */
private array $aliasesLeft = [];
private const MIB = 1048576;
public function __construct(private RestoreRepository $repo, private JobRepository $jobs, private GroupMembers $groups,
private SogoService $sogo, private DovecotService $dovecot, private LegacyDomain $legacy, private bool $externalGotoDisabled = false)
{
}
/**
* Action `restore`: one mailbox of a running restore job. A mailbox in params.create_mailboxes that does not exist
* is created from the backup first (result key `mailbox`); when that fails, nothing else is merged.
*
* @param mixed $data the mailbox's section of the domain export
* @return array<string, array{added: int, skipped: int, errors: string[]}> per component of the job merged here
* @throws UserError
*/
public function merge(mixed $jobId, mixed $username, mixed $data): array
{
$job = $this->runningRestore($jobId);
$username = strtolower((string)Input::text($username));
if ($username === '' || !in_array($username, array_map('strtolower', $job->mailboxes()), true)) {
throw new AccessDenied();
}
if (!is_array($data) || !is_array($data['mailbox'] ?? null) || strtolower((string)($data['mailbox']['username'] ?? '')) !== $username) {
throw new UserError('backup_export_invalid');
}
return $this->locked($job, $username, fn(): array => $this->mergeMailbox($job, $username, $data));
}
/**
* Action `restore_aliases`: a part of the aliases of the domain (scope domain), before its mailboxes. Missing aliases
* are created with their gotos that are valid now; existing ones are left to the mailboxes' merge.
*
* @return array{aliases: array{added: int, skipped: int, errors: string[]}}
* @throws UserError
*/
public function mergeAliases(mixed $jobId, mixed $aliases): array
{
$job = $this->runningRestore($jobId);
if (($job->params['scope'] ?? '') !== 'domain' || !in_array('aliases', $job->components(), true)) {
throw new AccessDenied();
}
if (!is_array($aliases)) {
throw new UserError('backup_export_invalid');
}
if (count($aliases) > self::MAX_ALIASES) {
throw new UserError('backup_entries_exceeded', [self::MAX_ALIASES]);
}
return $this->locked($job, '', function () use ($job, $aliases): array {
if ($this->permissionRevoked($job)) {
return ['aliases' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked']]];
}
$domain = strtolower((string)$job->domain);
$run = new RestoreRun('', $domain, [], $job->params, [], $this->allowedDomains($job, $domain), $job->requestedBy);
$run->start('aliases');
try {
$this->repo->inTransaction(fn() => $this->aliases($run, array_values(array_filter($aliases, 'is_array'))));
} catch (PDOException $e) {
$run->failed('aliases', 'database error ' . $e->getCode());
}
$this->sogo->refreshStaticView($run->sogoUsers);
return $run->results;
});
}
/** The mailbox's merge under the lock: create it if needed, then the components */
private function mergeMailbox(Job $job, string $username, array $data): array
{
if ($this->permissionRevoked($job)) {
return ['mailbox' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked'], 'created' => 0]];
}
$domain = strtolower((string)$job->domain);
$run = new RestoreRun($username, $domain, $this->mailbox($username) ?? [], $job->params, $data, $this->allowedDomains($job, $domain), $job->requestedBy);
$results = [];
if (in_array($username, array_map('strtolower', $job->createMailboxes()), true)) {
$created = ['added' => 0, 'skipped' => 0, 'errors' => [], 'created' => 0];
if (!empty($run->mailbox)) {
$created['skipped'] = 1;
} else {
$error = $this->createMailbox($run);
if ($error !== null) {
$created['errors'][] = $error;
return ['mailbox' => $created];
}
$created = ['added' => 1, 'skipped' => 0, 'errors' => [], 'created' => 1];
$run = new RestoreRun($username, $domain, $this->mailbox($username) ?? [], $job->params, $data, $run->domains, $job->requestedBy);
$run->created = true;
$run->sogo($username);
$run->auth($username);
}
$results['mailbox'] = $created;
}
if (empty($run->mailbox)) {
throw new UserError('backup_mailbox_not_found', [htmlspecialchars($username)]);
}
$components = array_values(array_intersect(self::COMPONENTS, $job->components()));
$this->repo->inTransaction(function () use ($run, $components): void {
foreach (array_diff($components, ['groups']) as $component) {
$run->start($component);
try {
$this->repo->savepoint($component, fn() => $this->component($run, $component));
} catch (PDOException $e) {
$run->failed($component, 'database error ' . $e->getCode());
}
}
});
if (in_array('groups', $components, true)) {
$run->start('groups');
$this->groups($run);
}
$this->sogo->refreshStaticView($run->sogoUsers);
$this->dovecot->flushAuthCache($run->authUsers);
return $results + $run->results;
}
/**
* Creates a mailbox deleted since the snapshot from its exported row, with the side effects of the legacy mailbox
* add (own alias, quota rows, user ACL, tags) and within the domain's limits
*
* @return ?string null on success, else why it was not created
*/
private function createMailbox(RestoreRun $run): ?string
{
$backup = self::decode($run->data['mailbox']);
$username = $run->username;
[$local, $domain] = array_pad(explode('@', $username, 2), 2, '');
if (!filter_var($username, FILTER_VALIDATE_EMAIL) || $domain !== $run->domain) {
return 'invalid mailbox';
}
$domainRow = $this->repo->rows('domain', ['domain' => $domain])[0] ?? null;
if ($domainRow === null) {
return 'the domain does not exist';
}
$kind = $this->repo->addressKind($username);
// deleting a forwarding mailbox leaves its address as an alias to the forward targets: that one is taken back
$leftover = ($kind === 'alias') ? $this->leftoverAlias($run) : null;
if ($kind !== null && $leftover === null) {
return "the address is in use ($kind)";
}
$quota = max(0, (int)($backup['quota'] ?? 0));
if ((string)($backup['kind'] ?? '') === '') {
if ($this->repo->mailboxCount($domain) >= (int)($domainRow['mailboxes'] ?? 0)) {
return 'mailbox limit of the domain reached';
}
$error = $this->quotaError($run, $quota);
if ($error !== null) {
return "quota: $error";
}
}
$this->repo->inTransaction(function () use ($run, $backup, $username, $local, $domain, $leftover): void {
$this->repo->insert('mailbox', ['username' => $username, 'local_part' => $local, 'domain' => $domain]
+ array_diff_key($backup, array_flip(['modified', 'tags'])));
$this->repo->resetQuota($username);
if ($leftover !== null) {
$this->repo->update('alias', ['id' => (int)$leftover['id']], ['goto' => $username, 'active' => (int)($backup['active'] ?? 1)]);
} elseif (empty($this->repo->rows('alias', ['address' => $username]))) {
$this->repo->insert('alias', ['address' => $username, 'goto' => $username, 'domain' => $domain,
'active' => (int)($backup['active'] ?? 1)]);
}
// the user ACL of the backup (a domain admin's restore: defaults for what he may not grant), else the defaults
if (empty($this->repo->rows('user_acl', ['username' => $username]))) {
$acl = is_array($run->data['user_acl'] ?? null) ? self::decode($run->data['user_acl']) : [];
$this->repo->insert('user_acl', ['username' => $username] + array_diff_key($acl, array_flip($this->lockedUserAcl($run))));
}
foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '',
(array)($run->data['mailbox']['tags'] ?? [])), 'strlen')) as $tag) {
$this->repo->insert('tags_mailbox', ['username' => $username, 'tag_name' => $tag]);
}
});
return null;
}
/**
* A domain admin's restore stops when he lost what it was requested with: his account, the domain, or an ACL he had
* then (columns of da_acl)
*/
private function permissionRevoked(Job $job): bool
{
if (($job->params['requested_role'] ?? '') === 'admin') {
return false;
}
$username = $job->requestedBy;
if (empty($this->repo->rows('admin', ['username' => $username, 'superadmin' => 0, 'active' => 1]))
|| empty($this->repo->rows('domain_admins', ['username' => $username, 'domain' => strtolower($job->domain), 'active' => 1]))) {
return true;
}
$acl = $this->repo->rows('da_acl', ['username' => $username])[0] ?? [];
foreach ((array)($job->params['acl'] ?? []) as $name => $flag) {
if ((int)$flag === 1 && array_key_exists((string)$name, $acl) && (int)$acl[$name] !== 1) {
return true;
}
}
return false;
}
/**
* User ACL columns a domain admin may not grant: those of da_acl he does not have himself (legacy acl('get', 'user'));
* none for admins
*
* @return string[]
*/
private function lockedUserAcl(RestoreRun $run): array
{
if ($run->byAdmin()) {
return [];
}
return array_values(array_filter(array_diff($this->repo->columns('da_acl'), ['username']), fn(string $column): bool => !$run->may($column)));
}
/** @throws UserError backup_job_busy while another request merges the same mailbox of the job */
private function locked(Job $job, string $username, callable $work): array
{
$result = $this->repo->locked($job->id . ':' . $username, $work);
return is_array($result) ? $result : throw new UserError('backup_job_busy');
}
/** @throws UserError backup_job_not_running */
private function runningRestore(mixed $jobId): Job
{
$id = Input::intInRange($jobId, 1, PHP_INT_MAX);
$job = ($id === null) ? null : $this->jobs->find($id);
if ($job === null || $job->type !== 'restore' || $job->state !== 'running') {
throw new UserError('backup_job_not_running');
}
return $job;
}
/** @return string[] domains whose objects the restore may change: [] = all (admins), else the job's domain and its alias domains */
private function allowedDomains(Job $job, string $domain): array
{
return (($job->params['requested_role'] ?? '') === 'admin') ? [] : array_values(array_unique(array_merge([$domain], $this->repo->aliasDomainsOf($domain))));
}
private function component(RestoreRun $run, string $component): void
{
match ($component) {
'aliases' => $this->aliases($run, []),
'sender_acl' => $this->senderAcl($run),
'mail_policies' => $this->policies($run, 'mail_policies', 'mail_policies', 'mail_policy', 'mail_policy_member', 'policy_id'),
'retention_rules' => $this->policies($run, 'retention_rules', 'retention', 'retention_rule', 'retention_rule_member', 'rule_id'),
'filters' => $this->filters($run),
'spam' => $this->spam($run),
'bcc' => $this->bcc($run),
'syncjobs' => $this->syncJobs($run),
'app_passwords' => $this->appPasswords($run),
'tfa' => $this->tfa($run),
'settings' => $this->settings($run),
'password' => $this->password($run),
};
}
/**
* Aliases whose goto named the mailbox, or a part of the domain's aliases: a missing alias is created with its gotos
* that are valid now, an existing one gets the mailbox back into its goto. Group addresses belong to their group
* and are left alone.
*/
private function aliases(RestoreRun $run, array $domainAliases): void
{
if ($run->created) {
$this->ownAlias($run);
}
$seen = [];
$created = array_map('strtolower', (array)($run->params['create_mailboxes'] ?? []));
foreach (array_merge($domainAliases, $run->rows('aliases')) as $row) {
$row = self::decode($row);
$address = strtolower(trim((string)($row['address'] ?? '')));
$gotos = ExportService::gotos(strtolower((string)($row['goto'] ?? '')));
// the own alias of a mailbox comes with the mailbox (ownAlias())
if ($address === '' || isset($seen[$address]) || $gotos === [$address] || in_array($address, $created, true)) {
continue;
}
$seen[$address] = true;
if (($row['group_id'] ?? null) !== null) {
$run->skipped('aliases');
continue;
}
$domain = self::domainOf($address);
if (!$run->mayChangeDomain($domain)) {
$run->error('aliases', "$address: outside the domain of the restore");
continue;
}
$current = $this->repo->rows('alias', ['address' => $address])[0] ?? null;
// the own alias of another mailbox is its forwarding, it stays as its owner set it
if ($current !== null && $this->mailbox($address) !== null) {
$run->skipped('aliases');
continue;
}
if ($current !== null) {
$this->aliasGoto($run, $current, $gotos);
continue;
}
// naming itself: the own alias of a deleted mailbox (forwarding with a copy), as an alias it would block the mailbox
if (in_array($address, $gotos, true)) {
$run->skipped('aliases');
continue;
}
$this->createAlias($run, $row, $address, $domain, $gotos);
}
}
/**
* The alias at the address of a deleted mailbox when it is what the deletion left of the mailbox's forwarding: the
* forward targets of the backup's own alias, without the mailbox. Any other alias (e.g. one added since) blocks.
*/
private function leftoverAlias(RestoreRun $run): ?array
{
$backup = is_array($run->data['own_alias'] ?? null) ? self::decode($run->data['own_alias']) : [];
$forward = array_unique(array_diff(ExportService::gotos(strtolower((string)($backup['goto'] ?? ''))), [$run->username]));
$current = $this->repo->rows('alias', ['address' => $run->username])[0] ?? null;
if ($current === null || empty($forward) || ($current['group_id'] ?? null) !== null) {
return null;
}
$now = array_unique(ExportService::gotos(strtolower((string)$current['goto'])));
sort($forward);
sort($now);
return ($now === $forward) ? $current : null;
}
/**
* The own alias of a mailbox the restore created gets the forwarding of the backup: the gotos that are valid now, with
* the mailbox itself when it kept a copy. Without a valid one it keeps delivering to the mailbox.
*/
private function ownAlias(RestoreRun $run): void
{
$backup = is_array($run->data['own_alias'] ?? null) ? self::decode($run->data['own_alias']) : [];
$gotos = ExportService::gotos(strtolower((string)($backup['goto'] ?? '')));
$forward = array_values(array_diff($gotos, [$run->username]));
$current = $this->repo->rows('alias', ['address' => $run->username])[0] ?? null;
if (empty($forward) || $current === null) {
return;
}
$valid = $this->validGotos($run, $run->username, $forward);
if (empty($valid)) {
return;
}
$goto = in_array($run->username, $gotos, true) ? array_merge([$run->username], $valid) : $valid;
$this->repo->update('alias', ['id' => (int)$current['id']], ['goto' => implode(',', $goto)]);
$run->added('aliases');
}
/** The mailbox goes back into the goto of an existing alias (never into a group address or null/spam/ham) */
private function aliasGoto(RestoreRun $run, array $current, array $backupGotos): void
{
$gotos = ExportService::gotos((string)$current['goto']);
$lower = array_map('strtolower', $gotos);
if ($run->username === '' || !in_array($run->username, $backupGotos, true) || in_array($run->username, $lower, true) || ($current['group_id'] ?? null) !== null
|| !empty(array_intersect($lower, self::SPECIAL_GOTOS))) {
$run->skipped('aliases');
return;
}
$gotos[] = $run->username;
$this->repo->update('alias', ['id' => (int)$current['id']], ['goto' => implode(',', $gotos)]);
$run->added('aliases');
$run->sogo($run->username);
}
/** Creates a missing alias with the checks of the legacy alias form */
private function createAlias(RestoreRun $run, array $row, string $address, string $domain, array $backupGotos): void
{
if (!self::validAddress($address)) {
$run->error('aliases', "$address: invalid address");
return;
}
$primary = $this->repo->primaryDomain($domain);
if ($primary === null) {
$run->error('aliases', "$address: domain does not exist");
return;
}
$kind = str_starts_with($address, '@') ? null : $this->repo->addressKind($address);
if ($kind !== null) {
$run->error('aliases', "$address: address is in use ($kind)");
return;
}
$this->aliasesLeft[$primary] ??= $this->repo->aliasesLeft($primary);
if ($this->aliasesLeft[$primary] <= 0) {
$run->error('aliases', "$address: alias limit of the domain reached");
return;
}
$gotos = $this->validGotos($run, $address, $backupGotos);
if (empty($gotos)) {
$run->error('aliases', "$address: no valid goto");
return;
}
$values = array_diff_key($row, array_flip([...self::NEW_ROW, 'group_id']));
$this->repo->insert('alias', ['address' => $address, 'goto' => implode(',', $gotos), 'domain' => $domain] + $values);
$this->aliasesLeft[$primary]--;
$run->added('aliases');
$run->sogo(...array_filter($gotos, fn(string $goto): bool => $this->repo->addressKind($goto) === 'mailbox'));
}
/**
* Gotos of a new alias that are valid now: null/spam/ham alone, local addresses that exist or that the restore creates
* (no resources), external
* ones unless external gotos are disabled (globally or by the domain admin's ACL). Dropped ones are reported.
*
* @return string[]
*/
private function validGotos(RestoreRun $run, string $address, array $gotos): array
{
$special = array_intersect($gotos, self::SPECIAL_GOTOS);
if (!empty($special)) {
return (count($gotos) === 1) ? $gotos : [];
}
$valid = [];
foreach (array_unique($gotos) as $goto) {
if ($goto === $address || !filter_var($goto, FILTER_VALIDATE_EMAIL)) {
$run->error('aliases', "$address: goto $goto dropped (invalid)");
continue;
}
if ($this->repo->isLocalDomain(self::domainOf($goto))) {
// mailboxes the restore creates count as existing (the domain's aliases come before its mailboxes)
if (!in_array($this->repo->addressKind($goto), ['mailbox', 'alias'], true)
&& !in_array($goto, array_map('strtolower', (array)($run->params['create_mailboxes'] ?? [])), true)) {
$run->error('aliases', "$address: goto $goto dropped (no mailbox or alias)");
continue;
}
} elseif ($this->externalGotoDisabled || !$run->may('alias_external_goto')) {
$run->error('aliases', "$address: goto $goto dropped (external gotos are not allowed)");
continue;
}
$valid[] = $goto;
}
return $valid;
}
/**
* Send-as rights of the mailbox and rights of others to send as it, for addresses and mailboxes that exist now.
* Rows of groups are rebuilt by the group module and left out.
*/
private function senderAcl(RestoreRun $run): void
{
foreach ($run->rows('sender_acl') as $row) {
if (($row['group_id'] ?? null) !== null) {
continue;
}
$sendAs = strtolower(trim((string)($row['send_as'] ?? '')));
$external = (int)($row['external'] ?? 0);
$error = $this->sendAsError($run, $sendAs, $external);
if ($error !== null) {
$run->error('sender_acl', "$sendAs: $error");
} elseif (!empty($this->repo->rows('sender_acl', ['logged_in_as' => $run->username, 'send_as' => $sendAs]))) {
$run->skipped('sender_acl');
} else {
$this->repo->insert('sender_acl', ['logged_in_as' => $run->username, 'send_as' => $sendAs, 'external' => $external]);
$run->added('sender_acl');
$run->sogo($run->username);
}
}
foreach ($run->rows('sender_acl_granted') as $row) {
if (($row['group_id'] ?? null) !== null) {
continue;
}
$login = strtolower(trim((string)($row['logged_in_as'] ?? '')));
if (empty($this->repo->rows('mailbox', ['username' => $login]))) {
$run->error('sender_acl', "$login: mailbox does not exist");
} elseif (!$run->mayChangeDomain(self::domainOf($login))) {
$run->error('sender_acl', "$login: outside the domain of the restore");
} elseif (!empty($this->repo->rows('sender_acl', ['logged_in_as' => $login, 'send_as' => $run->username]))) {
$run->skipped('sender_acl');
} else {
$this->repo->insert('sender_acl', ['logged_in_as' => $login, 'send_as' => $run->username, 'external' => 0]);
$run->added('sender_acl');
$run->sogo($login);
}
}
}
/** Why the mailbox may not send as this address (anymore), null when it may */
private function sendAsError(RestoreRun $run, string $sendAs, int $external): ?string
{
if ($sendAs === '*') {
return $run->byAdmin() ? null : 'only admins grant *';
}
$domain = self::domainOf($sendAs);
if (!self::validAddress($sendAs)) {
return 'invalid address';
}
if ($external === 1) {
if (!$run->may('extend_sender_acl')) {
return 'not allowed';
}
return $this->repo->domainExists($domain) ? 'domain is local now' : null;
}
if (!$run->mayChangeDomain($domain)) {
return 'outside the domain of the restore';
}
if (str_starts_with($sendAs, '@')) {
return $this->repo->domainExists($domain) ? null : 'domain does not exist';
}
return in_array($this->repo->addressKind($sendAs), ['mailbox', 'alias'], true) ? null : 'address does not exist';
}
/**
* Assignments to mail policies or retention rules of the mailbox's domain that exist now (by id, else by name)
*
* @param string $key the list in the backup and the component
* @param string $acl the domain admin ACL it needs
* @param string $member the member table and its $column with the id
*/
private function policies(RestoreRun $run, string $key, string $acl, string $table, string $member, string $column): void
{
$entries = $run->rows($key);
if (!empty($entries) && !$run->may($acl)) {
$run->error($key, 'not allowed');
return;
}
foreach ($entries as $entry) {
$name = (string)($entry['name'] ?? '');
$target = $this->repo->rows($table, ['id' => (int)($entry['id'] ?? 0), 'domain' => $run->domain])[0]
?? $this->repo->rows($table, ['domain' => $run->domain, 'name' => $name])[0] ?? null;
if ($target === null) {
$run->error($key, "$name: does not exist");
} elseif (!empty($this->repo->rows($member, [$column => (int)$target['id'], 'username' => $run->username]))) {
$run->skipped($key);
} else {
$this->repo->insert($member, [$column => (int)$target['id'], 'username' => $run->username]);
$run->added($key);
}
}
}
/**
* mailcow filters (sieve_filters) missing with the same type and script; a restored filter is active only if it
* was and the mailbox has no active filter of its type now.
*/
private function filters(RestoreRun $run): void
{
foreach ($run->rows('sieve_filters') as $row) {
$type = (string)($row['filter_type'] ?? '');
$script = (string)($row['script_data'] ?? '');
$description = (string)($row['script_desc'] ?? '');
if (!in_array($type, ['prefilter', 'postfilter'], true) || trim($script) === '') {
$run->error('filters', "$description: invalid filter");
continue;
}
$current = $this->repo->rows('sieve_filters', ['username' => $run->username, 'filter_type' => $type]);
if (!empty(array_filter($current, fn(array $filter): bool => $filter['script_data'] === $script))) {
$run->skipped('filters');
continue;
}
$active = ($row['script_name'] ?? '') === 'active' && empty(array_filter($current, fn(array $filter): bool => $filter['script_name'] === 'active'));
$this->repo->insert('sieve_filters', ['username' => $run->username, 'script_name' => $active ? 'active' : 'inactive']
+ array_diff_key($row, array_flip(self::NEW_ROW)));
$run->added('filters');
}
}
/**
* Spam settings (filterconf): missing allow/deny list entries and spam score levels (an existing level stays);
* spam aliases that are still valid, whose address is free and in a domain of the mailbox.
*/
private function spam(RestoreRun $run): void
{
foreach ($run->rows('filterconf') as $row) {
$option = (string)($row['option'] ?? '');
$value = (string)($row['value'] ?? '');
$current = $this->repo->rows('filterconf', ['object' => $run->username]);
$present = match ($option) {
'whitelist_from', 'blacklist_from' => array_filter($current, fn(array $c): bool => in_array($c['option'], ['whitelist_from', 'blacklist_from'], true)
&& strtolower((string)$c['value']) === strtolower($value)),
'lowspamlevel', 'highspamlevel' => array_filter($current, fn(array $c): bool => $c['option'] === $option),
default => array_filter($current, fn(array $c): bool => $c['option'] === $option && (string)$c['value'] === $value),
};
if ($option === '' || !empty($present)) {
$run->skipped('spam');
continue;
}
$this->repo->insert('filterconf', ['object' => $run->username, 'option' => $option, 'value' => $value]);
$run->added('spam');
}
foreach ($run->rows('spamalias') as $row) {
$address = strtolower(trim((string)($row['address'] ?? '')));
if ((int)($row['permanent'] ?? 0) !== 1 && (int)($row['validity'] ?? 0) < time()) {
$run->skipped('spam');
continue;
}
$current = $this->repo->rows('spamalias', ['address' => $address])[0] ?? null;
if ($current !== null && strtolower((string)$current['goto']) === $run->username) {
$run->skipped('spam');
continue;
}
// like the legacy form: spam aliases in the mailbox's domain and its alias domains only
if (!filter_var($address, FILTER_VALIDATE_EMAIL) || !in_array(self::domainOf($address),
array_merge([$run->domain], $this->repo->aliasDomainsOf($run->domain)), true)) {
$run->error('spam', "$address: not a domain of the mailbox");
continue;
}
$kind = $this->repo->addressKind($address);
if ($kind !== null) {
$run->error('spam', "$address: address is in use ($kind)");
continue;
}
$this->repo->insert('spamalias', ['address' => $address, 'goto' => $run->username] + array_diff_key($row, array_flip(self::NEW_ROW)));
$run->added('spam');
}
}
/** BCC maps of the mailbox: one per type */
private function bcc(RestoreRun $run): void
{
foreach ($run->rows('bcc_maps') as $row) {
$type = (string)($row['type'] ?? '');
$destination = (string)($row['bcc_dest'] ?? '');
if (!in_array($type, ['sender', 'rcpt'], true) || !filter_var($destination, FILTER_VALIDATE_EMAIL)) {
$run->error('bcc', "$destination: invalid BCC map");
continue;
}
if (!empty($this->repo->rows('bcc_maps', ['local_dest' => $run->username, 'type' => $type]))) {
$run->skipped('bcc');
continue;
}
$this->repo->insert('bcc_maps', ['local_dest' => $run->username, 'domain' => $run->domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
$run->added('bcc');
}
}
/**
* Sync jobs missing by user1, host and port. The source must exist now (same id with that host and port, else
* another source the mailbox may use). Restored jobs are inactive: an old migration must not start on its own.
*/
private function syncJobs(RestoreRun $run): void
{
$current = array_map(fn(array $job): string => self::syncKey((string)$job['user1'], (string)$job['host1'], (int)$job['port1']),
$this->repo->syncJobsOf($run->username));
foreach ($run->rows('imapsync') as $row) {
$row = self::decode($row);
$user1 = (string)($row['user1'] ?? '');
$host = (string)($row['source_host1'] ?? '');
$port = (int)($row['source_port1'] ?? 0);
if (in_array(self::syncKey($user1, $host, $port), $current, true)) {
$run->skipped('syncjobs');
continue;
}
// the backup's source when the mailbox may still use it, else another one it may use
$usable = $this->repo->syncSourcesFor($host, $port, $run->username, $run->domain);
$sourceId = in_array((int)($row['source_id'] ?? 0), $usable, true) ? (int)$row['source_id'] : ($usable[0] ?? null);
if ($sourceId === null || $user1 === '') {
$run->error('syncjobs', "$user1 ($host:$port): sync source does not exist");
continue;
}
$values = array_diff_key($row, array_flip([...self::NEW_ROW, 'is_running', 'returned_text', 'last_run', 'success', 'exit_status', 'prio']));
$this->repo->insert('imapsync', ['user2' => $run->username, 'source_id' => $sourceId, 'active' => 0] + $values);
$current[] = self::syncKey($user1, $host, $port);
$run->added('syncjobs');
}
}
/** App passwords missing by name */
private function appPasswords(RestoreRun $run): void
{
foreach ($run->rows('app_passwd') as $row) {
$name = (string)($row['name'] ?? '');
if ($name === '' || (string)($row['password'] ?? '') === '') {
$run->error('app_passwords', "$name: invalid app password");
continue;
}
if (!empty($this->repo->rows('app_passwd', ['mailbox' => $run->username, 'name' => $name]))) {
$run->skipped('app_passwords');
continue;
}
$this->repo->insert('app_passwd', ['mailbox' => $run->username, 'domain' => $run->domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
$run->added('app_passwords');
$run->auth($run->username);
}
}
/**
* 2FA entries missing (same key_id, keyHandle or secret = present) and FIDO2 keys of the passwordless login
* missing by credential id.
*/
private function tfa(RestoreRun $run): void
{
foreach ($run->rows('tfa') as $row) {
$current = $this->repo->rows('tfa', ['username' => $run->username]);
$present = array_filter($current, fn(array $tfa): bool => (string)$tfa['key_id'] === (string)($row['key_id'] ?? '')
|| (($row['keyHandle'] ?? '') !== '' && ($row['keyHandle'] ?? null) !== null && $tfa['keyHandle'] === $row['keyHandle'])
|| (($row['secret'] ?? '') !== '' && ($row['secret'] ?? null) !== null && $tfa['secret'] === $row['secret']));
if (!empty($present)) {
$run->skipped('tfa');
continue;
}
$this->repo->insert('tfa', ['username' => $run->username] + array_diff_key($row, array_flip(self::NEW_ROW)));
$run->added('tfa');
}
foreach ($run->rows('fido2') as $row) {
$row = self::decode($row);
$credentialId = (string)($row['credentialId'] ?? '');
if ($credentialId === '') {
$run->error('tfa', 'FIDO2 key without credential id');
continue;
}
$present = array_filter($this->repo->rows('fido2', ['username' => $run->username]), fn(array $key): bool => $key['credentialId'] === $credentialId);
if (!empty($present)) {
$run->skipped('tfa');
continue;
}
$this->repo->insert('fido2', ['username' => $run->username] + array_diff_key($row, array_flip(self::NEW_ROW)));
$run->added('tfa');
}
}
/**
* Overwrites quota (within the domain's limits), attributes (protocols, relayhost, ...), custom attributes, user
* ACL, Pushover settings and the rate limit; adds missing tags. Counts changed values as added, equal ones as skipped.
*/
private function settings(RestoreRun $run): void
{
$backup = self::decode($run->data['mailbox']);
$mailbox = $this->mailbox($run->username) ?? $run->mailbox;
$changes = [];
if (isset($backup['quota']) && is_numeric($backup['quota'])) {
$quota = (int)$backup['quota'];
$error = ($quota === (int)$mailbox['quota']) ? null : $this->quotaError($run, $quota);
if ($error !== null) {
$run->error('settings', "quota: $error");
} elseif ($quota === (int)$mailbox['quota']) {
$run->skipped('settings');
} else {
$changes['quota'] = $quota;
$run->added('settings');
}
}
$attributes = self::json($mailbox['attributes'] ?? null);
$changed = false;
foreach (self::json($backup['attributes'] ?? null) as $key => $value) {
if (in_array($key, self::KEEP_ATTRIBUTES, true)) {
continue;
}
if (array_key_exists($key, $attributes) && $attributes[$key] === $value) {
$run->skipped('settings');
continue;
}
if (isset(self::ATTRIBUTE_ACL[$key]) && !$run->may(self::ATTRIBUTE_ACL[$key])) {
$run->error('settings', "$key: not allowed");
continue;
}
if ($key === 'relayhost' && (string)$value !== '0' && (string)$value !== '' && empty($this->repo->rows('relayhosts', ['id' => (int)$value]))) {
$run->error('settings', 'relayhost: does not exist');
continue;
}
$attributes[$key] = $value;
$changed = true;
$run->added('settings');
}
if ($changed) {
$changes['attributes'] = self::jsonText($attributes);
}
if (array_key_exists('custom_attributes', $backup)) {
$custom = self::json($mailbox['custom_attributes'] ?? null);
$restored = array_merge($custom, self::json($backup['custom_attributes']));
if ($restored !== $custom) {
$changes['custom_attributes'] = self::jsonText($restored);
$run->added('settings');
}
}
if (!empty($changes)) {
$this->repo->update('mailbox', ['username' => $run->username], $changes);
$run->auth($run->username);
$run->sogo($run->username);
}
$this->tags($run, (array)($run->data['mailbox']['tags'] ?? []));
if (is_array($run->data['user_acl'] ?? null)) {
$this->overwriteRow($run, 'user_acl', self::decode($run->data['user_acl']));
}
if (is_array($run->data['pushover'] ?? null)) {
if ($run->may('pushover')) {
$this->overwriteRow($run, 'pushover', self::decode($run->data['pushover']));
} else {
$run->error('settings', 'pushover: not allowed');
}
}
$rateLimit = $run->data['ratelimit'] ?? null;
if (is_string($rateLimit) && preg_match('/^\d+ \/ 1[smhd]$/', $rateLimit)) {
if (($this->legacy->rateLimits([$run->username])[$run->username] ?? null) === $rateLimit) {
$run->skipped('settings');
} elseif ($run->may('ratelimit')) {
$this->legacy->setRateLimit($run->username, $rateLimit);
$run->added('settings');
} else {
$run->error('settings', 'rate limit: not allowed');
}
}
}
/** Why the quota cannot be restored, null when it can (domain limits like the legacy mailbox form) */
private function quotaError(RestoreRun $run, int $quota): ?string
{
if ($quota < 0) {
return 'invalid';
}
if ($quota === 0) {
return $run->may('unlimited_quota') ? null : 'unlimited quota not allowed';
}
$domain = $this->repo->rows('domain', ['domain' => $run->domain])[0] ?? null;
if ($domain === null) {
return 'domain does not exist';
}
if ($quota > (int)$domain['maxquota'] * self::MIB) {
return 'exceeds the maximum quota of a mailbox in the domain';
}
if ($quota > (int)$domain['quota'] * self::MIB - $this->repo->quotaInUse($run->domain, $run->username)) {
return 'exceeds the quota left in the domain';
}
return null;
}
/** @param array $tags tags of the backup: missing ones are added */
private function tags(RestoreRun $run, array $tags): void
{
$current = array_map(fn(array $row): string => (string)$row['tag_name'], $this->repo->rows('tags_mailbox', ['username' => $run->username]));
foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', $tags), 'strlen')) as $tag) {
if (in_array($tag, $current, true)) {
$run->skipped('settings');
continue;
}
$this->repo->insert('tags_mailbox', ['username' => $run->username, 'tag_name' => $tag]);
$run->added('settings');
}
}
/** Overwrites the mailbox's row of user_acl or pushover with the backup's values (creates it if missing) */
private function overwriteRow(RestoreRun $run, string $table, array $backup): void
{
$values = array_diff_key($backup, ['username' => true]);
$current = $this->repo->rows($table, ['username' => $run->username])[0] ?? null;
if ($table === 'user_acl') {
// a domain admin's restore keeps what he may not grant
foreach ($this->lockedUserAcl($run) as $column) {
if (array_key_exists($column, $values) && $current !== null && array_key_exists($column, $current) && (string)$current[$column] !== (string)$values[$column]) {
$run->error('settings', "user ACL $column: not allowed");
}
unset($values[$column]);
}
}
if ($current === null) {
$this->repo->insert($table, ['username' => $run->username] + $values);
$run->added('settings');
return;
}
$changed = array_filter($values, fn(mixed $value, string $column): bool => array_key_exists($column, $current)
&& (string)$current[$column] !== (string)$value, ARRAY_FILTER_USE_BOTH);
$run->skipped('settings', count(array_intersect_key($values, $current)) - count($changed));
if (!empty($changed)) {
$this->repo->update($table, ['username' => $run->username], $changed);
$run->added('settings', count($changed));
}
}
/** Overwrites the password hash (mailboxes that log in with mailcow passwords only) */
private function password(RestoreRun $run): void
{
$backup = $run->data['mailbox'];
$hash = (string)($backup['password'] ?? '');
$mailbox = $this->mailbox($run->username) ?? $run->mailbox;
if ($hash === '') {
$run->error('password', 'no password in the backup');
} elseif (($mailbox['authsource'] ?? 'mailcow') !== 'mailcow') {
$run->error('password', 'the mailbox logs in through an identity provider');
} elseif (hash_equals((string)$mailbox['password'], $hash)) {
$run->skipped('password');
} else {
$attributes = self::json($mailbox['attributes'] ?? null);
$attributes['passwd_update'] = self::json($backup['attributes'] ?? null)['passwd_update'] ?? date('Y-m-d H:i:s');
$this->repo->update('mailbox', ['username' => $run->username], ['password' => $hash, 'attributes' => self::jsonText($attributes)]);
$run->added('password');
$run->auth($run->username);
}
}
/**
* Manual memberships in groups of the mailbox's domain that exist now (by id, else by name), through the group
* module as the requester (a domain admin with his rights now: groups read-only for him are not allowed).
* IdP memberships are the IdP sync's.
*/
private function groups(RestoreRun $run): void
{
$context = $run->requester();
foreach ($run->rows('groups') as $entry) {
$name = (string)($entry['name'] ?? '');
if (($entry['source'] ?? 'manual') === 'idp') {
$run->skipped('groups');
continue;
}
$group = $this->repo->rows('mailbox_group', ['id' => (int)($entry['id'] ?? 0), 'domain' => $run->domain])[0]
?? $this->repo->rows('mailbox_group', ['domain' => $run->domain, 'name' => $name])[0] ?? null;
if ($group === null) {
$run->error('groups', "$name: does not exist");
continue;
}
if (!empty($this->repo->rows('mailbox_group_member', ['group_id' => (int)$group['id'], 'username' => $run->username]))) {
$run->skipped('groups');
continue;
}
try {
$this->groups->add($context, (int)$group['id'], $run->username);
$run->added('groups');
} catch (AccessDenied) {
$run->error('groups', "$name: not allowed");
} catch (UserError $e) {
$run->error('groups', "$name: " . $e->key);
} catch (PDOException $e) {
$run->error('groups', "$name: database error " . $e->getCode());
}
}
}
private function mailbox(string $username): ?array
{
return $this->repo->rows('mailbox', ['username' => $username])[0] ?? null;
}
private static function syncKey(string $user1, string $host, int $port): string
{
return strtolower($user1) . '|' . strtolower($host) . '|' . $port;
}
/** user@domain or @domain */
private static function validAddress(string $address): bool
{
return str_starts_with($address, '@') ? (bool)preg_match('/^@[a-z0-9.-]+\.[a-z0-9-]+$/i', $address)
: filter_var($address, FILTER_VALIDATE_EMAIL) !== false;
}
private static function domainOf(string $address): string
{
return strtolower(substr((string)strrchr($address, '@'), 1));
}
/** Values of a backup row: {"base64": ...} back to binary, other lists as JSON text */
public static function decode(array $row): array
{
foreach ($row as $column => $value) {
if (is_array($value)) {
$row[$column] = (array_keys($value) === ['base64']) ? base64_decode((string)$value['base64'], true) : json_encode($value);
}
}
return $row;
}
/** A JSON column as array ({} when empty or broken) */
private static function json(mixed $value): array
{
$decoded = is_string($value) ? json_decode($value, true) : $value;
return is_array($decoded) ? $decoded : [];
}
private static function jsonText(array $value): string
{
return (string)json_encode($value ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}