Compare commits

...
Author SHA1 Message Date
FreddleSpl0it 6cf0390d58 [Web][Restic][Dovecot][Dockerapi] Add restic backups with restore in the UI
- Scheduled, encrypted backups to S3, SFTP, REST or a local folder (new restic-mailcow container)
- Restore of mailboxes and whole domains in the UI, also deleted ones, for admins and domain admins
- Scope set by RESTIC_BACKUP_MODE in mailcow.conf (all, data, config)
- Optional backup before updates (update.sh), helper-scripts/restic.sh for a full restore
2026-11-08 22:56:27 +01:00
66 changed files with 15214 additions and 97 deletions

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# _modules/scripts/backup.sh
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
# Backup with restic before the update when it is set up (target, confirmed password, initialized repository):
# interactive: asked for; --backup: without asking; --force: none (with --backup: without asking, a failure or a backup
# that cannot run stops the update; only "not set up" continues)
backup_failed() {
echo -e "\e[31m${1}\e[0m"
if [ "$FORCE" ]; then
echo "The update was started with --force --backup, it stops here. Nothing was changed."
exit 1
fi
read -r -p "Continue the update without a backup? [y/N] " backup_response
if [[ ! "${backup_response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "OK, exiting."
exit 1
fi
}
# Whether to back up (asked with the other questions); the backup itself runs before the first change of the checkout
backup_decide() {
local output rc
RUN_BACKUP=
if [ "$FORCE" ] && [ ! "$BACKUP" ]; then
return 0
fi
if [[ -z "$($COMPOSE_COMMAND ps -q restic-mailcow 2> /dev/null)" ]]; then
output="the backup container does not run"
rc=1
else
# exit code 0: set up, 2: not set up, others: error (e.g. credentials this container cannot read)
output=$($COMPOSE_COMMAND exec -T restic-mailcow python3 /usr/local/bin/restic_runner.py --configured 2>&1 < /dev/null)
rc=$?
fi
if [[ ${rc} -eq 2 ]]; then
[ "$BACKUP" ] && echo -e "\e[33mNo backup before the update: ${output}.\e[0m"
return 0
elif [[ ${rc} -ne 0 ]]; then
if [ "$BACKUP" ]; then
backup_failed "No backup before the update possible: ${output}"
elif [[ -n "$($COMPOSE_COMMAND ps -q restic-mailcow 2> /dev/null)" ]]; then
echo -e "\e[33mNo backup before the update: ${output}\e[0m"
fi
return 0
fi
if [ ! "$BACKUP" ]; then
read -r -p "Create a backup before the update? [Y/n] " backup_response
if [[ "${backup_response}" =~ ^([nN][oO]|[nN])$ ]]; then
echo "OK, no backup."
return 0
fi
fi
RUN_BACKUP=y
}
# The backup chosen by backup_decide, once
backup_run() {
if [ ! "$RUN_BACKUP" ]; then
return 0
fi
RUN_BACKUP=
if [[ "${RESTIC_BACKUP_MODE}" =~ ^(data|config)$ ]]; then
echo -e "\e[33mRESTIC_BACKUP_MODE=${RESTIC_BACKUP_MODE}: the backup holds no complete mailcow directory, a restore does not bring back this mailcow version.\e[0m"
fi
echo -e "\e[32mCreating a backup before the update...\e[0m"
if ! $COMPOSE_COMMAND exec -T restic-mailcow python3 /usr/local/bin/restic_runner.py --backup < /dev/null; then
backup_failed "The backup before the update failed, see the output above and the backup jobs in the mailcow UI."
fi
}
+15
View File
@@ -61,6 +61,8 @@ adapt_new_options() {
"ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL"
"NGINX_MAX_BODY_SIZE"
"RESTIC_LOCAL_PATH"
"RESTIC_BACKUP_MODE"
)
sed -i --follow-symlinks '$a\' mailcow.conf
@@ -315,6 +317,19 @@ adapt_new_options() {
echo '# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.' >> mailcow.conf
echo 'NGINX_MAX_BODY_SIZE=128m' >> mailcow.conf
;;
RESTIC_LOCAL_PATH)
echo '# Folder on the host for backups to the target "local" (backup settings in the mailcow UI), as an absolute path,' >> mailcow.conf
echo '# e.g. a NAS mounted from another disk. The repository is created in a subfolder of it.' >> mailcow.conf
echo '# Empty: the Docker volume restic-repo-vol-1 of mailcow, on the same disk as the mailcow data.' >> mailcow.conf
echo 'RESTIC_LOCAL_PATH=' >> mailcow.conf
;;
RESTIC_BACKUP_MODE)
echo '# What the backups of the mailcow UI hold:' >> mailcow.conf
echo '# - all (default),' >> mailcow.conf
echo '# - data (vmail, vmail-index, database, redis, mail crypt keys and mailcow.conf)' >> mailcow.conf
echo '# - config (database, redis, mail crypt keys, mailcow.conf and data/conf).' >> mailcow.conf
echo 'RESTIC_BACKUP_MODE=all' >> mailcow.conf
;;
*)
echo "${option}=" >> mailcow.conf
;;
+3
View File
@@ -159,6 +159,9 @@ async def post_containers(container_id : str, post_action : str, request: Reques
api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
if getattr(api_call_method, 'long_running', False):
# runs for minutes (e.g. sogo-tool of a backup restore): in a worker thread, the other api calls go on
return await asyncio.to_thread(api_call_method, request_json, container_id=container_id)
return api_call_method(request_json, container_id=container_id)
except Exception as e:
dockerapi.logger.error("error - container_post: %s" % str(e))
@@ -6,9 +6,15 @@ import time
import json
import asyncio
import platform
import docker
from datetime import datetime
from fastapi import FastAPI, Response, Request
def long_running(method):
"""Marks an api call that runs for minutes: main.py runs it in a worker thread, the other api calls go on"""
method.long_running = True
return method
class DockerApi:
def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
self.redis_client = redis_client
@@ -445,6 +451,35 @@ class DockerApi:
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'users required' }, indent=4), media_type="application/json")
init_return = container.exec_run(["/bin/bash", "-c", 'for user in "$@"; do sogo-tool init-user "$user" || exit 1; done', "init_user"] + users, user='sogo')
return self.exec_run_handler('generic', init_return)
# api call: container_post - post_action: exec - cmd: sogo - task: restore
# Resets calendars, address books and preferences of a user to the sogo-tool backup file <dir>/<username> in a
# restore batch of the backup (/restore/<job>/b<batch>/sogo_backup, only the sogo user can read it); values are
# checked and passed as arguments, never through the shell. Runs in a worker thread (main.py) with its own client.
@long_running
def container_post__exec__sogo__restore(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
directory = str(request_json.get('dir', ''))
username = str(request_json.get('username', ''))
if (not re.fullmatch(r'/restore/[0-9]+/b[0-9]+/sogo_backup', directory)
or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._+-]*@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+', username)):
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'dir and username required' }, indent=4), media_type="application/json")
# the folder: no symlink, uid 999, mode 0700; the file: plain, uid 999, mode 0600. sogo-tool restores folders and
# preferences in two runs, both stopped before the runner gives up waiting (30 minutes)
script = ('[ -d "$1" ] && [ ! -L "$1" ] && [ "$(realpath -e -- "$1")" = "$1" ] && [ "$(stat -c "%u %a" -- "$1")" = "999 700" ] '
'&& [ -f "$1/$2" ] && [ ! -L "$1/$2" ] && [ "$(stat -c "%u %a" -- "$1/$2")" = "999 600" ] '
'|| { echo "invalid directory or file"; exit 1; }; '
'timeout -k 30 850 sogo-tool restore -f ALL "$1" "$2" && exec timeout -k 30 850 sogo-tool restore -p "$1" "$2"')
client = docker.DockerClient(base_url='unix:///var/run/docker.sock', version='auto', timeout=1800)
try:
for container in client.containers.list(filters=filters):
restore_return = container.exec_run(["/bin/bash", "-c", script, "restore", directory, username], user='sogo')
return self.exec_run_handler('generic', restore_return)
finally:
client.close()
# api call: container_post - post_action: exec - cmd: doveadm - task: subscribe_shared
# Subscribes users to every folder of a mailbox shared with them (Shared/<owner>/...), or unsubscribes them
def container_post__exec__doveadm__subscribe_shared(self, request_json, **kwargs):
+1
View File
@@ -151,6 +151,7 @@ COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
COPY imapsync /usr/local/bin/imapsync
COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
COPY retention_runner.py /usr/local/bin/retention_runner.py
COPY restore_runner.py /usr/local/bin/restore_runner.py
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
@@ -282,6 +282,7 @@ chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
/usr/local/bin/imapsync_runner.py \
/usr/local/bin/retention_runner.py \
/usr/local/bin/restore_runner.py \
/usr/local/bin/imapsync \
/usr/local/bin/trim_logs.sh \
/usr/local/bin/sa-rules.sh \
+702
View File
@@ -0,0 +1,702 @@
#!/usr/bin/python3
# Mail part of restores (ofelia, dovecot container, as vmail): the restic container restores a batch of mailboxes
# into /restore/<job id>/b<n> and sets the job to step 'mails'. Per mailbox this imports the mails folder by folder
# (into "Restored <snapshot time>" or only the missing ones into the original folders), adds IMAP ACLs and missing
# Sieve scripts, merges the results into the job and sets step 'mails_done'; the restic runner then removes the staging
# and goes on with the next batch. Nothing that exists is changed or deleted.
import base64
import fcntl
import json
import os
import re
import smtplib
import stat
import subprocess
import sys
import threading
import time
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import MySQLdb
import MySQLdb.cursors
lock_file = '/tmp/restore_runner.lock'
restore_dir = '/restore'
max_log = 1000000
# import, moves and the removal of the temporary folder ignore the quota (a restore may need more than is free),
# the quota is recalculated afterwards
no_quota = ['-o', 'plugin/quota=']
# uid ranges per import (one argument stays far below the 128 KiB limit), folders per fetch and status
uid_ranges = 2000
folder_batch = 200
# the restic runner waits for the batch while this heartbeat is fresh
heartbeat_interval = 30
# seconds to wait for the next batch of a restore
batch_wait = 120
rights_by_letter = {'l': 'lookup', 'r': 'read', 'w': 'write', 's': 'write-seen', 't': 'write-deleted', 'i': 'insert',
'p': 'post', 'e': 'expunge', 'k': 'create', 'x': 'delete', 'a': 'admin'}
components_here = {'mails', 'imap_acl', 'filters', 'sieve_activate'}
conn = None
def connect():
return MySQLdb.connect(unix_socket='/var/run/mysqld/mysqld.sock', user=os.environ['DBUSER'],
passwd=os.environ['DBPASS'], db=os.environ['DBNAME'], charset='utf8mb4', autocommit=True)
# Reconnect when the server dropped the connection (imports can outlast wait_timeout)
def query(sql, args=None):
global conn
try:
conn.ping()
except (AttributeError, MySQLdb.Error):
conn = connect()
cur = conn.cursor(MySQLdb.cursors.DictCursor)
cur.execute(sql, args)
return cur
# state_restore_seen while a batch runs, in its own connection
class Heartbeat(threading.Thread):
def __init__(self):
super().__init__(daemon=True)
self.done = threading.Event()
def run(self):
db = None
while not self.done.is_set():
try:
if db is None:
db = connect()
db.cursor().execute("""INSERT INTO backup_setting (name, value) VALUES ('state_restore_seen', %s)
ON DUPLICATE KEY UPDATE value = VALUES(value)""", (str(int(time.time())),))
except MySQLdb.Error:
db = None
self.done.wait(heartbeat_interval)
def log(job_id, text):
print('restore job %d: %s' % (job_id, text), flush=True)
line = '%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), text)
query("UPDATE backup_job SET log = RIGHT(CONCAT(IFNULL(log, ''), %s), %s) WHERE id = %s", (line, max_log, job_id))
def save(job_id, result):
query("UPDATE backup_job SET result = %s WHERE id = %s", (json.dumps(result), job_id))
def doveadm(*args, data=None, check=True, partial=False):
result = subprocess.run(['doveadm', *args], input=data, stdin=None if data is not None else subprocess.DEVNULL,
capture_output=True, timeout=86400)
# mailbox status fails for folders that cannot be opened (implicit parents); the others are listed
if check and result.returncode != 0 and not (partial and result.stdout.strip()):
# mail_log writes an info line per message, the errors are the other lines
lines = [l for l in result.stderr.decode('utf-8', 'replace').splitlines() if ': Info: ' not in l]
raise RuntimeError('\n'.join(lines)[-500:] or 'doveadm exit code %d' % result.returncode)
return result.stdout.decode('utf-8', 'replace')
def doveadm_json(*args, partial=False):
out = doveadm('-f', 'json', *args, partial=partial)
return json.loads(out) if out.strip() else []
# free disk space that must stay after the import: max(2 GiB, 5 % of the filesystem), a full disk stops mail
# (disk_reserve, mib and count_errors as in restic_runner.py of the backup image; tests/python/backup compares them)
def disk_reserve(total):
return max(2 << 30, total // 20)
def import_space_ok(need, free, total):
return need * 11 // 10 + disk_reserve(total) <= free
def mib(size):
return '%.1f MiB' % (size / 1048576)
def component(errors=None):
return {'added': 0, 'skipped': 0, 'errors': errors or []}
# Folder names on disk: modified UTF-7, listescape writes . / \ as \xx
def mutf7_decode(text):
def utf16(m):
if not m.group(1):
return '&'
data = m.group(1).replace(',', '/')
return base64.b64decode(data + '=' * (-len(data) % 4)).decode('utf-16-be')
return re.sub(r'&([A-Za-z0-9+,]*)-', utf16, text)
def unescape(part):
return mutf7_decode(re.sub(r'\\([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), part))
# Maildir++: the maildir is INBOX, ".A.B" is A/B
def folder_name(entry):
return '/'.join(unescape(part) for part in entry[1:].split('.')) if entry else 'INBOX'
def is_dir(path):
try:
return stat.S_ISDIR(os.lstat(path).st_mode)
except OSError:
return False
def read_file(path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as f:
return f.read()
# Prefixes of the shared and public namespaces, their folders belong to others
def shared_prefixes():
prefixes, namespace, depth = {'Shared/'}, None, 0
# as vmail doveconf cannot read every included file (mail_crypt keys) and fails, but prints the namespaces
for line in subprocess.run(['doveconf', '-n'], capture_output=True, text=True).stdout.splitlines():
stripped = line.strip()
if stripped.endswith('{'):
depth += 1
if depth == 1 and stripped.startswith('namespace'):
namespace = {}
elif stripped == '}':
if depth == 1 and namespace is not None:
if namespace.get('type') in ('shared', 'public') and namespace.get('prefix'):
prefixes.add(namespace['prefix'].split('%')[0])
namespace = None
depth = max(depth - 1, 0)
elif depth == 1 and namespace is not None and '=' in stripped:
key, value = (part.strip() for part in stripped.split('=', 1))
namespace[key] = value
return tuple(prefix for prefix in prefixes if prefix)
# name => guid of the user's own folders (implicit parents have none); the names are looked up literally
def own_folders(user, prefixes):
names = [n for n in doveadm('mailbox', 'list', '-u', user).splitlines() if n and not n.startswith(prefixes)]
wanted, guids = set(names), {}
for i in range(0, len(names), folder_batch):
for row in doveadm_json('mailbox', 'status', '-u', user, 'guid', '--', *names[i:i + folder_batch], partial=True):
if row.get('mailbox') in wanted and re.fullmatch(r'[0-9a-f]{32}', row.get('guid') or ''):
guids[row['mailbox']] = row['guid']
return guids
def free_name(base, existing):
name, count = base, 1
while name in existing or any(folder.startswith(name + '/') for folder in existing):
count += 1
name = '%s (%d)' % (base, count)
return name
# Path of the maildir now (/var/vmail/<domain>/<local>/Maildir), None for unknown or inactive mailboxes
def mail_location(user):
result = subprocess.run(['doveadm', 'user', '-f', 'mail', '--', user], capture_output=True, text=True)
m = re.match(r'maildir:([^:]+)', result.stdout.strip()) if result.returncode == 0 else None
return m.group(1).rstrip('/') if m else None
# The restored maildir of a mailbox (may hold only ACL files), None if the snapshot has nothing of it
def staged_maildir(staging, home, location):
for path in (staging + location, home + '/' + os.environ.get('MAILDIR_SUB', 'Maildir'), home):
if is_dir(path):
return path
return None
def uid_chunks(uids):
ranges, start, prev = [], None, None
for uid in sorted(uids):
if start is None:
start = prev = uid
elif uid == prev + 1:
prev = uid
else:
ranges.append('%d:%d' % (start, prev) if prev != start else str(start))
start = prev = uid
if start is not None:
ranges.append('%d:%d' % (start, prev) if prev != start else str(start))
return [','.join(ranges[i:i + uid_ranges]) for i in range(0, len(ranges), uid_ranges)]
# Same message: same Message-ID, without one the same Date and size
def message_key(row):
message_id = re.sub(r'\s+', '', row.get('hdr.message-id') or '').strip('<>')
if message_id:
return 'id:' + message_id
return 'date:%s:%s' % (row.get('date.sent'), row.get('size.physical'))
def present_keys(user, guids):
keys = set()
guids = list(guids)
for i in range(0, len(guids), folder_batch):
search = []
for guid in guids[i:i + folder_batch]:
search += (['OR'] if search else []) + ['mailbox-guid', guid]
keys.update(message_key(row) for row in doveadm_json('fetch', '-u', user, 'hdr.message-id date.sent size.physical',
*search))
return keys
def location(path):
if ':' in path:
raise RuntimeError('unsupported path')
return 'maildir:%s:INDEX=MEMORY' % path
# The folders of a restored maildir as standalone maildirs (their messages are the INBOX there): the subfolders are
# moved out of the maildir, so every folder is imported without the name rules of the source (listescape)
def split_folders(maildir, home):
folders, failed = [('INBOX', maildir)], []
out = home + '/_restore_folders'
for entry in sorted(os.listdir(maildir)):
path = os.path.join(maildir, entry)
if not entry.startswith('.') or entry in ('.', '..') or not is_dir(path) or not is_dir(path + '/cur'):
continue
try:
name = folder_name(entry)
except (ValueError, UnicodeDecodeError):
failed.append(entry)
continue
if not os.path.isdir(out):
os.mkdir(out, 0o700)
target = '%s/f%d' % (out, len(folders))
os.rename(path, target)
folders.append((name, target))
return folders, failed
def source_messages(user, root):
return doveadm_json('-o', 'mail_location=' + location(root), 'fetch', '-u', user,
'uid hdr.message-id date.sent size.physical', 'mailbox', 'INBOX')
def remove_tree(user, name):
doveadm(*no_quota, 'mailbox', 'delete', '-u', user, '-r', '-s', '--', name, check=False)
def folder_guid(user, name):
rows = doveadm_json('mailbox', 'status', '-u', user, 'guid', '--', name)
for row in rows:
if row.get('mailbox') == name:
return row['guid']
raise RuntimeError('folder not found after the import')
def quota_room(user):
# free bytes of the storage quota, None without a limit
for row in doveadm_json('quota', 'get', '-u', user):
if row.get('type') == 'STORAGE' and str(row.get('limit', '-')).isdigit():
return (int(row['limit']) - int(row.get('value') or 0)) * 1024
return None
# Subscriptions of the backup (subscriptions file: "V\t2", then names with \t between the levels)
def backup_subscriptions(maildir):
try:
lines = read_file(maildir + '/subscriptions').decode('utf-8', 'replace').splitlines()
except OSError:
return []
if lines and lines[0].startswith('V\t'):
return ['/'.join(unescape(part) for part in line.split('\t')) for line in lines[1:] if line.strip()]
# version 1: storage names with the Maildir++ separator
return [folder_name('.' + line.strip()) for line in lines if line.strip()]
def restore_subscriptions(user, wanted, prefixes):
if not wanted:
return 0
existing = set(own_folders(user, prefixes)) | {'INBOX'}
subscribed = set(doveadm('mailbox', 'list', '-u', user, '-s').splitlines())
missing = [name for name in dict.fromkeys(wanted) if name in existing and name not in subscribed]
if missing:
doveadm('mailbox', 'subscribe', '-u', user, '--', *missing)
return len(missing)
# Mails folder by folder: original = only messages missing anywhere in the mailbox, into their original folders;
# folder = all messages below "Restored <snapshot time>"
def restore_mails(job_id, user, home, maildir, mode, snapshot_time, prefixes, check_quota):
result = component()
result.update(folders=0, folders_failed=0)
if not maildir or not is_dir(maildir + '/cur'):
# a key the UI translates
result['note'] = 'no_mails'
return result
tmp = 'Restoring %d' % job_id
# a leftover of an interrupted run goes first, it is never counted as present
remove_tree(user, tmp)
subscriptions = backup_subscriptions(maildir)
folders, broken = split_folders(maildir, home)
for entry in broken:
result['errors'].append('folder %s: name not readable' % entry)
result['folders_failed'] += 1
live = own_folders(user, prefixes)
present = present_keys(user, live.values()) if mode == 'original' else set()
plan, need = [], 0
for name, root in folders:
try:
rows = source_messages(user, root)
except RuntimeError as e:
result['errors'].append('%s: %s' % (name, e))
result['folders_failed'] += 1
continue
wanted = [row for row in rows if mode != 'original' or message_key(row) not in present]
result['skipped'] += len(rows) - len(wanted)
if wanted:
size = sum(int(row.get('size.physical') or 0) for row in wanted)
plan.append((name, root, [int(row['uid']) for row in wanted], size))
need += size
if not plan:
if mode == 'original':
result['subscribed'] = restore_subscriptions(user, subscriptions, prefixes)
return result
vfs = os.statvfs('/var/vmail')
free, total = vfs.f_bavail * vfs.f_frsize, vfs.f_blocks * vfs.f_frsize
if not import_space_ok(need, free, total):
raise RuntimeError('not enough free disk space for the mails: %s needed plus %s reserve, %s free' % (
mib(need * 11 // 10), mib(disk_reserve(total)), mib(free)))
if check_quota:
room = quota_room(user)
if room is not None and need > room:
raise RuntimeError('the mails do not fit into the quota of the mailbox: %s needed, %s free' % (
mib(need), mib(max(room, 0))))
names = set(live) | {'INBOX'}
dest = None
if mode != 'original':
dest = free_name('Restored ' + snapshot_time, names)
doveadm('mailbox', 'create', '-u', user, '-s', '--', dest)
names.add(dest)
result['folder'] = dest
created = []
try:
for number, (name, root, uids, size) in enumerate(plan):
target = dest + '/' + name if dest else name
stage = '%s/f%d' % (tmp, number)
try:
for chunk in uid_chunks(uids):
doveadm(*no_quota, 'import', '-u', user, '-s', location(root), stage, 'mailbox', 'INBOX', 'uid', chunk)
guid = folder_guid(user, stage + '/INBOX')
if target not in names:
doveadm('mailbox', 'create', '-u', user, '-s', '--', target)
names.add(target)
created.append(target)
doveadm(*no_quota, 'move', '-u', user, '--', target, 'mailbox-guid', guid, 'all')
result['added'] += len(uids)
result['folders'] += 1
except RuntimeError as e:
result['errors'].append('%s: %s' % (name, e))
result['folders_failed'] += 1
finally:
remove_tree(user, tmp)
result['folders_created'] = len(created)
if mode == 'original':
result['subscribed'] = restore_subscriptions(user, subscriptions, prefixes)
return result
def acl_entries(path):
entries = []
try:
lines = read_file(path).decode('utf-8', 'replace').splitlines()
except OSError:
return entries
for line in lines:
parts = line.split()
# negative rights would take rights away
if len(parts) < 2 or parts[0].startswith('-'):
continue
rights = [rights_by_letter[c] for c in parts[1] if c in rights_by_letter] + [p for p in parts[2:] if p.startswith(':')]
if rights:
entries.append((parts[0], rights))
return entries
# (folder, entries) of the dovecot-acl files of a restored maildir
def maildir_acls(maildir):
acls = []
if not maildir or not is_dir(maildir):
return acls
for entry in [''] + sorted(e for e in os.listdir(maildir) if e.startswith('.') and e not in ('.', '..')):
path = os.path.join(maildir, entry, 'dovecot-acl') if entry else os.path.join(maildir, 'dovecot-acl')
if entry and not is_dir(os.path.join(maildir, entry)):
continue
try:
if stat.S_ISREG(os.lstat(path).st_mode):
acls.append((folder_name(entry), acl_entries(path)))
except (OSError, ValueError, UnicodeDecodeError):
continue
return acls
# owner => (folder, entries) of every mailbox in the ACL staging (domain admins: only their domains)
def staged_acls(acl_dir, allowed):
acls = {}
base = (acl_dir or '') + '/var/vmail'
sub = os.environ.get('MAILDIR_SUB', '')
if not acl_dir or not is_dir(base):
return acls
for domain in os.listdir(base):
if '.' not in domain or not is_dir(os.path.join(base, domain)) or (allowed is not None and domain.lower() not in allowed):
continue
for local in os.listdir(os.path.join(base, domain)):
home = os.path.join(base, domain, local)
maildir = os.path.join(home, sub) if sub and is_dir(os.path.join(home, sub)) else home
found = maildir_acls(maildir)
if found:
acls['%s@%s' % (local, domain)] = found
return acls
class AclRestore:
def __init__(self, prefixes, allowed):
self.prefixes = prefixes
self.allowed = allowed
self.users, self.folders = {}, {}
# anyone/authenticated only when the configuration allows them
self.anyone = os.environ.get('ACL_ANYONE', 'disallow') == 'allow'
def in_scope(self, address):
return self.allowed is None or address.rpartition('@')[2].lower() in self.allowed
def user_exists(self, user):
if user not in self.users:
self.users[user] = query("SELECT 1 FROM mailbox WHERE username = %s", (user,)).fetchone() is not None
return self.users[user]
def identifier_ok(self, identifier):
if identifier.startswith('user='):
return self.user_exists(identifier[5:].lower())
# group=mcg_<id> belongs to the mailbox groups, the group grants cron keeps them
if identifier.startswith('group=mcg_'):
return False
if identifier in ('anyone', 'authenticated'):
return self.anyone
return True
def shown(self, identifier):
# domain admins do not see addresses of other domains
if identifier.startswith('user=') and not self.in_scope(identifier[5:]):
return 'user=(other domain)'
return identifier
def add(self, owner, folder, identifier, rights, result):
if not self.in_scope(owner):
result['skipped'] += 1
return
try:
if owner not in self.folders:
self.folders[owner] = set(own_folders(owner, self.prefixes)) | {'INBOX'}
if folder not in self.folders[owner] or not self.identifier_ok(identifier):
result['skipped'] += 1
return
have = set()
for row in doveadm_json('acl', 'get', '-u', owner, '--', folder):
if row.get('id') == identifier:
have.update((row.get('rights') or '').split())
missing = [right for right in rights if right not in have]
if not missing:
result['skipped'] += 1
return
doveadm('acl', 'add', '-u', owner, '--', folder, identifier, *missing)
result['added'] += 1
except RuntimeError as e:
result['errors'].append('%s %s %s: %s' % (owner, folder, self.shown(identifier), e))
# Rights the mailbox granted on its folders and rights other mailboxes granted to it; added, never removed
def restore_acl(acl, user, own, granted):
result = component()
for folder, entries in own:
for identifier, rights in entries:
acl.add(user, folder, identifier, rights, result)
if acl.user_exists(user):
for owner, folders in granted.items():
if owner == user or not acl.in_scope(owner) or not acl.user_exists(owner):
continue
for folder, entries in folders:
for identifier, rights in entries:
if identifier.lower() == 'user=' + user:
acl.add(owner, folder, identifier, rights, result)
return result
# Own Sieve scripts (incl. the SOGo vacation script): missing names are added; with activate the active script of the
# backup is activated if none is active now
def restore_sieve(user, home, active_link, activate):
result = component()
folder = home + '/sieve'
scripts = []
if is_dir(folder):
scripts = sorted(f[:-6] for f in os.listdir(folder) if f.endswith('.sieve') and stat.S_ISREG(os.lstat(os.path.join(folder, f)).st_mode))
current, active = set(), None
for line in doveadm('sieve', 'list', '-u', user).splitlines():
name, _, flag = line.rpartition(' ')
if name:
current.add(name)
if flag == 'ACTIVE':
active = name
for name in scripts:
if name in current:
result['skipped'] += 1
continue
try:
doveadm('sieve', 'put', '-u', user, '--', name, data=read_file(os.path.join(folder, name + '.sieve')))
current.add(name)
result['added'] += 1
except (OSError, RuntimeError) as e:
result['errors'].append('%s: %s' % (name, e))
if not activate:
return result
target = os.readlink(active_link) if os.path.islink(active_link) else ''
backup_active = os.path.basename(target)[:-6] if target.endswith('.sieve') else None
if backup_active and not active and backup_active in current:
try:
doveadm('sieve', 'activate', '-u', user, '--', backup_active)
result['activated'] = backup_active
except RuntimeError as e:
result['errors'].append('%s: %s' % (backup_active, e))
return result
# filters: backup.php reported its part already, the counts are added up
def merge(results, key, part):
old = results.get(key)
if not isinstance(old, dict):
results[key] = part
return
for count in ('added', 'skipped'):
old[count] = (old.get(count) or 0) + part.get(count, 0)
old['errors'] = (old.get('errors') or []) + part.get('errors', [])
for name, value in part.items():
if name not in ('added', 'skipped', 'errors'):
old[name] = value
def count_errors(result):
count = sum(len(result.get(key, {}).get('errors') or []) for key in ('domain', 'aliases') if isinstance(result.get(key), dict))
for entry in (result.get('mailboxes') or {}).values():
count += int(bool(entry.get('error')))
count += sum(len(value.get('errors') or []) for value in entry.values() if isinstance(value, dict))
return count
def restore_mailbox(job_id, user, components, mode, staging, snapshot_time, prefixes, acl, granted, check_quota, result):
local, _, domain = user.rpartition('@')
home = '%s/var/vmail/%s/%s' % (staging, domain, local)
location = mail_location(user)
if not location:
for key in components & {'mails', 'imap_acl', 'filters'}:
merge(result, key, component(['the mailbox does not exist now or is inactive']))
return
maildir = staged_maildir(staging, home, location)
# the ACL files are read before the folders of the maildir are moved for the import
own = maildir_acls(maildir) if 'imap_acl' in components else []
if 'mails' in components:
try:
result['mails'] = restore_mails(job_id, user, home, maildir, mode, snapshot_time, prefixes, check_quota)
except Exception as e:
result['mails'] = component([str(e)[:1000]])
try:
doveadm('quota', 'recalc', '-u', user)
except RuntimeError as e:
result['mails']['errors'].append('quota recalc: %s' % e)
# new folders
acl.folders.pop(user, None)
if 'imap_acl' in components:
try:
result['imap_acl'] = restore_acl(acl, user, own, granted)
except Exception as e:
result['imap_acl'] = component([str(e)[:1000]])
if 'filters' in components:
try:
part = restore_sieve(user, home, '%s/var/vmail/sieve/%s.sieve' % (staging, user), 'sieve_activate' in components)
except Exception as e:
part = component([str(e)[:1000]])
merge(result, 'filters', part)
def fail(job_id, error):
query("""UPDATE backup_job SET state = IF(state = 'cancelled', state, 'failed'), error = %s, finished = NOW()
WHERE id = %s""", (error, job_id))
log(job_id, 'restore job failed: ' + error)
notify(job_id, error)
def notify(job_id, error):
hostname = os.environ.get('MAILCOW_HOSTNAME', '')
row = query("SELECT value FROM backup_setting WHERE name = 'notify_email'").fetchone()
rcpts = [r.strip() for r in (row['value'] if row else '').split(',') if re.fullmatch(r'[^@\s<>,]+@[^@\s<>,]+', r.strip())]
if not rcpts:
return
msg = EmailMessage()
msg['From'] = 'backup@' + hostname
msg['To'] = ', '.join(rcpts)
msg['Subject'] = 'mailcow backup on %s: restore job %d failed' % (hostname, job_id)
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain=hostname or None)
msg.set_content('The restore job %d failed:\n\n%s\n\nThe job log is in the backup section of the mailcow UI.\n'
% (job_id, error))
try:
with smtplib.SMTP('postfix', 588, hostname or None, timeout=60) as smtp:
smtp.send_message(msg)
except (OSError, smtplib.SMTPException) as e:
print('restore job %d: the notification could not be sent: %s' % (job_id, e), file=sys.stderr)
def staging_dir(job_id, path):
# the batch folder of this job, never a symlink
if not isinstance(path, str) or not re.fullmatch(r'/restore/%d/[A-Za-z0-9_-]+' % job_id, path) or \
os.path.realpath(path) != path or not is_dir(path):
return None
return path
def run_batch(job):
job_id = job['id']
params = json.loads(job['params'] or '{}')
result = json.loads(job['result'] or '{}')
batch = result.get('batch') or {}
staging = staging_dir(job_id, batch.get('dir'))
acl_dir = staging_dir(job_id, batch.get('acl_dir')) if batch.get('acl_dir') else None
mailboxes = [m for m in batch.get('mailboxes') or [] if isinstance(m, str)]
components = {c for c in params.get('components') or [] if c in components_here}
mode = 'original' if params.get('mail_target') == 'original' else 'folder'
allowed = {d.lower() for d in params['allowed_domains']} if isinstance(params.get('allowed_domains'), list) else None
check_quota = params.get('requested_role', 'admin') != 'admin'
snapshot_time = (result.get('snapshot') or {}).get('time') or time.strftime('%Y-%m-%d %H:%M')
results = result.setdefault('mailboxes', {})
prefixes = shared_prefixes()
acl = AclRestore(prefixes, allowed)
granted = staged_acls(acl_dir, allowed) if 'imap_acl' in components else {}
log(job_id, 'restoring %s for %d mailboxes%s' % (', '.join(sorted(components)), len(mailboxes),
' (mails: %s)' % mode if 'mails' in components else ''))
for user in mailboxes:
entry = results.setdefault(user, {})
if entry.get('error'):
continue
started = time.monotonic()
if not staging:
for key in components & {'mails', 'imap_acl', 'filters'}:
merge(entry, key, component(['the restored files are missing']))
else:
restore_mailbox(job_id, user, components, mode, staging, snapshot_time, prefixes, acl, granted, check_quota, entry)
summary = ', '.join('%s +%d' % (key, entry[key].get('added', 0)) for key in sorted(components) if isinstance(entry.get(key), dict))
errors = sum(len(entry[key].get('errors') or []) for key in components if isinstance(entry.get(key), dict))
log(job_id, '%s: %s%s (%d s)' % (user, summary, ', %d errors' % errors if errors else '', time.monotonic() - started))
result['errors'] = count_errors(result)
save(job_id, result)
result['errors'] = count_errors(result)
save(job_id, result)
# the restic runner removes the staging and goes on
query("UPDATE backup_job SET step = 'mails_done' WHERE id = %s AND state = 'running' AND step = 'mails_running'", (job_id,))
def main():
global conn
os.umask(0o077)
# released by the kernel when the process ends, a crashed run leaves no stale lock
lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
sys.exit("restore: can't lock " + lock_file)
conn = connect()
heartbeat = Heartbeat()
heartbeat.start()
try:
# under the lock: a batch in mails_running was left by a crashed or stopped run, its temporary folders go
for row in query("""SELECT id, result FROM backup_job WHERE type = 'restore' AND state = 'running'
AND step = 'mails_running'""").fetchall():
try:
for user in (json.loads(row['result'] or '{}').get('batch') or {}).get('mailboxes') or []:
remove_tree(user, 'Restoring %d' % row['id'])
doveadm('quota', 'recalc', '-u', user, check=False)
except (ValueError, AttributeError, RuntimeError):
pass
fail(row['id'], 'interrupted: the restore of the mails was stopped')
last, waited = None, 0
while True:
job = query("""SELECT id, params, result FROM backup_job WHERE type = 'restore' AND state = 'running'
AND step = 'mails' ORDER BY id LIMIT 1""").fetchone()
if not job:
# the next batch of a restore follows within seconds: wait for it instead of the next ofelia run
row = query("SELECT state FROM backup_job WHERE id = %s", (last,)).fetchone() if last else None
if row and row['state'] == 'running' and waited < batch_wait:
time.sleep(2)
waited += 2
continue
break
last, waited = job['id'], 0
if query("UPDATE backup_job SET step = 'mails_running' WHERE id = %s AND state = 'running' AND step = 'mails'",
(job['id'],)).rowcount != 1:
continue
try:
run_batch(job)
except Exception as e:
fail(job['id'], str(e)[:1000])
finally:
heartbeat.done.set()
if __name__ == '__main__':
main()
+28
View File
@@ -0,0 +1,28 @@
FROM alpine:3.24
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
# MariaDB Connector/C >= 3.4 refuses DB servers without TLS, the runner connects through the socket
ENV MARIADB_TLS_DISABLE_PEER_VERIFICATION=1
RUN apk add --no-cache --update \
ca-certificates \
git \
mariadb-client \
openssh-client \
py3-mysqlclient \
py3-pynacl \
python3 \
restic \
tini \
tzdata \
# dump, export, manifest and the mailcow directory are below /mailcow (paths of the snapshot), only root may read them
&& mkdir -m 700 /mailcow
COPY restic_runner.py /usr/local/bin/restic_runner.py
COPY sogo_restore.py /usr/local/bin/sogo_restore.py
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/bin/python3", "-u", "/usr/local/bin/restic_runner.py"]
File diff suppressed because it is too large. Load diff
+118
View File
@@ -0,0 +1,118 @@
"""SOGo part of a mailbox restore (component sogo, overwrites), called by the restic runner for one mailbox.
Runs `sogo-tool restore -f ALL <dir> <user>` and `sogo-tool restore -p <dir> <user>` in the sogo container through
dockerapi on the user's file of the nightly sogo-tool backup: missing calendars, address books and entries come back,
the names and shares of existing folders and all SOGo preferences are replaced by the backup's, entries changed since
stay. Then the user's entries in SOGo's cache (memcached) are removed, so the change shows at once.
"""
import json
import os
import re
import socket
import ssl
import urllib.request
dockerapi_url = 'https://dockerapi:443'
memcached_addr = ('memcached-mailcow', 11211)
# dockerapi stops each of the two sogo-tool runs after 850 s
restore_timeout = 1800
# accepted by the dockerapi handler as well
user_re = re.compile(r'[A-Za-z0-9_][A-Za-z0-9._+-]*@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+')
def _short(text, limit=300):
# sogo-tool may print database URLs: no credentials, one line, the end has the error
text = re.sub(r'([A-Za-z][A-Za-z0-9+.-]*://)[^\s/@]*@', r'\1***@', str(text))
text = ' '.join(text.split())
return text if len(text) <= limit else '...' + text[-limit:]
def _api(path, body=None, timeout=60):
# dockerapi uses the mail certificate, its name does not match
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(dockerapi_url + path, data=data, headers={'Content-Type': 'application/json'},
method='GET' if body is None else 'POST')
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as response:
return json.load(response)
def _sogo_container():
project = os.environ.get('COMPOSE_PROJECT_NAME', '').lower()
by_name = None
for cid, info in _api('/containers/json').items():
if not cid.isalnum() or not isinstance(info, dict):
continue
labels = (info.get('Config') or {}).get('Labels') or {}
if labels.get('com.docker.compose.service') == 'sogo-mailcow':
if not project or labels.get('com.docker.compose.project', '').lower() == project:
return cid
elif by_name is None and 'sogo-mailcow' in str(info.get('Name', '')):
by_name = cid
if by_name:
return by_name
raise RuntimeError('sogo container not found')
def _memcached_delete(keys):
keys = [k for k in keys if 0 < len(k.encode()) <= 250 and not re.search(r'[\s\x00-\x1f\x7f]', k)]
if not keys:
return
with socket.create_connection(memcached_addr, timeout=10) as s:
for key in keys:
s.sendall(b'delete ' + key.encode() + b'\r\n')
reply = b''
while not reply.endswith(b'\r\n'):
chunk = s.recv(64)
if not chunk:
raise RuntimeError('memcached closed the connection')
reply += chunk
if reply.strip() not in (b'DELETED', b'NOT_FOUND'):
raise RuntimeError('memcached: %s' % _short(reply.decode('ascii', 'replace')))
def _cache_keys(db, username):
# preferences and the ACLs of the user's folders (<uid>/Calendar/personal+acl); never read the c_*location
# columns of sogo_folder_info (database credentials)
keys = ['%s+defaults' % username, '%s+settings' % username]
cur = db.cursor()
try:
cur.execute('SELECT c_path FROM sogo_folder_info WHERE c_path2 = %s', (username,))
keys += ['%s+acl' % row[0][len('/Users/'):] for row in cur.fetchall() if row[0].startswith('/Users/')]
finally:
cur.close()
return keys
def restore(db, backup_dir, username, log):
"""Restores the SOGo data of one user from <backup_dir>/<username> (staged readable for the sogo user only).
db: open MySQLdb connection; log: callable for short progress lines. Returns {"added": 0|1, "errors": [...]}."""
result = {'added': 0, 'errors': []}
if not user_re.fullmatch(username):
result['errors'].append('username not supported by the SOGo restore')
return result
path = os.path.join(backup_dir, username)
if not os.path.isfile(path) or os.path.islink(path):
# sogo-tool backup ALL skips users without SOGo folders
# a key the UI translates
result['note'] = 'no_sogo_data'
log('sogo: no SOGo data in the snapshot')
return result
try:
res = _api('/containers/%s/exec' % _sogo_container(), {'cmd': 'sogo', 'task': 'restore', 'dir': backup_dir,
'username': username}, timeout=restore_timeout)
if not isinstance(res, dict) or res.get('type') != 'success':
raise RuntimeError('sogo-tool restore failed: %s' % _short(res.get('msg', '') if isinstance(res, dict) else res))
result['added'] = 1
log('sogo: calendars, address books and preferences reset to the backup')
except Exception as e:
result['errors'].append(_short(e))
try:
# SOGo caches ACLs and preferences for 15 minutes
_memcached_delete(_cache_keys(db, username))
except Exception as e:
result['errors'].append('SOGo cache not cleared, changes may show up after 15 minutes: %s' % _short(e))
return result
+9
View File
@@ -150,6 +150,15 @@ http {
access_log /var/log/nginx/access.log;
root /dynmaps;
# backup endpoint of the restic runner: large restore requests, long merges of mailboxes and domains
location = /backup.php {
client_max_body_size 32m;
fastcgi_read_timeout 600s;
fastcgi_pass {{ PHPFPMHOST }}:9001;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
+97
View File
@@ -0,0 +1,97 @@
<?php
// Backup with restic: endpoint of the runner in the restic container (Mailcow\Backup)
// GET ?domains=1: {"domains": [...]}, all domains
// GET ?export=<domain>[&after=<username>]: a page of the export of a domain (the first with domain_data and aliases,
// every page with a part of the mailboxes and "next_after", the last username to ask for the next page with, null
// after the last), incl. password hashes, DKIM keys and secrets; the runner merges the pages into
// /mailcow/export/<domain>.json of the snapshot.
// POST {"action": "restore_domain", "job": <id>, "data": <domain_data>}: recreates a deleted domain
// POST {"action": "restore_aliases", "job": <id>, "aliases": [...]}: a part (at most 500) of the aliases of a domain restore
// POST {"action": "restore", "job": <id>, "username": <mailbox>, "data": <its section of the export>}: one mailbox
// (created first when it was deleted); all answer {"result": {<component>: {"added", "skipped", "errors"}}}
// All need the header X-Backup-Token: HMAC-SHA256 of "backup" with the database password as key
header('Content-Type: application/json');
require_once "vars.inc.php";
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
// Do not show errors, we log to using error_log
ini_set('error_reporting', 0);
if (empty($database_pass) || !hash_equals(hash_hmac('sha256', 'backup', $database_pass), (string)($_SERVER['HTTP_X_BACKUP_TOKEN'] ?? ''))) {
http_response_code(403);
exit;
}
// Init database
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
}
catch (PDOException $e) {
error_log("BACKUP: " . $e . PHP_EOL);
http_response_code(501);
exit;
}
try {
// DKIM keys, rate limits and the domain map are kept in Redis
$redis = new Redis();
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
}
else {
$redis->connect('redis-mailcow', 6379);
}
$redis->auth(getenv("REDISPASS"));
$config = array(
'timezone' => getenv('TZ') ?: 'UTC',
'alias_disable_external_domains' => ($ALIAS_DISABLE_EXTERNAL_DOMAINS ?? false) === true,
);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = json_decode((string)file_get_contents('php://input'), true);
if (!is_array($body)) {
http_response_code(400);
exit;
}
// The restore has the side effects of the UI: SOGo view and restart, Dovecot auth cache, group shares (legacy code)
require_once __DIR__ . '/../web/inc/functions.inc.php';
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
require_once __DIR__ . '/../web/inc/functions.docker.inc.php';
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
// The legacy mailbox functions run as admin, without a session
$_SESSION = array(
'mailcow_cc_username' => 'admin',
'mailcow_cc_role' => 'admin',
'access_all_exception' => '0',
'acl' => array_fill_keys(array('tls_policy', 'quarantine_notification', 'quarantine_category', 'ratelimit', 'sogo_access',
'protocol_access', 'mailbox_relayhost', 'unlimited_quota'), '1'),
);
$backup = \Mailcow\Backup\BackupModule::create($pdo, \Mailcow\Core\Context::system(), $config, $redis);
echo json_encode($backup->api->post($body), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
else {
$backup = \Mailcow\Backup\BackupModule::create($pdo, \Mailcow\Core\Context::system(), $config, $redis);
if (isset($_GET['export'])) {
echo json_encode($backup->api->export((string)$_GET['export'], (isset($_GET['after']) && is_string($_GET['after'])) ? $_GET['after'] : null),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
elseif (isset($_GET['domains'])) {
echo json_encode($backup->api->domains());
}
else {
http_response_code(400);
}
}
}
catch (\Mailcow\Core\UserError $e) {
http_response_code(($e->key === 'domain_not_found') ? 404 : 400);
echo json_encode(array('error' => $e->key, 'args' => $e->args));
}
catch (Throwable $e) {
error_log("BACKUP: " . $e->getMessage() . PHP_EOL);
http_response_code(500);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
protect_route(['admin']);
$role = "admin";
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/restore.inc.php';
+3
View File
@@ -13,6 +13,7 @@ $js_minifier->add('/web/js/site/admin.js');
$js_minifier->add('/web/js/presets/rspamd.js');
$js_minifier->add('/web/js/site/pwgen.js');
$js_minifier->add('/web/js/site/identity.js');
$js_minifier->add('/web/js/site/backup.js');
// all domains
$domains = mailbox('get', 'domains');
@@ -121,6 +122,8 @@ $template_data = [
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
'iam_settings' => $iam_settings,
'mbox_templates' => $mbox_templates,
'backup_timezone' => getenv('TZ') ?: 'UTC',
'backup_hostname' => getenv('MAILCOW_HOSTNAME') ?: '',
'lang_admin' => json_encode($lang['admin']),
'lang_datatables' => json_encode($lang['datatables'])
];
+933
View File
@@ -1597,6 +1597,174 @@ paths:
type: boolean
type: object
summary: Create retention rule
/api/v1/add/backup-job:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- backup
- add
- job
- type: backup
msg:
- backup_job_added
- backup
- 11
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- warning
- error
type: string
type: object
description: OK
headers: {}
tags:
- Backup
description: >-
Queue a job for the backup container (admins): backup (database dump,
export of the domains, restic backup, forget by the keep policy), check
(restic check of the structure and 1/N of the data), prune (forget and
delete unused data) or init (test the connection, create the repository
if it does not exist). One job of each type at a time
(backup_job_active). Needs a complete target and repository password
(backup_not_configured); a backup also the confirmation that the
repository password is stored outside of mailcow
(backup_password_not_confirmed). Follow the job with
get/backup-job/{id}.
operationId: Create backup job
requestBody:
content:
application/json:
schema:
example:
type: backup
properties:
type:
description: backup, check, prune or init
type: string
type: object
summary: Create backup job
/api/v1/add/backup-restore:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- backup
- add
- restore
- snapshot: 3ee0b6f7
scope: mailbox
mailboxes:
- user@example.org
components:
- mails
- aliases
mail_target: folder
msg:
- backup_restore_added
- 12
- 1
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- warning
- error
type: string
type: object
description: OK
headers: {}
tags:
- Backup
description: >-
Queue a restore from a snapshot (admins; domain admins for their
domains). Scope "mailbox" restores the given mailboxes of one domain,
scope "domain" all mailboxes of the domain; admins can also restore a
deleted domain. Deleted mailboxes are created from the backup first.
The restore only adds what is missing, except "sogo", "settings" and
"password", which replace the current values with the backup's.
Components: mails (mail_target "folder": a copy below "Restored
<snapshot time>"; "original": only missing messages, into their
folders), imap_acl, aliases, groups, sender_acl, mail_policies,
retention_rules, filters, sieve_activate (needs filters), spam, bcc,
syncjobs, app_passwords, tfa, sogo, settings, password. Domain admins
need the matching ACL (else backup_component_denied). Snapshots made
with RESTIC_BACKUP_MODE=config hold no mails (backup_components_no_mails).
Results per mailbox and component: get/backup-job/{id}.
operationId: Create restore job
requestBody:
content:
application/json:
schema:
example:
snapshot: 3ee0b6f7
scope: mailbox
mailboxes:
- user@example.org
components:
- mails
- imap_acl
- aliases
- sender_acl
- filters
mail_target: folder
properties:
snapshot:
description: id or short id of the snapshot (get/backup-snapshots)
type: string
scope:
description: mailbox (the given mailboxes, default) or domain (all mailboxes of the domain)
type: string
mailboxes:
description: mailboxes of one domain, scope mailbox only, max. 1000; deleted ones from the snapshot's content list are created
type: array
domain:
description: the domain (required for scope domain, a deleted one for admins; for scope mailbox it must exist and match the mailboxes)
type: string
components:
description: what to restore, see above
type: array
mail_target:
description: folder (default) or original
type: string
type: object
summary: Create restore job
/api/v1/add/oauth2-client:
post:
responses:
@@ -3186,6 +3354,67 @@ paths:
type: object
type: object
summary: Delete retention rules
/api/v1/delete/backup-job:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- backup
- delete
- job
- id:
- 12
msg:
- backup_job_cancelled
- 12
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- warning
- error
type: string
type: object
description: OK
headers: {}
tags:
- Backup
description: >-
Cancel backup jobs by ID (admins any job, domain admins the restores of
their domains). Queued jobs and running jobs can be cancelled, except a
restore while the dovecot container restores the mails (step mails or
mails_running; backup_job_not_cancellable). A running job stops at its
next check; what it restored so far stays. One message per ID.
operationId: Cancel backup jobs
requestBody:
content:
application/json:
schema:
example:
- 12
properties:
items:
description: contains list of job IDs you want to cancel
type: object
type: object
summary: Cancel backup jobs
/api/v1/delete/identity-link:
post:
responses:
@@ -5029,6 +5258,198 @@ paths:
type: object
type: object
summary: Update retention rules
/api/v1/edit/backup-settings:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- backup
- edit
- settings
- enabled: "1"
schedule:
- days: [1, 3]
time: "12:00"
- days: [5, 6, 0]
time: "18:00"
keep_daily: 14
msg: backup_settings_saved
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- warning
- error
type: string
type: object
description: OK
headers: {}
tags:
- Backup
description: >-
Save the backup settings (admins). Settings you do not send keep their
value. Secrets (password, s3_secret_key, rest_password) are write-only:
an empty value keeps the stored one. A new repository password resets
password_confirmed unless the same request confirms it. Secrets are
stored encrypted with the public key of the backup container (sealed
box); until the container has published its key, saving a secret
fails with backup_secrets_key_missing. A new repository password only
works for a new repository (restic keeps the password a repository was
created with). Changing the target, its connection or the password
makes the backup container test the repository again; changing the
SFTP host or port forgets the host key. enabled needs a complete
target and repository password (backup_not_configured). Invalid values
are refused with backup_setting_invalid and the name of the setting;
nothing is saved then.
operationId: Update backup settings
requestBody:
content:
application/json:
schema:
example:
items:
- settings
attr:
enabled: "1"
target: s3
s3_endpoint: https://s3.eu-central-1.amazonaws.com
s3_bucket: mailcow-backup
s3_access_key: AKIAEXAMPLE
s3_secret_key: secret
password: a-long-repository-password
password_confirmed: "1"
schedule:
- days: [1, 3]
time: "12:00"
- days: [5, 6, 0]
time: "18:00"
keep_daily: 14
properties:
attr:
properties:
enabled:
description: back up at the times of schedule (needs at least one entry)
type: boolean
target:
description: s3, sftp, rest or local
type: string
s3_endpoint:
description: http(s) URL of the S3 endpoint
type: string
s3_bucket:
type: string
s3_path:
description: path in the bucket (optional)
type: string
s3_region:
description: optional
type: string
s3_access_key:
type: string
s3_secret_key:
description: write-only
type: string
s3_path_style:
description: path-style bucket access (restic -o s3.bucket-lookup=path)
type: boolean
sftp_host:
type: string
sftp_port:
type: number
sftp_user:
type: string
sftp_path:
description: directory of the repository, relative to the home directory or absolute
type: string
rest_url:
description: URL of the restic REST server without credentials, e.g. https://backup.example.org:8000/mailcow
type: string
rest_user:
type: string
rest_password:
description: write-only
type: string
local_path:
description: subdirectory below RESTIC_LOCAL_PATH (default ./data/restic), no ".."
type: string
password:
description: restic repository password, at least 8 characters, write-only
type: string
password_confirmed:
description: the admin confirms that the repository password is stored outside of mailcow; backups need it
type: boolean
schedule:
description: the scheduled backups, up to 24 entries (also as a JSON string); each backs up on its weekdays at its time
type: array
items:
type: object
properties:
days:
description: weekdays, 0 (Sunday) to 6, at least one
type: array
items:
type: number
time:
description: HH:MM in the time zone of mailcow
type: string
keep_hourly:
description: hourly snapshots to keep (for several backups a day), 0 to 1000
type: number
keep_daily:
description: daily snapshots to keep, 0 to 1000
type: number
keep_weekly:
type: number
keep_monthly:
type: number
keep_yearly:
type: number
prune_weekday:
description: 0 (Sunday) to 6, prune once a week after the first scheduled backup from that day on; empty string = never (manual prune jobs only, e.g. for append-only or object-lock targets)
oneOf:
- type: number
- type: string
max_unused:
description: restic prune --max-unused, 0% to 100%
type: string
check_subset:
description: after the first scheduled backup of a day check 1/N of the data (0 = off), 0 to 365
type: number
include_index:
description: back up the Dovecot index
type: boolean
include_fts:
description: include the full-text search index (with include_index)
type: boolean
limit_upload:
description: KiB/s, 0 = unlimited
type: number
notify_email:
description: addresses separated by commas that get a mail when a job fails, max. 20
type: string
type: object
items:
description: 'contains ["settings"]'
type: object
type: object
summary: Update backup settings
/api/v1/edit/identity-provider-sync:
post:
responses:
@@ -7257,6 +7678,516 @@ paths:
mailboxes they manage.
operationId: Get effective retention rules
summary: Get effective retention rules
/api/v1/get/backup-settings:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
target: sftp
s3_endpoint: https://s3.amazonaws.com
s3_bucket: ""
s3_path: ""
s3_region: ""
s3_access_key: ""
s3_secret_key_set: 0
s3_path_style: 0
sftp_host: backup.example.org
sftp_port: 22
sftp_user: mailcow
sftp_path: backups/mailcow
sftp_public_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... mailcow-backup@mail.example.org
sftp_host_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
rest_url: ""
rest_user: ""
rest_password_set: 0
local_path: mailcow
password_set: 1
password_confirmed: 1
schedule:
- days: [0, 1, 2, 3, 4, 5, 6]
time: "02:30"
keep_hourly: 0
keep_daily: 14
keep_weekly: 4
keep_monthly: 12
keep_yearly: 0
prune_weekday: 0
max_unused: 10%
check_subset: 30
include_index: 1
include_fts: 1
limit_upload: 0
notify_email: admin@example.org
state_initialized: 1
state_runner_seen: 1794093697
state_last_backup: 6
state_last_success: 1794089278
state_repo_size: 84418594
state_repo_stats:
total_size: 84418594
snapshots_count: 2
configured: 1
description: OK
headers: {}
tags:
- Backup
description: >-
The backup settings (admins, also read-only API keys). Secrets are
never returned, password_set, s3_secret_key_set and rest_password_set
tell whether they are set. sftp_public_key (add it to the
authorized_keys of the SFTP user), sftp_host_key and the state_* values
are written by the backup container. configured: target and repository
password are complete. {} for everybody else.
operationId: Get backup settings
summary: Get backup settings
/api/v1/get/backup-status:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
target: local
configured: 1
password_confirmed: 1
initialized: 1
runner_seen: 1794094257
runner_age: 4
runner_ok: 1
last_success: 1794089278
repo_size: 84418594
repo_stats:
total_size: 84418594
snapshots_count: 2
snapshots: 2
latest_snapshot: 1794089277
next_run: 1794101400
schedule_gap: 86400
secrets_error: null
mode: all
jobs:
backup:
id: 26
type: backup
state: done
step: null
params: {}
result:
snapshot: f4f5a5fec226eec1cdd8ddd8f02b1fe8e6a1441ce130c507ca877f8b26a0bb3e
error: null
domain: ""
requested_by: schedule
created: 1794093646
started: 1794093646
finished: 1794093653
check: null
prune: null
init: null
restore: null
active: []
description: OK
headers: {}
tags:
- Backup
description: >-
State of the backup (admins): configuration, heartbeat of the backup
container (runner_ok: reported within the last 120 seconds), last
successful backup, repository size, number of snapshots, the next
scheduled run (null while the schedule is off or waits for the
password confirmation), schedule_gap (the longest time between two
scheduled backups in seconds; a last success older than that is
overdue), the newest job of each type and the queued and running jobs. secrets_error (null when fine): the backup container
cannot decrypt the stored secrets (e.g. after the database was restored on another
server); enter the repository password and the credentials again.
mode: RESTIC_BACKUP_MODE of mailcow.conf (all, data or config; null
until the backup container reported it). Times are unix timestamps.
operationId: Get backup status
summary: Get backup status
/api/v1/get/backup-snapshots:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
short_id: 3ee0b6f7
time: 1794089277
hostname: mail.example.org
tags:
- mailcow
summary:
files_new: 104
files_changed: 24
data_added: 4103499
total_bytes_processed: 92154276
domains:
- example.org
mode: all
description: OK
headers: {}
tags:
- Backup
description: >-
The snapshots in the repository, newest first, as of the last backup
or prune (admins). Domain admins get the snapshots that hold one of
their domains and were made after the domain was created, with id,
short_id, time, mode and those domains only. domains is null for
snapshots not made by mailcow. mode: what the backup holds, from
RESTIC_BACKUP_MODE (tag mode:<mode>): all, data (without the mailcow
directory) or config (without mails).
operationId: Get backup snapshots
summary: Get backup snapshots
"/api/v1/get/backup-snapshot-content/{snapshot}":
get:
parameters:
- description: id or short id of the snapshot
example: 3ee0b6f7
in: path
name: snapshot
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
time: 1794089277
available: 1
domains:
- domain: example.org
exists: 1
mailboxes: 42
missing: 2
- domain: old.example.org
exists: 0
mailboxes: 5
missing: 5
description: OK
headers: {}
tags:
- Backup
description: >-
The domains of a snapshot with their state now: exists (the domain
exists now), mailboxes (in the snapshot) and missing (of them deleted
now). Admins get all domains, domain admins the domains they
administer now (snapshots made after the domain was created). available
0: the snapshot has no content list (made before mailcow kept one), the
list then comes from its manifest and counts the mailboxes that exist
now; only those can be restored. {} if the snapshot does not exist or
is not visible.
operationId: Get backup snapshot content
summary: Get backup snapshot content
"/api/v1/get/backup-snapshot-content/{snapshot}/{domain}":
get:
parameters:
- description: id or short id of the snapshot
example: 3ee0b6f7
in: path
name: snapshot
required: true
schema:
type: string
- description: a domain of the snapshot
example: example.org
in: path
name: domain
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
domain: example.org
exists: 1
mailboxes:
- username: info@example.org
name: Info
exists: 1
- username: former@example.org
name: Former Colleague
exists: 0
description: OK
headers: {}
tags:
- Backup
description: >-
The mailboxes of a domain in a snapshot with their state now (exists 0:
deleted since, a restore creates it from the backup). Same visibility
as get/backup-snapshot-content/{snapshot}; {} if not visible.
operationId: Get backup snapshot domain content
summary: Get backup snapshot domain content
"/api/v1/get/backup-snapshot-search/{snapshot}/{term}":
get:
parameters:
- description: id or short id of the snapshot
example: 3ee0b6f7
in: path
name: snapshot
required: true
schema:
type: string
- description: search term, URL-encoded (path segment)
example: carol
in: path
name: term
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
domains:
- domain: example.org
exists: 0
mailboxes:
- username: carol@example.org
name: Carol
exists: 0
truncated: 0
description: OK
headers: {}
tags:
- Backup
description: >-
Search the domains and mailboxes of a snapshot (case-insensitive
"contains"). A plain term matches the domain name, the local part, the
full address or the display name; a matching domain comes with all its
mailboxes. "x@" matches the local part only, "@d" returns all mailboxes
of the matching domains (a matching domain also appears without
mailboxes), "x@d" matches the full address only. An empty term or "@"
returns no domains. At most 500 mailboxes, then truncated is 1. Same
visibility as get/backup-snapshot-content (domain admins: domains they
administer, snapshots made after the domain was created); {} when the
snapshot does not exist or is not visible. For snapshots without a
content list the current mailboxes of the snapshot's domains are
searched. The path is decoded before json_api.php reads it: encode the
term twice when it may contain "&", "+", "%" or "#" (the UI always
encodes it twice).
operationId: Search backup snapshot
summary: Search backup snapshot
"/api/v1/get/backup-jobs/{count}":
get:
parameters:
- description: Number of jobs to return, newest first (default 100 without this part, max. 1000)
example: 100
in: path
name: count
required: true
schema:
type: number
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 12
type: restore
state: done
step: null
params:
snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
snapshot_time: 1794089277
scope: mailbox
domain: example.org
mailboxes:
- user@example.org
components:
- mails
- aliases
mail_target: folder
requested_role: admin
create_domain: 0
create_mailboxes:
- user@example.org
result:
snapshot:
id: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
short_id: 3ee0b6f7
time: "2026-11-07 23:07"
mailboxes:
user@example.org:
mailbox:
added: 1
created: 1
mails:
added: 32
skipped: 0
errors: []
folder: Restored 2026-11-07 23:07
aliases:
added: 1
skipped: 2
errors: []
errors: 0
error: null
domain: example.org
requested_by: admin
created: 1794094015
started: 1794094016
finished: 1794094062
description: OK
headers: {}
tags:
- Backup
description: >-
Backup jobs without their log, newest first: admins all jobs, domain
admins the restores of their domains. state: queued, running, done,
failed or cancelled; step: what a running job does. The result of a
restore holds per mailbox and component the numbers of added and
already existing (skipped) entries and the errors; "mailbox" tells
whether a deleted mailbox was created, "domain" (next to "mailboxes")
the creation of a deleted domain. Finished jobs are kept for 180 days.
operationId: Get backup jobs
summary: Get backup jobs
"/api/v1/get/backup-jobs/{count}/{type}":
get:
parameters:
- description: Number of jobs to return, newest first; 0 = 100, max. 1000
example: 0
in: path
name: count
required: true
schema:
type: number
- description: only jobs of this type (backup, check, prune, init or restore); an unknown type returns []
example: restore
in: path
name: type
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 12
type: restore
state: done
step: null
params:
snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
scope: mailbox
domain: example.org
mailboxes:
- user@example.org
components:
- mails
mail_target: folder
result: null
error: null
domain: example.org
requested_by: admin
created: 1794094015
started: 1794094016
finished: 1794094062
description: OK
headers: {}
tags:
- Backup
description: >-
Backup jobs of one type without their log, newest first, with the
visibility of get/backup-jobs/{count} (domain admins: the restores of
their domains). E.g. get/backup-jobs/0/restore for the restores.
operationId: Get backup jobs of a type
summary: Get backup jobs of a type
"/api/v1/get/backup-job/{id}":
get:
parameters:
- description: id of the job
example: 12
in: path
name: id
required: true
schema:
type: number
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
id: 11
type: backup
state: done
step: null
params: {}
result:
dump_bytes: 667156
export:
domains: 1
failed: []
snapshot: f4f5a5fec226eec1cdd8ddd8f02b1fe8e6a1441ce130c507ca877f8b26a0bb3e
summary:
files_new: 1696
files_changed: 0
data_added: 93096838
total_bytes_processed: 95911108
forget:
removed: 0
snapshots: 3
error: null
domain: ""
requested_by: schedule
created: 1794093646
started: 1794093646
finished: 1794093653
log: "2026-11-08 02:30:01 backup job started (requested by schedule)\n2026-11-08 02:30:03 snapshot f4f5a5fe: 1696 new and 0 changed files, 88 MiB added, 91 MiB processed\n2026-11-08 02:30:07 backup job done"
description: OK
headers: {}
tags:
- Backup
description: >-
A backup job with its log (the same entry as in get/backup-jobs plus
"log"). {} if the job does not exist or the caller may not see it.
operationId: Get backup job
summary: Get backup job
/api/v1/get/identity-provider/sync-status:
get:
responses:
@@ -8817,6 +9748,8 @@ tags:
description: Whom mailboxes and groups may send to and receive from
- name: Retention rules
description: Delete mail from folders after a number of days
- name: Backup
description: Encrypted backups with restic, restore of mailboxes and domains
- name: Routing
description: Define your own email routes
- name: Resources
+133 -83
View File
@@ -1,83 +1,133 @@
.pagination a {
text-decoration: none !important;
}
.panel.panel-default {
overflow: visible !important;
}
.table-responsive {
overflow: visible !important;
}
.table-responsive {
overflow-x: scroll !important;
}
body {
overflow-y:scroll;
}
/* Fix modal moving content left */
body.modal-open {
overflow-y:scroll;
padding-right: inherit !important;
}
@media (min-width: 992px) {
.container {
width: 80%;
}
}
.mass-actions-admin {
user-select: none;
}
.inputMissingAttr {
border-color: #FF4136;
}
.rotate {
-moz-transition: all 0.3s linear;
-webkit-transition: all 0.3s linear;
transition: all 0.3s linear;
}
.rotate.animation {
-ms-transform:rotateX(180deg);
-moz-transform:rotateX(180deg);
-webkit-transform:rotateX(180deg);
transform:rotateX(180deg);
}
.anchor {
display: block;
height: 65px;
margin-top: -65px;
visibility: hidden;
}
.thumbnail img {
min-height:100px;
height:100px;
}
.nav-tabs>li>a {
z-index: 1;
}
.table-condensed .input-sm {
width: 100%!important;
}
.table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td {
padding: 3px;
}
table tbody tr td input[type="checkbox"] {
cursor: pointer;
}
#quarantine_template {
margin:20px;
}
.regex-input {
font-family: Consolas,monaco,monospace;
font-size: 1rem;
}
.label-keys {
font-size:100%;
margin: 0px !important;
white-space: normal !important;
}
.key-action {
font-weight:bold;
color:white !important;
}
.dkim-label {
margin: 0 0 8px !important;
}
.pagination a {
text-decoration: none !important;
}
.panel.panel-default {
overflow: visible !important;
}
.table-responsive {
overflow: visible !important;
}
.table-responsive {
overflow-x: scroll !important;
}
body {
overflow-y:scroll;
}
/* Fix modal moving content left */
body.modal-open {
overflow-y:scroll;
padding-right: inherit !important;
}
@media (min-width: 992px) {
.container {
width: 80%;
}
}
.mass-actions-admin {
user-select: none;
}
.inputMissingAttr {
border-color: #FF4136;
}
.rotate {
-moz-transition: all 0.3s linear;
-webkit-transition: all 0.3s linear;
transition: all 0.3s linear;
}
.rotate.animation {
-ms-transform:rotateX(180deg);
-moz-transform:rotateX(180deg);
-webkit-transform:rotateX(180deg);
transform:rotateX(180deg);
}
.anchor {
display: block;
height: 65px;
margin-top: -65px;
visibility: hidden;
}
.thumbnail img {
min-height:100px;
height:100px;
}
.nav-tabs>li>a {
z-index: 1;
}
.table-condensed .input-sm {
width: 100%!important;
}
.table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td {
padding: 3px;
}
table tbody tr td input[type="checkbox"] {
cursor: pointer;
}
#quarantine_template {
margin:20px;
}
.regex-input {
font-family: Consolas,monaco,monospace;
font-size: 1rem;
}
.label-keys {
font-size:100%;
margin: 0px !important;
white-space: normal !important;
}
.key-action {
font-weight:bold;
color:white !important;
}
.dkim-label {
margin: 0 0 8px !important;
}
/* backup schedule: the weekday toggles and the remove buttons of the entries react to the mouse, in every theme */
.backup-schedule-days .btn:hover {
background-image: linear-gradient(rgba(128, 128, 128, .3), rgba(128, 128, 128, .3));
}
.backup-schedule-remove {
opacity: .6;
}
.backup-schedule-remove:hover, .backup-schedule-remove:focus-visible {
opacity: 1;
color: var(--bs-danger) !important;
}
/* result and log of a backup job: the border of the theme in light and dark mode (Bootstrap's .border stays light) */
.backup-pre {
border: 1px solid var(--mc-border);
}
/* result of a restore job: an entry to open per mailbox, numbers in columns */
.backup-result {
border: 1px solid var(--mc-border);
}
.backup-result > details + details {
border-top: 1px solid var(--mc-border);
}
.backup-result summary {
list-style: none;
cursor: pointer;
}
.backup-result summary::-webkit-details-marker {
display: none;
}
.backup-result summary:hover {
background-image: linear-gradient(rgba(128, 128, 128, .12), rgba(128, 128, 128, .12));
}
.backup-result summary .bi-chevron-right {
transition: transform .15s;
}
.backup-result details[open] > summary .bi-chevron-right {
transform: rotate(90deg);
}
.backup-result-body {
padding-left: 2.6rem !important;
}
.backup-result-body .table {
--bs-table-bg: transparent;
}
.backup-result-body .num {
width: 8.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+13 -1
View File
@@ -495,4 +495,16 @@ input::placeholder {
}
.btn-light, .btn-light:hover {
background-image: none;
}
}
/* native time picker icon and popup in dark mode */
input[type="time"] {
color-scheme: dark;
}
/* switches while off: a light knob and a visible border (Bootstrap's dark knob is for light pages, the dark mode
does not set data-bs-theme) */
.form-switch .form-check-input:not(:checked):not(:focus) {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.6%29'/%3e%3c/svg%3e");
}
.form-switch .form-check-input:not(:checked) {
border-color: var(--mc-btn-outline-border);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
protect_route(['domainadmin']);
$role = "domainadmin";
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/restore.inc.php';
+1
View File
@@ -76,6 +76,7 @@ $globalVariables = [
'lang_groups' => json_encode($lang['groups']),
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
'lang_retention' => json_encode($lang['retention'] ?? []),
'lang_backup' => json_encode($lang['backup'] ?? []),
'docker_timeout' => $DOCKER_TIMEOUT,
'session_lifetime' => (int)$SESSION_LIFETIME,
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
+61
View File
@@ -681,6 +681,67 @@ function init_db_schema()
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"backup_setting" => array(
"cols" => array(
"name" => "VARCHAR(64) NOT NULL",
"value" => "TEXT NOT NULL",
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
),
"keys" => array(
"primary" => array(
"" => array("name")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"backup_job" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
"type" => "ENUM('backup', 'check', 'prune', 'init', 'restore') NOT NULL",
"state" => "ENUM('queued', 'running', 'done', 'failed', 'cancelled') NOT NULL DEFAULT 'queued'",
"step" => "VARCHAR(32) NULL DEFAULT NULL",
"params" => "MEDIUMTEXT NULL DEFAULT NULL",
"result" => "MEDIUMTEXT NULL DEFAULT NULL",
"log" => "MEDIUMTEXT NULL DEFAULT NULL",
"error" => "TEXT NULL DEFAULT NULL",
"domain" => "VARCHAR(255) NOT NULL DEFAULT ''",
"requested_by" => "VARCHAR(255) NOT NULL DEFAULT ''",
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
"started" => "DATETIME NULL DEFAULT NULL",
"finished" => "DATETIME NULL DEFAULT NULL"
),
"keys" => array(
"primary" => array(
"" => array("id")
),
"key" => array(
"state_type" => array("state", "type"),
"domain_created" => array("domain", "created")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"backup_snapshot" => array(
"cols" => array(
"id" => "VARCHAR(64) NOT NULL",
"short_id" => "VARCHAR(16) NOT NULL",
"time" => "DATETIME NOT NULL",
"hostname" => "VARCHAR(255) NOT NULL DEFAULT ''",
"tags" => "VARCHAR(255) NOT NULL DEFAULT ''",
"summary" => "TEXT NULL DEFAULT NULL",
"domains" => "MEDIUMTEXT NULL DEFAULT NULL",
"content" => "MEDIUMBLOB NULL DEFAULT NULL"
),
"keys" => array(
"primary" => array(
"" => array("id")
),
"key" => array(
"time" => array("time")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"mailbox_group_imap_grant" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup;
use Mailcow\Backup\Controller\BackupApiController;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Legacy\NoTemplates;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Repository\ExportRepository;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\RestoreRepository;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Backup\Repository\SnapshotRepository;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Backup\Service\GroupMembers;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use Mailcow\Dovecot\DovecotModule;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\MailboxGroup\Service\TemplateService;
use Mailcow\Sogo\SogoModule;
use PDO;
use Redis;
/**
* Composition root of the backup with restic: settings, jobs of the runners, snapshots, the export of a domain and the
* restore of mailboxes and domains. The runner in the restic container reaches the export and the restore actions
* through data/conf/rspamd/dynmaps/backup.php.
*
* process_get_return(BackupModule::fromGlobals()->api->getSettings());
* echo json_encode(BackupModule::create($pdo, Context::system(), $config, $redis)->api->export($domain, $page)); // backup.php
*
* $config: timezone (TZ of mailcow, for the schedule), alias_disable_external_domains ($ALIAS_DISABLE_EXTERNAL_DOMAINS).
* $redis: for DKIM keys, rate limits and the domain map (export and restore).
*/
class BackupModule
{
public function __construct(
public readonly SettingsService $settings,
public readonly JobService $jobs,
public readonly ExportService $export,
public readonly RestoreService $restore,
public readonly DomainRestoreService $domainRestore,
public readonly BackupApiController $api
) {
}
public static function create(PDO $pdo, Context $context, array $config = [], ?Redis $redis = null): self
{
$jobRepo = new JobRepository($pdo);
$restoreRepo = new RestoreRepository($pdo);
$legacy = new LegacyDomain($redis);
$settings = new SettingsService(new SettingsRepository($pdo), $context);
$jobs = new JobService($jobRepo, new SnapshotRepository($pdo), $settings, $context, (string)($config['timezone'] ?? 'UTC'));
$export = new ExportService(new ExportRepository($pdo), $legacy);
$sogo = SogoModule::create()->sogo;
$dovecot = DovecotModule::create()->dovecot;
// the group module without group templates: a restored mailbox keeps the settings of the backup
$groupRepo = new GroupRepository($pdo);
$lookup = new LookupRepository($pdo);
$templates = new TemplateService($lookup, new NoTemplates());
$effects = new ChangeEffects($templates, $sogo, $groupRepo, $dovecot);
$groups = new GroupMembers($groupRepo, $lookup, $templates, $effects, new SharingService($groupRepo, $dovecot, $sogo, $effects));
$restore = new RestoreService($restoreRepo, $jobRepo, $groups, $sogo, $dovecot, $legacy, (bool)($config['alias_disable_external_domains'] ?? false));
$domainRestore = new DomainRestoreService($restoreRepo, $jobRepo, $groups, $legacy);
return new self($settings, $jobs, $export, $restore, $domainRestore,
new BackupApiController($settings, $jobs, $export, $restore, $domainRestore, new ApiResponse(Settings::SECRETS)));
}
/** Legacy entry point for procedural callers: reads $pdo, $redis, $_SESSION, TZ and $ALIAS_DISABLE_EXTERNAL_DOMAINS */
public static function fromGlobals(?Context $context = null): self
{
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [
'timezone' => getenv('TZ') ?: 'UTC',
'alias_disable_external_domains' => ($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] ?? false) === true,
], ($GLOBALS['redis'] ?? null) instanceof Redis ? $GLOBALS['redis'] : null);
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Controller;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
/**
* HTTP API adapter for json_api.php (routes backup-settings, backup-status, backup-snapshots, backup-snapshot-content,
* backup-jobs, backup-job, backup-restore) and the runner's endpoint data/conf/rspamd/dynmaps/backup.php (domains,
* export pages, restore actions). Messages are logged as ['backup', action, object, data].
*/
class BackupApiController
{
public function __construct(private SettingsService $settings, private JobService $jobs, private ExportService $export,
private RestoreService $restore, private DomainRestoreService $domainRestore, private ApiResponse $response)
{
}
/** GET backup-settings: secrets as <key>_set; {} for non-admins */
public function getSettings(): array
{
return $this->settings->get()?->toArray() ?? [];
}
/** POST edit/backup-settings */
public function editSettings(array $attr): bool
{
return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
$this->settings->save($attr);
return $this->response->success('backup_settings_saved', $log);
});
}
/** GET backup-status */
public function getStatus(): array
{
return $this->jobs->status();
}
/** GET backup-snapshots */
public function getSnapshots(): array
{
return $this->jobs->snapshots();
}
/** GET backup-snapshot-content/<snapshot>[/<domain>]: domains of a snapshot, or the mailboxes of one, with their state now */
public function getSnapshotContent(mixed $snapshot, mixed $domain = null): array
{
return ($domain === null || $domain === '') ? $this->jobs->content($snapshot) : $this->jobs->domainContent($snapshot, $domain);
}
/** GET backup-snapshot-search/<snapshot>/<term>: domains and mailboxes of a snapshot matching the term */
public function getSnapshotSearch(mixed $snapshot, mixed $term): array
{
return $this->jobs->search($snapshot, $term);
}
/** GET backup-jobs[/<count>[/<type>]]: newest first, without logs; count 0 = default */
public function getJobs(int $limit = 0, mixed $type = null): array
{
return array_map(fn(Job $job): array => $this->jobs->view($job), $this->jobs->list($limit, $type));
}
/** GET backup-job/<id>: with its log */
public function getJob(mixed $id): array
{
$id = $this->toId($id);
$job = ($id === null) ? null : $this->jobs->get($id);
return ($job === null) ? [] : $this->jobs->view($job, true);
}
/** POST add/backup-job: {type: backup|check|prune|init} */
public function addJob(array $attr): bool
{
return $this->handle('add', 'job', $attr, function (array $log) use ($attr): bool {
$job = $this->jobs->create($attr);
return $this->response->success(['backup_job_added', $job->type, $job->id], $log);
});
}
/** POST add/backup-restore: {snapshot, scope, mailboxes | domain, components, mail_target} */
public function addRestore(array $attr): bool
{
return $this->handle('add', 'restore', $attr, function (array $log) use ($attr): bool {
$job = $this->jobs->restore($attr);
return $this->response->success(['backup_restore_added', $job->id, count($job->mailboxes())], $log);
});
}
/** POST delete/backup-job: cancels queued and running jobs, one message per id */
public function deleteJobs(array $items): bool
{
return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool {
foreach ($items as $id) {
try {
$job = $this->jobs->cancel($this->requireId($id));
$this->response->success(['backup_job_cancelled', $job->id], $log);
} catch (UserError $e) {
$this->response->error($e, $log);
}
}
return true;
});
}
/** backup.php ?domains=1 */
public function domains(): array
{
return ['domains' => $this->export->domains()];
}
/** backup.php ?export=<domain>[&after=<username>] @throws UserError */
public function export(string $domain, ?string $after = null): array
{
return $this->export->export($domain, $after);
}
/**
* backup.php POST of the runner, {"result": ...} of
* {"action": "restore_domain", "job": id, "data": <domain_data>}: creates a deleted domain
* {"action": "restore_aliases", "job": id, "aliases": [...]}: a part of the domain's aliases (scope domain)
* {"action": "restore", "job": id, "username": u, "data": <mailbox section>}: one mailbox
*
* @throws UserError
*/
public function post(array $body): array
{
return ['result' => match ($body['action'] ?? null) {
'restore' => $this->restore->merge($body['job'] ?? null, $body['username'] ?? null, $body['data'] ?? null),
'restore_aliases' => $this->restore->mergeAliases($body['job'] ?? null, $body['aliases'] ?? null),
'restore_domain' => $this->domainRestore->restore($body['job'] ?? null, $body['data'] ?? null),
default => throw new UserError('backup_action_invalid'),
}];
}
/**
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
*/
private function handle(string $action, string $object, mixed $data, callable $work): bool
{
$log = ['backup', $action, $object, $data];
try {
$result = $work($log);
} catch (UserError $e) {
$result = $this->response->error($e, $log);
} catch (PDOException $e) {
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
}
$this->response->flush();
return $result;
}
/** Integer id from request input ("12", 12); null for anything else */
private function toId(mixed $id): ?int
{
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
}
/** @throws AccessDenied for ids that cannot exist */
private function requireId(mixed $id): int
{
return $this->toId($id) ?? throw new AccessDenied();
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Legacy;
use Redis;
use RuntimeException;
/**
* What the legacy code keeps outside the database for domains and mailboxes: DKIM keys, rate limits and the domain
* map in Redis (functions.dkim/ratelimit/mailbox.inc.php), and the SOGo restart through dockerapi. Redis and docker()
* sit in protected methods so tests can replace them.
*/
class LegacyDomain
{
public function __construct(private ?Redis $redis = null)
{
}
/** @return ?array{selector: string, pubkey: string, privkey: string} the DKIM key of a domain */
public function dkim(string $domain): ?array
{
$pubkey = $this->redis()->hGet('DKIM_PUB_KEYS', $domain);
if (!is_string($pubkey) || $pubkey === '') {
return null;
}
$selector = (string)$this->redis()->hGet('DKIM_SELECTORS', $domain);
return ['selector' => $selector, 'pubkey' => $pubkey, 'privkey' => (string)$this->redis()->hGet('DKIM_PRIV_KEYS', $selector . '.' . $domain)];
}
/** Stores a DKIM key like dkim('import') does */
public function addDkim(string $domain, string $selector, string $pubkey, string $privkey): void
{
$this->redis()->hSet('DKIM_PUB_KEYS', $domain, $pubkey);
$this->redis()->hSet('DKIM_SELECTORS', $domain, $selector);
$this->redis()->hSet('DKIM_PRIV_KEYS', $selector . '.' . $domain, $privkey);
}
/**
* @param string[] $objects domains or mailboxes
* @return array<string, string> object => rate limit ("10 / 1h") of those with one
*/
public function rateLimits(array $objects): array
{
if (empty($objects)) {
return [];
}
$values = $this->redis()->hMGet('RL_VALUE', array_values($objects));
return array_filter(is_array($values) ? $values : [], fn(mixed $value): bool => is_string($value) && $value !== '');
}
public function setRateLimit(string $object, string $value): void
{
$this->redis()->hSet('RL_VALUE', $object, $value);
}
/** The domain is local for rspamd (legacy domain and alias domain add) */
public function addDomainMap(string $domain): void
{
$this->redis()->hSet('DOMAIN_MAP', $domain, 1);
}
/**
* SOGo reads its domains at start: restart it after a domain was added (like the "restart SOGo" option of the form)
*
* @return ?string null on success, else the error of dockerapi
*/
public function restartSogo(): ?string
{
$result = json_decode((string)$this->dockerRestart('sogo-mailcow'), true);
return (($result['type'] ?? '') === 'success') ? null : (string)($result['msg'] ?? 'dockerapi did not answer');
}
protected function redis(): Redis
{
return $this->redis ?? throw new RuntimeException('Redis is not connected');
}
protected function dockerRestart(string $service): mixed
{
return docker('post', $service, 'restart');
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Legacy;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
/**
* The group module's bridge to the mailbox code for restores: a membership restored from a backup never applies the
* group's mailbox template (the mailbox gets its settings from the backup).
*/
class NoTemplates extends LegacyMailbox
{
public function applyTemplate(string $username, string $name, string $template): ?string
{
return null;
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
/**
* A job of the backup runners (table `backup_job`): backup, check, prune and init run in the restic container; a
* restore runs there (step `files`, then the merge through backup.php) and, for mails, IMAP ACLs and Sieve
* scripts, in the dovecot container (step `mails`). Times are unix timestamps.
*/
class Job
{
public const TYPES = ['backup', 'check', 'prune', 'init', 'restore'];
/** Types an admin starts with add/backup-job */
public const MANUAL_TYPES = ['backup', 'check', 'prune', 'init'];
public const ACTIVE_STATES = ['queued', 'running'];
public ?int $id = null;
public string $type = 'backup';
public string $state = 'queued';
public ?string $step = null;
/**
* restore: snapshot, snapshot_time, scope, domain, mailboxes, components, mail_target, requested_role, create_domain (0|1),
* create_mailboxes (missing when requested), acl and allowed_domains (domain admins)
*/
public array $params = [];
public ?array $result = null;
/** Only loaded for a single job */
public ?string $log = null;
public ?string $error = null;
/** restore: the domain of its mailboxes, so domain admins see their restores */
public string $domain = '';
public string $requestedBy = '';
public ?int $created = null;
public ?int $started = null;
public ?int $finished = null;
public function __construct(string $type = 'backup')
{
$this->type = $type;
}
/** Row of `backup_job` with the times as unix timestamps */
public static function fromRow(array $row): self
{
$job = new self((string)$row['type']);
$job->id = (int)$row['id'];
$job->state = (string)$row['state'];
$job->step = isset($row['step']) ? (string)$row['step'] : null;
$params = is_string($row['params'] ?? null) ? json_decode($row['params'], true) : null;
$job->params = is_array($params) ? $params : [];
$result = is_string($row['result'] ?? null) ? json_decode($row['result'], true) : null;
$job->result = is_array($result) ? $result : null;
$job->log = isset($row['log']) ? (string)$row['log'] : null;
$job->error = isset($row['error']) ? (string)$row['error'] : null;
$job->domain = (string)($row['domain'] ?? '');
$job->requestedBy = (string)($row['requested_by'] ?? '');
foreach (['created', 'started', 'finished'] as $time) {
$job->$time = isset($row[$time]) ? (int)$row[$time] : null;
}
return $job;
}
/** Column values for a new row */
public function toRow(): array
{
return [
'type' => $this->type,
'state' => $this->state,
'step' => $this->step,
'params' => json_encode($this->params ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'domain' => $this->domain,
'requested_by' => $this->requestedBy,
];
}
/** @return string[] mailboxes of a restore */
public function mailboxes(): array
{
return array_values(array_filter((array)($this->params['mailboxes'] ?? []), 'is_string'));
}
/** @return string[] components of a restore */
public function components(): array
{
return array_values(array_filter((array)($this->params['components'] ?? []), 'is_string'));
}
/** @return string[] mailboxes of a restore that did not exist when it was requested */
public function createMailboxes(): array
{
return array_values(array_filter((array)($this->params['create_mailboxes'] ?? []), 'is_string'));
}
/** The restore creates its domain (requested by an admin while the domain did not exist) */
public function createsDomain(): bool
{
return (int)($this->params['create_domain'] ?? 0) === 1;
}
public function isActive(): bool
{
return in_array($this->state, self::ACTIVE_STATES, true);
}
/** Entry of the API; the log only with $withLog (get/backup-job/<id>) */
public function toArray(bool $withLog = false): array
{
$job = [
'id' => $this->id,
'type' => $this->type,
'state' => $this->state,
'step' => $this->step,
'params' => $this->params ?: new \stdClass(),
'result' => $this->result,
'error' => $this->error,
'domain' => $this->domain,
'requested_by' => $this->requestedBy,
'created' => $this->created,
'started' => $this->started,
'finished' => $this->finished,
];
if ($withLog) {
$job['log'] = (string)$this->log;
}
return $job;
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
use Mailcow\Core\Context;
/**
* The merge of one mailbox of a restore job: what the backup holds, who asked for the restore and what the merge
* did per component (added, skipped, errors), plus the caches to refresh afterwards.
*/
class RestoreRun
{
/** @var array<string, array{added: int, skipped: int, errors: string[]}> */
public array $results = [];
/** Mailboxes whose SOGo identities (aliases, send-as addresses) changed */
public array $sogoUsers = [];
/** Mailboxes whose userdb or passdb values changed (Dovecot auth cache) */
public array $authUsers = [];
/**
* @param array $mailbox the mailbox row as it is now
* @param array $params params of the restore job (requested_role, acl of a domain admin, scope, ...)
* @param array $data the mailbox's section of the domain export in the snapshot
* @param string[] $domains domains whose aliases and send-as addresses the restore may change, [] = all (admins)
* @param string $requestedBy who requested the restore
*/
/** The restore created the mailbox (its own alias gets the forwarding of the backup) */
public bool $created = false;
public function __construct(
public readonly string $username,
public readonly string $domain,
public readonly array $mailbox,
public readonly array $params,
public readonly array $data,
public readonly array $domains = [],
public readonly string $requestedBy = ''
) {
}
/**
* The caller the group module checks: an admin, or the domain admin who requested the restore with his ACL of then
* and his domains now
*/
public function requester(): Context
{
if ($this->byAdmin()) {
return new Context('admin', 'backup', [], true);
}
return new Context('domainadmin', $this->requestedBy, array_map(fn(mixed $flag): string => (string)(int)$flag, (array)($this->params['acl'] ?? [])));
}
public function start(string $component): void
{
$this->results[$component] = ['added' => 0, 'skipped' => 0, 'errors' => []];
}
public function added(string $component, int $count = 1): void
{
$this->results[$component]['added'] += $count;
}
public function skipped(string $component, int $count = 1): void
{
$this->results[$component]['skipped'] += $count;
}
public function error(string $component, string $error): void
{
$this->results[$component]['errors'][] = $error;
}
/** The part of a component was rolled back: nothing of it counts */
public function failed(string $component, string $error): void
{
$this->start($component);
$this->error($component, $error);
}
/** The restore was requested by an admin (domain admins are limited by their ACL and the job's domain) */
public function byAdmin(): bool
{
return ($this->params['requested_role'] ?? '') === 'admin';
}
/** Admins, or a domain admin with this ACL when he requested the restore */
public function may(string $acl): bool
{
return $this->byAdmin() || (int)($this->params['acl'][$acl] ?? 0) === 1;
}
public function mayChangeDomain(string $domain): bool
{
return $this->domains === [] || in_array(strtolower($domain), $this->domains, true);
}
/** Rows of a list in the backup (sieve_filters, tfa, ...) */
public function rows(string $key): array
{
return array_values(array_filter((array)($this->data[$key] ?? []), 'is_array'));
}
public function sogo(string ...$usernames): void
{
array_push($this->sogoUsers, ...$usernames);
$this->sogoUsers = array_values(array_unique($this->sogoUsers));
}
public function auth(string ...$usernames): void
{
array_push($this->authUsers, ...$usernames);
$this->authUsers = array_values(array_unique($this->authUsers));
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
/**
* Backup settings (table `backup_setting`, name/value pairs) over their defaults. Secrets are stored sealed for the
* runner (`sealed:v1:<base64>`) and never leave toArray(): the API gets `<key>_set` instead. The state_* values, the
* SSH public key and the SFTP host key are written by the runner in the restic container and cannot be set through
* the API (PHP writes state_target_changed when the target changes).
*/
class Settings
{
public const DEFAULTS = [
'enabled' => '0',
'target' => 's3',
's3_endpoint' => 'https://s3.amazonaws.com',
's3_bucket' => '',
's3_path' => '',
's3_region' => '',
's3_access_key' => '',
's3_secret_key' => '',
's3_path_style' => '0',
'sftp_host' => '',
'sftp_port' => '22',
'sftp_user' => '',
'sftp_path' => '',
'sftp_public_key' => '',
'sftp_host_key' => '',
'rest_url' => '',
'rest_user' => '',
'rest_password' => '',
'local_path' => 'mailcow',
'password' => '',
'password_confirmed' => '0',
// JSON list of the scheduled backups: {"days": [weekdays, 0 = Sunday], "time": "HH:MM"}
'schedule' => '[{"days":[0,1,2,3,4,5,6],"time":"01:00"}]',
'keep_hourly' => '0',
'keep_daily' => '7',
'keep_weekly' => '4',
'keep_monthly' => '12',
'keep_yearly' => '0',
'prune_weekday' => '0',
'max_unused' => '10%',
'check_subset' => '30',
'include_index' => '1',
'include_fts' => '1',
'limit_upload' => '0',
'notify_email' => '',
'state_initialized' => '0',
'state_runner_seen' => '',
'state_last_backup' => '',
'state_last_success' => '',
'state_repo_size' => '',
'state_repo_stats' => '',
'state_schedule_since' => '',
'state_schedule' => '',
// public key (base64, X25519) the secrets are sealed with; the private key stays in the restic container
'state_secrets_pubkey' => '',
'state_secrets_error' => '',
// JSON {old_target, old_addresses, changed, changed_by, time}: the runner mails the old addresses and clears it
'state_target_changed' => '',
// RESTIC_BACKUP_MODE of mailcow.conf as the runner sees it
'state_backup_mode' => '',
];
public const SECRETS = ['password', 's3_secret_key', 'rest_password'];
/** Written by the runner only */
public const RUNNER = ['sftp_public_key', 'sftp_host_key', 'state_initialized', 'state_runner_seen', 'state_last_backup',
'state_last_success', 'state_repo_size', 'state_repo_stats', 'state_schedule_since', 'state_schedule', 'state_secrets_pubkey',
'state_secrets_error', 'state_target_changed', 'state_backup_mode'];
/** Prefix of a sealed secret (sodium_crypto_box_seal with state_secrets_pubkey) */
public const SEALED = 'sealed:v1:';
public const TARGETS = ['s3', 'sftp', 'rest', 'local'];
/** The connection of each target: a change (or another target or repository password) resets state_initialized */
public const CONNECTION = [
's3' => ['s3_endpoint', 's3_bucket', 's3_path', 's3_region', 's3_access_key', 's3_secret_key', 's3_path_style'],
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
'rest' => ['rest_url', 'rest_user', 'rest_password'],
'local' => ['local_path'],
];
/** Values a target cannot work without (besides the repository password) */
public const REQUIRED = [
's3' => ['s3_endpoint', 's3_bucket', 's3_access_key', 's3_secret_key'],
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
'rest' => ['rest_url'],
'local' => ['local_path'],
];
/** Returned as numbers by the API ('' = not known yet: null; prune_weekday '' = never stays '') */
private const NUMBERS = ['enabled', 's3_path_style', 'sftp_port', 'password_confirmed', 'keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly',
'keep_yearly', 'prune_weekday', 'check_subset', 'include_index', 'include_fts', 'limit_upload', 'state_initialized',
'state_runner_seen', 'state_last_backup', 'state_last_success', 'state_repo_size', 'state_schedule_since'];
private const JSON = ['schedule', 'state_repo_stats', 'state_target_changed'];
/** @param array<string, string> $values every key of DEFAULTS */
public function __construct(public array $values = self::DEFAULTS)
{
}
/** Stored values (name => value) over the defaults; unknown names are dropped */
public static function fromStored(array $stored): self
{
$values = self::DEFAULTS;
foreach ($stored as $name => $value) {
if (array_key_exists($name, $values)) {
$values[$name] = (string)$value;
}
}
return new self($values);
}
public function get(string $name): string
{
return $this->values[$name] ?? '';
}
/** The target has its required values and the repository password is set */
public function configured(): bool
{
$required = self::REQUIRED[$this->get('target')] ?? null;
if ($required === null || $this->get('password') === '') {
return false;
}
foreach ($required as $name) {
if ($this->get($name) === '') {
return false;
}
}
return true;
}
/**
* The schedule as [weekday (0 = Sunday), hour, minute] slots, sorted and unique
*
* @return array<int, array{int, int, int}>
*/
public function scheduleSlots(): array
{
$entries = json_decode($this->get('schedule'), true);
$slots = [];
foreach (is_array($entries) ? $entries : [] as $entry) {
if (!is_array($entry) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', (string)($entry['time'] ?? ''), $time) || !is_array($entry['days'] ?? null)) {
continue;
}
foreach ($entry['days'] as $day) {
if (is_int($day) && $day >= 0 && $day <= 6) {
$slots[] = $day * 1440 + (int)$time[1] * 60 + (int)$time[2];
}
}
}
$slots = array_unique($slots);
sort($slots);
return array_map(fn(int $slot): array => [intdiv($slot, 1440), intdiv($slot % 1440, 60), $slot % 60], $slots);
}
/** The longest time between two scheduled backups of a week in seconds, null without a schedule */
public function scheduleGap(): ?int
{
$slots = array_map(fn(array $slot): int => $slot[0] * 86400 + $slot[1] * 3600 + $slot[2] * 60, $this->scheduleSlots());
if (empty($slots)) {
return null;
}
$gaps = [$slots[0] + 7 * 86400 - end($slots)];
for ($i = 1; $i < count($slots); $i++) {
$gaps[] = $slots[$i] - $slots[$i - 1];
}
return max($gaps);
}
/** Entry of get/backup-settings: every key, secrets as <key>_set, plus `configured` */
public function toArray(): array
{
$settings = [];
foreach ($this->values as $name => $value) {
if (in_array($name, self::SECRETS, true)) {
$settings[$name . '_set'] = (int)($value !== '');
} elseif (in_array($name, self::JSON, true)) {
$decoded = json_decode($value, true);
$settings[$name] = is_array($decoded) ? $decoded : null;
} elseif (in_array($name, self::NUMBERS, true)) {
$settings[$name] = ($value === '') ? (($name === 'prune_weekday') ? '' : null) : (int)$value;
} else {
$settings[$name] = $value;
}
}
$settings['configured'] = (int)$this->configured();
return $settings;
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
/**
* A snapshot of the repository (table `backup_snapshot`, the runner's cache of `restic snapshots --json`).
*/
class Snapshot
{
public string $id = '';
public string $shortId = '';
/** unix timestamp */
public int $time = 0;
public string $hostname = '';
/** @var string[] */
public array $tags = [];
/** restic's summary of the backup (data_added, files_new, ...) */
public ?array $summary = null;
/** @var ?string[] domains of its manifest, null for snapshots not made by mailcow */
public ?array $domains = null;
/** @var array<string, ?int> domain => its creation time (unix) when the snapshot was made, from the snapshot row */
public array $created = [];
/**
* @var ?array<string, array{created: ?int, mailboxes: array<string, string>}> per domain its creation time (unix, null
* when not recorded) and username => name of its mailboxes; null = not known (snapshots this installation did
* not make itself, e.g. of another server in the same repository)
*/
public ?array $content = null;
/** Row of `backup_snapshot` with the time as unix timestamp */
public static function fromRow(array $row): self
{
$snapshot = new self();
$snapshot->id = (string)$row['id'];
$snapshot->shortId = (string)$row['short_id'];
$snapshot->time = (int)$row['time'];
$snapshot->hostname = (string)($row['hostname'] ?? '');
$tags = (string)($row['tags'] ?? '');
$decoded = json_decode($tags, true);
$tags = is_array($decoded) ? $decoded : explode(',', $tags);
$snapshot->tags = array_values(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', $tags), 'strlen'));
$summary = is_string($row['summary'] ?? null) ? json_decode($row['summary'], true) : null;
$snapshot->summary = is_array($summary) ? $summary : null;
// {domain: creation time}: lists of snapshots need no content
$domains = is_string($row['domains'] ?? null) ? json_decode($row['domains'], true) : null;
if (is_array($domains)) {
$snapshot->domains = [];
foreach ($domains as $domain => $created) {
$snapshot->domains[] = (string)$domain;
$snapshot->created[strtolower((string)$domain)] = is_numeric($created) ? (int)$created : null;
}
}
if (is_string($row['content'] ?? null) && $row['content'] !== '') {
$snapshot->content = self::decodeContent($row['content']);
}
return $snapshot;
}
/**
* `content` (gzip JSON) written by the runner: {"domains": {"<domain>": {"created": <unix>, "mailboxes": {"<username>": "<name>"}}}}.
* Keys lower case; null when broken.
*/
public static function decodeContent(string $gzip): ?array
{
$json = @gzdecode($gzip);
$data = is_string($json) ? json_decode($json, true) : null;
if (!is_array($data) || !is_array($data['domains'] ?? null)) {
return null;
}
$content = [];
foreach ($data['domains'] as $domain => $entry) {
$mailboxes = is_array($entry) ? ($entry['mailboxes'] ?? []) : [];
$created = is_numeric($entry['created'] ?? null) ? (int)$entry['created'] : null;
$names = [];
foreach (is_array($mailboxes) ? $mailboxes : [] as $username => $name) {
$names[strtolower((string)$username)] = is_scalar($name) ? (string)$name : '';
}
$content[strtolower((string)$domain)] = ['created' => $created, 'mailboxes' => $names];
}
return $content;
}
/** Domains of the snapshot: those of its content and of its manifest (an export may have failed) */
public function domainList(): array
{
return array_values(array_unique(array_merge(array_map('strval', array_keys($this->content ?? [])), array_map('strtolower', $this->domains ?? []))));
}
/** @return ?array<string, string> username => name of the domain's mailboxes, null when the content does not list the domain */
public function mailboxesOf(string $domain): ?array
{
return $this->content[strtolower($domain)]['mailboxes'] ?? null;
}
/** Creation time of the domain when the snapshot was made, null when not recorded */
public function createdOf(string $domain): ?int
{
return $this->content[strtolower($domain)]['created'] ?? $this->created[strtolower($domain)] ?? null;
}
public function hasDomain(string $domain): bool
{
return in_array(strtolower($domain), $this->domainList(), true);
}
/**
* RESTIC_BACKUP_MODE of the backup (tag mode:<mode>, none for all): all, data (without the mailcow directory) or
* config (without mails)
*/
public function mode(): string
{
foreach ($this->tags as $tag) {
if (str_starts_with($tag, 'mode:')) {
return substr($tag, 5);
}
}
return 'all';
}
/** Snapshots not made by mailcow know neither domains nor mailboxes */
public function knowsDomains(): bool
{
return $this->content !== null || $this->domains !== null;
}
/** Entry of the API for admins */
public function toArray(): array
{
return [
'id' => $this->id,
'short_id' => $this->shortId,
'time' => $this->time,
'hostname' => $this->hostname,
'tags' => $this->tags,
'summary' => $this->summary,
'domains' => $this->domains,
'mode' => $this->mode(),
];
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Core\Repository;
use PDO;
/**
* Reads what the export of a domain holds: the domain with its settings, its aliases and a page of its mailboxes with
* every row that belongs to a mailbox. Rows are read with all their columns (SELECT *).
*/
class ExportRepository extends Repository
{
/** @return string[] all domains */
public function domains(): array
{
return $this->query("SELECT `domain` FROM `domain` ORDER BY `domain`")->fetchAll(PDO::FETCH_COLUMN);
}
public function dbVersion(): string
{
return (string)$this->query("SELECT `version` FROM `versions` WHERE `application` = 'db_schema'")->fetchColumn();
}
/** @return array[] rows of a table matching column => value (table and column names from code) */
public function rows(string $table, string $column, string $value): array
{
self::identifier($table);
self::identifier($column);
return $this->query("SELECT * FROM `$table` WHERE `$column` = :value", ['value' => $value])->fetchAll();
}
/** @return string[] usernames of the domain's mailboxes */
public function usernames(string $domain): array
{
return $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain", ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN);
}
/** @return array[] the domain's mailbox rows after $after in username order (keyset paging), at most $limit */
public function mailboxesAfter(string $domain, string $after, int $limit): array
{
return $this->query("SELECT * FROM `mailbox` WHERE `domain` = :domain AND `username` > :after ORDER BY `username` LIMIT " . max(1, $limit),
['domain' => $domain, 'after' => $after])->fetchAll();
}
/** @return array[] alias rows of the domain and its alias domains */
public function aliases(string $domain): array
{
return $this->query("SELECT * FROM `alias` WHERE `domain` = :domain
OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target) ORDER BY `id`",
['domain' => $domain, 'target' => $domain])->fetchAll();
}
/** @return array[] alias rows of any domain whose goto may name a mailbox of the domain (the caller checks) */
public function aliasesPointingTo(string $domain): array
{
return $this->query("SELECT * FROM `alias` WHERE `goto` LIKE :pattern ORDER BY `id`",
['pattern' => '%@' . addcslashes($domain, '%_\\') . '%'])->fetchAll();
}
/**
* @param string $table table with rows of mailboxes
* @param string $column its column holding the username
* @param string[] $usernames
* @return array[] rows of these mailboxes
*/
public function rowsOfMailboxes(string $table, string $column, array $usernames): array
{
self::identifier($table);
self::identifier($column);
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
return $this->query("SELECT * FROM `$table` WHERE `$column` IN ($in)", $params)->fetchAll();
}
/** @return array[] username, id, name, domain, source of the group memberships of these mailboxes */
public function groupMemberships(array $usernames): array
{
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
return $this->query("SELECT `gm`.`username`, `g`.`id`, `g`.`name`, `g`.`domain`, `gm`.`source` FROM `mailbox_group_member` `gm`
JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` WHERE `gm`.`username` IN ($in) ORDER BY `g`.`id`", $params)->fetchAll();
}
/**
* @param string $table mail_policy or retention_rule
* @param string $member its member table
* @param string $key the member table's column with the id
* @return array[] username, id, name, domain of the direct assignments of these mailboxes
*/
public function assignments(string $table, string $member, string $key, array $usernames): array
{
self::identifier($table);
self::identifier($member);
self::identifier($key);
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
return $this->query("SELECT `a`.`username`, `p`.`id`, `p`.`name`, `p`.`domain` FROM `$member` `a`
JOIN `$table` `p` ON `p`.`id` = `a`.`$key` WHERE `a`.`username` IN ($in) ORDER BY `p`.`id`", $params)->fetchAll();
}
/** @return array[] sync jobs of these mailboxes (without their log) with host1, port1 and name of the source */
public function syncJobs(array $usernames): array
{
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
$rows = $this->query("SELECT `i`.*, `s`.`host1` AS `source_host1`, `s`.`port1` AS `source_port1`, `s`.`name` AS `source_name`
FROM `imapsync` `i` LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` IN ($in) ORDER BY `i`.`id`", $params)->fetchAll();
foreach ($rows as &$row) {
unset($row['returned_text']);
}
return $rows;
}
/** @return array[] BCC maps of the whole domain (local_dest "@domain", older rows "domain") */
public function domainBccMaps(string $domain): array
{
return $this->query("SELECT * FROM `bcc_maps` WHERE `local_dest` IN (:domain, :at)", ['domain' => $domain, 'at' => '@' . $domain])->fetchAll();
}
/**
* @param string $table mail_policy or retention_rule
* @param string $member its member table
* @param string $key the member table's column with the id
* @return array[] rows of the domain, each with `group_members`: the names of the groups it is assigned to
*/
public function domainRules(string $table, string $member, string $key, string $domain): array
{
self::identifier($table);
self::identifier($member);
self::identifier($key);
$rows = $this->query("SELECT * FROM `$table` WHERE `domain` = :domain ORDER BY `id`", ['domain' => $domain])->fetchAll();
foreach ($rows as &$row) {
$row['group_members'] = $this->query("SELECT `g`.`name` FROM `$member` `a` JOIN `mailbox_group` `g` ON `g`.`id` = `a`.`group_id`
WHERE `a`.`$key` = :id ORDER BY `g`.`name`", ['id' => $row['id']])->fetchAll(PDO::FETCH_COLUMN);
}
return $rows;
}
/** @return array{0: string, 1: array} placeholders and parameters of an IN list */
private static function in(array $values): array
{
$names = [];
$params = [];
foreach (array_values($values) as $i => $value) {
$names[] = ':in' . $i;
$params['in' . $i] = $value;
}
return [implode(', ', $names), $params];
}
/** Table and column names come from code only; this keeps it that way */
private static function identifier(string $name): void
{
if (!preg_match('/^[a-z_0-9]+$/i', $name)) {
throw new \InvalidArgumentException('invalid identifier');
}
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Backup\Model\Job;
use Mailcow\Core\Context;
use Mailcow\Core\Repository;
use Mailcow\Core\UserError;
use PDO;
/**
* Table `backup_job` (the runners claim, log and finish the jobs) and the lookups a new restore job needs.
*/
class JobRepository extends Repository
{
private const COLUMNS = "`id`, `type`, `state`, `step`, `params`, `result`, `error`, `domain`, `requested_by`,
UNIX_TIMESTAMP(`created`) AS `created`, UNIX_TIMESTAMP(`started`) AS `started`, UNIX_TIMESTAMP(`finished`) AS `finished`";
/** Serializes the check for active jobs and the insert of a new one */
private const LOCK = 'mailcow_backup_job';
public function find(int $id, bool $withLog = false): ?Job
{
$row = $this->query("SELECT " . self::COLUMNS . ($withLog ? ", `log`" : "") . " FROM `backup_job` WHERE `id` = :id", ['id' => $id])->fetch();
return is_array($row) ? Job::fromRow($row) : null;
}
/**
* @param ?string $type only jobs of this type
* @return Job[] newest first: all jobs for admins, the restores of their domains (since the domain was created) for
* domain admins
*/
public function visible(Context $context, int $limit, ?string $type = null): array
{
if ($context->hasAdminRights()) {
[$where, $params] = ['1=1', []];
} elseif ($context->isDomainAdmin()) {
// restores of their domains, requested since the domain was created (not those of a former domain of that name)
[$where, $params] = ["`type` = 'restore' AND `domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :da)
AND `created` >= (SELECT `d`.`created` FROM `domain` `d` WHERE `d`.`domain` = `backup_job`.`domain`)", ['da' => $context->username]];
} else {
return [];
}
if ($type !== null) {
$where .= " AND `type` = :type";
$params['type'] = $type;
}
return array_map(fn(array $row): Job => Job::fromRow($row),
$this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE $where ORDER BY `id` DESC LIMIT " . max(1, $limit), $params)->fetchAll());
}
/** @return Job[] queued and running jobs */
public function active(): array
{
return array_map(fn(array $row): Job => Job::fromRow($row),
$this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `state` IN ('queued', 'running') ORDER BY `id`")->fetchAll());
}
/** @return array<string, Job> type => its newest job */
public function latestOfEachType(): array
{
$jobs = [];
foreach ($this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `id` IN (SELECT MAX(`id`) FROM `backup_job` GROUP BY `type`)")->fetchAll() as $row) {
$jobs[(string)$row['type']] = Job::fromRow($row);
}
return $jobs;
}
/** A backup job was created by the schedule since this unix time */
public function scheduledSince(int $since): bool
{
return (bool)$this->query("SELECT 1 FROM `backup_job` WHERE `type` = 'backup' AND `requested_by` = 'schedule' AND `created` >= FROM_UNIXTIME(:since) LIMIT 1",
['since' => $since])->fetchColumn();
}
/**
* Inserts the job after $check(active jobs) passed; $check throws a UserError on a conflict. A named lock keeps two
* requests from queueing the same work at once.
*
* @param callable(Job[]): void $check
* @throws UserError
*/
public function insertExclusive(Job $job, callable $check): int
{
if ((int)$this->query("SELECT GET_LOCK('" . self::LOCK . "', 10)")->fetchColumn() !== 1) {
throw new UserError('backup_job_active', [$job->type]);
}
try {
$check($this->active());
$job->id = $this->insertRow('backup_job', $job->toRow());
return $job->id;
} finally {
$this->query("SELECT RELEASE_LOCK('" . self::LOCK . "')");
}
}
/**
* Cancels a queued or running job; the restic runner stops at its next check. A restore in the dovecot step
* (mails, mails_running) cannot be cancelled. False when the job finished or reached that step meanwhile.
*/
public function cancel(int $id): bool
{
return $this->query("UPDATE `backup_job` SET `state` = 'cancelled', `finished` = NOW() WHERE `id` = :id
AND `state` IN ('queued', 'running') AND NOT (`type` = 'restore' AND COALESCE(`step`, '') IN ('mails', 'mails_running'))",
['id' => $id])->rowCount() > 0;
}
public function isDomain(string $domain): bool
{
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
}
/** Domain of a mailbox, null if there is none */
public function mailboxDomain(string $username): ?string
{
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username", ['username' => $username])->fetchColumn();
return is_string($domain) ? strtolower($domain) : null;
}
/** Unix time the domain was created, null if it does not exist */
public function domainCreated(string $domain): ?int
{
$created = $this->query("SELECT UNIX_TIMESTAMP(`created`) FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
return ($created === false || $created === null) ? null : (int)$created;
}
/**
* @param string[] $domains
* @return array{0: array<string, true>, 1: array<string, array<string, true>>} the domains that exist, and per domain
* the usernames of its mailboxes; one query each per 500 domains
*/
public function domainsAndUsernames(array $domains): array
{
$existing = [];
$usernames = [];
foreach (array_chunk(array_values(array_unique($domains)), 500) as $chunk) {
$params = [];
foreach ($chunk as $i => $domain) {
$params['d' . $i] = $domain;
}
$in = implode(', ', array_map(fn(string $name): string => ':' . $name, array_keys($params)));
foreach ($this->query("SELECT `domain` FROM `domain` WHERE `domain` IN ($in)", $params)->fetchAll(PDO::FETCH_COLUMN) as $domain) {
$existing[strtolower((string)$domain)] = true;
}
foreach ($this->query("SELECT `domain`, `username` FROM `mailbox` WHERE `domain` IN ($in)", $params)->fetchAll() as $row) {
$usernames[strtolower((string)$row['domain'])][strtolower((string)$row['username'])] = true;
}
}
return [$existing, $usernames];
}
/** @return array<string, string> username => name of the mailboxes of a domain */
public function mailboxNames(string $domain): array
{
$names = [];
foreach ($this->query("SELECT `username`, `name` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`", ['domain' => $domain])->fetchAll() as $row) {
$names[strtolower((string)$row['username'])] = (string)$row['name'];
}
return $names;
}
/** @return string[] all mailboxes of a domain */
public function mailboxesOf(string $domain): array
{
return array_map('strtolower', $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`",
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
}
/** @return string[] alias domains of a domain */
public function aliasDomainsOf(string $domain): array
{
return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain",
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
}
}
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Core\Repository;
use PDO;
use Throwable;
/**
* Reads and writes of the restore merge. Rows of a backup may come from an older schema, so inserts and updates only
* use the columns the table has now. Table and column names come from code, never from the backup.
*/
class RestoreRepository extends Repository
{
/** @var array<string, string[]> table => columns */
private array $columns = [];
/** Runs $work in one transaction */
public function inTransaction(callable $work): mixed
{
return $this->transaction($work);
}
/** Runs $work in a savepoint of the running transaction: an error undoes only its writes and is thrown on */
public function savepoint(string $name, callable $work): mixed
{
self::identifier($name);
$this->pdo->exec("SAVEPOINT `$name`");
try {
$result = $work();
$this->pdo->exec("RELEASE SAVEPOINT `$name`");
return $result;
} catch (Throwable $e) {
$this->pdo->exec("ROLLBACK TO SAVEPOINT `$name`");
throw $e;
}
}
/**
* Runs $work while holding a named lock, so a retried request of the runner waits for the first one instead of
* merging the same mailbox twice; null when the lock was not free within $timeout seconds
*/
public function locked(string $key, callable $work, int $timeout = 120): mixed
{
$name = 'mailcow_backup_' . md5($key);
if ((int)$this->query("SELECT GET_LOCK(:name, " . max(0, $timeout) . ")", ['name' => $name])->fetchColumn() !== 1) {
return null;
}
try {
return $work();
} finally {
$this->query("SELECT RELEASE_LOCK(:name)", ['name' => $name]);
}
}
/** Quota rows of a new mailbox (like the legacy mailbox add) */
public function resetQuota(string $username): void
{
foreach (['quota2', 'quota2replica'] as $table) {
$this->query("INSERT INTO `$table` (`username`, `bytes`, `messages`) VALUES (:username, 0, 0)
ON DUPLICATE KEY UPDATE `bytes` = 0, `messages` = 0", ['username' => $username]);
}
}
/** Regular mailboxes of a domain (the domain's mailbox limit counts these) */
public function mailboxCount(string $domain): int
{
return (int)$this->query("SELECT COUNT(*) FROM `mailbox` WHERE `domain` = :domain AND `kind` = ''", ['domain' => $domain])->fetchColumn();
}
/** External send-as rights for a domain that is local now (legacy domain and alias domain add) */
public function dropExternalSenderAcl(string $domain): void
{
$this->query("DELETE FROM `sender_acl` WHERE `external` = 1 AND `send_as` LIKE :domain", ['domain' => '%@' . addcslashes($domain, '%_\\')]);
}
/** @return string[] the columns of a table in the current schema */
public function columns(string $table): array
{
self::identifier($table);
return $this->columns[$table] ??= $this->query("SELECT `COLUMN_NAME` FROM `information_schema`.`COLUMNS`
WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = :table ORDER BY `ORDINAL_POSITION`", ['table' => $table])->fetchAll(PDO::FETCH_COLUMN);
}
/** @return array[] rows of a table matching every condition (column => value) */
public function rows(string $table, array $where): array
{
[$condition, $params] = $this->where($table, $where);
return $this->query("SELECT * FROM `$table` WHERE $condition", $params)->fetchAll();
}
/** Inserts a row with the values of the columns the table has now; returns the new id (0 without one) */
public function insert(string $table, array $values): int
{
return $this->insertRow($table, $this->existing($table, $values));
}
/** Updates the rows matching $where with the values of the columns the table has now */
public function update(string $table, array $where, array $values): int
{
$values = $this->existing($table, $values);
if (empty($values)) {
return 0;
}
[$condition, $params] = $this->where($table, $where);
$set = [];
foreach (array_keys($values) as $i => $column) {
$set[] = "`$column` = :v$i";
$params['v' . $i] = $values[$column];
}
return $this->query("UPDATE `$table` SET " . implode(', ', $set) . " WHERE $condition", $params)->rowCount();
}
/** A primary or alias domain */
public function domainExists(string $domain): bool
{
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias",
['domain' => $domain, 'alias' => $domain])->fetchColumn();
}
/** The primary domain of a domain or alias domain, null if mailcow has neither */
public function primaryDomain(string $domain): ?string
{
$primary = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain
UNION SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :alias", ['domain' => $domain, 'alias' => $domain])->fetchColumn();
return is_string($primary) ? strtolower($primary) : null;
}
/** An active primary or alias domain (like the legacy is_local_mailcow_domain()) */
public function isLocalDomain(string $domain): bool
{
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain AND `active` = 1
UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias AND `active` = 1", ['domain' => $domain, 'alias' => $domain])->fetchColumn();
}
/** @return string[] alias domains of a domain */
public function aliasDomainsOf(string $domain): array
{
return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain",
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
}
/**
* What an address is now: 'mailbox' (also local@alias-domain of a mailbox), 'resource' (a mailbox of kind
* location, thing or group), 'alias', 'spamalias' or null
*/
public function addressKind(string $address): ?string
{
$kind = $this->query("SELECT `kind` FROM `mailbox` WHERE `username` = :address", ['address' => $address])->fetchColumn();
if (is_string($kind)) {
return preg_match('/location|thing|group/', $kind) ? 'resource' : 'mailbox';
}
[$local, $domain] = array_pad(explode('@', $address, 2), 2, '');
if ((bool)$this->query("SELECT 1 FROM `mailbox` `m` JOIN `alias_domain` `ad` ON `ad`.`alias_domain` = :domain
WHERE `m`.`username` = CONCAT(:local, '@', `ad`.`target_domain`)", ['domain' => $domain, 'local' => $local])->fetchColumn()) {
return 'mailbox';
}
if ((bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) {
return 'alias';
}
if ((bool)$this->query("SELECT 1 FROM `spamalias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) {
return 'spamalias';
}
return null;
}
/** Aliases the domain may still get (domain limit minus its aliases and those of its alias domains, like the legacy code) */
public function aliasesLeft(string $domain): int
{
$limit = $this->query("SELECT `aliases` FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
if ($limit === false) {
return 0;
}
$used = (int)$this->query("SELECT COUNT(*) FROM `alias` WHERE (`domain` = :domain
OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target))
AND `address` NOT IN (SELECT `username` FROM `mailbox`)", ['domain' => $domain, 'target' => $domain])->fetchColumn();
return (int)$limit - $used;
}
/** Quota (bytes) the other mailboxes of the domain have */
public function quotaInUse(string $domain, string $exceptUsername): int
{
return (int)$this->query("SELECT COALESCE(SUM(`quota`), 0) FROM `mailbox` WHERE `domain` = :domain AND `kind` = '' AND `username` != :username",
['domain' => $domain, 'username' => $exceptUsername])->fetchColumn();
}
/** @return array[] user1, host1 and port1 of the mailbox's sync jobs */
public function syncJobsOf(string $username): array
{
return $this->query("SELECT `i`.`user1`, `s`.`host1`, `s`.`port1` FROM `imapsync` `i`
LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` = :username", ['username' => $username])->fetchAll();
}
/** @return int[] ids of the sync sources with this host and port the mailbox may use, oldest first */
public function syncSourcesFor(string $host, int $port, string $username, string $domain): array
{
return array_map('intval', $this->query("SELECT `s`.`id` FROM `imapsync_source` `s` WHERE `s`.`host1` = :host AND `s`.`port1` = :port
AND (`s`.`scope` = 'all'
OR (`s`.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` `d` WHERE `d`.`source_id` = `s`.`id` AND `d`.`domain` = :domain))
OR (`s`.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` `u` WHERE `u`.`source_id` = `s`.`id` AND `u`.`username` = :username)))
ORDER BY `s`.`id`", ['host' => $host, 'port' => $port, 'domain' => $domain, 'username' => $username])->fetchAll(PDO::FETCH_COLUMN));
}
/** @return array{0: string, 1: array} condition and parameters of column => value pairs */
private function where(string $table, array $where): array
{
self::identifier($table);
$parts = [];
$params = [];
foreach (array_keys($where) as $i => $column) {
self::identifier((string)$column);
if ($where[$column] === null) {
$parts[] = "`$column` IS NULL";
continue;
}
$parts[] = "`$column` = :w$i";
$params['w' . $i] = $where[$column];
}
return [empty($parts) ? '1=1' : implode(' AND ', $parts), $params];
}
/** The values of the columns the table has now */
private function existing(string $table, array $values): array
{
return array_intersect_key($values, array_flip($this->columns($table)));
}
/** Table, column and savepoint names come from code only; this keeps it that way */
private static function identifier(string $name): void
{
if (!preg_match('/^[a-z_0-9]+$/i', $name)) {
throw new \InvalidArgumentException('invalid identifier');
}
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Core\Repository;
use PDO;
/**
* Table `backup_setting` (name/value pairs; the runner writes its state_* values into it as well).
*/
class SettingsRepository extends Repository
{
/** @return array<string, string> name => value of the stored settings */
public function load(): array
{
return $this->query("SELECT `name`, `value` FROM `backup_setting`")->fetchAll(PDO::FETCH_KEY_PAIR);
}
/** Stores the given values (name => value) */
public function save(array $values): void
{
$this->transaction(function () use ($values): void {
foreach ($values as $name => $value) {
$this->query("INSERT INTO `backup_setting` (`name`, `value`) VALUES (:name, :value)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)", ['name' => (string)$name, 'value' => (string)$value]);
}
});
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Backup\Model\Snapshot;
use Mailcow\Core\Repository;
/**
* Table `backup_snapshot`: the runner's cache of the repository's snapshots (read only here).
*/
class SnapshotRepository extends Repository
{
private const COLUMNS = "`id`, `short_id`, UNIX_TIMESTAMP(`time`) AS `time`, `hostname`, `tags`, `summary`, `domains`";
/** By id or short id, with its content (the mailboxes in it) */
public function find(string $id): ?Snapshot
{
$row = $this->query("SELECT " . self::COLUMNS . ", `content` FROM `backup_snapshot` WHERE `id` = :id OR `short_id` = :short_id ORDER BY `id` = :exact DESC LIMIT 1",
['id' => $id, 'short_id' => $id, 'exact' => $id])->fetch();
return is_array($row) ? Snapshot::fromRow($row) : null;
}
/** @return Snapshot[] newest first; the content (the mailboxes in it) only with $withContent */
public function all(bool $withContent = false): array
{
return array_map(fn(array $row): Snapshot => Snapshot::fromRow($row),
$this->query("SELECT " . self::COLUMNS . ($withContent ? ", `content`" : "") . " FROM `backup_snapshot` ORDER BY `time` DESC, `id`")->fetchAll());
}
}
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\RestoreRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Throwable;
/**
* Action `restore_domain`: recreates a domain deleted since the snapshot from its export (`domain_data`), before its
* mailboxes are restored. Only for running restores an admin requested while the domain did not exist
* (params.create_domain).
*
* Creates the domain like the legacy domain add (row, tags, domain map, external send-as rights for it dropped, SOGo
* restarted so it knows the domain), unless it exists by now. Then adds what is missing: alias domains, DKIM keys, rate
* limit, spam settings, BCC maps, footer, MTA-STS, domain admin assignments of accounts that still exist, groups (no
* members, send-as rights or shares; members follow with the mailboxes), mail policies and retention rules with their
* group assignments (matched by name).
*/
class DomainRestoreService
{
private const NEW_ROW = ['id', 'created', 'modified'];
private const POLICY_LISTS = ['send_allow', 'send_block', 'receive_allow', 'receive_block'];
private const RATE_LIMIT = '/^\d+ \/ 1[smhd]$/';
public function __construct(private RestoreRepository $repo, private JobRepository $jobs, private GroupMembers $groups,
private LegacyDomain $legacy)
{
}
/**
* @return array{domain: array{added: int, skipped: int, errors: string[], created: int}}
* @throws UserError
*/
public function restore(mixed $jobId, mixed $data): array
{
$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');
}
if (!$job->createsDomain() || ($job->params['requested_role'] ?? '') !== 'admin') {
throw new AccessDenied();
}
$domain = strtolower((string)$job->domain);
if (!is_array($data) || !is_array($data['domain'] ?? null) || strtolower((string)($data['domain']['domain'] ?? '')) !== $domain) {
throw new UserError('backup_export_invalid');
}
$result = $this->repo->locked($job->id . ':domain', fn(): array => $this->restoreDomain($job, $domain, $data));
return is_array($result) ? ['domain' => $result] : throw new UserError('backup_job_busy');
}
private function restoreDomain(Job $job, string $domain, array $data): array
{
$result = ['added' => 0, 'skipped' => 0, 'errors' => [], 'created' => 0];
if (!empty($this->repo->rows('alias_domain', ['alias_domain' => $domain]))) {
$result['errors'][] = "$domain is an alias domain now";
return $result;
}
if (!empty($this->repo->rows('domain', ['domain' => $domain]))) {
// created meanwhile: only what is missing is added
$result['skipped']++;
} else {
$this->createDomain($domain, $data, $result);
}
$this->repo->inTransaction(function () use ($domain, $data, &$result): void {
$this->aliasDomains($domain, $data, $result);
$this->domainSettings($domain, $data, $result);
});
$this->redisSettings($domain, $data, $result);
$groupIds = $this->createGroups($domain, $data, $result);
$this->repo->inTransaction(function () use ($domain, $data, $groupIds, &$result): void {
$this->rules($domain, $data, 'mail_policies', 'mail_policy', 'mail_policy_member', 'policy_id', $groupIds, $result);
$this->rules($domain, $data, 'retention_rules', 'retention_rule', 'retention_rule_member', 'rule_id', $groupIds, $result);
});
return $result;
}
/** The domain row, its tags and the effects of the legacy domain add */
private function createDomain(string $domain, array $data, array &$result): void
{
// the domain keeps its creation time: domain admins may use the snapshots made after it
$row = array_diff_key(RestoreService::decode($data['domain']), array_flip(['modified']));
$relayhost = (string)($row['relayhost'] ?? '0');
if ($relayhost !== '0' && $relayhost !== '' && empty($this->repo->rows('relayhosts', ['id' => (int)$relayhost]))) {
$row['relayhost'] = '0';
$result['errors'][] = 'relayhost: does not exist';
}
$this->repo->inTransaction(function () use ($domain, $row, $data): void {
$this->repo->insert('domain', ['domain' => $domain] + $row);
$this->repo->dropExternalSenderAcl($domain);
foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', (array)($data['tags'] ?? [])), 'strlen')) as $tag) {
$this->repo->insert('tags_domain', ['domain' => $domain, 'tag_name' => $tag]);
}
});
$result['added']++;
$result['created'] = 1;
$this->effect($result, 'domain map', fn() => $this->legacy->addDomainMap($domain));
$error = $this->legacy->restartSogo();
if ($error !== null) {
$result['errors'][] = 'SOGo restart failed: ' . $error;
}
}
private function aliasDomains(string $domain, array $data, array &$result): void
{
foreach (self::rows($data, 'alias_domains') as $row) {
$alias = strtolower(trim((string)($row['alias_domain'] ?? '')));
if ($alias === '' || $this->repo->domainExists($alias)) {
$result['skipped']++;
continue;
}
$this->repo->insert('alias_domain', ['alias_domain' => $alias, 'target_domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
$this->repo->dropExternalSenderAcl($alias);
$this->effect($result, "domain map of $alias", fn() => $this->legacy->addDomainMap($alias));
$result['added']++;
}
}
/** Spam settings, BCC maps, footer, MTA-STS and domain admins of the domain that are missing */
private function domainSettings(string $domain, array $data, array &$result): void
{
foreach (self::rows($data, 'filterconf') as $row) {
$option = (string)($row['option'] ?? '');
$value = (string)($row['value'] ?? '');
$current = $this->repo->rows('filterconf', ['object' => $domain]);
$present = in_array($option, ['whitelist_from', 'blacklist_from'], true)
? array_filter($current, fn(array $c): bool => in_array($c['option'], ['whitelist_from', 'blacklist_from'], true) && strtolower((string)$c['value']) === strtolower($value))
: array_filter($current, fn(array $c): bool => $c['option'] === $option && (in_array($option, ['lowspamlevel', 'highspamlevel'], true) || (string)$c['value'] === $value));
if ($option === '' || !empty($present)) {
$result['skipped']++;
continue;
}
$this->repo->insert('filterconf', ['object' => $domain, 'option' => $option, 'value' => $value]);
$result['added']++;
}
foreach (self::rows($data, 'bcc_maps') as $row) {
$localDest = strtolower((string)($row['local_dest'] ?? ''));
if (!in_array($localDest, [$domain, '@' . $domain], true) || !filter_var((string)($row['bcc_dest'] ?? ''), FILTER_VALIDATE_EMAIL)
|| !in_array($row['type'] ?? '', ['sender', 'rcpt'], true)) {
$result['errors'][] = "BCC map $localDest: invalid";
} elseif (!empty($this->repo->rows('bcc_maps', ['local_dest' => $localDest, 'type' => $row['type']]))) {
$result['skipped']++;
} else {
$this->repo->insert('bcc_maps', ['domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
$result['added']++;
}
}
foreach (['footer' => 'domain_wide_footer', 'mta_sts' => 'mta_sts'] as $key => $table) {
if (!is_array($data[$key] ?? null)) {
continue;
}
if (!empty($this->repo->rows($table, ['domain' => $domain]))) {
$result['skipped']++;
continue;
}
$this->repo->insert($table, ['domain' => $domain] + array_diff_key(RestoreService::decode($data[$key]), array_flip(['created', 'modified'])));
$result['added']++;
}
foreach (self::rows($data, 'domain_admins') as $row) {
$username = (string)($row['username'] ?? '');
if (empty($this->repo->rows('admin', ['username' => $username, 'superadmin' => 0]))) {
$result['errors'][] = "domain admin $username: does not exist";
} elseif (!empty($this->repo->rows('domain_admins', ['username' => $username, 'domain' => $domain]))) {
$result['skipped']++;
} else {
$this->repo->insert('domain_admins', ['username' => $username, 'domain' => $domain, 'active' => (int)($row['active'] ?? 1)]);
$result['added']++;
}
}
}
/** DKIM keys of the domain and its alias domains, and the rate limit of the domain (Redis), when missing */
private function redisSettings(string $domain, array $data, array &$result): void
{
$domains = array_merge([$domain], $this->repo->aliasDomainsOf($domain));
foreach (self::rows($data, 'dkim') as $key) {
$name = strtolower((string)($key['domain'] ?? ''));
$selector = (string)($key['selector'] ?? '');
if (!in_array($name, $domains, true)) {
continue;
}
if (!preg_match('/^[a-z0-9._-]{1,63}$/i', $selector) || (string)($key['pubkey'] ?? '') === '' || (string)($key['privkey'] ?? '') === '') {
$result['errors'][] = "DKIM key of $name: invalid";
continue;
}
$this->effect($result, "DKIM key of $name", function () use ($name, $selector, $key, &$result): void {
if ($this->legacy->dkim($name) !== null) {
$result['skipped']++;
return;
}
$this->legacy->addDkim($name, $selector, (string)$key['pubkey'], (string)$key['privkey']);
$result['added']++;
});
}
$rateLimit = $data['ratelimit'] ?? null;
if (is_string($rateLimit) && preg_match(self::RATE_LIMIT, $rateLimit)) {
$this->effect($result, 'rate limit', function () use ($domain, $rateLimit, &$result): void {
if (isset($this->legacy->rateLimits([$domain])[$domain])) {
$result['skipped']++;
return;
}
$this->legacy->setRateLimit($domain, $rateLimit);
$result['added']++;
});
}
}
/**
* Groups of the domain missing by name, through the group module (address, flags, priority, IdP link)
*
* @return array<int, int> id in the backup => id now, of all groups found by name
*/
private function createGroups(string $domain, array $data, array &$result): array
{
$ids = [];
$admin = new Context('admin', 'backup', [], true);
foreach (self::rows($data, 'groups') as $row) {
$name = (string)($row['name'] ?? '');
$current = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => $name])[0] ?? null;
if ($current !== null) {
$ids[(int)($row['id'] ?? 0)] = (int)$current['id'];
$result['skipped']++;
continue;
}
$input = ['domain' => $domain, 'name' => $name, 'description' => (string)($row['description'] ?? '')];
foreach (['sender_allowed', 'sogo_visible', 'internal', 'active', 'priority'] as $key) {
if (isset($row[$key])) {
$input[$key] = (int)$row[$key];
}
}
foreach (['address', 'idp_ref'] as $key) {
if (($row[$key] ?? null) !== null && $row[$key] !== '') {
$input[$key] = (string)$row[$key];
}
}
try {
$ids[(int)($row['id'] ?? 0)] = $this->groups->create($admin, $input);
$result['added']++;
} catch (UserError $e) {
$result['errors'][] = "group $name: " . $e->key;
}
}
return $ids;
}
/**
* Mail policies or retention rules missing by name, with their group assignments; group:<id> entries of policies
* follow the groups' new ids
*/
private function rules(string $domain, array $data, string $key, string $table, string $member, string $column, array $groupIds, array &$result): void
{
foreach (self::rows($data, $key) as $row) {
$name = (string)($row['name'] ?? '');
if (!empty($this->repo->rows($table, ['domain' => $domain, 'name' => $name]))) {
$result['skipped']++;
continue;
}
$values = array_diff_key($row, array_flip([...self::NEW_ROW, 'group_members']));
foreach (self::POLICY_LISTS as $list) {
if (isset($values[$list])) {
$values[$list] = json_encode($this->remapGroups((string)$values[$list], $groupIds, "$name ($list)", $result));
}
}
$id = $this->repo->insert($table, ['domain' => $domain, 'name' => $name] + $values);
foreach ((array)($row['group_members'] ?? []) as $groupName) {
$group = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => (string)$groupName])[0] ?? null;
if ($group === null) {
$result['errors'][] = "$name: group $groupName does not exist";
continue;
}
$this->repo->insert($member, [$column => $id, 'group_id' => (int)$group['id']]);
}
$result['added']++;
}
}
/** @return string[] entries of a JSON list with group:<id> of the backup turned into the group's id now */
private function remapGroups(string $json, array $groupIds, string $what, array &$result): array
{
$entries = [];
foreach ((array)json_decode($json, true) as $entry) {
if (!is_string($entry)) {
continue;
}
if (preg_match('/^group:(\d+)$/', $entry, $match)) {
if (!isset($groupIds[(int)$match[1]])) {
$result['errors'][] = "$what: $entry dropped (group does not exist)";
continue;
}
$entry = 'group:' . $groupIds[(int)$match[1]];
}
$entries[] = $entry;
}
return $entries;
}
/** A Redis or dockerapi step: a failure is reported and the rest goes on */
private function effect(array &$result, string $what, callable $work): void
{
try {
$work();
} catch (Throwable) {
$result['errors'][] = "$what: not stored (Redis)";
}
}
private static function rows(array $data, string $key): array
{
return array_values(array_filter((array)($data[$key] ?? []), 'is_array'));
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Repository\ExportRepository;
use Mailcow\Core\UserError;
/**
* Export of a domain for the backup (/mailcow/export/<domain>.json in a snapshot), in pages of mailboxes the runner
* merges into one file. The first page holds version, db_version, domain, domain_data (what recreating the domain
* needs), the aliases and `mailbox_aliases` (username => the aliases of any domain whose goto names the mailbox,
* without its own alias and group addresses; the runner moves them into the mailbox sections when it reads the file);
* every page a part of `mailboxes` and `next_after`, the last username of the page to ask for the next one with
* (keyset paging; null after the last page).
*
* Per mailbox every row a restore needs, with all columns. Password hashes, DKIM keys and the secrets of app
* passwords, 2FA and sync jobs are part of it on purpose; only the runner gets it (backup.php with the token). Binary
* values (fido2) are written as {"base64": "..."}. Beside the keys of the spec, a mailbox has `own_alias` (its alias
* row: forwarding with or without a copy), `aliases` (empty, see mailbox_aliases) and `ratelimit` (Redis).
*/
class ExportService
{
public const VERSION = 1;
public const PAGE_SIZE = 500;
/** Lists of a mailbox: key => [table, column with the username] */
private const MAILBOX_ROWS = [
'sender_acl' => ['sender_acl', 'logged_in_as'],
'sender_acl_granted' => ['sender_acl', 'send_as'],
'sieve_filters' => ['sieve_filters', 'username'],
'filterconf' => ['filterconf', 'object'],
'spamalias' => ['spamalias', 'goto'],
'bcc_maps' => ['bcc_maps', 'local_dest'],
'app_passwd' => ['app_passwd', 'mailbox'],
'tfa' => ['tfa', 'username'],
// FIDO2 keys of mailbox users (passwordless login on the user page), like those of admins
'fido2' => ['fido2', 'username'],
'identity_link' => ['identity_link', 'username'],
];
/** Single rows of a mailbox: key => [table, column with the username] */
private const MAILBOX_ROW = [
'user_acl' => ['user_acl', 'username'],
'pushover' => ['pushover', 'username'],
];
public function __construct(private ExportRepository $repo, private LegacyDomain $legacy)
{
}
/** @return string[] all domains */
public function domains(): array
{
return array_values(array_map('strval', $this->repo->domains()));
}
/**
* @param ?string $after the last username of the previous page (keyset paging), null for the first page
* @throws UserError domain_not_found
*/
public function export(string $domain, ?string $after = null, int $pageSize = self::PAGE_SIZE): array
{
$domain = strtolower(trim($domain));
$row = ($domain === '') ? null : ($this->repo->rows('domain', 'domain', $domain)[0] ?? null);
if ($row === null) {
throw new UserError('domain_not_found', [htmlspecialchars($domain)]);
}
$rows = $this->repo->mailboxesAfter($domain, strtolower($after ?? ''), $pageSize + 1);
$more = count($rows) > $pageSize;
$rows = array_slice($rows, 0, $pageSize);
$export = ['version' => self::VERSION, 'domain' => $domain];
if ($after === null) {
$export += [
'db_version' => $this->repo->dbVersion(),
'domain_data' => $this->domainData($domain, $row),
'aliases' => array_map(fn(array $alias): array => self::encode($alias), $this->repo->aliases($domain)),
'mailbox_aliases' => $this->mailboxAliases($domain) ?: new \stdClass(),
];
}
$export['mailboxes'] = $this->mailboxes($domain, $rows) ?: new \stdClass();
$export['next_after'] = ($more && !empty($rows)) ? strtolower((string)end($rows)['username']) : null;
return $export;
}
/** What recreating the domain needs: its row and settings, alias domains, DKIM keys, groups, policies, rules */
private function domainData(string $domain, array $row): array
{
$aliasDomains = $this->repo->rows('alias_domain', 'target_domain', $domain);
$dkim = [];
foreach (array_merge([$domain], array_map(fn(array $alias): string => strtolower((string)$alias['alias_domain']), $aliasDomains)) as $name) {
$key = $this->legacy->dkim($name);
if ($key !== null) {
$dkim[] = ['domain' => $name] + $key;
}
}
return [
'domain' => self::encode($row),
'tags' => array_map(fn(array $tag): string => (string)$tag['tag_name'], $this->repo->rows('tags_domain', 'domain', $domain)),
'alias_domains' => $aliasDomains,
'dkim' => $dkim,
'ratelimit' => $this->legacy->rateLimits([$domain])[$domain] ?? null,
'filterconf' => $this->repo->rows('filterconf', 'object', $domain),
'bcc_maps' => $this->repo->domainBccMaps($domain),
'footer' => $this->repo->rows('domain_wide_footer', 'domain', $domain)[0] ?? null,
'mta_sts' => $this->repo->rows('mta_sts', 'domain', $domain)[0] ?? null,
'domain_admins' => $this->repo->rows('domain_admins', 'domain', $domain),
'groups' => $this->repo->rows('mailbox_group', 'domain', $domain),
'mail_policies' => $this->repo->domainRules('mail_policy', 'mail_policy_member', 'policy_id', $domain),
'retention_rules' => $this->repo->domainRules('retention_rule', 'retention_rule_member', 'rule_id', $domain),
];
}
/** @return array<string, array> username => its section */
private function mailboxes(string $domain, array $rows): array
{
$mailboxes = [];
foreach ($rows as $row) {
$mailboxes[strtolower((string)$row['username'])] = [
'mailbox' => self::encode($row) + ['tags' => []],
'user_acl' => null,
'pushover' => null,
'ratelimit' => null,
'own_alias' => null,
'aliases' => [],
'groups' => [],
'mail_policies' => [],
'retention_rules' => [],
'imapsync' => [],
] + array_fill_keys(array_keys(self::MAILBOX_ROWS), []);
}
$usernames = array_map('strval', array_keys($mailboxes));
if (empty($usernames)) {
return [];
}
// the mailbox of the page a row belongs to, null for anything else
$owner = fn(array $row, string $column): ?string => isset($mailboxes[strtolower((string)$row[$column])]) ? strtolower((string)$row[$column]) : null;
foreach ($this->repo->rowsOfMailboxes('tags_mailbox', 'username', $usernames) as $row) {
if (($username = $owner($row, 'username')) !== null) {
$mailboxes[$username]['mailbox']['tags'][] = (string)$row['tag_name'];
}
}
foreach (self::MAILBOX_ROW as $key => [$table, $column]) {
foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) {
if (($username = $owner($row, $column)) !== null) {
$mailboxes[$username][$key] = self::encode($row);
}
}
}
foreach (self::MAILBOX_ROWS as $key => [$table, $column]) {
foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) {
if (($username = $owner($row, $column)) !== null) {
$mailboxes[$username][$key][] = self::encode($row);
}
}
}
foreach ($this->legacy->rateLimits($usernames) as $username => $value) {
if (isset($mailboxes[strtolower((string)$username)])) {
$mailboxes[strtolower((string)$username)]['ratelimit'] = $value;
}
}
foreach ($this->repo->groupMemberships($usernames) as $row) {
if (($username = $owner($row, 'username')) !== null) {
$mailboxes[$username]['groups'][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain'],
'source' => (string)$row['source']];
}
}
foreach (['mail_policies' => ['mail_policy', 'mail_policy_member', 'policy_id'], 'retention_rules' => ['retention_rule', 'retention_rule_member', 'rule_id']]
as $key => [$table, $member, $column]) {
foreach ($this->repo->assignments($table, $member, $column, $usernames) as $row) {
if (($username = $owner($row, 'username')) !== null) {
$mailboxes[$username][$key][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain']];
}
}
}
foreach ($this->repo->syncJobs($usernames) as $row) {
if (($username = $owner($row, 'user2')) !== null) {
$mailboxes[$username]['imapsync'][] = self::encode($row);
}
}
foreach ($this->repo->rowsOfMailboxes('alias', 'address', $usernames) as $row) {
if (($username = $owner($row, 'address')) !== null) {
$mailboxes[$username]['own_alias'] = self::encode($row);
}
}
return $mailboxes;
}
/**
* @return array<string, array[]> username => aliases of any domain whose goto names the mailbox (once per domain: the
* alias table is scanned, not once per page)
*/
private function mailboxAliases(string $domain): array
{
$usernames = array_fill_keys(array_map(fn(mixed $username): string => strtolower((string)$username), $this->repo->usernames($domain)), true);
$aliases = [];
foreach ($this->repo->aliasesPointingTo($domain) as $row) {
if ($row['group_id'] !== null) {
continue;
}
$address = strtolower((string)$row['address']);
foreach (array_unique(array_map('strtolower', self::gotos((string)$row['goto']))) as $goto) {
if ($goto !== $address && isset($usernames[$goto])) {
$aliases[$goto][] = self::encode($row);
}
}
}
return $aliases;
}
/** @return string[] addresses of a goto list */
public static function gotos(string $goto): array
{
return array_values(array_filter(array_map('trim', explode(',', $goto)), 'strlen'));
}
/** Binary values (not UTF-8) as {"base64": "..."}, so the row survives JSON */
public static function encode(array $row): array
{
foreach ($row as $column => $value) {
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
$row[$column] = ['base64' => base64_encode($value)];
}
}
return $row;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\GroupService;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\MailboxGroup\Service\TemplateService;
/**
* Group writes of a restore through the group module (addresses, send-as rights, shares and caches follow), as the
* requester of the restore: an admin, or the domain admin with his rights now (GroupService::canEdit). The module is
* wired without group templates (Legacy\NoTemplates).
*/
class GroupMembers
{
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private TemplateService $templates,
private ChangeEffects $effects, private SharingService $sharing)
{
}
/** @throws UserError e.g. access_denied when the group is read-only for the requester */
public function add(Context $context, int $groupId, string $username): void
{
$this->service($context)->update($groupId, ['add_members' => [$username]]);
}
/**
* Creates a group (restore of a deleted domain); members follow with the mailboxes.
*
* @throws UserError
*/
public function create(Context $context, array $input): int
{
return (int)$this->service($context)->create($input)->id;
}
protected function service(Context $context): GroupService
{
return new GroupService($this->repo, $this->lookup, $this->templates, $this->effects, $this->sharing, $context);
}
}
@@ -0,0 +1,653 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use DateTimeImmutable;
use DateTimeZone;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Model\Snapshot;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\SnapshotRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
/**
* Jobs of the backup runners, the snapshots with their content and the status page.
*
* Backup, check, prune and init jobs: admins (not read-only API keys) queue them, one of each type at a time.
* Restores: admins, and domain admins for mailboxes or a domain they administer now. Mailboxes and domains deleted
* since the snapshot are created from it (deleted domains by admins only). A component a domain admin could not
* manage himself (e.g. sync jobs without the `syncjobs` ACL) is refused; his ACL and domains are stored with the job,
* so the merge and the runners apply the same limits.
* Reading: admins see every job and snapshot; domain admins the restores of their domains and, of each snapshot made
* after their domain was created, their domains in it.
*/
class JobService
{
/**
* Restore components in their order. mails, imap_acl, sieve_activate and the Sieve scripts of filters are restored by
* the dovecot runner, sogo by the restic runner, the others by RestoreService.
*/
public const COMPONENTS = ['mails', 'imap_acl', 'sogo', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules',
'filters', 'sieve_activate', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'];
/** Components that replace current data with the backup's (all others only add what is missing) */
public const OVERWRITE = ['sogo', 'settings', 'password'];
/** Components that need the mails: not in snapshots without them (RESTIC_BACKUP_MODE=config) */
public const MAIL_COMPONENTS = ['mails', 'imap_acl', 'sogo', 'sieve_activate'];
public const MAIL_TARGETS = ['folder', 'original'];
/** Domain admin ACL a component needs */
private const COMPONENT_ACL = [
'groups' => 'mailbox_groups',
'mail_policies' => 'mail_policies',
'retention_rules' => 'retention',
'filters' => 'filters',
'sieve_activate' => 'filters',
'spam' => 'spam_policy',
'bcc' => 'bcc_maps',
'syncjobs' => 'syncjobs',
'app_passwords' => 'app_passwds',
];
/** Components that only work together with another one */
private const REQUIRES = ['sieve_activate' => 'filters'];
/** Domain admin ACLs the merge respects, stored with the job */
private const RESTORE_ACL = ['filters', 'mailbox_groups', 'mail_policies', 'retention', 'spam_policy', 'bcc_maps', 'syncjobs',
'app_passwds', 'protocol_access', 'mailbox_relayhost', 'unlimited_quota', 'sogo_access', 'pushover', 'extend_sender_acl',
'alias_external_goto', 'ratelimit', 'tls_policy', 'quarantine', 'quarantine_notification', 'quarantine_category'];
private const MAX_MAILBOXES = 1000;
private const LIST_LIMIT = 100;
private const MAX_LIST = 1000;
/** Mailboxes a search returns at most */
private const SEARCH_LIMIT = 500;
/** The runner reports every 10 seconds */
private const RUNNER_TIMEOUT = 120;
/** @var array<string, array{bool, ?int}> domain => [access of the domain admin, creation time now], per request */
private array $domainState = [];
public function __construct(private JobRepository $jobs, private SnapshotRepository $snapshots, private SettingsService $settings,
private Context $context, private string $timezone = 'UTC')
{
}
/**
* @param mixed $type only jobs of this type ('' or null = all; an unknown type finds nothing)
* @return Job[] newest first, at most $limit (0 = LIST_LIMIT)
*/
public function list(int $limit = 0, mixed $type = null): array
{
$type = Input::text($type);
if ($type !== '' && !in_array($type, Job::TYPES, true)) {
return [];
}
return $this->jobs->visible($this->context, ($limit > 0) ? min($limit, self::MAX_LIST) : self::LIST_LIMIT, ($type === '') ? null : $type);
}
/**
* Entry of the API. Domain admins get it restricted: e-mail addresses outside the job's domain and its alias domains
* and repository details (URLs, restic repository strings, paths of the repository) are shown as "…".
*/
public function view(Job $job, bool $withLog = false): array
{
$entry = $job->toArray($withLog);
if ($this->context->hasAdminRights()) {
return $entry;
}
$domains = array_merge([strtolower($job->domain)], $this->jobs->aliasDomainsOf(strtolower($job->domain)));
foreach (['result', 'error', 'log'] as $key) {
if (isset($entry[$key])) {
$entry[$key] = self::restricted($entry[$key], $domains);
}
}
return $entry;
}
/** The job with its log, null when it does not exist or the caller may not see it */
public function get(int $id): ?Job
{
$job = $this->jobs->find($id, true);
return ($job !== null && $this->visible($job)) ? $job : null;
}
/**
* Queues a backup, check, prune or init job (admins).
*
* @throws UserError
*/
public function create(array $input): Job
{
$this->context->requireAdmin();
$type = Input::text($input['type'] ?? null);
if (!in_array($type, Job::MANUAL_TYPES, true)) {
throw new UserError('backup_job_type_invalid', [htmlspecialchars((string)$type)]);
}
$settings = $this->settings->load();
if (!$settings->configured()) {
throw new UserError('backup_not_configured');
}
if ($type === 'backup' && $settings->get('password_confirmed') !== '1') {
throw new UserError('backup_password_not_confirmed');
}
$job = new Job($type);
$job->requestedBy = (string)$this->context->username;
$this->jobs->insertExclusive($job, function (array $active) use ($type): void {
foreach ($active as $other) {
if ($other->type === $type) {
throw new UserError('backup_job_active', [$type]);
}
}
});
return $job;
}
/**
* Queues a restore: {snapshot, scope: mailbox|domain, mailboxes | domain, components, mail_target: folder|original}.
* All mailboxes belong to one domain. Mailboxes deleted since the snapshot are created from it (create_mailboxes);
* an admin may restore a deleted domain (create_domain). Scope domain restores the snapshot's mailboxes of the
* domain (the current ones for snapshots without content).
*
* @throws UserError
*/
public function restore(array $input): Job
{
$this->requireRestorer();
if (!$this->settings->load()->configured()) {
throw new UserError('backup_not_configured');
}
$snapshot = $this->snapshot($input['snapshot'] ?? null);
$scope = Input::text($input['scope'] ?? 'mailbox');
[$domain, $mailboxes] = match ($scope) {
'domain' => $this->domainScope($snapshot, $input['domain'] ?? null),
'mailbox' => $this->mailboxScope($snapshot, $input['mailboxes'] ?? null, $input['domain'] ?? null),
default => throw new UserError('backup_scope_invalid'),
};
$components = $this->components($input['components'] ?? null);
$missing = array_values(array_intersect($components, self::MAIL_COMPONENTS));
if ($snapshot->mode() === 'config' && !empty($missing)) {
throw new UserError('backup_components_no_mails', [htmlspecialchars(implode(', ', $missing))]);
}
$mailTarget = Input::text($input['mail_target'] ?? null) ?: 'folder';
if (!in_array($mailTarget, self::MAIL_TARGETS, true)) {
throw new UserError('backup_mail_target_invalid');
}
$job = new Job('restore');
$job->step = 'files';
$job->domain = $domain;
$job->requestedBy = (string)$this->context->username;
$job->params = [
'snapshot' => $snapshot->id,
'snapshot_time' => $snapshot->time,
'scope' => $scope,
'domain' => $domain,
'mailboxes' => $mailboxes,
'components' => $components,
'mail_target' => $mailTarget,
'requested_role' => $this->context->hasAdminRights() ? 'admin' : 'domainadmin',
'create_domain' => (int)!$this->jobs->isDomain($domain),
'create_mailboxes' => array_values(array_filter($mailboxes, fn(string $username): bool => $this->jobs->mailboxDomain($username) === null)),
];
if (!$this->context->hasAdminRights()) {
$job->params['acl'] = array_combine(self::RESTORE_ACL, array_map(fn(string $acl): int => (int)$this->context->hasAcl($acl), self::RESTORE_ACL));
$job->params['allowed_domains'] = array_values(array_unique(array_merge([$domain], $this->jobs->aliasDomainsOf($domain))));
}
$domainAdmin = !$this->context->hasAdminRights();
$this->jobs->insertExclusive($job, function (array $active) use ($mailboxes, $domain, $domainAdmin): void {
foreach ($active as $other) {
if ($other->type !== 'restore') {
continue;
}
// a domain admin has one restore per domain at a time
if ($domainAdmin && strtolower($other->domain) === $domain) {
throw new UserError('backup_restore_domain_active', [htmlspecialchars($domain)]);
}
$both = array_intersect($mailboxes, $other->mailboxes());
if (!empty($both)) {
throw new UserError('backup_restore_active', [htmlspecialchars((string)reset($both))]);
}
}
});
return $job;
}
/**
* Cancels a queued or running job (the restic runner stops at its next check): admins any, domain admins the
* restores of their domains that domain admins requested. A restore in the dovecot step (mails, mails_running) runs
* to its end.
*
* @throws UserError
*/
public function cancel(int $id): Job
{
if ($this->context->isReadOnlyApi()) {
throw new AccessDenied();
}
$job = $this->jobs->find($id);
// domain admins cancel only restores domain admins requested
if ($job === null || !$this->visible($job) || (!$this->context->hasAdminRights() && ($job->params['requested_role'] ?? '') !== 'domainadmin')) {
throw new AccessDenied();
}
if (!$job->isActive() || ($job->type === 'restore' && in_array($job->step, ['mails', 'mails_running'], true)) || !$this->jobs->cancel($id)) {
throw new UserError('backup_job_not_cancellable', [$id]);
}
$job->state = 'cancelled';
return $job;
}
/**
* Snapshots, newest first. Domain admins get those holding one of their domains (see mayUse()), with id, short_id,
* time and those domains.
*/
public function snapshots(): array
{
if ($this->context->hasAdminRights()) {
return array_map(fn(Snapshot $snapshot): array => $snapshot->toArray(), $this->snapshots->all());
}
if (!$this->context->isDomainAdmin()) {
return [];
}
$snapshots = [];
// the domains with their creation time come from the snapshot row, the content is not needed here
foreach ($this->snapshots->all() as $snapshot) {
$domains = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain)));
if (!empty($domains)) {
$snapshots[] = ['id' => $snapshot->id, 'short_id' => $snapshot->shortId, 'time' => $snapshot->time, 'domains' => $domains,
'mode' => $snapshot->mode()];
}
}
return $snapshots;
}
/**
* Domains of a snapshot with their state now: GET backup-snapshot-content/<snapshot>. `available` 0: the snapshot has
* no content (not made by this installation), the list comes from its manifest and only existing mailboxes can be restored. A domain
* of the manifest missing in the content (its export failed) lists its current mailboxes as well.
*
* @return array{snapshot: string, time: int, available: int, domains: array[]}|array{} [] when not found or not visible
*/
public function content(mixed $snapshotId): array
{
$snapshot = $this->visibleSnapshot($snapshotId);
if ($snapshot === null) {
return [];
}
$visible = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain)));
[$existing, $usernames] = $this->jobs->domainsAndUsernames($visible);
$domains = [];
foreach ($visible as $domain) {
$current = $usernames[$domain] ?? [];
$mailboxes = $snapshot->mailboxesOf($domain) ?? $current;
$domains[] = ['domain' => $domain, 'exists' => (int)isset($existing[$domain]), 'mailboxes' => count($mailboxes),
'missing' => count(array_diff_key($mailboxes, $current))];
}
return ['snapshot' => $snapshot->id, 'time' => $snapshot->time, 'available' => (int)($snapshot->content !== null), 'domains' => $domains];
}
/**
* Search in a snapshot: GET backup-snapshot-search/<snapshot>/<term>. Matches domain names and mailboxes by local
* part, address or name ("x@": local part, "@d": domain, "x@d": address), with the rules of content(); at most
* SEARCH_LIMIT mailboxes. A matching domain comes with all its mailboxes.
*
* @return array{domains: array[], truncated: int}|array{} [] when the snapshot is not found or not visible
*/
public function search(mixed $snapshotId, mixed $term): array
{
$snapshot = $this->visibleSnapshot($snapshotId);
$term = mb_strtolower((string)Input::text($term));
if ($snapshot === null) {
return [];
}
if (trim($term, '@') === '') {
return ['domains' => [], 'truncated' => 0];
}
$domains = [];
$hits = 0;
$truncated = 0;
[$local, $at, $domainTerm] = str_contains($term, '@') ? [strstr($term, '@', true), true, substr((string)strstr($term, '@'), 1)] : [$term, false, ''];
foreach ($snapshot->domainList() as $domain) {
if (!$this->mayUse($snapshot, $domain)) {
continue;
}
$domainMatch = $at ? ($domainTerm !== '' && str_contains($domain, $domainTerm)) : str_contains($domain, $term);
if ($at && $domainTerm !== '' && !$domainMatch) {
continue;
}
$current = $this->jobs->mailboxNames($domain);
$mailboxes = [];
foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) {
$username = (string)$username;
$part = (string)strstr($username, '@', true);
$match = match (true) {
// "x@d": the address, "x@": the local part, "@d": every mailbox of the matching domains
$at && $local !== '' && $domainTerm !== '' => str_contains($username, $term),
$at && $local !== '' => str_contains($part, $local),
$at => true,
default => $domainMatch || str_contains($username, $term) || str_contains(mb_strtolower($name), $term),
};
if (!$match) {
continue;
}
if ($hits >= self::SEARCH_LIMIT) {
$truncated = 1;
break;
}
$hits++;
$mailboxes[] = ['username' => $username, 'name' => $name, 'exists' => (int)isset($current[$username])];
}
// a domain without matching mailboxes only for a search by domain
if (!empty($mailboxes) || ($domainMatch && !($at && $local !== '') && !$truncated)) {
$domains[] = ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes];
}
if ($truncated) {
break;
}
}
return ['domains' => $domains, 'truncated' => $truncated];
}
/**
* Mailboxes of a domain in a snapshot with their state now: GET backup-snapshot-content/<snapshot>/<domain>
*
* @return array{domain: string, exists: int, mailboxes: array[]}|array{} [] when not found or not visible
*/
public function domainContent(mixed $snapshotId, mixed $domain): array
{
$snapshot = $this->visibleSnapshot($snapshotId);
$domain = strtolower((string)Input::text($domain));
if ($snapshot === null || !$snapshot->hasDomain($domain) || !$this->mayUse($snapshot, $domain)) {
return [];
}
$current = $this->jobs->mailboxNames($domain);
$mailboxes = [];
foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) {
$mailboxes[] = ['username' => (string)$username, 'name' => $name, 'exists' => (int)isset($current[$username])];
}
return ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes];
}
/**
* State of the backup for admins: settings state, runner heartbeat, repository, newest job of each type, queued and
* running jobs, the next scheduled run. [] for everybody else.
*/
public function status(?int $now = null): array
{
if (!$this->context->hasAdminRights()) {
return [];
}
$now ??= time();
$settings = $this->settings->load();
$seen = ($settings->get('state_runner_seen') === '') ? null : (int)$settings->get('state_runner_seen');
$snapshots = $this->snapshots->all();
$latest = $this->jobs->latestOfEachType();
$stats = json_decode($settings->get('state_repo_stats'), true);
return [
'enabled' => (int)$settings->get('enabled'),
'target' => $settings->get('target'),
'configured' => (int)$settings->configured(),
'password_confirmed' => (int)$settings->get('password_confirmed'),
'initialized' => (int)$settings->get('state_initialized'),
// the runner cannot decrypt the stored credentials (e.g. a database restored on another server): enter them again
'secrets_error' => ($settings->get('state_secrets_error') === '') ? null : $settings->get('state_secrets_error'),
// RESTIC_BACKUP_MODE (null until the runner reported it)
'mode' => ($settings->get('state_backup_mode') === '') ? null : $settings->get('state_backup_mode'),
'runner_seen' => $seen,
'runner_age' => ($seen === null) ? null : max(0, $now - $seen),
'runner_ok' => (int)($seen !== null && $now - $seen <= self::RUNNER_TIMEOUT),
'last_success' => ($settings->get('state_last_success') === '') ? null : (int)$settings->get('state_last_success'),
'repo_size' => ($settings->get('state_repo_size') === '') ? null : (int)$settings->get('state_repo_size'),
'repo_stats' => is_array($stats) ? $stats : null,
'snapshots' => count($snapshots),
'latest_snapshot' => empty($snapshots) ? null : $snapshots[0]->time,
'next_run' => $this->nextRun($settings, $now),
// the longest time between two scheduled backups (seconds): a later last success is overdue
'schedule_gap' => $settings->scheduleGap(),
'jobs' => array_map(fn(string $type): ?array => isset($latest[$type]) ? $latest[$type]->toArray() : null,
array_combine(Job::TYPES, Job::TYPES)),
'active' => array_map(fn(Job $job): array => $job->toArray(), $this->jobs->active()),
];
}
/**
* Unix time of the next scheduled backup, null while the schedule is off. Like the runner: the schedule is armed
* (state_schedule_since/state_schedule) when it is switched on or changed, a run is due at the first schedule time
* after that; a due run without a scheduled backup since its time is the next one (now).
*/
private function nextRun(Settings $settings, int $now): ?int
{
if ($settings->get('enabled') !== '1' || $settings->get('password_confirmed') !== '1' || !$settings->configured()) {
return null;
}
$slots = $settings->scheduleSlots();
try {
$zone = new DateTimeZone($this->timezone);
} catch (\Exception) {
$zone = new DateTimeZone('UTC');
}
$today = (new DateTimeImmutable('@' . $now))->setTimezone($zone);
// the last schedule time up to now, and the one after it
$due = self::slot($slots, $today, -1);
$next = self::slot($slots, $today, 1);
$since = $settings->get('state_schedule_since');
if ($due === null || $since === '' || $settings->get('state_schedule') !== $settings->get('schedule') || (int)$since > $due) {
return $next;
}
return $this->jobs->scheduledSince($due) ? $next : $due;
}
/**
* The newest schedule time up to $now ($direction -1) or the first one after it (1) as Unix time, null without
* slots ([weekday, hour, minute], sorted)
*/
private static function slot(array $slots, DateTimeImmutable $now, int $direction): ?int
{
for ($i = 0; $i <= 7; $i++) {
$day = $now->modify(sprintf('%+d day', $direction * $i));
foreach (($direction < 0) ? array_reverse($slots) : $slots as [$weekday, $hour, $minute]) {
if ($weekday !== (int)$day->format('w')) {
continue;
}
$slot = $day->setTime($hour, $minute)->getTimestamp();
if (($direction < 0) ? $slot <= $now->getTimestamp() : $slot > $now->getTimestamp()) {
return $slot;
}
}
}
return null;
}
/** @throws AccessDenied unless the caller may request restores */
private function requireRestorer(): void
{
if ($this->context->isReadOnlyApi() || !($this->context->hasAdminRights() || $this->context->isDomainAdmin())) {
throw new AccessDenied();
}
}
/** Admins every job; domain admins the restores of their domains requested after the domain was created */
private function visible(Job $job): bool
{
if ($this->context->hasAdminRights()) {
return true;
}
if (!$this->context->isDomainAdmin() || $job->type !== 'restore' || $job->domain === '' || !$this->context->canAccessDomain($job->domain)) {
return false;
}
$created = $this->jobs->domainCreated(strtolower($job->domain));
return $created !== null && (int)$job->created >= $created;
}
/**
* A value of a job as a domain admin sees it: e-mail addresses outside $domains and repository details become "…"
*
* @param string[] $domains
*/
private static function restricted(mixed $value, array $domains): mixed
{
if (is_array($value)) {
$restricted = [];
foreach ($value as $key => $item) {
$restricted[is_string($key) ? (string)self::restricted($key, $domains) : $key] = self::restricted($item, $domains);
}
return $restricted;
}
if (!is_string($value)) {
return $value;
}
$value = preg_replace(['~\b(?:s3|sftp|rest|b2|azure|gs|swift|rclone):\S+~i', '~\bhttps?://\S+~i', '~/repository\S*~'], '…', $value);
return preg_replace_callback('/[^\s@<>"\'(),;:\[\]]+@([a-z0-9.-]+\.[a-z]{2,})/i',
fn(array $match): string => in_array(strtolower($match[1]), $domains, true) ? $match[0] : '…', (string)$value);
}
/** @throws UserError backup_snapshot_not_found (also for snapshots a domain admin may not use) */
private function snapshot(mixed $value): Snapshot
{
$id = (string)Input::text($value);
$snapshot = ($id === '') ? null : $this->snapshots->find($id);
if ($snapshot === null || (!$this->context->hasAdminRights() && empty(array_filter($snapshot->domainList(),
fn(string $domain): bool => $this->mayUse($snapshot, $domain))))) {
throw new UserError('backup_snapshot_not_found', [htmlspecialchars($id)]);
}
return $snapshot;
}
private function visibleSnapshot(mixed $value): ?Snapshot
{
try {
return ($this->context->hasAdminRights() || $this->context->isDomainAdmin()) ? $this->snapshot($value) : null;
} catch (UserError) {
return null;
}
}
/**
* Admins every domain of the snapshot; domain admins the domains they administer now, when the snapshot holds this
* domain and not a former one of the same name: its recorded creation time is the domain's (snapshots without it:
* made after the domain was created)
*/
private function mayUse(Snapshot $snapshot, string $domain): bool
{
if ($this->context->hasAdminRights()) {
return true;
}
if (!$this->context->isDomainAdmin()) {
return false;
}
$this->domainState[$domain] ??= [$this->context->canAccessDomain($domain), $this->jobs->domainCreated($domain)];
[$access, $created] = $this->domainState[$domain];
if (!$access) {
return false;
}
$recorded = $snapshot->createdOf($domain);
return $created !== null && (($recorded !== null) ? $recorded === $created : $snapshot->time > $created);
}
/**
* @return array{0: string, 1: string[]} the domain and its mailboxes in the snapshot (now, for snapshots without content)
* @throws UserError
*/
private function domainScope(Snapshot $snapshot, mixed $value): array
{
$domain = strtolower((string)Input::text($value));
$exists = $domain !== '' && $this->jobs->isDomain($domain);
// a deleted domain is created from the snapshot's export: only when the content lists it
if (!$exists && !($this->context->hasAdminRights() && $snapshot->mailboxesOf($domain) !== null)) {
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
}
$this->requireDomain($snapshot, $domain);
$listed = $snapshot->mailboxesOf($domain);
$mailboxes = ($listed === null) ? $this->jobs->mailboxesOf($domain) : array_map('strval', array_keys($listed));
// a deleted domain without mailboxes comes back with its aliases and settings
if (empty($mailboxes) && $exists) {
throw new UserError('backup_mailboxes_empty');
}
return [$domain, $mailboxes];
}
/**
* @return array{0: string, 1: string[]} the domain of the mailboxes and the mailboxes
* @throws UserError
*/
private function mailboxScope(Snapshot $snapshot, mixed $value, mixed $domainValue): array
{
$mailboxes = array_values(array_unique(array_map('strtolower', self::values($value))));
if (empty($mailboxes)) {
throw new UserError('backup_mailboxes_empty');
}
if (count($mailboxes) > self::MAX_MAILBOXES) {
throw new UserError('backup_entries_exceeded', [self::MAX_MAILBOXES]);
}
$domain = strtolower((string)Input::text($domainValue));
foreach ($mailboxes as $username) {
// a deleted mailbox can be restored when the snapshot knows it
$mailboxDomain = $this->jobs->mailboxDomain($username) ?? (isset($snapshot->mailboxesOf(self::domainOf($username))[$username]) ? self::domainOf($username) : null);
if ($mailboxDomain === null) {
throw new UserError('backup_mailbox_not_found', [htmlspecialchars($username)]);
}
if ($domain !== '' && $mailboxDomain !== $domain) {
throw new UserError('backup_mailboxes_one_domain');
}
$domain = $mailboxDomain;
}
if (!$this->jobs->isDomain($domain) && !$this->context->hasAdminRights()) {
throw new AccessDenied();
}
$this->requireDomain($snapshot, $domain);
return [$domain, $mailboxes];
}
/** @throws UserError the snapshot does not hold the domain, or the caller may not restore into it */
private function requireDomain(Snapshot $snapshot, string $domain): void
{
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
throw new AccessDenied();
}
if ($snapshot->knowsDomains() && !$snapshot->hasDomain($domain)) {
throw new UserError('backup_domain_not_in_snapshot', [htmlspecialchars($domain)]);
}
if (!$this->mayUse($snapshot, $domain)) {
throw new UserError('backup_snapshot_not_found', [htmlspecialchars($snapshot->shortId)]);
}
}
/**
* @return string[] the requested components in their order
* @throws UserError
*/
private function components(mixed $value): array
{
$requested = array_values(array_unique(self::values($value)));
if (empty($requested)) {
throw new UserError('backup_components_invalid', ['']);
}
foreach ($requested as $component) {
if (!in_array($component, self::COMPONENTS, true)) {
throw new UserError('backup_components_invalid', [htmlspecialchars($component)]);
}
if (!$this->context->hasAdminRights() && isset(self::COMPONENT_ACL[$component]) && !$this->context->hasAcl(self::COMPONENT_ACL[$component])) {
throw new UserError('backup_component_denied', [$component]);
}
if (isset(self::REQUIRES[$component]) && !in_array(self::REQUIRES[$component], $requested, true)) {
throw new UserError('backup_component_requires', [$component, self::REQUIRES[$component]]);
}
}
return array_values(array_intersect(self::COMPONENTS, $requested));
}
private static function domainOf(string $address): string
{
return strtolower(substr((string)strrchr($address, '@'), 1));
}
/**
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces.
*
* @return string[]
*/
private static function values(mixed $value): array
{
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value));
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
}
}
@@ -0,0 +1,996 @@
<?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;
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;
}
$source = $this->repo->rows('imapsync_source', ['id' => (int)($row['source_id'] ?? 0)])[0] ?? null;
$sourceId = ($source !== null && strtolower((string)$source['host1']) === strtolower($host) && (int)$source['port1'] === $port)
? (int)$source['id'] : ($this->repo->syncSourcesFor($host, $port, $run->username, $run->domain)[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);
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
/**
* Backup settings: admins read them (also read-only API keys) and save them. Values are checked strictly (at least as
* strictly as the runner), since the runner passes them to restic and ssh. Secrets are write-only and stored sealed
* with the public key the runner published (only the runner can decrypt them); an empty secret keeps the stored one,
* and without a published key no secret can be saved. A changed target, connection or repository password resets
* state_initialized (the runner tests the repository again); a changed SFTP host or port drops the known host key. A
* new repository password needs a new confirmation that it is stored outside mailcow. A change of a configured target,
* its connection or the notify addresses leaves state_target_changed for the runner, which mails the notify addresses
* of before the change.
*/
class SettingsService
{
private const FLAGS = ['enabled', 's3_path_style', 'password_confirmed', 'include_index', 'include_fts'];
/** Integer settings: name => [min, max] */
private const RANGES = [
'sftp_port' => [1, 65535],
'keep_hourly' => [0, 1000],
'keep_daily' => [0, 1000],
'keep_weekly' => [0, 1000],
'keep_monthly' => [0, 1000],
'keep_yearly' => [0, 1000],
'prune_weekday' => [0, 6],
'check_subset' => [0, 365],
'limit_upload' => [0, 100000000],
];
private const MIN_PASSWORD = 8;
private const MAX_LENGTH = 255;
private const MAX_NOTIFY = 20;
/** Entries of the schedule */
private const MAX_SCHEDULE = 24;
private const HOSTNAME = '/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i';
public function __construct(private SettingsRepository $repo, private Context $context)
{
}
/** The settings for admins, null for everybody else */
public function get(): ?Settings
{
return $this->context->hasAdminRights() ? $this->load() : null;
}
/** The settings without permission check (status, job rules) */
public function load(): Settings
{
return Settings::fromStored($this->repo->load());
}
/**
* Saves the given settings; missing ones keep their value, unknown and runner-owned ones are ignored.
*
* @throws UserError
*/
public function save(array $input): Settings
{
$this->context->requireAdmin();
$current = $this->load();
$values = $current->values;
foreach ($input as $name => $value) {
if (!is_string($name) || !array_key_exists($name, Settings::DEFAULTS) || in_array($name, Settings::RUNNER, true)) {
continue;
}
$value = $this->normalize($name, $value);
if (in_array($name, Settings::SECRETS, true)) {
if ($value === '') {
continue;
}
$value = self::seal($value, $current->get('state_secrets_pubkey'));
}
$values[$name] = $value;
}
if ($values['password'] !== $current->get('password')
&& !(array_key_exists('password_confirmed', $input) && $values['password_confirmed'] === '1')) {
$values['password_confirmed'] = '0';
}
foreach (array_merge(['target', 'password'], Settings::CONNECTION[$values['target']]) as $name) {
if ($values[$name] !== $current->get($name)) {
$values['state_initialized'] = '0';
break;
}
}
if ($values['sftp_host'] !== $current->get('sftp_host') || $values['sftp_port'] !== $current->get('sftp_port')) {
$values['sftp_host_key'] = '';
}
$settings = new Settings($values);
if ($values['enabled'] === '1' && !$settings->configured()) {
throw new UserError('backup_not_configured');
}
// switched on without a time
if ($values['enabled'] === '1' && empty($settings->scheduleSlots())) {
throw self::invalid('schedule');
}
$values['state_target_changed'] = $this->targetChange($current, $values);
$settings = new Settings($values);
$changed = array_diff_assoc($values, $current->values);
if (!empty($changed)) {
$this->repo->save($changed);
}
return $settings;
}
/**
* state_target_changed after a save: when the target, its connection or the notify addresses change, what the target
* was, whom to tell (the notify addresses before the change) and what changed ("target", "notify_email"). An earlier
* change the runner has not mailed yet keeps its state and addresses and only gets the new kinds of change, so new
* addresses followed by a new target still go to the original addresses. Nothing is recorded for a target that was
* not configured or without notify addresses.
*/
private function targetChange(Settings $current, array $values): string
{
$pending = json_decode($current->get('state_target_changed'), true);
if (!$current->configured() && !is_array($pending)) {
return $current->get('state_target_changed');
}
$old = $current->get('target');
$watched = array_unique(array_merge(['target'], Settings::CONNECTION[$old] ?? [], Settings::CONNECTION[$values['target']] ?? []));
$changed = [];
if (!empty(array_filter($watched, fn(string $name): bool => $values[$name] !== $current->get($name)))) {
$changed[] = 'target';
}
if ($values['notify_email'] !== $current->get('notify_email')) {
$changed[] = 'notify_email';
}
if (is_array($pending)) {
$pending['changed'] = array_values(array_unique(array_merge((array)($pending['changed'] ?? ['target']), $changed)));
return (string)json_encode($pending, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$addresses = array_values(array_filter(explode(',', $current->get('notify_email')), 'strlen'));
if (empty($changed) || empty($addresses)) {
return '';
}
$summary = ['target' => $old];
foreach (array_diff(Settings::CONNECTION[$old] ?? [], Settings::SECRETS) as $name) {
$summary[$name] = $current->get($name);
}
return (string)json_encode(['old_target' => $summary, 'old_addresses' => $addresses, 'changed' => $changed,
'changed_by' => (string)$this->context->username, 'time' => time()], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* A secret sealed for the runner: sealed:v1:<base64 of sodium_crypto_box_seal>
*
* @throws UserError backup_secrets_key_missing while the runner has not published its key
*/
private static function seal(string $secret, string $publicKey): string
{
$key = base64_decode($publicKey, true);
if ($key === false || strlen($key) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new UserError('backup_secrets_key_missing');
}
return Settings::SEALED . base64_encode(sodium_crypto_box_seal($secret, $key));
}
/**
* The value to store, '' for an empty optional value.
*
* @throws UserError backup_setting_invalid, backup_password_too_short
*/
private function normalize(string $name, mixed $value): string
{
if (in_array($name, self::FLAGS, true)) {
return self::flag($value) ?? throw self::invalid($name);
}
if ($name === 'notify_email' && is_string($value)) {
// one address per line is fine as well
$value = preg_replace('/[\r\n\t]+/', ',', $value);
}
if ($name === 'schedule') {
return self::schedule($value) ?? throw self::invalid($name);
}
// secrets as typed: spaces at the ends are part of a password
$text = (in_array($name, Settings::SECRETS, true) && is_string($value)) ? $value : Input::text($value);
if ($text === null || mb_strlen($text) > (($name === 'notify_email') ? self::MAX_LENGTH * self::MAX_NOTIFY : self::MAX_LENGTH) || preg_match('/[\x00-\x1f\x7f]/', $text) || !mb_check_encoding($text, 'UTF-8')) {
throw self::invalid($name);
}
// no scheduled prune (append-only or object-lock targets); a prune job still works
if ($name === 'prune_weekday' && $text === '') {
return '';
}
if (isset(self::RANGES[$name])) {
$int = Input::intInRange($text, ...self::RANGES[$name]);
return ($int === null) ? throw self::invalid($name) : (string)$int;
}
$valid = match ($name) {
'target' => in_array($text, Settings::TARGETS, true),
// like the runner: scheme, host and port only
's3_endpoint' => self::url($text) && in_array(parse_url($text, PHP_URL_PATH) ?? '', ['', '/'], true),
'rest_url' => $text === '' || self::url($text),
's3_bucket' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{1,62}$/i', $text),
's3_path' => $text === '' || self::path(trim($text, '/'), false),
's3_region' => $text === '' || preg_match('/^[a-z0-9-]{1,64}$/i', $text),
's3_access_key' => preg_match('/^[\x21-\x7e]*$/', $text),
's3_secret_key', 'rest_password' => true,
'sftp_host' => $text === '' || preg_match(self::HOSTNAME, $text) || filter_var($text, FILTER_VALIDATE_IP),
// the runner's character set (ssh -l)
'sftp_user' => $text === '' || preg_match('/^[a-z0-9_][a-z0-9._-]{0,63}$/i', $text),
'sftp_path' => $text === '' || self::path($text, true),
'rest_user' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,63}$/i', $text),
// the runner's character set, below the repository folder
'local_path' => preg_match('~^[a-z0-9._-]+(/[a-z0-9._-]+)*$~i', $text) && self::path($text, false),
'password' => true,
'max_unused' => preg_match('/^(\d{1,3})\s*%?$/', $text, $percent) && (int)$percent[1] <= 100,
'notify_email' => true,
default => false,
};
if (!$valid) {
throw self::invalid($name);
}
return match ($name) {
's3_path' => trim($text, '/'),
's3_endpoint' => rtrim($text, '/'),
'sftp_path' => rtrim($text, '/'),
'password' => self::password($text),
'max_unused' => (int)$text . '%',
'notify_email' => self::emails($text),
default => $text,
};
}
/**
* The schedule as stored: a list (or its JSON) of {days: weekdays 0 (Sunday) to 6, time: "H:MM"}, up to MAX_SCHEDULE
* entries, each with at least one weekday; days sorted, times as HH:MM, repeated entries dropped. Null when invalid.
*/
private static function schedule(mixed $value): ?string
{
if (is_string($value)) {
$value = json_decode($value, true);
}
if (!is_array($value) || !array_is_list($value) || count($value) > self::MAX_SCHEDULE) {
return null;
}
$entries = [];
foreach ($value as $entry) {
$days = $entry['days'] ?? null;
$time = is_array($entry) && is_string($entry['time'] ?? null) ? $entry['time'] : '';
if (!is_array($days) || empty($days) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $time, $match)) {
return null;
}
foreach ($days as $day) {
if (Input::intInRange($day, 0, 6) === null) {
return null;
}
}
$days = array_values(array_unique(array_map('intval', $days)));
sort($days);
$entries[] = ['days' => $days, 'time' => sprintf('%02d:%02d', $match[1], $match[2])];
}
return (string)json_encode(array_values(array_unique($entries, SORT_REGULAR)));
}
/** http(s) URL without credentials, query or fragment (rest_user/rest_password hold the credentials) */
private static function url(string $url): bool
{
$parts = parse_url($url);
return filter_var($url, FILTER_VALIDATE_URL) !== false && is_array($parts) && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
&& !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment'])
&& (preg_match(self::HOSTNAME, (string)($parts['host'] ?? '')) || filter_var(trim((string)($parts['host'] ?? ''), '[]'), FILTER_VALIDATE_IP));
}
/** A path of plain names separated by /, without . and .. parts; a leading / only where $absolute is allowed */
private static function path(string $path, bool $absolute): bool
{
if ($path === '' || (str_starts_with($path, '/') && !$absolute)) {
return false;
}
foreach (explode('/', trim($path, '/')) as $part) {
if ($part === '.' || $part === '..' || !preg_match('/^[a-z0-9_~+@][a-z0-9._~+@-]*$/i', $part)) {
return false;
}
}
return true;
}
/** @throws UserError backup_password_too_short ('' keeps the stored password) */
private static function password(string $password): string
{
if ($password !== '' && mb_strlen($password) < self::MIN_PASSWORD) {
throw new UserError('backup_password_too_short', [self::MIN_PASSWORD]);
}
return $password;
}
/** @throws UserError for an invalid address or too many */
private static function emails(string $text): string
{
$emails = array_values(array_unique(array_filter(preg_split('/[\s,;]+/', $text), 'strlen')));
if (count($emails) > self::MAX_NOTIFY) {
throw self::invalid('notify_email');
}
foreach ($emails as $email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw self::invalid('notify_email');
}
}
return implode(',', $emails);
}
/** Checkbox or API flag as '0'/'1'; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked; null = invalid */
private static function flag(mixed $value): ?string
{
if (is_array($value)) {
$value = end($value);
}
if (!is_scalar($value) && $value !== null) {
return null;
}
$flag = filter_var($value ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return ($flag === null) ? null : (string)(int)$flag;
}
private static function invalid(string $name): UserError
{
return new UserError('backup_setting_invalid', [$name]);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
// E-Mail > Restore of admins and domain admins (admin/restore.php, domainadmin/restore.php set $role after protect_route):
// restores from the snapshots of the backup, also of deleted mailboxes (and domains for admins)
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
$js_minifier->add('/web/js/site/backup.js');
$template = 'restore.twig';
$template_data = [
'acl' => $_SESSION['acl'],
'role' => $role,
'lang_datatables' => json_encode($lang['datatables']),
'backup_configured' => \Mailcow\Backup\BackupModule::fromGlobals()->settings->load()->configured(),
'backup_timezone' => getenv('TZ') ?: 'UTC',
];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
File diff suppressed because it is too large. Load diff
+43
View File
@@ -286,6 +286,12 @@ if (isset($_GET['query'])) {
case "retention-rule":
process_add_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->addRule($attr));
break;
case "backup-job":
process_add_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->addJob($attr));
break;
case "backup-restore":
process_add_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->addRestore($attr));
break;
case "domain":
switch ($object) {
case "template":
@@ -898,6 +904,37 @@ if (isset($_GET['query'])) {
}
break;
case "backup-settings":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSettings());
break;
case "backup-status":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getStatus());
break;
case "backup-snapshots":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSnapshots(), false);
break;
// backup-snapshot-content/<snapshot>[/<domain>]: domains of a snapshot, or the mailboxes of one, with their state now
case "backup-snapshot-content":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSnapshotContent($object, $extra));
break;
// backup-snapshot-search/<snapshot>/<term>: domains and mailboxes of a snapshot matching the term (at most 500)
case "backup-snapshot-search":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSnapshotSearch($object, $extra));
break;
// backup-jobs[/<count>[/<type>]]: newest first, without logs; count 0 = default, type e.g. restore
case "backup-jobs":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getJobs(isset($object) ? intval(preg_replace('/[^\d]/', '', $object)) : 0, $extra), false);
break;
case "backup-job":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getJob($object));
break;
case "logs":
switch ($object) {
case "dovecot":
@@ -1830,6 +1867,9 @@ if (isset($_GET['query'])) {
case "retention-rule":
process_delete_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->deleteRules($items));
break;
case "backup-job":
process_delete_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->deleteJobs($items));
break;
case "app-passwd":
process_delete_return(app_passwd('delete', array('id' => $items)));
break;
@@ -2010,6 +2050,9 @@ if (isset($_GET['query'])) {
case "retention-rule":
process_edit_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->editRules($items, $attr));
break;
case "backup-settings":
process_edit_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->editSettings($attr));
break;
case "recipient_map":
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
break;
+369
View File
@@ -503,6 +503,346 @@
"syncjob_max_kb_per_second": "Bandbreiten-Limit pro Prozess (KB/s)",
"syncjob_max_kb_per_second_info": "0 = unbegrenzt. Begrenzt jeden Sync-Prozess in KB/s; ein Job-Limit (Bytes/s) wird hierdurch zusätzlich begrenzt. Gesamt ≈ dieser Wert × parallele Prozesse."
},
"backup": {
"active_line": "%s #%s – %s",
"added": "Hinzugefügt",
"advanced": "Erweitert",
"advanced_info": "Bereinigen, ungenutzter Platz, Prüfung, Index, Upload-Limit",
"all_items": "Alle Bestandteile",
"all_ticked": "Alle Mailboxen gewählt –",
"and_more": "und %s weitere",
"backup_now": "Jetzt sichern",
"backup_now_info": "Sichert jetzt, unabhängig vom Zeitplan.",
"cancel": "Abbrechen",
"cancel_job": "Auftrag abbrechen",
"cancel_running_confirm": "Laufenden Auftrag abbrechen? Was er bisher wiederhergestellt hat, bleibt erhalten.",
"check_info": "Prüft das Repository und liest einen Teil der Daten, wie nach der ersten geplanten Sicherung eines Tages.",
"check_now": "Prüfen",
"check_subset": "Vollständige Prüfung in",
"check_subset_info": "Nach der ersten geplanten Sicherung eines Tages wird 1/N der Daten gelesen, nach N Sicherungstagen also alles einmal.",
"check_subset_unit": "Sicherungstagen (0 = aus)",
"clear_selection": "Auswahl aufheben",
"close": "Schließen",
"collapse": "Mailboxen von %s ausblenden",
"component": "Bestandteil",
"component_aliases": "Aliasse",
"component_app_passwords": "App-Passwörter",
"component_bcc": "BCC-Maps",
"component_domain": "Domain",
"component_filters": "Filter und Sieve-Skripte (inkl. Abwesenheitsnotiz)",
"component_groups": "Gruppenmitgliedschaften",
"component_imap_acl": "IMAP-Freigaben (Ordnerrechte)",
"component_mail_policies": "Mail-Richtlinien (Zuordnungen)",
"component_mailbox": "Mailbox",
"component_mails": "E-Mails mit Ordnern und Markierungen",
"component_password": "Mailbox-Passwort aus dem Backup",
"component_retention_rules": "Aufbewahrungsregeln (Zuordnungen)",
"component_sender_acl": "Absenderberechtigungen (Senden als)",
"component_settings": "Mailbox-Einstellungen",
"component_sieve_activate": "Sieve-Skript aus dem Backup aktivieren, wenn keines aktiv ist",
"component_sogo": "Kalender und Kontakte auf Backup-Stand zurücksetzen",
"component_sogo_new": "Kalender und Kontakte",
"component_spam": "Spam-Einstellungen (Deny/Allowlist, Spam-Score, Spam-Aliasse)",
"component_syncjobs": "Sync-Jobs",
"component_tfa": "Zwei-Faktor-Authentifizierung",
"components": "Bestandteile",
"connection_ok": "Verbindung OK",
"content_unavailable": "Dieser Snapshot hat keine Liste seiner Mailboxen (älter als diese Funktion): Nur Domains und Mailboxen, die es jetzt gibt, lassen sich wiederherstellen.",
"copy": "Kopieren",
"count_deleted": "%s (%s gelöscht)",
"create_note": "Gelöschte Mailboxen und Domains werden aus dem Backup neu angelegt, mit dem Passwort aus dem Backup.",
"created": "Erstellt",
"created_from_backup": "Aus dem Backup angelegt",
"data_added": "Hinzugefügt",
"deleted_domain_children": "Die Domain wurde gelöscht: Nur die ganze Domain lässt sich wiederherstellen.",
"details": "Details",
"domains": "Domains",
"duration": "Dauer",
"enabled": "Zeitplan",
"enabled_label": "Aktiv",
"error": "Fehler",
"errors": "Fehler",
"expand": "Mailboxen von %s anzeigen",
"failed_line": "%s #%s fehlgeschlagen",
"finished": "Beendet",
"first_run": "Einrichtung",
"first_run_1": "Backup-Ziel wählen und ausfüllen",
"first_run_2": "Repository-Passwort erzeugen und außerhalb von mailcow aufbewahren",
"first_run_3": "Änderungen speichern",
"first_run_4": "„Verbindung testen“ (legt das Repository an)",
"first_run_5": "Zeitplan einschalten und speichern",
"group_access": "Zugänge",
"group_addresses": "Adressen und Mitgliedschaften",
"group_calendar": "Kalender und Kontakte",
"group_filters": "Filter und Spam",
"group_mail": "E-Mails",
"hostname": "Host",
"id": "ID",
"include_fts": "Einschließlich des Volltext-Suchindex",
"include_index": "Dovecot-Index sichern (schnellere Wiederherstellung, er lässt sich auch neu aufbauen)",
"intro": "Verschlüsselte tägliche Backups mit restic. Wiederherstellen:",
"intro_restore_link": "E-Mail › Wiederherstellung",
"job": "Auftrag #%s",
"jobs": "Aufträge",
"jobs_none": "Noch keine Aufträge",
"keep": "Snapshots behalten",
"keep_daily": "tägl.",
"keep_hourly": "stündl.",
"keep_info": "Wie viele stündliche, tägliche, wöchentliche, monatliche und jährliche Snapshots erhalten bleiben; ältere werden nach jeder Sicherung verworfen. Von mehreren Sicherungen am Tag bleibt als tägliche nur die letzte, „stündl.“ behält auch die anderen.",
"keep_monthly": "monatl.",
"keep_weekly": "wöch.",
"keep_yearly": "jährl.",
"limit_upload": "Upload-Limit",
"limit_upload_info": "0 = unbegrenzt",
"load_error": "Die Backup-Daten konnten nicht geladen werden",
"local_path": "Verzeichnis",
"local_path_info": "Unterverzeichnis von RESTIC_LOCAL_PATH aus mailcow.conf (leer: das Docker-Volume restic-repo-vol-1). Schützt nicht vor dem Verlust des Servers.",
"log": "Protokoll",
"log_empty": "Noch keine Protokolleinträge",
"mail_target": "E-Mails",
"mail_target_folder": "in einen eigenen Ordner „%s“",
"mail_target_folder_name": "Restored <Datum des Snapshots>",
"mail_target_folder_named": "eigener Ordner „%s“",
"mail_target_folder_short": "eigener Ordner",
"mail_target_original": "in die ursprünglichen Ordner (nur fehlende E-Mails)",
"mail_target_original_short": "ursprüngliche Ordner",
"mailboxes": "Mailboxen",
"maintenance": "Wartung",
"manual_by": "manuell: %s",
"max_unused": "Max. ungenutzter Platz",
"max_unused_info": "Die Bereinigung darf bis zu diesem Anteil ungenutzter Daten im Repository lassen und schreibt dafür weniger um (restic --max-unused).",
"minutes_ago": "vor %s min",
"modal_title_domain": "Domain %s wiederherstellen",
"modal_title_mailbox": "%s wiederherstellen",
"modal_title_mailboxes": "%s Mailboxen in %s wiederherstellen",
"mode_config": "Ohne Mails (RESTIC_BACKUP_MODE=config)",
"mode_data": "Vom mailcow-Verzeichnis nur mailcow.conf (RESTIC_BACKUP_MODE=data)",
"mode_invalid": "RESTIC_BACKUP_MODE in mailcow.conf ist ungültig (%s), erlaubt sind all, data und config. Sicherungen schlagen fehl.",
"more": "Mehr",
"more_items": "+ %s weitere",
"name": "Name",
"nav": "Wiederherstellung",
"no_components": "Wählen Sie mindestens einen Bestandteil aus.",
"no_domains": "Der Snapshot enthält keine Domains, die Sie wiederherstellen können.",
"no_mail_data": "Dieser Snapshot enthält keine Mails (RESTIC_BACKUP_MODE=config): E-Mails, IMAP-Freigaben, Kalender und Kontakte und die Sieve-Aktivierung sind nicht wiederherstellbar.",
"no_mailboxes": "Der Snapshot enthält keine Mailboxen dieser Domain.",
"no_matches": "Nichts passt zur Suche.",
"no_snapshots_any": "Es gibt noch keine Snapshots.",
"no_snapshots_backup_now": "Jetzt sichern (System › Konfiguration › Backup)",
"not_configured": "Backups sind nicht eingerichtet.",
"not_configured_da": "Backups sind nicht eingerichtet. Bitte wenden Sie sich an den Administrator.",
"note_app_passwords": "Alte App-Passwörter ermöglichen wieder den Zugang.",
"note_inactive": "Inaktive Mailboxen erhalten keine E-Mails, IMAP-Freigaben und Sieve-Skripte zurück.",
"note_retention_rules": "Eine Aufbewahrungsregel kann ältere wiederhergestellte E-Mails bei ihrem nächsten Lauf löschen.",
"note_settings": "Quota, Protokolle, Attribute, Berechtigungen, Pushover, Relayhost (Tags werden ergänzt)",
"note_sieve_activate": "Es kann wieder E-Mails weiterleiten oder Abwesenheitsnotizen senden.",
"note_sogo": "Bestehende Kalender, Adressbücher und SOGo-Einstellungen erhalten den Stand des Backups.",
"note_sogo_more": "Fehlende Kalender, Adressbücher und Einträge kommen zurück. Namen und Freigaben bestehender Kalender und Adressbücher sowie alle SOGo-Einstellungen (Signaturen, Identitäten, Abonnements, Ansicht) werden durch die aus dem Backup ersetzt. Seitdem geänderte Einträge bleiben erhalten.",
"note_tfa": "Die Anmeldung braucht wieder den alten zweiten Faktor.",
"notify_email": "Bei Fehlern benachrichtigen",
"notify_email_info": "Adressen durch Kommas getrennt; sie erhalten eine E-Mail, wenn ein Auftrag fehlschlägt.",
"only_deleted": "Nur gelöschte",
"optional": "optional",
"overwrite": "Überschreibt aktuelle Werte",
"overwrite_confirm": "%s werden für %s Mailbox(en) mit dem Stand des Backups überschrieben. Fortfahren?",
"overwrite_info": "Diese Bestandteile ersetzen die aktuellen Werte durch die aus dem Backup.",
"overwrite_short_password": "Passwort",
"overwrite_short_settings": "Einstellungen",
"overwrite_short_sogo": "Kalender und Kontakte",
"password": "Repository-Passwort",
"password_change": "Ein neues Passwort gilt nur für ein neues, leeres Repository: Ein bestehendes behält sein Passwort.",
"password_confirmed": "Ich habe das Repository-Passwort außerhalb von mailcow aufbewahrt. Ohne das Passwort lässt sich das Backup nicht wiederherstellen.",
"password_confirmed_short": "Außerhalb von mailcow aufbewahrt (Pflicht)",
"password_generate": "Erzeugen",
"password_info": "Mind. 8 Zeichen, wird nicht wieder angezeigt.",
"password_unconfirmed": "Sicherungen laufen erst, wenn Sie unten bestätigen, dass das Repository-Passwort außerhalb von mailcow aufbewahrt ist.",
"prune_confirm": "Jetzt bereinigen? Die Daten nicht mehr behaltener Snapshots werden aus dem Repository gelöscht.",
"prune_info": "Löscht die Daten nicht mehr behaltener Snapshots.",
"prune_now": "Bereinigen",
"prune_weekday": "Bereinigen am",
"prune_weekday_info": "Einmal pro Woche, nach der ersten geplanten Sicherung ab diesem Tag, werden nicht mehr benötigte Daten aus dem Repository gelöscht. „Nie“ passt zu Zielen mit Append-only oder Object Lock.",
"queued_inline": "Wiederherstellungsauftrag #%s eingereiht.",
"refresh": "Neu laden",
"repo_not_tested": "Das Repository ist noch nicht getestet: „Verbindung testen“ verwenden.",
"requested_admin": "Administrator",
"requested_by": "Angefordert von",
"requested_by_short": "von %s",
"requested_schedule": "Zeitplan",
"rest_password": "Passwort",
"rest_url": "URL",
"rest_url_info": "z. B. https://backup.example.org:8000/mailcow, ohne Benutzer und Passwort.",
"rest_user": "Benutzer",
"restore": "Wiederherstellen",
"restore_added_many": "Wiederherstellungsauftrag #%s für %s Mailboxen eingereiht",
"restore_added_one": "Wiederherstellungsauftrag #%s für %s Mailbox eingereiht",
"restore_background": "Die Wiederherstellung läuft im Hintergrund. Den Fortschritt zeigt die Liste „Bisherige Wiederherstellungen“.",
"restore_card": "Aus Backup wiederherstellen",
"restore_info": "Ergänzt nur, was fehlt. Nichts Vorhandenes wird überschrieben oder gelöscht, außer den unten markierten Bestandteilen.",
"restore_item": "%s wiederherstellen",
"restore_overwrite": "Wiederherstellen und überschreiben",
"restore_target_domain": "Alle Mailboxen der Domain %s",
"restore_target_domain_short": "Domain %s",
"restore_target_mailbox": "Mailbox %s",
"restore_target_mailboxes": "%s Mailboxen: %s",
"restore_target_mailboxes_of": "%s Mailboxen von %s",
"restore_title": "Aus Backup wiederherstellen",
"restore_what": "Auswahl",
"restores": "Bisherige Wiederherstellungen",
"restores_info": "Wiederherstellungen der letzten 180 Tage. „Details“ zeigt das Ergebnis je Mailbox und Bestandteil. Laufende lassen sich abbrechen, bis die E-Mails an der Reihe sind.",
"restores_none": "Noch keine Wiederherstellungen",
"result": "Ergebnis",
"result_activated": "Aktiviertes Skript",
"result_details": "Details",
"result_empty": "kein Ergebnis",
"result_errors": "%s Fehler",
"result_failed": "mit Fehlern: %s",
"result_folder": "Ordner",
"result_folders": "Ordner",
"result_folders_created": "Angelegte Ordner",
"result_folders_failed": "Ordner mit Fehlern",
"result_mailboxes": "Mailboxen: %s",
"result_none": "noch kein Ergebnis",
"result_note_no_mails": "Der Snapshot enthält keine E-Mails dieser Mailbox.",
"result_note_no_sogo_data": "Der Snapshot enthält keine SOGo-Daten dieser Mailbox.",
"result_subscribed": "Hinzugefügte Abonnements",
"result_summary": "%s hinzugefügt, %s bereits vorhanden",
"result_unchanged": "Ohne Änderung: %s",
"runner_missing": "Der Backup-Container (restic-mailcow) hat sich seit %s nicht gemeldet. Sicherungen und Wiederherstellungen laufen erst wieder, wenn er läuft.",
"runner_never": "Der Backup-Container (restic-mailcow) hat sich noch nicht gemeldet. Sicherungen und Wiederherstellungen laufen erst, wenn er läuft.",
"s3_access_key": "Access Key",
"s3_bucket": "Bucket",
"s3_endpoint": "Endpunkt",
"s3_endpoint_info": "URL mit https://, z. B. https://s3.eu-central-1.amazonaws.com oder die URL eines S3-kompatiblen Speichers.",
"s3_path": "Pfad im Bucket",
"s3_path_style": "Pfad-Adressierung (für manche S3-kompatiblen Speicher nötig, z. B. MinIO)",
"s3_region": "Region",
"s3_secret_key": "Secret Key",
"save": "Änderungen speichern",
"schedule": "Zeitplan",
"schedule_add": "Hinzufügen",
"schedule_at": "um",
"schedule_daily": "täglich",
"schedule_days": "Wochentage",
"schedule_entry": "%s – %s Uhr",
"schedule_none": "Keine Zeiten eingetragen.",
"schedule_remove": "Entfernen",
"schedule_time": "Uhrzeit",
"schedule_time_info": "Zeitzone: %s (TZ in mailcow.conf).",
"scope": "Umfang",
"scope_all": "Alles",
"scope_all_in": "E-Mails, Datenbank, Redis, Rspamd, Kalender und Kontakte, das komplette mailcow-Verzeichnis",
"scope_config": "Nur Konfiguration",
"scope_config_in": "Datenbank, Redis, Mail-Crypt-Schlüssel, mailcow.conf, data/conf",
"scope_config_out": "E-Mails, Rspamd, Kalender und Kontakte",
"scope_data": "Nur Daten",
"scope_data_in": "E-Mails, Datenbank, Redis, Rspamd, Kalender und Kontakte, mailcow.conf",
"scope_data_out": "Übriges mailcow-Verzeichnis, z. B. data/conf und data/web",
"scope_domain": "ganze Domain",
"scope_info": "Festgelegt mit RESTIC_BACKUP_MODE in mailcow.conf (all, data oder config), in der Oberfläche nicht änderbar. Nach einer Änderung: docker compose up -d.",
"scope_mailbox": "ausgewählte Mailboxen",
"scope_unknown": "Noch nicht bekannt: Der Backup-Container hat sich noch nicht gemeldet.",
"search_placeholder": "Domain oder Mailbox suchen (ab 2 Zeichen)",
"search_truncated": "Mehr als 500 Treffer: Suche verfeinern.",
"seconds_ago": "vor %s s",
"secret_keep": "Gespeichert. Leer lassen, um es zu behalten.",
"secrets_error": "Die gespeicherten Zugangsdaten lassen sich nicht entschlüsseln (z. B. nach einer Wiederherstellung auf einem anderen Server). Bitte Repository-Passwort und Zugangsdaten neu eingeben.",
"select_whole_domain": "ganze Domain wählen",
"selected_domain": "Ganze Domain %s",
"selected_mailbox_in": "%s Mailbox in %s",
"selected_mailboxes_in": "%s Mailboxen in %s",
"selection_none": "Nichts ausgewählt",
"selection_replaced": "Eine Wiederherstellung umfasst eine Domain: Die Auswahl in %s wurde ersetzt.",
"selection_reset": "Die Liste wurde nach einer abgeschlossenen Wiederherstellung aktualisiert; die Auswahl wurde aufgehoben.",
"settings": "Einstellungen",
"sftp_host": "Host",
"sftp_host_key": "Host-Schlüssel des Servers",
"sftp_host_key_info": "Wird bei der ersten Verbindung übernommen und danach geprüft. Er wird verworfen, wenn sich Host oder Port ändern.",
"sftp_path": "Pfad",
"sftp_path_info": "Verzeichnis des Repositorys auf dem Server, relativ zum Home-Verzeichnis des Benutzers oder absolut.",
"sftp_port": "Port",
"sftp_public_key": "Öffentlicher Schlüssel von mailcow",
"sftp_public_key_info": "mailcow meldet sich mit diesem Schlüssel an. Tragen Sie ihn auf dem SFTP-Server in ~/.ssh/authorized_keys des Benutzers ein.",
"sftp_public_key_missing": "Der Backup-Container erzeugt den Schlüssel beim Start.",
"sftp_user": "Benutzer",
"show_more": "%s weitere anzeigen (noch %s)",
"show_more_domains": "%s weitere Domains anzeigen (noch %s)",
"size": "Datenmenge",
"skipped": "Bereits vorhanden",
"snapshot": "Snapshot",
"snapshot_label": "Sicherungsstand (Snapshot)",
"snapshot_mode_config": "ohne Mails",
"snapshot_mode_data": "nur Daten",
"snapshots": "Snapshots",
"snapshots_none": "Noch keine Snapshots",
"started": "Gestartet",
"state": "Status",
"state_cancelled": "Abgebrochen",
"state_done": "Fertig",
"state_failed": "Fehlgeschlagen",
"state_queued": "Eingereiht",
"state_running": "Läuft",
"status_deleted": "gelöscht",
"status_last": "Letzte Sicherung %s (%s)",
"status_never": "Noch keine Sicherung",
"status_next": "nächste %s",
"status_restoring": "wird wiederhergestellt",
"status_schedule_off": "Zeitplan aus",
"status_snapshot": "%s Snapshot",
"status_snapshots": "%s Snapshots",
"status_stale": "Sicherung laut Zeitplan überfällig (zuletzt erfolgreich %s).",
"step_aliases": "Wiederherstellen der Aliasse",
"step_backup": "Upload",
"step_check": "Prüfung",
"step_connect": "Verbindungsaufbau",
"step_domain": "Anlegen der Domain",
"step_dump": "Datenbank-Dump",
"step_export": "Export der Domains",
"step_files": "Zurückholen der Dateien",
"step_forget": "Verwerfen alter Snapshots",
"step_init": "Anlegen des Repositorys",
"step_mails": "Warten auf den Mail-Container",
"step_mails_done": "E-Mails wiederhergestellt, Abschluss",
"step_mails_running": "Wiederherstellen der E-Mails",
"step_prune": "Bereinigung",
"step_settings": "Wiederherstellen der Einstellungen",
"step_snapshots": "Lesen der Snapshots",
"step_sogo": "Wiederherstellen von Kalendern und Kontakten",
"step_stats": "Ermitteln der Repository-Größe",
"summary_backup": "Snapshot %s, %s hinzugefügt",
"summary_check": "Teil %s geprüft",
"summary_created": "Repository angelegt",
"summary_snapshots": "Snapshots: %s",
"tab": "Backup",
"target": "Backup-Ziel",
"target_count": "%s Mailboxen im Snapshot, davon %s gelöscht",
"target_count_all": "%s Mailboxen im Snapshot",
"target_count_one": "1 Mailbox im Snapshot",
"target_count_one_deleted": "1 Mailbox im Snapshot, gelöscht",
"target_local": "Lokales Verzeichnis",
"target_rest": "restic-REST-Server",
"target_s3": "S3 (Amazon S3 oder S3-kompatibel)",
"target_sftp": "SFTP",
"test_connection": "Verbindung testen",
"test_connection_info": "Prüft die Verbindung und legt das Repository an, falls es noch nicht existiert.",
"time": "Zeit",
"to_backup_settings": "Zu den Backup-Einstellungen",
"type": "Art",
"type_backup": "Sicherung",
"type_check": "Prüfung",
"type_init": "Verbindungstest",
"type_prune": "Bereinigung",
"type_restore": "Wiederherstellung",
"warnings": "Warnungen",
"weekday_0": "Sonntag",
"weekday_1": "Montag",
"weekday_2": "Dienstag",
"weekday_3": "Mittwoch",
"weekday_4": "Donnerstag",
"weekday_5": "Freitag",
"weekday_6": "Samstag",
"weekday_never": "nie (nur manuell)",
"will_be_created": "wird angelegt"
},
"danger": {
"access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
"alias_domain_invalid": "Alias-Domain %s ist ungültig",
@@ -519,6 +859,31 @@
"app_name_empty": "App-Name darf nicht leer sein",
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
"backup_action_invalid": "Ungültige Aktion für den Backup-Endpunkt",
"backup_component_denied": "Sie dürfen %s nicht wiederherstellen",
"backup_component_requires": "%s geht nur zusammen mit %s",
"backup_components_invalid": "Ungültiger oder fehlender Bestandteil der Wiederherstellung: %s",
"backup_components_no_mails": "Der Snapshot enthält keine Mails (RESTIC_BACKUP_MODE=config), nicht wiederherstellbar: %s",
"backup_domain_not_in_snapshot": "Der Snapshot enthält die Domain %s nicht",
"backup_entries_exceeded": "Zu viele Einträge (höchstens %s)",
"backup_export_invalid": "Der Export im Snapshot fehlt oder ist ungültig",
"backup_job_active": "Ein Auftrag der Art „%s“ ist bereits eingereiht oder läuft",
"backup_job_busy": "Der Auftrag wird gerade von einer anderen Anfrage bearbeitet, bitte erneut versuchen",
"backup_job_not_cancellable": "Auftrag #%s ist nicht mehr eingereiht und kann nicht abgebrochen werden",
"backup_job_not_running": "Der Wiederherstellungsauftrag läuft nicht",
"backup_job_type_invalid": "Ungültiger Auftragstyp %s",
"backup_mail_target_invalid": "Ungültiges Ziel für die E-Mails (Ordner oder Original)",
"backup_mailbox_not_found": "Mailbox %s existiert nicht",
"backup_mailboxes_empty": "Keine Mailboxen zum Wiederherstellen",
"backup_mailboxes_one_domain": "Alle Mailboxen einer Wiederherstellung müssen zu einer Domain gehören",
"backup_not_configured": "Backup-Ziel und Repository-Passwort sind nicht vollständig eingerichtet",
"backup_password_not_confirmed": "Bitte zuerst bestätigen, dass das Repository-Passwort außerhalb von mailcow aufbewahrt wird",
"backup_password_too_short": "Das Repository-Passwort muss mindestens %s Zeichen lang sein",
"backup_restore_active": "Eine Wiederherstellung von %s ist bereits eingereiht oder läuft",
"backup_scope_invalid": "Ungültiger Umfang der Wiederherstellung (Mailbox oder Domain)",
"backup_secrets_key_missing": "Der Backup-Container hat seinen Schlüssel noch nicht bereitgestellt: Bitte den Container (restic-mailcow) starten und erneut speichern",
"backup_setting_invalid": "Ungültiger Wert für %s",
"backup_snapshot_not_found": "Snapshot %s nicht gefunden",
"bcc_empty": "BCC-Ziel darf nicht leer sein",
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
"bcc_must_be_email": "BCC-Ziel %s ist keine gültige E-Mail-Adresse",
@@ -1439,6 +1804,10 @@
"app_links": "Änderungen an App-Links wurden gespeichert",
"app_passwd_added": "App-Passwort wurde gespeichert",
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
"backup_job_added": "Auftrag #%2$s (%1$s) eingereiht",
"backup_job_cancelled": "Auftrag #%s abgebrochen",
"backup_restore_added": "Wiederherstellungsauftrag #%s für %s Mailbox(en) eingereiht",
"backup_settings_saved": "Backup-Einstellungen gespeichert",
"bcc_deleted": "BCC-Map-Einträge gelöscht: %s",
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
+369
View File
@@ -503,6 +503,346 @@
"syncjob_max_kb_per_second": "Bandwidth limit per process (KB/s)",
"syncjob_max_kb_per_second_info": "0 = unlimited. Caps every sync process in KB/s; a per-job limit (bytes/s) is capped by this. Total ≈ this × max parallel."
},
"backup": {
"active_line": "%s #%s – %s",
"added": "Added",
"advanced": "Advanced",
"advanced_info": "prune, unused space, check, index, upload limit",
"all_items": "All items",
"all_ticked": "All mailboxes selected –",
"and_more": "and %s more",
"backup_now": "Back up now",
"backup_now_info": "Backs up now, independent of the schedule.",
"cancel": "Cancel",
"cancel_job": "Cancel job",
"cancel_running_confirm": "Cancel the running job? Anything restored so far is kept.",
"check_info": "Verifies the repository and reads a part of the data, as after the first scheduled backup of a day.",
"check_now": "Check",
"check_subset": "Full check in",
"check_subset_info": "After the first scheduled backup of a day 1/N of the data is read, so all of it once in N backup days.",
"check_subset_unit": "backup days (0 = off)",
"clear_selection": "Clear selection",
"close": "Close",
"collapse": "Hide the mailboxes of %s",
"component": "Item",
"component_aliases": "Aliases",
"component_app_passwords": "App passwords",
"component_bcc": "BCC maps",
"component_domain": "Domain",
"component_filters": "Filters and Sieve scripts (incl. vacation reply)",
"component_groups": "Group memberships",
"component_imap_acl": "IMAP shares (folder permissions)",
"component_mail_policies": "Mail policies (assignments)",
"component_mailbox": "Mailbox",
"component_mails": "Mails with folders and flags",
"component_password": "Mailbox password from the backup",
"component_retention_rules": "Retention rules (assignments)",
"component_sender_acl": "Sender permissions (send as)",
"component_settings": "Mailbox settings",
"component_sieve_activate": "Activate the Sieve script of the backup if none is active",
"component_sogo": "Reset calendars and contacts to the backup",
"component_sogo_new": "Calendars and contacts",
"component_spam": "Spam settings (deny/allowlist, spam score, spam aliases)",
"component_syncjobs": "Sync jobs",
"component_tfa": "Two-factor authentication",
"components": "Items",
"connection_ok": "Connection OK",
"content_unavailable": "This snapshot has no list of its mailboxes (made before mailcow kept one): only domains and mailboxes that exist now can be restored.",
"copy": "Copy",
"count_deleted": "%s (%s deleted)",
"create_note": "Deleted mailboxes and domains are created again from the backup, with the password from the backup.",
"created": "Created",
"created_from_backup": "Created from the backup",
"data_added": "Added",
"deleted_domain_children": "The domain was deleted: only the whole domain can be restored.",
"details": "Details",
"domains": "Domains",
"duration": "Duration",
"enabled": "Schedule",
"enabled_label": "Active",
"error": "Error",
"errors": "Errors",
"expand": "Show the mailboxes of %s",
"failed_line": "%s #%s failed",
"finished": "Finished",
"first_run": "Setup",
"first_run_1": "Choose and fill in the backup target",
"first_run_2": "Generate the repository password and store it outside of mailcow",
"first_run_3": "Save the changes",
"first_run_4": "\"Test connection\" (creates the repository)",
"first_run_5": "Switch the schedule on and save",
"group_access": "Access",
"group_addresses": "Addresses and memberships",
"group_calendar": "Calendars and contacts",
"group_filters": "Filters and spam",
"group_mail": "Mails",
"hostname": "Host",
"id": "ID",
"include_fts": "Including the full-text search index",
"include_index": "Back up the Dovecot index (faster restore, it can be rebuilt as well)",
"intro": "Encrypted daily backups with restic. Restore:",
"intro_restore_link": "E-Mail › Restore",
"job": "Job #%s",
"jobs": "Jobs",
"jobs_none": "No jobs yet",
"keep": "Keep snapshots",
"keep_daily": "daily",
"keep_hourly": "hourly",
"keep_info": "How many hourly, daily, weekly, monthly and yearly snapshots are kept; older ones are forgotten after each backup. Of several backups a day only the last one is kept as daily, \"hourly\" keeps the others too.",
"keep_monthly": "monthly",
"keep_weekly": "weekly",
"keep_yearly": "yearly",
"limit_upload": "Upload limit",
"limit_upload_info": "0 = unlimited",
"load_error": "The backup data could not be loaded",
"local_path": "Directory",
"local_path_info": "Subdirectory below RESTIC_LOCAL_PATH of mailcow.conf (empty: the Docker volume restic-repo-vol-1). It does not protect against the loss of the server.",
"log": "Log",
"log_empty": "No log entries yet",
"mail_target": "Mails",
"mail_target_folder": "into a separate folder \"%s\"",
"mail_target_folder_name": "Restored <date of the snapshot>",
"mail_target_folder_named": "separate folder \"%s\"",
"mail_target_folder_short": "separate folder",
"mail_target_original": "into the original folders (missing mails only)",
"mail_target_original_short": "original folders",
"mailboxes": "Mailboxes",
"maintenance": "Maintenance",
"manual_by": "manual: %s",
"max_unused": "Max. unused space",
"max_unused_info": "The prune may leave up to this share of unused data in the repository and rewrites less (restic --max-unused).",
"minutes_ago": "%s min ago",
"modal_title_domain": "Restore domain %s",
"modal_title_mailbox": "Restore %s",
"modal_title_mailboxes": "Restore %s mailboxes in %s",
"mode_config": "Without mails (RESTIC_BACKUP_MODE=config)",
"mode_data": "Of the mailcow directory only mailcow.conf (RESTIC_BACKUP_MODE=data)",
"mode_invalid": "RESTIC_BACKUP_MODE in mailcow.conf is invalid (%s), allowed are all, data and config. Backups fail.",
"more": "More",
"more_items": "+ %s more",
"name": "Name",
"nav": "Restore",
"no_components": "Choose at least one item.",
"no_domains": "The snapshot contains no domains you can restore.",
"no_mail_data": "This snapshot holds no mails (RESTIC_BACKUP_MODE=config): mails, IMAP shares, calendars and contacts and the Sieve activation cannot be restored.",
"no_mailboxes": "The snapshot contains no mailboxes of this domain.",
"no_matches": "Nothing matches the search.",
"no_snapshots_any": "There are no snapshots yet.",
"no_snapshots_backup_now": "Back up now (System › Configuration › Backup)",
"not_configured": "Backups are not set up.",
"not_configured_da": "Backups are not set up. Please contact the administrator.",
"note_app_passwords": "Old app passwords give access again.",
"note_inactive": "Inactive mailboxes do not get mails, IMAP shares and Sieve scripts back.",
"note_retention_rules": "A retention rule can delete older restored mails on its next run.",
"note_settings": "Quota, protocols, attributes, permissions, Pushover, relayhost (tags are added)",
"note_sieve_activate": "It may forward mails or send vacation replies again.",
"note_sogo": "Existing calendars, address books and SOGo settings get the state of the backup.",
"note_sogo_more": "Missing calendars, address books and entries come back. Names and shares of existing calendars and address books and all SOGo settings (signatures, identities, subscriptions, view) are replaced by those of the backup. Entries changed since are kept.",
"note_tfa": "The login needs the old second factor again.",
"notify_email": "Notify on errors",
"notify_email_info": "Addresses separated by commas; they get a mail when a job fails.",
"only_deleted": "Deleted only",
"optional": "optional",
"overwrite": "Overwrites current values",
"overwrite_confirm": "%s will be overwritten with the state of the backup for %s mailbox(es). Continue?",
"overwrite_info": "These items replace the current values with those of the backup.",
"overwrite_short_password": "password",
"overwrite_short_settings": "settings",
"overwrite_short_sogo": "calendars and contacts",
"password": "Repository password",
"password_change": "A new password only works for a new, empty repository: an existing one keeps its password.",
"password_confirmed": "I have stored the repository password outside of mailcow. Without it the backup cannot be restored.",
"password_confirmed_short": "Stored outside of mailcow (required)",
"password_generate": "Generate",
"password_info": "At least 8 characters, it is not shown again.",
"password_unconfirmed": "Backups do not run before you confirm below that the repository password is stored outside of mailcow.",
"prune_confirm": "Prune now? The data of snapshots that are no longer kept is deleted from the repository.",
"prune_info": "Deletes the data of snapshots that are no longer kept.",
"prune_now": "Prune",
"prune_weekday": "Prune on",
"prune_weekday_info": "Once a week, after the first scheduled backup from this day on, data no longer needed is deleted from the repository. \"Never\" suits append-only or object-lock targets.",
"queued_inline": "Restore job #%s queued.",
"refresh": "Refresh",
"repo_not_tested": "The repository has not been tested yet: use \"Test connection\".",
"requested_admin": "Administrator",
"requested_by": "Requested by",
"requested_by_short": "by %s",
"requested_schedule": "Schedule",
"rest_password": "Password",
"rest_url": "URL",
"rest_url_info": "e.g. https://backup.example.org:8000/mailcow, without user and password.",
"rest_user": "User",
"restore": "Restore",
"restore_added_many": "Restore job #%s queued for %s mailboxes",
"restore_added_one": "Restore job #%s queued for %s mailbox",
"restore_background": "The restore runs in the background. Its progress is shown in \"Previous restores\".",
"restore_card": "Restore from backup",
"restore_info": "Only what is missing is added. Nothing that exists is overwritten or deleted, except the items marked below.",
"restore_item": "Restore %s",
"restore_overwrite": "Restore and overwrite",
"restore_target_domain": "All mailboxes of the domain %s",
"restore_target_domain_short": "Domain %s",
"restore_target_mailbox": "Mailbox %s",
"restore_target_mailboxes": "%s mailboxes: %s",
"restore_target_mailboxes_of": "%s mailboxes of %s",
"restore_title": "Restore from backup",
"restore_what": "Selection",
"restores": "Previous restores",
"restores_info": "Restores of the last 180 days. \"Details\" shows the result per mailbox and item. Running restores can be cancelled until the mails are restored.",
"restores_none": "No restores yet",
"result": "Result",
"result_activated": "Activated script",
"result_details": "Details",
"result_empty": "no result",
"result_errors": "Errors: %s",
"result_failed": "with errors: %s",
"result_folder": "Folder",
"result_folders": "Folders",
"result_folders_created": "Folders created",
"result_folders_failed": "Folders with errors",
"result_mailboxes": "Mailboxes: %s",
"result_none": "no result yet",
"result_note_no_mails": "The snapshot has no mails of this mailbox.",
"result_note_no_sogo_data": "The snapshot has no SOGo data of this mailbox.",
"result_subscribed": "Subscriptions added",
"result_summary": "%s added, %s already present",
"result_unchanged": "Unchanged: %s",
"runner_missing": "The backup container (restic-mailcow) has not reported since %s. Backups and restores do not run until it is running again.",
"runner_never": "The backup container (restic-mailcow) has not reported yet. Backups and restores do not run until it is running.",
"s3_access_key": "Access key",
"s3_bucket": "Bucket",
"s3_endpoint": "Endpoint",
"s3_endpoint_info": "URL with https://, e.g. https://s3.eu-central-1.amazonaws.com or the URL of an S3-compatible storage.",
"s3_path": "Path in the bucket",
"s3_path_style": "Path-style access (needed by some S3-compatible storages, e.g. MinIO)",
"s3_region": "Region",
"s3_secret_key": "Secret key",
"save": "Save changes",
"schedule": "Schedule",
"schedule_add": "Add",
"schedule_at": "at",
"schedule_daily": "daily",
"schedule_days": "Weekdays",
"schedule_entry": "%s – %s",
"schedule_none": "No times set.",
"schedule_remove": "Remove",
"schedule_time": "Time",
"schedule_time_info": "Time zone: %s (TZ in mailcow.conf).",
"scope": "Scope",
"scope_all": "Everything",
"scope_all_in": "Mails, database, Redis, Rspamd, calendars and contacts, the whole mailcow directory",
"scope_config": "Configuration only",
"scope_config_in": "Database, Redis, mail crypt keys, mailcow.conf, data/conf",
"scope_config_out": "Mails, Rspamd, calendars and contacts",
"scope_data": "Data only",
"scope_data_in": "Mails, database, Redis, Rspamd, calendars and contacts, mailcow.conf",
"scope_data_out": "The rest of the mailcow directory, e.g. data/conf and data/web",
"scope_domain": "whole domain",
"scope_info": "Set with RESTIC_BACKUP_MODE in mailcow.conf (all, data or config), it cannot be changed in the UI. After a change: docker compose up -d.",
"scope_mailbox": "selected mailboxes",
"scope_unknown": "Not known yet: the backup container has not reported yet.",
"search_placeholder": "Search domain or mailbox (from 2 characters)",
"search_truncated": "More than 500 hits: refine the search.",
"seconds_ago": "%s s ago",
"secret_keep": "Stored. Leave empty to keep it.",
"secrets_error": "The stored credentials cannot be decrypted (e.g. after a restore on another server). Please enter the repository password and the credentials again.",
"select_whole_domain": "select the whole domain",
"selected_domain": "Whole domain %s",
"selected_mailbox_in": "%s mailbox in %s",
"selected_mailboxes_in": "%s mailboxes in %s",
"selection_none": "Nothing selected",
"selection_replaced": "A restore covers one domain: the selection in %s was replaced.",
"selection_reset": "The list was updated after a finished restore; the selection was cleared.",
"settings": "Settings",
"sftp_host": "Host",
"sftp_host_key": "Host key of the server",
"sftp_host_key_info": "Accepted at the first connection and checked from then on. It is forgotten when host or port change.",
"sftp_path": "Path",
"sftp_path_info": "Directory of the repository on the server, relative to the home directory of the user or absolute.",
"sftp_port": "Port",
"sftp_public_key": "Public key of mailcow",
"sftp_public_key_info": "mailcow logs in with this key. Add it to ~/.ssh/authorized_keys of the user on the SFTP server.",
"sftp_public_key_missing": "The backup container creates the key when it starts.",
"sftp_user": "User",
"show_more": "Show %s more (%s left)",
"show_more_domains": "Show %s more domains (%s left)",
"size": "Data",
"skipped": "Already present",
"snapshot": "Snapshot",
"snapshot_label": "Backup point (snapshot)",
"snapshot_mode_config": "without mails",
"snapshot_mode_data": "data only",
"snapshots": "Snapshots",
"snapshots_none": "No snapshots yet",
"started": "Started",
"state": "State",
"state_cancelled": "Cancelled",
"state_done": "Done",
"state_failed": "Failed",
"state_queued": "Queued",
"state_running": "Running",
"status_deleted": "deleted",
"status_last": "Last backup %s (%s)",
"status_never": "No backup yet",
"status_next": "next %s",
"status_restoring": "being restored",
"status_schedule_off": "schedule off",
"status_snapshot": "%s snapshot",
"status_snapshots": "%s snapshots",
"status_stale": "Backup overdue by the schedule (last success %s).",
"step_aliases": "restoring the aliases",
"step_backup": "uploading",
"step_check": "checking",
"step_connect": "connecting",
"step_domain": "creating the domain",
"step_dump": "database dump",
"step_export": "export of the domains",
"step_files": "restoring files",
"step_forget": "forgetting old snapshots",
"step_init": "creating the repository",
"step_mails": "waiting for the mail container",
"step_mails_done": "mails restored, finishing",
"step_mails_running": "restoring mails",
"step_prune": "pruning",
"step_settings": "restoring settings",
"step_snapshots": "reading the snapshots",
"step_sogo": "restoring calendars and contacts",
"step_stats": "repository size",
"summary_backup": "snapshot %s, %s added",
"summary_check": "part %s checked",
"summary_created": "repository created",
"summary_snapshots": "Snapshots: %s",
"tab": "Backup",
"target": "Backup target",
"target_count": "%s mailboxes in the snapshot, %s of them deleted",
"target_count_all": "%s mailboxes in the snapshot",
"target_count_one": "1 mailbox in the snapshot",
"target_count_one_deleted": "1 mailbox in the snapshot, deleted",
"target_local": "Local directory",
"target_rest": "restic REST server",
"target_s3": "S3 (Amazon S3 or S3-compatible)",
"target_sftp": "SFTP",
"test_connection": "Test connection",
"test_connection_info": "Checks the connection and creates the repository if it does not exist yet.",
"time": "Time",
"to_backup_settings": "Backup settings",
"type": "Type",
"type_backup": "Backup",
"type_check": "Check",
"type_init": "Connection test",
"type_prune": "Prune",
"type_restore": "Restore",
"warnings": "Warnings",
"weekday_0": "Sunday",
"weekday_1": "Monday",
"weekday_2": "Tuesday",
"weekday_3": "Wednesday",
"weekday_4": "Thursday",
"weekday_5": "Friday",
"weekday_6": "Saturday",
"weekday_never": "never (only manually)",
"will_be_created": "will be created"
},
"danger": {
"access_denied": "Access denied or invalid form data",
"alias_group_managed": "Alias %s belongs to a group and can only be changed there",
@@ -510,6 +850,31 @@
"api_key_description_invalid": "API key description must not exceed 255 characters",
"api_key_network_invalid": "Invalid IP address or network: %s",
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
"backup_action_invalid": "Invalid backup endpoint action",
"backup_component_denied": "You may not restore %s",
"backup_component_requires": "%s needs %s as well",
"backup_components_invalid": "Invalid or missing restore component: %s",
"backup_components_no_mails": "The snapshot holds no mails (RESTIC_BACKUP_MODE=config), cannot restore: %s",
"backup_domain_not_in_snapshot": "The snapshot does not contain the domain %s",
"backup_entries_exceeded": "Too many entries (at most %s)",
"backup_export_invalid": "The export in the snapshot is missing or invalid",
"backup_job_active": "A job of the type %s is already queued or running",
"backup_job_busy": "The job is being processed by another request, try again",
"backup_job_not_cancellable": "Job #%s is no longer queued and cannot be cancelled",
"backup_job_not_running": "The restore job is not running",
"backup_job_type_invalid": "Invalid job type %s",
"backup_mail_target_invalid": "Invalid target for the mails (folder or original)",
"backup_mailbox_not_found": "Mailbox %s does not exist",
"backup_mailboxes_empty": "No mailboxes to restore",
"backup_mailboxes_one_domain": "All mailboxes of a restore must belong to one domain",
"backup_not_configured": "Backup target and repository password are not configured completely",
"backup_password_not_confirmed": "Confirm first that the repository password is stored outside of mailcow",
"backup_password_too_short": "The repository password must have at least %s characters",
"backup_restore_active": "A restore of %s is already queued or running",
"backup_scope_invalid": "Invalid restore scope (mailbox or domain)",
"backup_secrets_key_missing": "The backup container has not provided its key yet: start it (restic-mailcow) and save again",
"backup_setting_invalid": "Invalid value for %s",
"backup_snapshot_not_found": "Snapshot %s not found",
"group_address_invalid": "Group address %s is invalid or not in the domain of the group",
"group_description_invalid": "The group description must not exceed 255 characters",
"group_grant_invalid": "Invalid share for mailbox %s",
@@ -1446,6 +1811,10 @@
"app_links": "Saved changes to app links",
"app_passwd_added": "Added new app password",
"app_passwd_removed": "Removed app password ID %s",
"backup_job_added": "%s job #%s queued",
"backup_job_cancelled": "Job #%s cancelled",
"backup_restore_added": "Restore job #%s queued for %s mailbox(es)",
"backup_settings_saved": "Backup settings saved",
"bcc_deleted": "BCC map entries deleted: %s",
"bcc_edited": "BCC map entry %s edited",
"bcc_saved": "BCC map entry saved",
+6
View File
@@ -28,6 +28,7 @@
<li><button class="dropdown-item" data-bs-target="#tab-config-customize" aria-selected="false" aria-controls="tab-config-customize" role="tab" data-bs-toggle="tab">{{ lang.admin.customize }}</button></li>
</ul>
</li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-config-backup" aria-selected="false" aria-controls="tab-config-backup" role="tab" data-bs-toggle="tab">{{ lang.backup.tab }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-routing" aria-selected="false" aria-controls="tab-routing" role="tab" data-bs-toggle="tab">{{ lang.admin.routing }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-sys-mails" aria-selected="false" aria-controls="tab-sys-mails" role="tab" data-bs-toggle="tab">{{ lang.admin.sys_mails }}</button></li>
<li class="nav-item dropdown">
@@ -56,6 +57,7 @@
{% include 'admin/tab-config-rsettings.twig' %}
{% include 'admin/tab-config-customize.twig' %}
{% include 'admin/tab-config-password-settings.twig' %}
{% include 'admin/tab-config-backup.twig' %}
{% include 'admin/tab-sys-mails.twig' %}
{% include 'admin/tab-globalfilter-regex.twig' %}
</div>
@@ -64,9 +66,13 @@
</div>
{% include 'modals/admin.twig' %}
{% include 'modals/backup-job.twig' %}
<script type='text/javascript'>
var lang = {{ lang_admin|raw }};
lang.backup = {{ lang_backup|raw }};
var backup_timezone = '{{ backup_timezone|e('js') }}';
var backup_hostname = '{{ backup_hostname|e('js') }}';
var lang_datatables = {{ lang_datatables|raw }};
var admin_username = '{{ mailcow_cc_username }}';
var csrf_token = '{{ csrf_token }}';
@@ -0,0 +1,378 @@
{# Backup with restic: status, actions, settings, jobs and snapshots, filled by js/site/backup.js #}
{% set legend_style = 'font-family:var(--bs-body-font-family)' %}
<div class="tab-pane fade" id="tab-config-backup" role="tabpanel" aria-labelledby="tab-config-backup">
<div class="card mb-4">
<div class="card-header d-flex align-items-center fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-backup" data-bs-toggle="collapse" aria-controls="collapse-tab-config-backup">
{{ lang.backup.tab }}
</button>
<span class="d-none d-md-block">{{ lang.backup.tab }}</span>
<button type="button" class="btn btn-xs btn-secondary ms-auto backup-refresh" title="{{ lang.backup.refresh }}" aria-label="{{ lang.backup.refresh }}"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div id="collapse-tab-config-backup" class="card-body collapse" data-bs-parent="#admin-content">
<div class="row mb-4">
<div class="col-sm-9 offset-sm-3">
<p class="text-muted">{{ lang.backup.intro }} <a href="/admin/restore">{{ lang.backup.intro_restore_link }}</a></p>
<div id="backup_status">
<div class="backup-status-summary mb-2" aria-live="polite"><span class="text-muted">{{ lang.footer.loading }}</span></div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button type="button" class="btn btn-sm btn-xs-lg btn-success backup-job-request" data-type="backup" title="{{ lang.backup.backup_now_info }}"><i class="bi bi-cloud-arrow-up"></i> {{ lang.backup.backup_now }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary backup-job-request" data-type="init" title="{{ lang.backup.test_connection_info }}"><i class="bi bi-plug"></i> {{ lang.backup.test_connection }}</button>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary dropdown-toggle backup-maintenance" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-tools"></i> {{ lang.backup.maintenance }}</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item backup-job-request" href="#" data-type="check" title="{{ lang.backup.check_info }}"><i class="bi bi-shield-check"></i> {{ lang.backup.check_now }}</a></li>
<li><a class="dropdown-item backup-job-request" href="#" data-type="prune" title="{{ lang.backup.prune_info }}"><i class="bi bi-scissors"></i> {{ lang.backup.prune_now }}</a></li>
</ul>
</div>
</div>
<div id="backup_status_alerts"></div>
</div>
</div>
</div>
<form class="form-horizontal" id="backup_settings" role="form" autocapitalize="none" autocorrect="off" autocomplete="off">
<legend class="fs-5 fw-semibold mb-0" style="{{ legend_style }}">{{ lang.backup.settings }}</legend>
<hr />
{# RESTIC_BACKUP_MODE of mailcow.conf as the backup container reports it, filled by js/site/backup.js #}
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.scope_info }}"></i>
<span class="control-label">{{ lang.backup.scope }}</span>
</div>
<div class="col-sm-9 pt-sm-2 backup-scope">
<div><span class="fw-semibold backup-scope-title"></span> <small class="text-muted font-monospace ms-1 backup-scope-var"></small></div>
<div class="small mt-1 d-none backup-scope-in"><i class="bi bi-check-lg text-success me-1"></i><span></span></div>
<div class="small d-none backup-scope-out"><i class="bi bi-x-lg text-danger me-1"></i><span></span></div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_target">{{ lang.backup.target }}</label>
</div>
<div class="col-sm-9">
<select class="form-control" name="target" id="backup_target">
<option value="s3">{{ lang.backup.target_s3 }}</option>
<option value="sftp">{{ lang.backup.target_sftp }}</option>
<option value="rest">{{ lang.backup.target_rest }}</option>
<option value="local">{{ lang.backup.target_local }}</option>
</select>
</div>
</div>
<div class="backup-target" data-target="s3">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.s3_endpoint_info }}"></i>
<label class="control-label" for="backup_s3_endpoint">{{ lang.backup.s3_endpoint }}</label>
</div>
<div class="col-sm-9">
<input type="url" class="form-control" name="s3_endpoint" id="backup_s3_endpoint" maxlength="255" placeholder="https://s3.eu-central-1.amazonaws.com">
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_bucket">{{ lang.backup.s3_bucket }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="s3_bucket" id="backup_s3_bucket" maxlength="63">
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_path">{{ lang.backup.s3_path }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-7"><input type="text" class="form-control" name="s3_path" id="backup_s3_path" maxlength="255" placeholder="{{ lang.backup.optional }}"></div>
<div class="col-md-5">
<div class="input-group">
<label class="input-group-text" for="backup_s3_region">{{ lang.backup.s3_region }}</label>
<input type="text" class="form-control" name="s3_region" id="backup_s3_region" maxlength="64" placeholder="{{ lang.backup.optional }}">
</div>
</div>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_access_key">{{ lang.backup.s3_access_key }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="s3_access_key" id="backup_s3_access_key" maxlength="255">
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_secret_key">{{ lang.backup.s3_secret_key }}</label>
</div>
<div class="col-sm-9">
<input type="password" class="form-control backup-secret" name="s3_secret_key" id="backup_s3_secret_key" maxlength="255" autocomplete="new-password" data-formcache-skip>
</div>
</div>
<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="s3_path_style" value="1" id="backup_s3_path_style">
<label class="form-check-label" for="backup_s3_path_style">{{ lang.backup.s3_path_style }}</label>
</div>
</div>
</div>
</div>
<div class="backup-target" data-target="sftp">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_sftp_host">{{ lang.backup.sftp_host }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-8"><input type="text" class="form-control" name="sftp_host" id="backup_sftp_host" maxlength="253" placeholder="backup.example.org"></div>
<div class="col-md-4">
<div class="input-group">
<label class="input-group-text" for="backup_sftp_port">{{ lang.backup.sftp_port }}</label>
<input type="number" class="form-control" name="sftp_port" id="backup_sftp_port" min="1" max="65535">
</div>
</div>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_sftp_user">{{ lang.backup.sftp_user }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-5"><input type="text" class="form-control" name="sftp_user" id="backup_sftp_user" maxlength="64"></div>
<div class="col-md-7">
<div class="input-group">
<label class="input-group-text" for="backup_sftp_path">{{ lang.backup.sftp_path }}</label>
<input type="text" class="form-control" name="sftp_path" id="backup_sftp_path" maxlength="255" placeholder="mailcow" title="{{ lang.backup.sftp_path_info }}">
</div>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.sftp_public_key_info }}"></i>
<label class="control-label" for="backup_sftp_public_key">{{ lang.backup.sftp_public_key }}</label>
</div>
<div class="col-sm-9">
<div class="input-group">
<input type="text" class="form-control font-monospace" id="backup_sftp_public_key" readonly>
<button class="btn btn-secondary backup-copy" type="button" data-copy="#backup_sftp_public_key"><i class="bi bi-clipboard"></i> {{ lang.backup.copy }}</button>
</div>
<small class="text-muted d-none backup-key-missing">{{ lang.backup.sftp_public_key_missing }}</small>
<small class="text-muted d-none text-break backup-host-key" title="{{ lang.backup.sftp_host_key_info }}">{{ lang.backup.sftp_host_key }}: <code></code></small>
</div>
</div>
</div>
<div class="backup-target" data-target="rest">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.rest_url_info }}"></i>
<label class="control-label" for="backup_rest_url">{{ lang.backup.rest_url }}</label>
</div>
<div class="col-sm-9">
<input type="url" class="form-control" name="rest_url" id="backup_rest_url" maxlength="255" placeholder="https://backup.example.org:8000/mailcow">
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_rest_user">{{ lang.backup.rest_user }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-5"><input type="text" class="form-control" name="rest_user" id="backup_rest_user" maxlength="64" placeholder="{{ lang.backup.optional }}"></div>
<div class="col-md-7">
<div class="input-group">
<label class="input-group-text" for="backup_rest_password">{{ lang.backup.rest_password }}</label>
<input type="password" class="form-control backup-secret" name="rest_password" id="backup_rest_password" maxlength="255" autocomplete="new-password" data-formcache-skip>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="backup-target" data-target="local">
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.local_path_info }}"></i>
<label class="control-label" for="backup_local_path">{{ lang.backup.local_path }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="local_path" id="backup_local_path" maxlength="255" placeholder="mailcow">
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.password_info }}"></i>
<label class="control-label" for="backup_password">{{ lang.backup.password }}</label>
</div>
<div class="col-sm-9">
<div class="input-group">
<input type="password" class="form-control backup-secret" name="password" id="backup_password" maxlength="255" autocomplete="new-password" data-formcache-skip>
<button class="btn btn-secondary backup-password-generate" type="button"><i class="bi bi-arrow-repeat"></i> {{ lang.backup.password_generate }}</button>
</div>
<small class="d-none backup-password-change"><i class="bi bi-exclamation-triangle-fill text-warning"></i> {{ lang.backup.password_change }}</small>
<div class="form-check mt-1 backup-password-confirm">
<input type="checkbox" class="form-check-input" name="password_confirmed" value="1" id="backup_password_confirmed">
<label class="form-check-label fw-semibold" for="backup_password_confirmed" title="{{ lang.backup.password_confirmed }}">{{ lang.backup.password_confirmed_short }}</label>
</div>
</div>
</div>
<div class="row mt-3 mb-4">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.schedule_time_info|format(backup_timezone) }}"></i>
<label class="control-label" for="backup_enabled">{{ lang.backup.schedule }}</label>
</div>
<div class="col-sm-9">
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" role="switch" name="enabled" value="1" id="backup_enabled">
<label class="form-check-label" for="backup_enabled">{{ lang.backup.enabled_label }}</label>
</div>
{# a new entry of the schedule (weekdays and time, no names); js/site/backup.js lists the entries below as schedule #}
<div class="d-flex flex-wrap align-items-center column-gap-4 row-gap-2 backup-schedule-new" data-setting="schedule">
<div class="btn-group backup-schedule-days" role="group" aria-label="{{ lang.backup.schedule_days }}">
{% for day in [1, 2, 3, 4, 5, 6, 0] %}
<input type="checkbox" class="btn-check" id="backup_schedule_day_{{ day }}" value="{{ day }}" autocomplete="off">
<label class="btn btn-outline-secondary btn-check-label" for="backup_schedule_day_{{ day }}" title="{{ lang.backup['weekday_' ~ day] }}">{{ lang.backup['weekday_' ~ day]|slice(0, 2) }}</label>
{% endfor %}
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<span>{{ lang.backup.schedule_at }}</span>
<input type="time" class="form-control w-auto backup-schedule-time" aria-label="{{ lang.backup.schedule_time }}">
<button type="button" class="btn btn-secondary backup-schedule-add"><i class="bi bi-plus-lg"></i> {{ lang.backup.schedule_add }}</button>
</div>
</div>
<ol class="mt-3 mb-0 ps-4 backup-schedule-list" aria-label="{{ lang.backup.schedule }}"></ol>
<div class="mt-3 text-muted d-none backup-schedule-none">{{ lang.backup.schedule_none }}</div>
</div>
</div>
<div class="row mb-2" role="group" aria-labelledby="backup_keep_label">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.keep_info }}"></i>
<div class="control-label" id="backup_keep_label">{{ lang.backup.keep }}</div>
</div>
<div class="col-sm-9">
<div class="d-flex flex-wrap align-items-center gap-2">
{% for keep in ['hourly', 'daily', 'weekly', 'monthly', 'yearly'] %}
<div class="input-group w-auto backup-keep-{{ keep }}">
<input type="number" class="form-control" style="max-width:5.5rem" name="keep_{{ keep }}" id="backup_keep_{{ keep }}" min="0" max="1000">
<label class="input-group-text" for="backup_keep_{{ keep }}">{{ lang.backup['keep_' ~ keep] }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.notify_email_info }}"></i>
<label class="control-label" for="backup_notify_email">{{ lang.backup.notify_email }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="notify_email" id="backup_notify_email" placeholder="admin@example.org, ops@example.org">
</div>
</div>
<legend class="fs-6 fw-semibold mb-0 mt-3" style="{{ legend_style }}">
<button type="button" class="btn btn-link p-0 text-reset text-decoration-none fw-semibold backup-collapse-toggle" data-bs-toggle="collapse" data-bs-target="#backup_advanced" aria-expanded="false" aria-controls="backup_advanced"><i class="bi bi-plus-square"></i> {{ lang.backup.advanced }}</button>
<small class="text-muted fw-normal">({{ lang.backup.advanced_info }})</small>
</legend>
<hr />
<div id="backup_advanced" class="collapse">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.prune_weekday_info }}"></i>
<label class="control-label" for="backup_prune_weekday">{{ lang.backup.prune_weekday }}</label>
</div>
<div class="col-sm-9">
<select class="form-control" name="prune_weekday" id="backup_prune_weekday">
{% for day in [1, 2, 3, 4, 5, 6, 0] %}
<option value="{{ day }}">{{ lang.backup['weekday_' ~ day] }}</option>
{% endfor %}
<option value="">{{ lang.backup.weekday_never }}</option>
</select>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.max_unused_info }}"></i>
<label class="control-label" for="backup_max_unused">{{ lang.backup.max_unused }}</label>
</div>
<div class="col-sm-9">
<div class="input-group w-auto d-inline-flex">
<input type="number" class="form-control" style="max-width:6rem" name="max_unused" id="backup_max_unused" min="0" max="100">
<span class="input-group-text">%</span>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.check_subset_info }}"></i>
<label class="control-label" for="backup_check_subset">{{ lang.backup.check_subset }}</label>
</div>
<div class="col-sm-9">
<div class="input-group w-auto d-inline-flex">
<input type="number" class="form-control" style="max-width:6rem" name="check_subset" id="backup_check_subset" min="0" max="365">
<span class="input-group-text">{{ lang.backup.check_subset_unit }}</span>
</div>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="include_index" value="1" id="backup_include_index">
<label class="form-check-label" for="backup_include_index">{{ lang.backup.include_index }}</label>
</div>
<div class="form-check ms-4">
<input type="checkbox" class="form-check-input" name="include_fts" value="1" id="backup_include_fts">
<label class="form-check-label" for="backup_include_fts">{{ lang.backup.include_fts }}</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.limit_upload_info }}"></i>
<label class="control-label" for="backup_limit_upload">{{ lang.backup.limit_upload }}</label>
</div>
<div class="col-sm-9">
<div class="input-group w-auto d-inline-flex">
<input type="number" class="form-control" style="max-width:9rem" name="limit_upload" id="backup_limit_upload" min="0" max="100000000">
<span class="input-group-text">KiB/s</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="offset-sm-3 col-sm-9">
<button type="submit" class="btn btn-sm btn-xs-lg d-block d-sm-inline btn-success backup-settings-save"><i class="bi bi-check-lg"></i> {{ lang.backup.save }}</button>
</div>
</div>
</form>
<div class="backup-jobs-section d-none mt-4">
<legend class="fs-5 fw-semibold mb-0" style="{{ legend_style }}">{{ lang.backup.jobs }}</legend>
<hr />
<table id="backup_jobs_table" class="table table-striped dt-responsive w-100"></table>
</div>
<div class="backup-snapshots-section d-none mt-4">
<legend class="fs-5 fw-semibold mb-0" style="{{ legend_style }}">
<button type="button" class="btn btn-link p-0 text-reset text-decoration-none fw-semibold fs-5 backup-collapse-toggle" data-bs-toggle="collapse" data-bs-target="#backup_snapshots_collapse" aria-expanded="false" aria-controls="backup_snapshots_collapse"><i class="bi bi-plus-square"></i> {{ lang.backup.snapshots }} (<span class="backup-snapshot-count">0</span>)</button>
</legend>
<hr />
<div id="backup_snapshots_collapse" class="collapse">
<table id="backup_snapshots_table" class="table table-striped dt-responsive w-100"></table>
</div>
</div>
</div>
</div>
</div>
+2
View File
@@ -75,6 +75,7 @@
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-expanded="false">{{ lang.header.email }}</a>
<ul class="dropdown-menu">
<li><a href="/admin/mailbox" class="dropdown-item {% if is_uri('mailbox') %}active{% endif %}">{{ lang.header.mailcow_config }}</a></li>
<li><a href="/admin/restore" class="dropdown-item {% if is_uri('restore') %}active{% endif %}">{{ lang.backup.nav }}</a></li>
<li><a href="/quarantine" class="dropdown-item {% if is_uri('quarantine') %}active{% endif %}">{{ lang.header.quarantine }}</a></li>
<li><a href="/admin/queue" class="dropdown-item {% if is_uri('queue') %}active{% endif %}">{{ lang.queue.queue_manager }}</a></li>
<li><a href="#" class="dropdown-item" data-bs-toggle="modal" data-container="sogo-mailcow" data-bs-target="#RestartContainer">{{ lang.header.restart_sogo }}</a></li>
@@ -96,6 +97,7 @@
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-expanded="false">{{ lang.header.email }}</a>
<ul class="dropdown-menu">
<li><a href="/domainadmin/mailbox" class="dropdown-item {% if is_uri('mailbox') %}active{% endif %}">{{ lang.header.mailcow_config }}</a></li>
<li><a href="/domainadmin/restore" class="dropdown-item {% if is_uri('restore') %}active{% endif %}">{{ lang.backup.nav }}</a></li>
<li><a href="/quarantine" class="dropdown-item {% if is_uri('quarantine') %}active{% endif %}">{{ lang.header.quarantine }}</a></li>
</ul>
</li>
+19
View File
@@ -0,0 +1,19 @@
{# Details of a backup job: overview, restore result per mailbox and item, log; filled by js/site/backup.js #}
<div class="modal fade" id="backupJobModal" tabindex="-1" role="dialog" aria-labelledby="backupJobModalTitle" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="backupJobModalTitle"></h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.backup.close }}"></button>
</div>
<div class="modal-body">
<div class="backup-job-body"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-xs-lg btn-danger me-auto backup-job-cancel d-none"><i class="bi bi-x-circle"></i> {{ lang.backup.cancel_job }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary backup-job-refresh d-none"><i class="bi bi-arrow-clockwise"></i> {{ lang.backup.refresh }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary" data-bs-dismiss="modal">{{ lang.backup.close }}</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,112 @@
{# Restore modal of E-Mail > Restore: what is restored, the items, the mail target; js/site/backup.js opens and sends it.
Domain admins only see the items they have the ACL for (the API refuses the others as well). For deleted targets the
overwrite block is hidden and SOGo moves into "Calendars and contacts" (nothing to overwrite). #}
{% macro component(key, label, note, sub) %}
<div class="form-check{% if sub %} ms-4{% endif %}">
<input class="form-check-input backup-component" type="checkbox" value="{{ key }}" id="backup_restore_{{ key }}"{% if sub %} data-parent="{{ sub }}"{% endif %}>
<label class="form-check-label" for="backup_restore_{{ key }}">{{ label }}</label>
{% if note %}<div class="small text-muted">{{ note }}</div>{% endif %}
</div>
{% endmacro %}
{% import _self as restore %}
{% set is_admin = mailcow_cc_role == 'admin' %}
<div class="modal fade" id="backupRestoreModal" tabindex="-1" role="dialog" aria-labelledby="backupRestoreModalTitle" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<form class="modal-content" id="backup_restore_form" role="form" autocomplete="off">
<div class="modal-header">
<div class="text-break">
<h3 class="modal-title" id="backupRestoreModalTitle">{{ lang.backup.restore_title }}</h3>
<div class="small text-muted backup-restore-subtitle"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.backup.close }}"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-sm-3 col-lg-2 text-sm-end">{{ lang.backup.restore_what }}</div>
<div class="col-sm-9 col-lg-10">
<div class="backup-restore-summary text-break"></div>
<p class="text-muted small mb-1 mt-2">{{ lang.backup.restore_info }}</p>
<p class="text-muted small mb-1"><i class="bi bi-info-circle"></i> {{ lang.backup.note_inactive }}</p>
<div class="alert alert-info py-2 mb-0 d-none backup-create-note"><i class="bi bi-info-circle"></i> {{ lang.backup.create_note }}</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 col-lg-2 text-sm-end">{{ lang.backup.components }}</div>
<div class="col-sm-9 col-lg-10">
<p class="small mb-2 d-none backup-no-mail-note"><i class="bi bi-exclamation-triangle-fill text-warning"></i> {{ lang.backup.no_mail_data }}</p>
<div class="row">
<div class="col-lg-6">
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_mail }}</legend>
{{ restore.component('mails', lang.backup.component_mails) }}
<div class="ms-4 mb-1 backup-mail-target" role="radiogroup" aria-label="{{ lang.backup.mail_target }}">
<div class="form-check">
<input class="form-check-input" type="radio" name="mail_target" value="folder" id="backup_restore_target_folder" checked>
<label class="form-check-label" for="backup_restore_target_folder">{{ lang.backup.mail_target_folder|e|format('<span class="backup-folder-name"></span>')|raw }}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="mail_target" value="original" id="backup_restore_target_original">
<label class="form-check-label" for="backup_restore_target_original">{{ lang.backup.mail_target_original }}</label>
</div>
</div>
{{ restore.component('imap_acl', lang.backup.component_imap_acl) }}
</fieldset>
<fieldset class="mb-3 d-none backup-sogo-normal">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_calendar }}</legend>
</fieldset>
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_addresses }}</legend>
{{ restore.component('aliases', lang.backup.component_aliases) }}
{% if is_admin or acl.mailbox_groups == 1 %}{{ restore.component('groups', lang.backup.component_groups) }}{% endif %}
{{ restore.component('sender_acl', lang.backup.component_sender_acl) }}
{% if is_admin or acl.mail_policies == 1 %}{{ restore.component('mail_policies', lang.backup.component_mail_policies) }}{% endif %}
{% if is_admin or acl.retention == 1 %}{{ restore.component('retention_rules', lang.backup.component_retention_rules, lang.backup.note_retention_rules) }}{% endif %}
</fieldset>
</div>
<div class="col-lg-6">
{% if is_admin or acl.filters == 1 or acl.spam_policy == 1 or acl.bcc_maps == 1 %}
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_filters }}</legend>
{% if is_admin or acl.filters == 1 %}
{{ restore.component('filters', lang.backup.component_filters) }}
{{ restore.component('sieve_activate', lang.backup.component_sieve_activate, lang.backup.note_sieve_activate, 'filters') }}
{% endif %}
{% if is_admin or acl.spam_policy == 1 %}{{ restore.component('spam', lang.backup.component_spam) }}{% endif %}
{% if is_admin or acl.bcc_maps == 1 %}{{ restore.component('bcc', lang.backup.component_bcc) }}{% endif %}
</fieldset>
{% endif %}
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_access }}</legend>
{% if is_admin or acl.syncjobs == 1 %}{{ restore.component('syncjobs', lang.backup.component_syncjobs) }}{% endif %}
{% if is_admin or acl.app_passwds == 1 %}{{ restore.component('app_passwords', lang.backup.component_app_passwords, lang.backup.note_app_passwords) }}{% endif %}
{{ restore.component('tfa', lang.backup.component_tfa, lang.backup.note_tfa) }}
</fieldset>
<fieldset class="border border-warning rounded px-3 py-2 mb-3 backup-overwrite">
<legend class="fs-6 fw-bold mb-1 float-none w-auto px-1"><i class="bi bi-exclamation-triangle-fill text-warning"></i> {{ lang.backup.overwrite }}</legend>
<p class="small text-muted mb-1">{{ lang.backup.overwrite_info }}</p>
<div class="form-check backup-sogo-item">
<input class="form-check-input backup-component" type="checkbox" value="sogo" id="backup_restore_sogo">
<label class="form-check-label" for="backup_restore_sogo" data-overwrite="{{ lang.backup.component_sogo }}" data-normal="{{ lang.backup.component_sogo_new }}">{{ lang.backup.component_sogo }}</label>
<div class="small text-muted backup-sogo-note">
{{ lang.backup.note_sogo }}
<button type="button" class="btn btn-link btn-sm p-0 align-baseline" data-bs-toggle="collapse" data-bs-target="#backup_sogo_more" aria-expanded="false" aria-controls="backup_sogo_more">{{ lang.backup.more }}</button>
<div class="collapse" id="backup_sogo_more">{{ lang.backup.note_sogo_more }}</div>
</div>
</div>
{{ restore.component('settings', lang.backup.component_settings, lang.backup.note_settings) }}
{{ restore.component('password', lang.backup.component_password) }}
</fieldset>
</div>
</div>
</div>
</div>
<div class="text-danger small d-none backup-restore-error" role="alert"></div>
</div>
<div class="modal-footer">
<small class="text-muted me-auto">{{ lang.backup.restore_background }}</small>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary" data-bs-dismiss="modal">{{ lang.backup.cancel }}</button>
<button type="submit" class="btn btn-sm btn-xs-lg btn-success backup-restore-submit" data-normal="{{ lang.backup.restore }}" data-overwrite="{{ lang.backup.restore_overwrite }}"><i class="bi bi-clock-history"></i> <span>{{ lang.backup.restore }}</span></button>
</div>
</form>
</div>
</div>
+81
View File
@@ -0,0 +1,81 @@
{% extends 'base.twig' %}
{# E-Mail > Restore (admins, domain admins): snapshot, a tree of its domains and mailboxes (also deleted ones) with
a selection, the restore modal, the restores. Filled by js/site/backup.js. #}
{% block content %}
{% set is_admin = mailcow_cc_role == 'admin' %}
<div id="backup_restore_page">
<div class="card mb-4">
<div class="card-header d-flex align-items-center fs-5">
<span>{{ lang.backup.restore_card }}</span>
</div>
<div class="card-body">
<div class="backup-restore-hints"></div>
{# left-aligned with the search of the toolbar below #}
<div class="d-flex flex-wrap align-items-center gap-2 mb-3 px-2">
<label class="fw-semibold mb-0" for="backup_restore_snapshot">{{ lang.backup.snapshot_label }}</label>
<div class="flex-grow-1" style="min-width:16rem;max-width:28rem">
<select class="form-control" id="backup_restore_snapshot" data-live-search="true" data-show-subtext="true" data-width="100%"></select>
</div>
</div>
<div class="backup-tree-panel d-none">
<div class="backup-content-note"></div>
<div class="backup-restore-queued"></div>
<div class="position-sticky py-2 px-2 rounded backup-tree-toolbar" style="top:0;z-index:5;background:var(--bs-card-bg,var(--bs-body-bg))">
<div class="d-flex flex-wrap align-items-center gap-2">
<div class="flex-grow-1" style="min-width:12rem;max-width:28rem">
<input type="search" class="form-control form-control-sm backup-tree-search" placeholder="{{ lang.backup.search_placeholder }}" aria-label="{{ lang.backup.search_placeholder }}" autocomplete="off">
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input backup-tree-deleted" type="checkbox" role="switch" id="backup_tree_deleted">
<label class="form-check-label small" for="backup_tree_deleted">{{ lang.backup.only_deleted }}</label>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
<span class="small text-muted backup-selection-text" aria-live="polite"></span>
<button type="button" class="btn btn-sm btn-link px-1 backup-selection-clear d-none">{{ lang.backup.clear_selection }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-success backup-selection-restore" disabled><i class="bi bi-clock-history"></i> {{ lang.backup.restore }}</button>
</div>
</div>
<div class="small mt-1 d-none backup-selection-hint" role="status"></div>
</div>
<table class="table table-sm table-hover align-middle mb-0 backup-tree" id="backup_tree">
<thead>
<tr>
<th>{{ lang.backup.name }}</th>
<th class="d-none d-md-table-cell">{{ lang.backup.state }}</th>
<th class="d-none d-sm-table-cell">{{ lang.backup.mailboxes }}</th>
<th class="text-end"><span class="visually-hidden">{{ lang.backup.restore }}</span></th>
</tr>
</thead>
{# not empty: mailcow removes empty tbody elements on load #}
<tbody><tr class="backup-tree-message"><td colspan="4" class="text-muted">{{ lang.footer.loading }}</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header d-flex align-items-center fs-5">
<span>{{ lang.backup.restores }}</span>
<button type="button" class="btn btn-xs btn-secondary ms-auto backup-table-refresh" data-table="backup_restores_table" title="{{ lang.backup.refresh }}" aria-label="{{ lang.backup.refresh }}"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div class="card-body">
<p class="text-muted small">{{ lang.backup.restores_info }}</p>
<table id="backup_restores_table" class="table table-striped dt-responsive w-100"></table>
</div>
</div>
</div>
{% include 'modals/backup-restore.twig' %}
{% include 'modals/backup-job.twig' %}
<script type='text/javascript'>
var lang = {backup: {{ lang_backup|raw }}};
var lang_datatables = {{ lang_datatables|raw }};
var csrf_token = '{{ csrf_token }}';
var pagination_size = Math.trunc('{{ pagination_size }}');
var role = '{{ role }}';
var backup_configured = {{ backup_configured ? 'true' : 'false' }};
var backup_timezone = '{{ backup_timezone|e('js') }}';
</script>
{% endblock %}
+57 -2
View File
@@ -1,5 +1,5 @@
# Version of the database schema
x-db-version: &db_version DB_VERSION=07112026_2330
x-db-version: &db_version DB_VERSION=08112026_2100
services:
@@ -241,6 +241,7 @@ services:
- mysql-socket-vol-1:/var/run/mysqld/:z
- sogo-web-vol-1:/sogo_web
- sogo-userdata-backup-vol-1:/sogo_backup
- restic-restore-vol-1:/restore:ro
labels:
ofelia.enabled: "true"
ofelia.job-exec.sogo_sessions.schedule: "0 * * * * *"
@@ -281,6 +282,7 @@ services:
- ./data/assets/templates:/templates:z
- rspamd-vol-1:/var/lib/rspamd
- mysql-socket-vol-1:/var/run/mysqld/:z
- restic-restore-vol-1:/restore
environment:
- *db_version
- DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-}
@@ -325,6 +327,9 @@ services:
ofelia.job-exec.dovecot_retention.schedule: "0 30 3 * * *"
ofelia.job-exec.dovecot_retention.no-overlap: "true"
ofelia.job-exec.dovecot_retention.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/retention_runner.py || exit 0\""
ofelia.job-exec.dovecot_restore_runner.schedule: "0 * * * * *"
ofelia.job-exec.dovecot_restore_runner.no-overlap: "true"
ofelia.job-exec.dovecot_restore_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/restore_runner.py || exit 0\""
ofelia.job-exec.dovecot_trim_logs.schedule: "0 * * * * *"
ofelia.job-exec.dovecot_trim_logs.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/trim_logs.sh || exit 0\""
ofelia.job-exec.dovecot_quarantine.schedule: "0 */20 * * * *"
@@ -616,7 +621,7 @@ services:
- watchdog
dockerapi-mailcow:
image: ghcr.io/mailcow/dockerapi:2.15
image: ghcr.io/mailcow/dockerapi:2.16
security_opt:
- label=disable
restart: always
@@ -654,6 +659,53 @@ services:
aliases:
- olefy
restic-mailcow:
image: ghcr.io/mailcow/restic:0.18.1-1
depends_on:
- mysql-mailcow
- redis-mailcow
stop_grace_period: 30s
security_opt:
- label=disable
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- FSETID
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
- TZ=${TZ}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- REDISPASS=${REDISPASS}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MASTER=${MASTER:-y}
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-mailcow-dockerized}
- RESTIC_LOCAL_PATH=${RESTIC_LOCAL_PATH:-}
- RESTIC_BACKUP_MODE=${RESTIC_BACKUP_MODE:-all}
volumes:
- vmail-vol-1:/var/vmail:ro
- vmail-index-vol-1:/var/vmail_index:ro
- crypt-vol-1:/mail_crypt:ro
- redis-vol-1:/redis:ro
- rspamd-vol-1:/rspamd:ro
- sogo-userdata-backup-vol-1:/sogo_backup:ro
- mysql-socket-vol-1:/var/run/mysqld/:ro
- ./:/mailcow/install:ro
- restic-vol-1:/cache
- restic-restore-vol-1:/restore
- ${RESTIC_LOCAL_PATH:-restic-repo-vol-1}:/repository
restart: always
networks:
mailcow-network:
aliases:
- restic
ofelia-mailcow:
image: mcuadros/ofelia:latest
restart: always
@@ -700,3 +752,6 @@ volumes:
sogo-web-vol-1:
sogo-userdata-backup-vol-1:
clamd-db-vol-1:
restic-vol-1:
restic-restore-vol-1:
restic-repo-vol-1:
+11
View File
@@ -264,6 +264,17 @@ ACL_ANYONE=disallow
# Check interval is hourly
MAILDIR_GC_TIME=7200
# Folder on the host for backups to the target "local" (backup settings in the mailcow UI), as an absolute path,
# e.g. a NAS mounted from another disk. The repository is created in a subfolder of it.
# Empty: the Docker volume restic-repo-vol-1 of mailcow, on the same disk as the mailcow data.
RESTIC_LOCAL_PATH=
# What the backups of the mailcow UI hold:
# - all (default),
# - data (vmail, vmail-index, database, redis, mail crypt keys and mailcow.conf)
# - config (database, redis, mail crypt keys, mailcow.conf and data/conf).
RESTIC_BACKUP_MODE=all
# Additional SAN for the certificate
#
# You can use wildcard records to create specific names for every domain you add to mailcow.
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Runs the Python unit tests of the backup runners (tests/python/backup) in a throwaway container of the locally built
# restic image: the backup runner (data/Dockerfiles/restic) and the dovecot restore runner (data/Dockerfiles/dovecot).
# No network, no database: the tests replace the database and the clock.
# Usage: helper-scripts/dev_tests/run_python_unit_tests.sh [unittest arguments, e.g. test_restic_runner]
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE="mailcow-restic-tests"
if [ $# -eq 0 ]; then
set -- discover -v
fi
docker build --quiet -t "${IMAGE}" "${REPO_DIR}/data/Dockerfiles/restic" > /dev/null
docker run --rm --network none --security-opt label=disable \
-v "${REPO_DIR}/data/Dockerfiles/restic:/app/restic:ro" \
-v "${REPO_DIR}/data/Dockerfiles/dovecot:/app/dovecot:ro" \
-v "${REPO_DIR}/tests/python/backup:/tests:ro" \
-w /tests -e PYTHONPATH=/app/restic:/app/dovecot:/tests -e PYTHONDONTWRITEBYTECODE=1 \
--entrypoint python3 "${IMAGE}" -m unittest "$@"
+717
View File
@@ -0,0 +1,717 @@
#!/usr/bin/env bash
# Restore of mailcow from a restic snapshot of the backup feature (System > Configuration > Backup).
#
# helper-scripts/restic.sh snapshots list the snapshots of the repository
# helper-scripts/restic.sh restore [id] full restore (default: the latest mailcow snapshot): the mailcow
# directory with its version and configuration, all data volumes
# and the database, as they were when the snapshot was made
# helper-scripts/restic.sh restore --data-only [id] only the data volumes and the database (optionally data/conf and
# mailcow.conf) into this mailcow, e.g. to move them into a newer one
#
# Snapshots made with RESTIC_BACKUP_MODE=data (of the mailcow directory only mailcow.conf) or config (without mails) are
# restored with --data-only; a config snapshot leaves mails, mail index, Rspamd and SOGo data as they are.
#
# A full restore moves this directory aside (<dir>.before-restore-<date>) and restores the snapshot's directory in its
# place, then starts that version with its images. On a new server only Docker, a clone of mailcow and this script are
# needed (no generate_config.sh, no first start).
#
# The repository settings come from the mailcow database when mailcow runs, else from the environment:
# RESTIC_REPOSITORY e.g. s3:https://s3.example.com/bucket/path, sftp:user@host:/path, rest:https://host:8000/path,
# /repository/<path> for a local repository (folder RESTIC_LOCAL_PATH, else the volume restic-repo-vol-1)
# RESTIC_PASSWORD asked for when not set
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION (S3), RESTIC_REST_USERNAME, RESTIC_REST_PASSWORD (REST)
# RESTIC_OPTIONS extra options, e.g. "-o s3.bucket-lookup=path"
# RESTIC_SSH_KEY private key file for SFTP (the host key is accepted on first use)
# RESTIC_LOCAL_PATH host folder of a local repository (as in mailcow.conf; empty: the volume restic-repo-vol-1)
# A full restore replaces the directory of this script: it runs from a copy
if [[ "${BASH_SOURCE[0]}" == "${0}" && -z ${RESTIC_SH_COPY} ]]; then
RESTIC_SH_COPY=$(mktemp -d)
cp "${BASH_SOURCE[0]}" "${RESTIC_SH_COPY}/restic.sh"
export RESTIC_SH_COPY RESTIC_SH_MAILCOW_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd -P )"
exec bash "${RESTIC_SH_COPY}/restic.sh" "$@"
fi
MAILCOW_DIR="${RESTIC_SH_MAILCOW_DIR:-$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd -P )}"
# the copy goes when the script ends (load_config adds its temp folder to this)
[[ "${BASH_SOURCE[0]}" == "${0}" && -n ${RESTIC_SH_COPY} ]] && trap 'rm -rf "${RESTIC_SH_COPY}"' EXIT
# volumes of a snapshot: path in the snapshot => compose volume
declare -A SNAPSHOT_VOLUMES=(
[/var/vmail]=vmail-vol-1
[/var/vmail_index]=vmail-index-vol-1
[/mail_crypt]=crypt-vol-1
[/redis]=redis-vol-1
[/rspamd]=rspamd-vol-1
[/sogo_backup]=sogo-userdata-backup-vol-1
)
declare -A MANIFEST
# docker compose in a mailcow directory with its .env (mailcow.conf): the files of COMPOSE_FILE, else
# docker-compose.yml and docker-compose.override.yml
function compose() {
local dir=${1}
shift
local files=()
if [[ -z ${COMPOSE_FILE} ]]; then
files=(-f "${dir}/docker-compose.yml")
[[ -f ${dir}/docker-compose.override.yml ]] && files+=(-f "${dir}/docker-compose.override.yml")
fi
( cd "${dir}" && ${COMPOSE_COMMAND} --project-directory "${dir}" "${files[@]}" --env-file "${dir}/.env" "$@" )
}
# Runs restic in its image with the repository of RESTIC_ENV_FILE; docker options before --, restic arguments after it
function restic_run() {
local docker_args=()
while [[ $# -gt 0 && ${1} != "--" ]]; do
docker_args+=("${1}")
shift
done
shift
# the network of mailcow if it runs (targets in it), the cache and the SSH key of the runner if they exist
local network= cache= repo=()
if [[ -n ${CMPS_PRJ} ]]; then
network=$(docker network ls -qf name=^${CMPS_PRJ}_mailcow-network$)
cache=$(docker volume ls -qf name=^${CMPS_PRJ}_restic-vol-1$)
fi
# a local repository: the folder RESTIC_LOCAL_PATH, else the volume of mailcow
if [[ -n ${RESTIC_LOCAL_DIR} ]]; then
mkdir -p "${RESTIC_LOCAL_DIR}"
repo=(-v "${RESTIC_LOCAL_DIR}:/repository")
elif [[ -n ${CMPS_PRJ} && -n $(docker volume ls -qf name=^${CMPS_PRJ}_restic-repo-vol-1$) ]]; then
repo=(-v "${CMPS_PRJ}_restic-repo-vol-1:/repository")
fi
docker run --rm --security-opt label=disable ${network:+--network ${network}} -e TZ=${TZ} --env-file "${RESTIC_ENV_FILE}" \
${cache:+-v ${cache}:/cache} \
"${repo[@]}" \
${RESTIC_SSH_KEY:+-v "${RESTIC_SSH_KEY}":/run/ssh_key:ro} \
"${docker_args[@]}" --entrypoint /usr/bin/python3 ${RESTIC_IMAGE} -c "
import json, os, sys
opts = json.loads(os.environ.pop('MAILCOW_RESTIC_OPTS', '[]'))
os.execvp('restic', ['restic'] + opts + sys.argv[1:])" "$@"
}
function project_name() {
echo "${1}" | tr -cd "[0-9A-Za-z-_]"
}
function load_config() {
if [[ ! -f ${MAILCOW_DIR}/docker-compose.yml ]]; then
echo -e "\e[31mdocker-compose.yml not found in ${MAILCOW_DIR}\e[0m"
exit 1
fi
# mailcow.conf is missing in a fresh clone (new server)
CMPS_PRJ=
if [[ -f ${MAILCOW_DIR}/mailcow.conf ]]; then
source "${MAILCOW_DIR}/mailcow.conf"
CMPS_PRJ=$(project_name "${COMPOSE_PROJECT_NAME}")
fi
if docker compose version > /dev/null 2>&1; then
COMPOSE_COMMAND="docker compose"
elif command -v docker-compose > /dev/null; then
COMPOSE_COMMAND="docker-compose"
else
echo -e "\e[31mNeither docker compose nor docker-compose found\e[0m"
exit 1
fi
RESTIC_IMAGE=$(grep -oE 'ghcr.io/mailcow/restic:[^[:space:]"]+' "${MAILCOW_DIR}/docker-compose.yml" | head -n 1)
if [[ -z ${RESTIC_IMAGE} ]]; then
echo -e "\e[31mCould not find the restic image in ${MAILCOW_DIR}/docker-compose.yml\e[0m"
exit 1
fi
# schema of this checkout, DDMMYYYY_HHMM
CHECKOUT_DB_VERSION=$(grep -oE 'DB_VERSION=[0-9]{8}_[0-9]{4}' "${MAILCOW_DIR}/docker-compose.yml" | head -n 1 | cut -d= -f2)
# a local repository folder: absolute, or ./relative to the mailcow directory; empty: the volume restic-repo-vol-1
RESTIC_LOCAL_DIR=${RESTIC_LOCAL_PATH}
if [[ -n ${RESTIC_LOCAL_DIR} ]]; then
if [[ ! ${RESTIC_LOCAL_DIR} =~ ^(/|\./) || /${RESTIC_LOCAL_DIR}/ == */../* ]]; then
echo -e "\e[31mRESTIC_LOCAL_PATH must be an absolute path (or ./relative to the mailcow directory) without ..\e[0m"
exit 1
fi
[[ ${RESTIC_LOCAL_DIR} =~ ^/ ]] || RESTIC_LOCAL_DIR="${MAILCOW_DIR}/${RESTIC_LOCAL_DIR#./}"
fi
# repository settings with secrets: only for this run, readable by root only
RESTIC_TMP=$(mktemp -d)
chmod 700 "${RESTIC_TMP}"
trap 'rm -rf "${RESTIC_TMP}" ${RESTIC_SH_COPY:+"${RESTIC_SH_COPY}"}' EXIT
RESTIC_ENV_FILE="${RESTIC_TMP}/restic.env"
touch "${RESTIC_ENV_FILE}"
chmod 600 "${RESTIC_ENV_FILE}"
}
function mysql_container() {
docker ps -qf name=mysql-mailcow -f label=com.docker.compose.project=${CMPS_PRJ} | head -n 1
}
# Repository settings: from the environment, else from the backup settings in the database
function repository_env() {
local cache=
[[ -n ${CMPS_PRJ} ]] && cache=$(docker volume ls -qf name=^${CMPS_PRJ}_restic-vol-1$)
if [[ -z ${RESTIC_REPOSITORY} ]]; then
if [[ -n ${CMPS_PRJ} && -n $(mysql_container) ]]; then
# the runner code builds repository, options and credentials from backup_setting (secrets never in argv)
DBPASS="${DBPASS}" docker run --rm --security-opt label=disable -e DBNAME=${DBNAME} -e DBUSER=${DBUSER} -e DBPASS -e MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} \
-v ${CMPS_PRJ}_mysql-socket-vol-1:/var/run/mysqld/ ${cache:+-v ${cache}:/cache} \
--entrypoint /usr/bin/python3 ${RESTIC_IMAGE} -c "
import json, sys
sys.path.insert(0, '/usr/local/bin')
import restic_runner as rr
try:
s = rr.settings()
repo = rr.Repository(s)
repo.prepare(s)
except Exception as e:
sys.exit('%s' % e)
for key, value in sorted(repo.env.items()):
if key.startswith(('RESTIC_', 'AWS_')) and '\n' not in value:
print('%s=%s' % (key, value))
print('MAILCOW_RESTIC_OPTS=' + json.dumps(repo.options))" > "${RESTIC_ENV_FILE}" 2> "${RESTIC_TMP}/error"
if [[ $? -eq 0 ]]; then
echo "Using the repository of the backup settings in the mailcow database."
return 0
fi
echo -e "\e[33mThe backup settings in the database cannot be used: $(cat "${RESTIC_TMP}/error")\e[0m"
fi
echo "Pass the repository in the environment (see the top of this script), e.g. RESTIC_REPOSITORY=sftp:user@host:/path"
read -ep "Repository: " RESTIC_REPOSITORY
[[ -z ${RESTIC_REPOSITORY} ]] && exit 1
fi
if [[ -z ${RESTIC_PASSWORD} ]]; then
IFS= read -rsp "Repository password: " RESTIC_PASSWORD
echo
fi
local opts=()
read -ra opts <<< "${RESTIC_OPTIONS}"
if [[ -n ${RESTIC_SSH_KEY} ]]; then
if [[ ! -f ${RESTIC_SSH_KEY} ]]; then
echo -e "\e[31m${RESTIC_SSH_KEY} not found\e[0m"
exit 1
fi
RESTIC_SSH_KEY=$(readlink -f "${RESTIC_SSH_KEY}")
opts+=(-o "sftp.args=-i /run/ssh_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/known_hosts")
fi
{
for var in RESTIC_REPOSITORY RESTIC_PASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION RESTIC_REST_USERNAME RESTIC_REST_PASSWORD; do
[[ -n ${!var} ]] && echo "${var}=${!var}"
done
echo "RESTIC_CACHE_DIR=/cache/restic"
printf 'MAILCOW_RESTIC_OPTS=['
local sep=
for opt in "${opts[@]}"; do
printf '%s"%s"' "${sep}" "$(echo "${opt}" | sed 's/\\/\\\\/g; s/"/\\"/g')"
sep=,
done
echo ']'
} > "${RESTIC_ENV_FILE}"
}
function snapshots() {
restic_run -- snapshots
}
# The newest snapshot made by mailcow, or the full id of the given one
function snapshot_id() {
if [[ -z ${1} ]]; then
set -- --tag mailcow latest
fi
restic_run -- snapshots --json "$@" | grep -oE '"id":"[0-9a-f]{64}"' | head -n 1 | cut -d'"' -f4
}
# The manifest of a snapshot into MANIFEST[...] (hostname, db_version, arch, instance, domains, mode, commit, branch,
# version, changed_count) and MANIFEST_CHANGED (locally changed files of the mailcow directory)
function read_manifest() {
local key value
MANIFEST=()
MANIFEST_CHANGED=()
while IFS=$'\t' read -r key value; do
if [[ ${key} == "changed" ]]; then
MANIFEST_CHANGED+=("${value}")
elif [[ -n ${key} ]]; then
MANIFEST[${key}]=${value}
fi
done < <(restic_run -- dump ${1} /mailcow/manifest.json 2> /dev/null | docker run --rm -i --security-opt label=disable --entrypoint /usr/bin/python3 ${RESTIC_IMAGE} -c '
import json, sys
try:
m = json.load(sys.stdin)
except ValueError:
sys.exit(0)
i = m.get("install") or {}
clean = lambda v: " ".join(str(v).split())
for key, value in (("hostname", m.get("hostname")), ("db_version", m.get("db_version")), ("arch", m.get("arch")),
("instance", m.get("instance")), ("domains", len(m.get("domains") or [])), ("mode", m.get("mode") or "all"), ("commit", i.get("commit")),
("branch", i.get("branch")), ("version", i.get("version")), ("changed_count", i.get("changed_count")),
("install", int(bool(i)))):
if value not in (None, ""):
print("%s\t%s" % (key, clean(value)))
for line in i.get("changed") or []:
print("changed\t%s" % clean(line))')
}
function show_manifest() {
echo "Snapshot of ${MANIFEST[hostname]}: database schema ${MANIFEST[db_version]}, ${MANIFEST[domains]:-0} domains"
[[ ${MANIFEST[mode]} != "all" ]] && echo "Backup mode ${MANIFEST[mode]}: $(mode_text ${MANIFEST[mode]})"
if [[ -n ${MANIFEST[commit]} ]]; then
echo "mailcow ${MANIFEST[version]:-?}, branch ${MANIFEST[branch]:-?}, commit ${MANIFEST[commit]:0:12}"
if [[ ${MANIFEST[changed_count]:-0} -gt 0 ]]; then
echo "Locally changed or added files (${MANIFEST[changed_count]}):"
printf ' %s\n' "${MANIFEST_CHANGED[@]:0:30}"
[[ ${MANIFEST[changed_count]} -gt 30 ]] && echo " ..."
fi
fi
}
function mode_text() {
case ${1} in
data) echo "of the mailcow directory only mailcow.conf" ;;
config) echo "without mails, mail index, Rspamd and SOGo data" ;;
*) echo "complete" ;;
esac
}
# DDMMYYYY_HHMM as YYYYMMDDHHMM
function version_key() {
[[ ${1} =~ ^([0-9]{2})([0-9]{2})([0-9]{4})_([0-9]{4})$ ]] && echo "${BASH_REMATCH[3]}${BASH_REMATCH[2]}${BASH_REMATCH[1]}${BASH_REMATCH[4]}"
}
# The dump of the snapshot is complete (read through, no database needed)
function check_dump() {
echo "Checking the database dump of the snapshot..."
restic_run -- dump ${1} /mailcow/mysql/mailcow.sql | tail -c 300 | grep -q "Dump completed"
if [[ ${PIPESTATUS[0]} -ne 0 || ${PIPESTATUS[2]} -ne 0 ]]; then
echo -e "\e[31mThe database dump of the snapshot could not be read or is incomplete, nothing was changed\e[0m"
return 1
fi
}
# Instance id of the backup container of a project on this server (empty on a new server)
function local_instance() {
local volume=$(docker volume ls -qf name=^${1}_restic-vol-1$)
[[ -n ${volume} ]] && docker run --rm --security-opt label=disable -v ${volume}:/cache:ro --entrypoint cat ${RESTIC_IMAGE} /cache/instance_id 2> /dev/null
}
# Scheduled backups go on only on the server that made the snapshot: two servers must not back up into one repository
function schedule_decision() {
local here=$(local_instance ${1})
if [[ -n ${MANIFEST[instance]} && ${here} == "${MANIFEST[instance]}" ]]; then
KEEP_SCHEDULE=y
SCHEDULE_NOTE="The snapshot was made by this server, the backup schedule stays as it was in the snapshot."
else
KEEP_SCHEDULE=n
SCHEDULE_NOTE="The snapshot was made by another mailcow (or this one lost its backup volume): scheduled backups are switched off, so two servers do not back up into the same repository. Switch them on again in the backup settings when the old server no longer backs up."
fi
}
function restore_volumes() {
local snapshot=${1} manifest_arch=${2} includes=() mounts=() path volume
# the backed up paths of the snapshot, one per line
local paths=$(restic_run -- snapshots --json ${snapshot} | grep -oE '"paths":\[[^]]*\]' | grep -oE '"/[^"]*"' | tr -d '"')
for path in "${!SNAPSHOT_VOLUMES[@]}"; do
volume=$(docker volume ls -qf name=^${CMPS_PRJ}_${SNAPSHOT_VOLUMES[${path}]}$)
if [[ -z ${volume} ]]; then
echo -e "\e[31mVolume ${CMPS_PRJ}_${SNAPSHOT_VOLUMES[${path}]} not found\e[0m"
return 1
fi
if ! grep -qx "${path}" <<< "${paths}"; then
if [[ ${path} == "/var/vmail_index" ]] && grep -qx /var/vmail <<< "${paths}"; then
echo "The snapshot has no mail index, emptying the index volume (Dovecot rebuilds it)..."
docker run --rm --security-opt label=disable -v ${volume}:/clean --entrypoint /bin/sh ${RESTIC_IMAGE} -c 'find /clean -mindepth 1 -delete'
NO_INDEX=y
else
echo -e "\e[33m${path} is not in the snapshot, ${volume} is left as it is\e[0m"
fi
continue
fi
if [[ ${path} == "/rspamd" && -n ${manifest_arch} && ${manifest_arch} != $(uname -m) ]]; then
echo -e "\e[33mThe snapshot was made on ${manifest_arch}, this is $(uname -m): Rspamd data is skipped\e[0m"
continue
fi
includes+=(--include ${path})
mounts+=(-v ${volume}:${path})
done
echo "Restoring the volumes (${includes[*]//--include /})..."
# --delete: the volumes get exactly the content of the snapshot
restic_run "${mounts[@]}" -- restore ${snapshot} --target / --delete "${includes[@]}"
}
# Copies the dump of the snapshot into the database container (a running MariaDB is waited for)
function fetch_dump() {
local snapshot=${1} mysql=${MYSQL_CONTAINER:-$(mysql_container)}
local i=0
# over TCP: MariaDB initializing a new volume listens only on its socket
until MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} mariadb -uroot -h 127.0.0.1 --protocol=TCP -e 'SELECT 1' > /dev/null 2>&1; do
if [[ ${i} -ge 150 ]]; then
echo -e "\e[31mThe database does not answer\e[0m"
return 1
fi
sleep 2
i=$((i + 1))
done
echo "Reading the database dump from the snapshot..."
# the client misreads long lines from a pipe: the dump goes to a file in the container first
restic_run -- dump ${snapshot} /mailcow/mysql/mailcow.sql | docker exec -i ${mysql} sh -c 'umask 077; cat > /tmp/mailcow-restore.sql'
if [[ ${PIPESTATUS[0]} -ne 0 || ${PIPESTATUS[1]} -ne 0 ]] \
|| ! docker exec ${mysql} sh -c 'tail -c 300 /tmp/mailcow-restore.sql | grep -q "Dump completed"'; then
docker exec ${mysql} rm -f /tmp/mailcow-restore.sql
echo -e "\e[31mThe database dump of the snapshot could not be read or is incomplete\e[0m"
return 1
fi
}
function restore_database() {
local mysql=${MYSQL_CONTAINER:-$(mysql_container)}
echo "Importing the database dump into ${DBNAME}..."
MYSQL_PWD="${DBROOT}" docker exec -i -e MYSQL_PWD ${mysql} mariadb -uroot \
-e "DROP DATABASE IF EXISTS \`${DBNAME}\`; CREATE DATABASE \`${DBNAME}\`;" || return 1
MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} sh -c 'mariadb -uroot "${1}" < /tmp/mailcow-restore.sql; rc=$?; rm -f /tmp/mailcow-restore.sql; exit ${rc}' sh ${DBNAME}
if [[ $? -ne 0 ]]; then
echo -e "\e[31mThe database import failed\e[0m"
return 1
fi
# jobs that were queued or running when the snapshot was made must not run again
local sql="UPDATE backup_job SET state = 'failed', error = 'interrupted: restored from a snapshot', finished = NOW() WHERE state IN ('queued', 'running');"
# the schedule counts from now: no catch-up backup of the days since the snapshot
sql+=" INSERT INTO backup_setting (name, value) VALUES ('state_schedule_since', UNIX_TIMESTAMP()) ON DUPLICATE KEY UPDATE value = VALUES(value);"
[[ ${KEEP_SCHEDULE} != "y" ]] && sql+=" UPDATE backup_setting SET value = '0' WHERE name = 'enabled';"
MYSQL_PWD="${DBROOT}" docker exec -i -e MYSQL_PWD ${mysql} mariadb -uroot ${DBNAME} -e "${sql}"
}
# data/conf (in place, or into a side folder when the snapshot is of another mailcow version) and data/assets/ssl
function restore_config() {
local snapshot=${1} conf_dir=${2}
if [[ ${conf_dir} == "${MAILCOW_DIR}/data/conf" ]]; then
echo "Restoring data/conf and data/assets/ssl (files of the snapshot overwrite the current ones, newer files stay)..."
else
echo "Restoring data/conf into ${conf_dir} and data/assets/ssl..."
mkdir -p "${conf_dir}"
fi
restic_run -v "${conf_dir}":/mailcow/install/data/conf -v "${MAILCOW_DIR}/data/assets/ssl":/mailcow/install/data/assets/ssl -- \
restore ${snapshot} --target / --include /mailcow/install/data/conf --include /mailcow/install/data/assets/ssl
}
# mailcow.conf of the snapshot; database credentials and project name stay those of this installation
function restore_mailcow_conf() {
local snapshot=${1} backup="${MAILCOW_DIR}/mailcow.conf.before-restore-$(date +%Y%m%d-%H%M%S)" var
restic_run -- dump ${snapshot} /mailcow/install/mailcow.conf > "${RESTIC_TMP}/mailcow.conf" || return 1
for var in DBNAME DBUSER DBPASS DBROOT COMPOSE_PROJECT_NAME; do
if grep -q "^${var}=" "${RESTIC_TMP}/mailcow.conf"; then
VALUE="${!var}" awk -v name="${var}" 'index($0, name "=") == 1 { print name "=" ENVIRON["VALUE"]; next } { print }' \
"${RESTIC_TMP}/mailcow.conf" > "${RESTIC_TMP}/mailcow.conf.new" && mv "${RESTIC_TMP}/mailcow.conf.new" "${RESTIC_TMP}/mailcow.conf"
fi
done
cp -p "${MAILCOW_DIR}/mailcow.conf" "${backup}"
cat "${RESTIC_TMP}/mailcow.conf" > "${MAILCOW_DIR}/mailcow.conf"
echo "mailcow.conf restored (DBNAME, DBUSER, DBPASS, DBROOT and COMPOSE_PROJECT_NAME kept), the old one is ${backup}"
}
# Project name in the mailcow.conf of a snapshot
function snapshot_project() {
project_name "$(restic_run -- dump ${1} /mailcow/install/mailcow.conf 2> /dev/null | grep -m 1 '^COMPOSE_PROJECT_NAME=' | cut -d= -f2-)"
}
# Stops the containers of a project (graceful, no volumes are removed)
function stop_project() {
local ids=$(docker ps -aq --filter label=com.docker.compose.project=${1})
[[ -z ${ids} ]] && return 0
echo "Stopping the containers of ${1}..."
docker stop -t 60 ${ids} > /dev/null
docker rm ${ids} > /dev/null
}
# After the directory was restored: its mailcow.conf (project, database credentials) and compose files
function load_restored_config() {
if [[ ! -f ${MAILCOW_DIR}/mailcow.conf ]]; then
echo -e "\e[31mThe restored directory has no mailcow.conf\e[0m"
return 1
fi
source "${MAILCOW_DIR}/mailcow.conf"
CMPS_PRJ=$(project_name "${COMPOSE_PROJECT_NAME}")
}
function failed_after_move() {
echo -e "\e[31mThe restore stopped. The previous mailcow directory is ${ASIDE_DIR}.\e[0m"
echo "To go back to it: stop the stack, remove ${MAILCOW_DIR}, move ${ASIDE_DIR} back to ${MAILCOW_DIR}"
echo "and start it with docker compose up -d (data volumes restored so far stay restored)."
if [[ -n ${OLD_DUMP} ]]; then
echo "The database as it was before the restore: ${ASIDE_DIR}/${OLD_DUMP} (import it with mariadb into ${DBNAME:-the database})."
fi
exit 1
}
function failed_data_only() {
echo -e "\e[31mThe restore stopped, mailcow is stopped.\e[0m"
echo "Fix the problem shown above and run the restore again, or start mailcow as it is with"
echo " ${COMPOSE_COMMAND} up -d"
echo "(volumes and database restored so far stay restored)."
exit 1
}
# The snapshots made by mailcow; asks which one to restore (Enter: the newest)
function choose_snapshot() {
local latest choice
restic_run -- snapshots --tag mailcow --compact >&2
latest=$(snapshot_id)
[[ -z ${latest} ]] && return 1
read -ep "Snapshot to restore (ID, Enter for the newest one, ${latest:0:8}): " choice >&2
if [[ -z ${choice} ]]; then
echo "${latest}"
elif [[ ${choice} =~ ^[0-9a-f]{8,64}$ ]]; then
snapshot_id "${choice}"
fi
}
# The database before a full restore, into the directory that is moved aside (needs the running database)
function dump_current_database() {
local mysql=$(docker ps -qf name=mysql-mailcow -f label=com.docker.compose.project=${1} | head -n 1)
[[ -z ${mysql} || -z ${DBROOT} || -z ${DBNAME} ]] && return 0
OLD_DUMP="mailcow-before-restore-$(date +%Y%m%d-%H%M%S).sql"
echo "Saving the current database to ${OLD_DUMP} (moves aside with the directory)..."
( umask 077; MYSQL_PWD="${DBROOT}" docker exec -e MYSQL_PWD ${mysql} mariadb-dump -uroot --single-transaction --routines \
--triggers --events --hex-blob "${DBNAME}" > "${MAILCOW_DIR}/${OLD_DUMP}" )
if [[ $? -ne 0 ]]; then
echo -e "\e[31mThe current database could not be saved, nothing was changed\e[0m"
rm -f "${MAILCOW_DIR}/${OLD_DUMP}"
exit 1
fi
}
function confirm() {
echo
echo -e "\e[31m${1}\e[0m"
echo
read -p "Type 'yes' to restore: " CONFIRM
[[ ${CONFIRM} == "yes" ]] || exit 1
}
# Full restore: the mailcow directory of the snapshot (version, configuration, local changes), its images, all data
# volumes and the database
function restore_full() {
local snapshot snap_prj repo_rel= other
if [[ -n ${1} ]]; then
snapshot=$(snapshot_id ${1})
else
snapshot=$(choose_snapshot)
fi
if [[ -z ${snapshot} ]]; then
echo -e "\e[31mSnapshot ${1:-latest} not found\e[0m"
exit 1
fi
restic_run -- snapshots ${snapshot}
read_manifest ${snapshot}
if [[ -z ${MANIFEST[db_version]} ]]; then
echo -e "\e[31mThe snapshot has no mailcow manifest, it was not made by the mailcow backup\e[0m"
exit 1
fi
if [[ ${MANIFEST[mode]} != "all" ]]; then
echo -e "\e[31mThe snapshot was made with RESTIC_BACKUP_MODE=${MANIFEST[mode]} ($(mode_text ${MANIFEST[mode]})), it has no complete"
echo -e "mailcow directory. Install mailcow (git clone, generate_config.sh) and use: restore --data-only ${snapshot:0:8}\e[0m"
exit 1
fi
snap_prj=$(snapshot_project ${snapshot})
if [[ ${MANIFEST[install]} != "1" || -z ${snap_prj} ]]; then
echo -e "\e[31mThe snapshot has no mailcow directory (made before full backups), use: restore --data-only ${snapshot:0:8}\e[0m"
exit 1
fi
show_manifest
# another installation of this project on this server (other directory) is not touched
other=$(docker ps -a --filter label=com.docker.compose.project=${snap_prj} --format '{{.Label "com.docker.compose.project.working_dir"}}' \
| sort -u | grep -vxF "${MAILCOW_DIR}" | head -n 1)
if [[ -n ${other} ]]; then
echo -e "\e[31mThe project ${snap_prj} of the snapshot runs from ${other}, not from ${MAILCOW_DIR}. Restore it there.\e[0m"
exit 1
fi
schedule_decision ${snap_prj}
ASIDE_DIR="${MAILCOW_DIR}.before-restore-$(date +%Y%m%d-%H%M)"
[[ -e ${ASIDE_DIR} ]] && ASIDE_DIR="${ASIDE_DIR}$(date +%S)"
confirm "WARNING: this restores mailcow completely as it was in snapshot ${snapshot:0:8}: the mailcow directory
(version and configuration), all mails, the mail index and crypt keys, Redis, Rspamd, the SOGo backup files and the
database. ${MAILCOW_DIR} is moved to ${ASIDE_DIR}, the database volume is recreated.
Everything newer than the snapshot is lost. mailcow is stopped during the restore."
check_dump ${snapshot} || exit 1
# nothing was changed until here
OLD_DUMP=
[[ -n ${CMPS_PRJ} ]] && dump_current_database ${CMPS_PRJ}
if [[ -n ${CMPS_PRJ} ]]; then
echo "Stopping mailcow..."
compose "${MAILCOW_DIR}" down
fi
stop_project ${snap_prj}
echo "Moving ${MAILCOW_DIR} to ${ASIDE_DIR}..."
mv "${MAILCOW_DIR}" "${ASIDE_DIR}" || exit 1
mkdir "${MAILCOW_DIR}"
chmod --reference="${ASIDE_DIR}" "${MAILCOW_DIR}"
chown --reference="${ASIDE_DIR}" "${MAILCOW_DIR}"
# a local repository inside the directory moved with it
if [[ ${RESTIC_LOCAL_DIR} == "${MAILCOW_DIR}"/* ]]; then
repo_rel=${RESTIC_LOCAL_DIR#${MAILCOW_DIR}/}
RESTIC_LOCAL_DIR="${ASIDE_DIR}/${repo_rel}"
fi
echo "Restoring the mailcow directory..."
restic_run -v "${MAILCOW_DIR}":/mailcow/install -- restore ${snapshot} --target / --include /mailcow/install || failed_after_move
if [[ -n ${repo_rel} ]]; then
mkdir -p "$(dirname "${MAILCOW_DIR}/${repo_rel}")"
rmdir "${MAILCOW_DIR}/${repo_rel}" 2> /dev/null
mv "${ASIDE_DIR}/${repo_rel}" "${MAILCOW_DIR}/${repo_rel}" && RESTIC_LOCAL_DIR="${MAILCOW_DIR}/${repo_rel}"
fi
load_restored_config || failed_after_move
echo "Fetching the images of the restored version..."
compose "${MAILCOW_DIR}" pull || failed_after_move
echo "Creating volumes and containers, the database volume is recreated..."
compose "${MAILCOW_DIR}" down
docker volume rm ${CMPS_PRJ}_mysql-vol-1 > /dev/null 2>&1
compose "${MAILCOW_DIR}" up --no-start || failed_after_move
restore_volumes ${snapshot} "${MANIFEST[arch]}" || failed_after_move
echo "Starting the database..."
compose "${MAILCOW_DIR}" up -d mysql-mailcow || failed_after_move
fetch_dump ${snapshot} || failed_after_move
restore_database || failed_after_move
echo "Starting mailcow..."
compose "${MAILCOW_DIR}" up -d || failed_after_move
echo
echo -e "\e[32mRestore of snapshot ${snapshot:0:8} finished.\e[0m"
echo "Next steps:"
echo "- ${SCHEDULE_NOTE}"
echo "- The previous mailcow directory is ${ASIDE_DIR}, remove it when everything works."
echo "- New server or new IP address: update the DNS records (A/AAAA of ${MAILCOW_HOSTNAME}, MX, SPF, PTR)."
echo "- SFTP target on a new server: the backup creates its own SSH key, add its public key (backup settings) to the"
echo " SFTP server, or copy the old key into the volume ${CMPS_PRJ}_restic-vol-1 (ssh/id_ed25519)."
if [[ ${NO_INDEX} == "y" ]]; then
echo "- The snapshot has no mail index: with full text search enabled, rebuild it:"
else
echo "- If the full text search index was excluded from the backups, rebuild it:"
fi
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm fts rescan -A"
echo "- If a mailbox shows wrong content, resync it; quotas:"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm force-resync -A '*'"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm quota recalc -A"
}
# Data only: volumes and database into this mailcow (same or newer version), optionally data/conf and mailcow.conf
function restore_data_only() {
local snapshot conf_dir
if [[ -z ${CMPS_PRJ} ]]; then
echo -e "\e[31mmailcow.conf not found: a data-only restore needs an installed mailcow (or use a full restore)\e[0m"
exit 1
fi
if [[ -n ${1} ]]; then
snapshot=$(snapshot_id ${1})
else
snapshot=$(choose_snapshot)
fi
if [[ -z ${snapshot} ]]; then
echo -e "\e[31mSnapshot ${1:-latest} not found\e[0m"
exit 1
fi
restic_run -- snapshots ${snapshot}
read_manifest ${snapshot}
if [[ -z ${MANIFEST[db_version]} ]]; then
echo -e "\e[31mThe snapshot has no mailcow manifest, it was not made by the mailcow backup\e[0m"
exit 1
fi
show_manifest
if [[ -z $(version_key "${MANIFEST[db_version]}") || -z $(version_key "${CHECKOUT_DB_VERSION}") ]]; then
echo -e "\e[31mThe database schema of the snapshot (${MANIFEST[db_version]}) or of this checkout (${CHECKOUT_DB_VERSION}) is unknown\e[0m"
exit 1
fi
if [[ $(version_key "${MANIFEST[db_version]}") > $(version_key "${CHECKOUT_DB_VERSION}") ]]; then
echo -e "\e[31mThe snapshot was made with a newer mailcow (database schema ${MANIFEST[db_version]}, this checkout has ${CHECKOUT_DB_VERSION})."
echo -e "Update this mailcow first (./update.sh) or use a full restore.\e[0m"
exit 1
fi
schedule_decision ${CMPS_PRJ}
if [[ ${MANIFEST[mode]} == "config" ]]; then
confirm "WARNING: this replaces the mail crypt keys, Redis and the whole database of this mailcow with the content of
snapshot ${snapshot:0:8}. The snapshot holds no mails: mails, mail index, Rspamd and SOGo data stay as they are.
Everything newer than the snapshot is lost. mailcow is stopped during the restore."
else
confirm "WARNING: this replaces all mails, the mail index, the mail crypt keys, Redis, Rspamd and SOGo backup data
and the whole database of this mailcow with the content of snapshot ${snapshot:0:8}.
Everything newer than the snapshot is lost. mailcow is stopped during the restore."
fi
RESTORE_CONF=
if [[ ${MANIFEST[install]} == "1" ]]; then
# data snapshots have only mailcow.conf of the mailcow directory
if [[ ${MANIFEST[mode]} != "data" ]]; then
conf_dir="${MAILCOW_DIR}/data/conf"
if [[ ${MANIFEST[db_version]} == "${CHECKOUT_DB_VERSION}" ]]; then
read -p "Also restore data/conf and data/assets/ssl from the snapshot? [y|N] " RESTORE_CONF
else
# the configuration of another mailcow version may not fit this checkout
conf_dir="${MAILCOW_DIR}/data/conf.from-snapshot-$(date +%Y%m%d-%H%M%S)"
echo "The snapshot was made with another mailcow version, its data/conf is restored into ${conf_dir} to compare."
read -p "Restore data/conf into that folder and data/assets/ssl? [y|N] " RESTORE_CONF
fi
fi
read -p "Also restore mailcow.conf from the snapshot (the current one is kept as a copy)? [y|N] " RESTORE_MAILCOW_CONF
fi
check_dump ${snapshot} || exit 1
echo "Stopping mailcow..."
compose "${MAILCOW_DIR}" down
echo "Starting the database..."
compose "${MAILCOW_DIR}" up -d mysql-mailcow
if ! fetch_dump ${snapshot}; then
echo "Nothing was changed, starting mailcow again..."
compose "${MAILCOW_DIR}" up -d
exit 1
fi
restore_volumes ${snapshot} "${MANIFEST[arch]}" || failed_data_only
restore_database || failed_data_only
[[ ${RESTORE_CONF,,} =~ ^(yes|y)$ ]] && { restore_config ${snapshot} "${conf_dir}" || failed_data_only; }
[[ ${RESTORE_MAILCOW_CONF,,} =~ ^(yes|y)$ ]] && { restore_mailcow_conf ${snapshot} || failed_data_only; }
echo "Starting mailcow..."
compose "${MAILCOW_DIR}" up -d
echo
echo -e "\e[32mRestore of snapshot ${snapshot:0:8} finished.\e[0m"
echo "Next steps:"
echo "- ${SCHEDULE_NOTE}"
echo "- When Dovecot runs, check the mailboxes. If a mailbox shows wrong content, resync it, and recalculate the quotas:"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm force-resync -A '*'"
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm quota recalc -A"
if [[ ${NO_INDEX} == "y" ]]; then
echo "- The snapshot has no mail index: with full text search enabled, rebuild it:"
else
echo "- If the full text search index was excluded from the backups, rebuild it:"
fi
echo " ${COMPOSE_COMMAND} exec dovecot-mailcow doveadm fts rescan -A"
echo "- Run an \"init\" job in the backup settings, it refreshes the list of snapshots."
if [[ ${conf_dir} != "${MAILCOW_DIR}/data/conf" && ${RESTORE_CONF,,} =~ ^(yes|y)$ ]]; then
echo "- Compare ${conf_dir} with data/conf and take over what you need."
fi
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
# restore [--data-only] [id], the option before or after the id
if [[ ${1} == "restore" ]]; then
SNAPSHOT_ARG=
for arg in "${@:2}"; do
if [[ ${arg} == "--data-only" ]]; then
MODE=data
elif [[ -z ${SNAPSHOT_ARG} && ${arg} =~ ^[0-9a-f]{8,64}$ ]]; then
SNAPSHOT_ARG=${arg}
else
echo "Unknown argument: ${arg}"
exit 1
fi
done
set -- restore "${SNAPSHOT_ARG}"
fi
if [[ ! ${1} =~ ^(snapshots|restore)$ ]]; then
echo "Usage: ${0##*/} snapshots | restore [snapshot id] | restore --data-only [snapshot id]"
exit 1
fi
load_config
repository_env
case ${1} in
snapshots) snapshots ;;
restore)
if [[ ${MODE} == "data" ]]; then
restore_data_only ${2}
else
restore_full ${2}
fi
;;
esac
fi
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Controller;
use Mailcow\Backup\Controller\BackupApiController;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class BackupApiControllerTest extends TestCase
{
private SettingsService&MockObject $settings;
private JobService&MockObject $jobs;
private RestoreService&MockObject $restore;
private DomainRestoreService&MockObject $domainRestore;
private BackupApiController $api;
protected function setUp(): void
{
$_SESSION = [];
$this->settings = $this->createMock(SettingsService::class);
$this->jobs = $this->createMock(JobService::class);
$this->jobs->method('view')->willReturnCallback(fn(Job $job, bool $withLog = false): array => $job->toArray($withLog));
$this->restore = $this->createMock(RestoreService::class);
$this->domainRestore = $this->createMock(DomainRestoreService::class);
$this->api = new BackupApiController($this->settings, $this->jobs, $this->createMock(ExportService::class), $this->restore,
$this->domainRestore, new ApiResponse(Settings::SECRETS));
}
private static function job(int $id, string $type): Job
{
$job = new Job($type);
$job->id = $id;
$job->params = ($type === 'restore') ? ['mailboxes' => ['a@firma.de', 'b@firma.de']] : [];
return $job;
}
public function testSecretsAreMaskedInTheLog(): void
{
$this->settings->expects($this->once())->method('save');
$this->assertTrue($this->api->editSettings(['password' => 'repo-pw', 's3_secret_key' => 'S3', 'rest_password' => '', 'keep_daily' => '9']));
$this->assertSame([['type' => 'success', 'log' => ['backup', 'edit', 'settings', ['password' => '*', 's3_secret_key' => '*', 'rest_password' => '',
'keep_daily' => '9']], 'msg' => 'backup_settings_saved']], $_SESSION['return']);
}
public function testJobMessages(): void
{
$this->jobs->method('create')->willReturnOnConsecutiveCalls(self::job(4, 'backup'), $this->throwException(new UserError('backup_job_active', ['backup'])),
$this->throwException(new PDOException('gone away')));
$this->jobs->method('restore')->willReturn(self::job(5, 'restore'));
$this->jobs->method('cancel')->willReturnCallback(fn(int $id): Job => ($id === 4) ? self::job(4, 'backup') : throw new UserError('backup_job_not_cancellable', [$id]));
$this->assertTrue($this->api->addJob(['type' => 'backup']));
$this->assertFalse($this->api->addJob(['type' => 'backup']));
$this->assertFalse($this->api->addJob(['type' => 'backup']));
$this->assertTrue($this->api->addRestore(['snapshot' => 'abc']));
$this->assertTrue($this->api->deleteJobs([4, '7', 'x']));
$this->assertSame([
['success', ['backup_job_added', 'backup', 4]],
['danger', ['backup_job_active', 'backup']],
['danger', ['mysql_error', 'gone away']],
['success', ['backup_restore_added', 5, 2]],
['success', ['backup_job_cancelled', 4]],
['danger', ['backup_job_not_cancellable', 7]],
['danger', 'access_denied'],
], array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return']));
$this->assertSame(['backup', 'delete', 'job', ['id' => [4, '7', 'x']]], $_SESSION['return'][4]['log']);
}
public function testReads(): void
{
$this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => ($id === 4) ? self::job(4, 'backup') : null);
$this->jobs->method('list')->willReturn([self::job(4, 'backup')]);
$this->assertSame([4, ''], [$this->api->getJob('4')['id'], $this->api->getJob('4')['log']]);
$this->assertSame([], $this->api->getJob('x'));
$this->assertSame([], $this->api->getJob(5));
$this->assertArrayNotHasKey('log', $this->api->getJobs()[0]);
$this->assertSame([], $this->api->getSettings());
}
public function testTheRunnersActions(): void
{
$this->restore->expects($this->once())->method('merge')->with(12, 'a@firma.de', ['mailbox' => []])
->willReturn(['bcc' => ['added' => 1, 'skipped' => 0, 'errors' => []]]);
$this->restore->expects($this->once())->method('mergeAliases')->with(12, [['address' => 'x@firma.de']])
->willReturn(['aliases' => ['added' => 1, 'skipped' => 0, 'errors' => []]]);
$this->domainRestore->expects($this->once())->method('restore')->with(12, ['domain' => ['domain' => 'firma.de']])
->willReturn(['domain' => ['added' => 1, 'skipped' => 0, 'errors' => [], 'created' => 1]]);
// the components come from the job, never from the request
$this->assertSame(['result' => ['bcc' => ['added' => 1, 'skipped' => 0, 'errors' => []]]],
$this->api->post(['action' => 'restore', 'job' => 12, 'username' => 'a@firma.de', 'data' => ['mailbox' => []], 'components' => ['password']]));
$this->assertSame(['result' => ['aliases' => ['added' => 1, 'skipped' => 0, 'errors' => []]]],
$this->api->post(['action' => 'restore_aliases', 'job' => 12, 'aliases' => [['address' => 'x@firma.de']]]));
$this->assertSame(1, $this->api->post(['action' => 'restore_domain', 'job' => 12, 'data' => ['domain' => ['domain' => 'firma.de']]])['result']['domain']['created']);
try {
$this->api->post(['action' => 'drop_everything']);
$this->fail('UserError expected');
} catch (UserError $e) {
$this->assertSame('backup_action_invalid', $e->key);
}
}
public function testSearchAndJobsOfOneType(): void
{
$this->jobs->method('search')->with('abc', 'anna@')->willReturn(['domains' => [], 'truncated' => 0]);
$this->jobs->expects($this->once())->method('list')->with(0, 'restore')->willReturn([self::job(5, 'restore')]);
$this->assertSame(['domains' => [], 'truncated' => 0], $this->api->getSnapshotSearch('abc', 'anna@'));
$this->assertSame(['restore'], array_column($this->api->getJobs(0, 'restore'), 'type'));
}
public function testSnapshotContent(): void
{
$this->jobs->method('content')->with('abc')->willReturn(['snapshot' => 'abc']);
$this->jobs->method('domainContent')->with('abc', 'firma.de')->willReturn(['domain' => 'firma.de']);
$this->assertSame(['snapshot' => 'abc'], $this->api->getSnapshotContent('abc', null));
$this->assertSame(['domain' => 'firma.de'], $this->api->getSnapshotContent('abc', 'firma.de'));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Legacy;
use Mailcow\Backup\Legacy\NoTemplates;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\TemplateService;
use PHPUnit\Framework\TestCase;
class NoTemplatesTest extends TestCase
{
public function testARestoredMembershipAppliesNoGroupTemplate(): void
{
$lookup = $this->createMock(LookupRepository::class);
// the mailbox gets a group with a template
$lookup->method('effectiveTemplates')->willReturn(['anna@firma.de' => 'Vertrieb']);
$lookup->method('mailboxNames')->willReturn(['anna@firma.de' => 'Anna']);
// the legacy mailbox code is never called (it is not loaded in the tests)
$this->assertSame([], (new TemplateService($lookup, new NoTemplates()))->applyChanged(['anna@firma.de' => null]));
}
}
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\GroupMembers;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Backup\Support\FakeRestoreRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class DomainRestoreServiceTest extends TestCase
{
private FakeRestoreRepository $repo;
private JobRepository&MockObject $jobs;
private GroupMembers&MockObject $groups;
private LegacyDomain&MockObject $legacy;
private Job $job;
/** @var array Redis of the legacy code: dkim, ratelimit, domain_map */
private array $redis = ['dkim' => [], 'ratelimit' => [], 'domain_map' => []];
private int $sogoRestarts = 0;
/** @var array[] groups created through the group module: [role, input] */
private array $createdGroups = [];
protected function setUp(): void
{
$this->repo = new FakeRestoreRepository([
'domain' => [['domain' => 'firma.de', 'aliases' => 400, 'mailboxes' => 10, 'quota' => 10240, 'maxquota' => 2048, 'active' => 1]],
'relayhosts' => [['id' => 1, 'hostname' => 'relay.example.org']],
'admin' => [['username' => 'da', 'superadmin' => 0, 'active' => 1], ['username' => 'admin', 'superadmin' => 1, 'active' => 1]],
'sender_acl' => [['id' => 1, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'info@alt.de', 'external' => 1, 'group_id' => null]],
]);
$this->job = new Job('restore');
$this->job->id = 20;
$this->job->state = 'running';
$this->job->domain = 'alt.de';
$this->job->params = ['scope' => 'domain', 'domain' => 'alt.de', 'mailboxes' => ['x@alt.de'], 'components' => ['mails', 'aliases'],
'requested_role' => 'admin', 'create_domain' => 1, 'create_mailboxes' => ['x@alt.de']];
$this->jobs = $this->createMock(JobRepository::class);
$this->jobs->method('find')->willReturnCallback(fn(int $id): ?Job => ($id === 20) ? $this->job : null);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('create')->willReturnCallback(function (Context $context, array $input): int {
if ($input['name'] === 'Broken') {
throw new UserError('group_address_invalid', ['x']);
}
$this->createdGroups[] = [$context->role, $input];
$id = 100 + count($this->createdGroups);
$this->repo->tables['mailbox_group'][] = ['id' => $id, 'domain' => $input['domain'], 'name' => $input['name'], 'address' => $input['address'] ?? null];
return $id;
});
$this->legacy = $this->createMock(LegacyDomain::class);
$this->legacy->method('dkim')->willReturnCallback(fn(string $domain): ?array => $this->redis['dkim'][$domain] ?? null);
$this->legacy->method('addDkim')->willReturnCallback(function (string $domain, string $selector, string $pubkey, string $privkey): void {
$this->redis['dkim'][$domain] = ['selector' => $selector, 'pubkey' => $pubkey, 'privkey' => $privkey];
});
$this->legacy->method('rateLimits')->willReturnCallback(fn(array $objects): array => array_intersect_key($this->redis['ratelimit'], array_flip($objects)));
$this->legacy->method('setRateLimit')->willReturnCallback(function (string $object, string $value): void {
$this->redis['ratelimit'][$object] = $value;
});
$this->legacy->method('addDomainMap')->willReturnCallback(function (string $domain): void {
$this->redis['domain_map'][] = $domain;
});
$this->legacy->method('restartSogo')->willReturnCallback(function (): ?string {
$this->sogoRestarts++;
return null;
});
}
private function service(): DomainRestoreService
{
return new DomainRestoreService($this->repo, $this->jobs, $this->groups, $this->legacy);
}
private static function data(): array
{
return [
'domain' => ['domain' => 'alt.de', 'description' => 'Alt GmbH', 'aliases' => 50, 'mailboxes' => 5, 'defquota' => 1024, 'maxquota' => 2048,
'quota' => 4096, 'relayhost' => '9', 'backupmx' => 0, 'gal' => 1, 'created' => '2020-01-01 00:00:00', 'active' => 1, 'gone_column' => 1],
'tags' => ['kunde', 'kunde', ''],
'alias_domains' => [['alias_domain' => 'alt.com', 'target_domain' => 'alt.de', 'active' => 1],
['alias_domain' => 'firma.de', 'target_domain' => 'alt.de', 'active' => 1]],
'dkim' => [['domain' => 'alt.de', 'selector' => 'dkim', 'pubkey' => 'PUB', 'privkey' => 'PRIV'],
['domain' => 'alt.com', 'selector' => 's2', 'pubkey' => 'PUB2', 'privkey' => 'PRIV2'],
['domain' => 'other.de', 'selector' => 'dkim', 'pubkey' => 'X', 'privkey' => 'X']],
'ratelimit' => '100 / 1h',
'filterconf' => [['prefid' => 1, 'object' => 'alt.de', 'option' => 'whitelist_from', 'value' => '*@partner.example'],
['prefid' => 2, 'object' => 'alt.de', 'option' => 'highspamlevel', 'value' => '12']],
'bcc_maps' => [['id' => 3, 'local_dest' => '@alt.de', 'bcc_dest' => 'archive@example.org', 'domain' => 'alt.de', 'type' => 'rcpt', 'active' => 1]],
'footer' => ['domain' => 'alt.de', 'html' => '<p>Alt</p>', 'plain' => 'Alt', 'mbox_exclude' => '[]', 'alias_domain_exclude' => '[]', 'skip_replies' => 0],
'mta_sts' => ['id' => 1700000000, 'domain' => 'alt.de', 'version' => 'STSv1', 'mode' => 'enforce', 'mx' => 'mail.alt.de', 'max_age' => '86400', 'active' => 1],
'domain_admins' => [['id' => 4, 'username' => 'da', 'domain' => 'alt.de', 'active' => 1], ['id' => 5, 'username' => 'gone', 'domain' => 'alt.de', 'active' => 1]],
'groups' => [['id' => 7, 'domain' => 'alt.de', 'name' => 'Team', 'description' => 'Alle', 'address' => 'team@alt.de', 'sender_allowed' => 1,
'sogo_visible' => 1, 'internal' => 0, 'active' => 1, 'priority' => 5, 'idp_ref' => null, 'template_id' => 3],
['id' => 8, 'domain' => 'alt.de', 'name' => 'Broken', 'description' => '', 'address' => null]],
'mail_policies' => [['id' => 3, 'domain' => 'alt.de', 'name' => 'Strict', 'send_mode' => 'list', 'send_allow' => '["group:7", "group:8", "x@alt.de"]',
'send_block' => '[]', 'receive_mode' => 'all', 'receive_allow' => '[]', 'receive_block' => '[]', 'group_members' => ['Team', 'Gone']]],
'retention_rules' => [['id' => 9, 'domain' => 'alt.de', 'name' => 'Papierkorb', 'folders' => '["trash"]', 'days' => 30, 'group_members' => []]],
];
}
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 testADeletedDomainIsCreatedWithItsSettings(): void
{
$result = $this->service()->restore(20, self::data())['domain'];
$domain = $this->repo->rows('domain', ['domain' => 'alt.de'])[0];
// the row of the backup with its creation time; a relayhost that is gone is not used
$this->assertSame(['Alt GmbH', 5, 4096, '0', '2020-01-01 00:00:00'], [$domain['description'], $domain['mailboxes'], $domain['quota'], $domain['relayhost'],
$domain['created']]);
$this->assertSame(['kunde'], array_column($this->repo->tables['tags_domain'], 'tag_name'));
// effects of the legacy domain add: domain map, SOGo restart, external send-as rights to the domain dropped
$this->assertSame(['alt.de', 'alt.com'], $this->redis['domain_map']);
$this->assertSame(1, $this->sogoRestarts);
$this->assertSame([], $this->repo->tables['sender_acl']);
// alias domains that are free; DKIM keys of the domain and its alias domains
$this->assertSame([['alt.com', 'alt.de']], array_map(fn(array $a): array => [$a['alias_domain'], $a['target_domain']], $this->repo->tables['alias_domain']));
$this->assertSame(['alt.de', 'alt.com'], array_keys($this->redis['dkim']));
$this->assertSame('PRIV2', $this->redis['dkim']['alt.com']['privkey']);
$this->assertSame(['alt.de' => '100 / 1h'], $this->redis['ratelimit']);
$this->assertSame([['whitelist_from', '*@partner.example'], ['highspamlevel', '12']],
array_map(fn(array $f): array => [$f['option'], $f['value']], $this->repo->tables['filterconf']));
$this->assertSame(['@alt.de', 'archive@example.org'], [$this->repo->tables['bcc_maps'][0]['local_dest'], $this->repo->tables['bcc_maps'][0]['bcc_dest']]);
$this->assertSame(['<p>Alt</p>', 'enforce'], [$this->repo->tables['domain_wide_footer'][0]['html'], $this->repo->tables['mta_sts'][0]['mode']]);
$this->assertSame([['da', 'alt.de']], array_map(fn(array $a): array => [$a['username'], $a['domain']], $this->repo->tables['domain_admins']));
// groups through the group module (no template), then policies and rules with the groups' new ids
$this->assertSame([['admin', ['domain' => 'alt.de', 'name' => 'Team', 'description' => 'Alle', 'sender_allowed' => 1, 'sogo_visible' => 1,
'internal' => 0, 'active' => 1, 'priority' => 5, 'address' => 'team@alt.de']]], $this->createdGroups);
$policy = $this->repo->rows('mail_policy', ['name' => 'Strict'])[0];
$this->assertSame(['["group:101","x@alt.de"]', 'list'], [$policy['send_allow'], $policy['send_mode']]);
$this->assertSame([[$policy['id'], 101]], array_map(fn(array $m): array => [$m['policy_id'], $m['group_id']], $this->repo->tables['mail_policy_member']));
$this->assertSame(['Papierkorb', '["trash"]'], [$this->repo->tables['retention_rule'][0]['name'], $this->repo->tables['retention_rule'][0]['folders']]);
$this->assertSame(1, $result['created']);
$this->assertSame([
'relayhost: does not exist',
'domain admin gone: does not exist',
'group Broken: group_address_invalid',
'Strict (send_allow): group:8 dropped (group does not exist)',
'Strict: group Gone does not exist',
], $result['errors']);
// domain, alias domain, 2 filterconf, BCC, footer, MTA-STS, domain admin, 2 DKIM keys, rate limit, group, policy, rule
$this->assertSame([14, 1], [$result['added'], $result['skipped']]);
}
public function testADomainCreatedMeanwhileGetsWhatIsMissing(): void
{
$this->repo->tables['domain'][] = ['domain' => 'alt.de', 'description' => 'Neu', 'aliases' => 10, 'mailboxes' => 1, 'quota' => 100, 'maxquota' => 100,
'relayhost' => '0', 'active' => 1];
$this->repo->tables['mailbox_group'][] = ['id' => 50, 'domain' => 'alt.de', 'name' => 'Team', 'address' => null];
$this->redis['dkim']['alt.de'] = ['selector' => 'new', 'pubkey' => 'NEW', 'privkey' => 'NEW'];
$result = $this->service()->restore(20, self::data())['domain'];
$this->assertSame([0, 'Neu'], [$result['created'], $this->repo->rows('domain', ['domain' => 'alt.de'])[0]['description']]);
$this->assertSame([0, 'NEW'], [$this->sogoRestarts, $this->redis['dkim']['alt.de']['pubkey']]);
// the existing group gets the policy's assignments
$this->assertSame([], $this->createdGroups);
$this->assertSame([50], array_column($this->repo->tables['mail_policy_member'], 'group_id'));
$again = $this->service()->restore(20, self::data())['domain'];
$this->assertSame(0, $again['added']);
}
public function testOnlyRunningDomainRestoresOfAdmins(): void
{
$this->repo->tables['alias_domain'][] = ['alias_domain' => 'alt.de', 'target_domain' => 'firma.de', 'active' => 1];
$this->assertSame(['alt.de is an alias domain now'], $this->service()->restore(20, self::data())['domain']['errors']);
$this->assertSame([], $this->repo->rows('domain', ['domain' => 'alt.de']));
$this->expectUserError('backup_export_invalid', fn() => $this->service()->restore(20, ['domain' => ['domain' => 'other.de']]));
$this->expectUserError('backup_job_not_running', fn() => $this->service()->restore(21, self::data()));
$this->job->params['create_domain'] = 0;
$this->expectUserError('access_denied', fn() => $this->service()->restore(20, self::data()));
$this->job->params['create_domain'] = 1;
$this->job->params['requested_role'] = 'domainadmin';
$this->expectUserError('access_denied', fn() => $this->service()->restore(20, self::data()));
$this->job->params['requested_role'] = 'admin';
$this->job->state = 'done';
$this->expectUserError('backup_job_not_running', fn() => $this->service()->restore(20, self::data()));
$this->job->state = 'running';
$this->repo->busyLocks = ['20:domain'];
$this->expectUserError('backup_job_busy', fn() => $this->service()->restore(20, self::data()));
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Repository\ExportRepository;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Core\UserError;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ExportServiceTest extends TestCase
{
private ExportRepository&MockObject $repo;
private LegacyDomain&MockObject $legacy;
/** @var string[] usernames of the domain */
private array $usernames = ['anna@firma.de', 'bob@firma.de'];
protected function setUp(): void
{
$this->repo = $this->createMock(ExportRepository::class);
$this->repo->method('dbVersion')->willReturn('08112026_1700');
$this->repo->method('rows')->willReturnCallback(fn(string $table, string $column, string $value): array => match ($table) {
'domain' => ($value === 'firma.de') ? [['domain' => 'firma.de', 'description' => 'Firma', 'mailboxes' => 10, 'relayhost' => '0']] : [],
'alias_domain' => [['alias_domain' => 'firma.com', 'target_domain' => 'firma.de', 'active' => 1]],
'tags_domain' => [['tag_name' => 'kunde', 'domain' => 'firma.de']],
'filterconf' => [['prefid' => 1, 'object' => 'firma.de', 'option' => 'whitelist_from', 'value' => '*@partner.example']],
'domain_wide_footer' => [['domain' => 'firma.de', 'html' => '<p>Firma</p>', 'plain' => 'Firma']],
'domain_admins' => [['id' => 1, 'username' => 'da', 'domain' => 'firma.de', 'active' => 1]],
'mailbox_group' => [['id' => 7, 'domain' => 'firma.de', 'name' => 'Team', 'address' => 'team@firma.de']],
default => [],
});
$this->repo->method('domainRules')->willReturnCallback(fn(string $table): array => ($table === 'mail_policy')
? [['id' => 3, 'domain' => 'firma.de', 'name' => 'Strict', 'send_allow' => '["group:7"]', 'group_members' => ['Team']]] : []);
$this->repo->method('mailboxesAfter')->willReturnCallback(fn(string $domain, string $after, int $limit): array => array_slice(array_values(array_filter(
array_map(fn(string $username): array => ['username' => $username, 'password' => '{SSHA256}hash', 'attributes' => '{}', 'quota' => 0], $this->usernames),
fn(array $row): bool => $row['username'] > $after)), 0, $limit));
$this->repo->method('aliases')->willReturn([['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'group_id' => null]]);
$this->repo->method('aliasesPointingTo')->willReturn([
['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'group_id' => null],
['id' => 2, 'address' => 'info@other.de', 'goto' => 'Anna@firma.de, bob@firma.de,x@ext.example', 'group_id' => null],
['id' => 3, 'address' => 'team@firma.de', 'goto' => 'anna@firma.de', 'group_id' => 7],
['id' => 4, 'address' => 'list@firma.de', 'goto' => 'notamailbox@firma.de', 'group_id' => null],
]);
$this->repo->method('rowsOfMailboxes')->willReturnCallback(fn(string $table, string $column, array $usernames): array =>
array_values(array_filter(match ($table) {
'tags_mailbox' => [['tag_name' => 'vip', 'username' => 'anna@firma.de']],
'user_acl' => [['username' => 'anna@firma.de', 'spam_alias' => 1]],
'fido2' => [['username' => 'bob@firma.de', 'credentialId' => "\x00\xff\xfe", 'friendlyName' => 'Key']],
'sender_acl' => ($column === 'send_as') ? [['id' => 5, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'anna@firma.de']] : [],
// anna forwards with a copy
'alias' => [['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de,fwd@ext.example', 'group_id' => null]],
default => [],
}, fn(array $row): bool => in_array(strtolower($row[$column]), $usernames, true))));
$this->repo->method('usernames')->willReturnCallback(fn(): array => $this->usernames);
$this->repo->method('groupMemberships')->willReturn([['username' => 'anna@firma.de', 'id' => '7', 'name' => 'Team', 'domain' => 'firma.de', 'source' => 'manual']]);
$this->repo->method('assignments')->willReturnCallback(fn(string $table, string $member, string $key, array $usernames): array =>
($table === 'retention_rule' && in_array('bob@firma.de', $usernames, true)) ? [['username' => 'bob@firma.de', 'id' => 5, 'name' => 'Papierkorb', 'domain' => 'firma.de']] : []);
$this->repo->method('syncJobs')->willReturnCallback(fn(array $usernames): array => in_array('anna@firma.de', $usernames, true)
? [['id' => 9, 'user2' => 'anna@firma.de', 'user1' => 'a@old.de', 'password1' => 'pw', 'source_host1' => 'imap.old.de', 'source_port1' => 993]] : []);
$this->legacy = $this->createMock(LegacyDomain::class);
$this->legacy->method('dkim')->willReturnCallback(fn(string $domain): ?array => ($domain === 'firma.de')
? ['selector' => 'dkim', 'pubkey' => 'PUB', 'privkey' => 'PRIV'] : null);
$this->legacy->method('rateLimits')->willReturnCallback(fn(array $objects): array =>
array_intersect_key(['firma.de' => '100 / 1h', 'bob@firma.de' => '10 / 1m'], array_flip($objects)));
}
private function export(?string $after = null, int $pageSize = ExportService::PAGE_SIZE): array
{
return (new ExportService($this->repo, $this->legacy))->export(' Firma.de ', $after, $pageSize);
}
public function testTheExportHoldsEverythingOfTheMailboxes(): void
{
$export = $this->export();
$this->assertSame([1, '08112026_1700', 'firma.de', null, 1], [$export['version'], $export['db_version'], $export['domain'],
$export['next_after'], count($export['aliases'])]);
$anna = $export['mailboxes']['anna@firma.de'];
$bob = $export['mailboxes']['bob@firma.de'];
// password hashes and secrets are part of the export on purpose
$this->assertSame(['{SSHA256}hash', ['vip']], [$anna['mailbox']['password'], $anna['mailbox']['tags']]);
$this->assertSame(['username' => 'anna@firma.de', 'spam_alias' => 1], $anna['user_acl']);
$this->assertSame([null, null, '10 / 1m'], [$bob['user_acl'], $bob['pushover'], $bob['ratelimit']]);
$this->assertSame([[7, 'Team', 'firma.de', 'manual']], array_map('array_values', $anna['groups']));
$this->assertSame('Papierkorb', $bob['retention_rules'][0]['name']);
$this->assertSame([['id' => 5, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'anna@firma.de']], $anna['sender_acl_granted']);
$this->assertSame('pw', $anna['imapsync'][0]['password1']);
// binary values survive JSON
$this->assertSame(['base64' => base64_encode("\x00\xff\xfe")], $bob['fido2'][0]['credentialId']);
$this->assertNotFalse(json_encode($export));
// aliases of any domain naming the mailbox, without its own alias and group addresses: once, with the first page
$this->assertSame([[2], [2], [], []], [array_column($export['mailbox_aliases']['anna@firma.de'], 'id'),
array_column($export['mailbox_aliases']['bob@firma.de'], 'id'), $anna['aliases'], $bob['aliases']]);
// the own alias with its forwarding comes with the mailbox
$this->assertSame(['anna@firma.de,fwd@ext.example', null], [$anna['own_alias']['goto'], $bob['own_alias']]);
$this->assertSame(['mailbox', 'user_acl', 'pushover', 'ratelimit', 'own_alias', 'aliases', 'groups', 'mail_policies', 'retention_rules', 'imapsync', 'sender_acl',
'sender_acl_granted', 'sieve_filters', 'filterconf', 'spamalias', 'bcc_maps', 'app_passwd', 'tfa', 'fido2', 'identity_link'], array_keys($anna));
}
public function testTheDomainDataRecreatesTheDomain(): void
{
$data = $this->export()['domain_data'];
$this->assertSame(['domain', 'tags', 'alias_domains', 'dkim', 'ratelimit', 'filterconf', 'bcc_maps', 'footer', 'mta_sts', 'domain_admins', 'groups',
'mail_policies', 'retention_rules'], array_keys($data));
$this->assertSame(['Firma', ['kunde'], 'firma.com', '100 / 1h', null], [$data['domain']['description'], $data['tags'],
$data['alias_domains'][0]['alias_domain'], $data['ratelimit'], $data['mta_sts']]);
// DKIM keys of the domain and its alias domains, with the private key
$this->assertSame([['domain' => 'firma.de', 'selector' => 'dkim', 'pubkey' => 'PUB', 'privkey' => 'PRIV']], $data['dkim']);
$this->assertSame(['Team'], $data['mail_policies'][0]['group_members']);
$this->assertSame('<p>Firma</p>', $data['footer']['html']);
}
public function testBigDomainsComeInPages(): void
{
$this->usernames = ['a@firma.de', 'anna@firma.de', 'bob@firma.de'];
$first = $this->export(null, 2);
$last = $this->export($first['next_after'], 2);
// pages by username: the next one starts after the last username, whatever was added or deleted meanwhile
$this->assertSame([['a@firma.de', 'anna@firma.de'], 'anna@firma.de'], [array_keys($first['mailboxes']), $first['next_after']]);
$this->assertSame([['bob@firma.de'], null], [array_keys($last['mailboxes']), $last['next_after']]);
// the domain's data and aliases come with the first page only
$this->assertArrayHasKey('domain_data', $first);
$this->assertSame(['version', 'domain', 'mailboxes', 'next_after'], array_keys($last));
// the aliases naming mailboxes of later pages come with the first page as well
$this->assertSame([[2], []], [array_column($first['mailbox_aliases']['bob@firma.de'], 'id'), $last['mailboxes']['bob@firma.de']['aliases']]);
$this->assertSame('{}', json_encode($this->export('zzz@firma.de', 2)['mailboxes']));
}
public function testUnknownDomain(): void
{
$this->expectException(UserError::class);
(new ExportService($this->repo, $this->legacy))->export('nix.de');
}
}
+604
View File
@@ -0,0 +1,604 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\Snapshot;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Backup\Repository\SnapshotRepository;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class JobServiceTest extends TestCase
{
private JobRepository&MockObject $jobs;
private SnapshotRepository&MockObject $snapshots;
private SettingsRepository&MockObject $settingsRepo;
private array $settings = ['target' => 'local', 'password' => 'repo-password', 'password_confirmed' => '1'];
/** @var Job[] queued or running jobs */
private array $active = [];
/** @var Job[] inserted jobs */
private array $inserted = [];
/** @var Job[] jobs by id */
private array $stored = [];
protected function setUp(): void
{
$this->settingsRepo = $this->createMock(SettingsRepository::class);
$this->settingsRepo->method('load')->willReturnCallback(fn(): array => $this->settings);
$this->jobs = $this->createMock(JobRepository::class);
$this->jobs->method('insertExclusive')->willReturnCallback(function (Job $job, callable $check): int {
$check($this->active);
$job->id = 100 + count($this->inserted);
$this->inserted[] = $job;
return $job->id;
});
$this->jobs->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true));
$this->jobs->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string =>
['anna@firma.de' => 'firma.de', 'bob@firma.de' => 'firma.de', 'carl@tochter.de' => 'tochter.de'][$username] ?? null);
$this->jobs->method('mailboxesOf')->willReturnCallback(fn(string $domain): array =>
['firma.de' => ['anna@firma.de', 'bob@firma.de'], 'tochter.de' => []][$domain] ?? []);
$this->jobs->method('mailboxNames')->willReturnCallback(fn(string $domain): array =>
['firma.de' => ['anna@firma.de' => 'Anna', 'bob@firma.de' => 'Bob'], 'tochter.de' => ['carl@tochter.de' => 'Carl']][$domain] ?? []);
$this->jobs->method('domainCreated')->willReturnCallback(fn(string $domain): ?int => ['firma.de' => 1500, 'tochter.de' => 100][$domain] ?? null);
$this->jobs->method('domainsAndUsernames')->willReturnCallback(function (array $domains): array {
$names = ['firma.de' => ['anna@firma.de', 'bob@firma.de'], 'tochter.de' => ['carl@tochter.de']];
$existing = array_fill_keys(array_values(array_intersect($domains, array_keys($names))), true);
$usernames = array_map(fn(array $list): array => array_fill_keys($list, true), array_intersect_key($names, array_flip($domains)));
return [$existing, $usernames];
});
$this->jobs->method('find')->willReturnCallback(fn(int $id): ?Job => $this->stored[$id] ?? null);
$this->jobs->method('active')->willReturnCallback(fn(): array => $this->active);
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturnCallback(fn(string $id): ?Snapshot => array_values(array_filter($this->snapshotList(),
fn(Snapshot $s): bool => $s->id === $id || $s->shortId === $id))[0] ?? null);
$this->snapshots->method('all')->willReturnCallback(fn(): array => $this->snapshotList());
}
/** @return Snapshot[] newest first */
private function snapshotList(): array
{
// alt.de was deleted since, so were dora@firma.de and the former firma.de of snapshot d (firma.de is newer)
$content = gzencode(json_encode(['domains' => ['firma.de' => ['created' => null, 'mailboxes' => ['anna@firma.de' => 'Anna', 'bob@firma.de' => 'Bob',
'dora@firma.de' => 'Dora']], 'tochter.de' => ['created' => null, 'mailboxes' => ['carl@tochter.de' => 'Carl']],
'alt.de' => ['created' => null, 'mailboxes' => ['x@alt.de' => 'X']]]]));
return [
Snapshot::fromRow(['id' => str_repeat('b', 64), 'short_id' => 'bbbbbbbb', 'time' => 2000, 'hostname' => 'mail', 'tags' => 'mailcow',
'summary' => '{"data_added": 5}', 'domains' => '{"firma.de": null, "tochter.de": null, "alt.de": null}', 'content' => $content]),
Snapshot::fromRow(['id' => str_repeat('d', 64), 'short_id' => 'dddddddd', 'time' => 1200, 'hostname' => 'mail', 'tags' => '',
'summary' => null, 'domains' => '{"firma.de": null}']),
Snapshot::fromRow(['id' => str_repeat('a', 64), 'short_id' => 'aaaaaaaa', 'time' => 1000, 'hostname' => 'mail', 'tags' => '',
'summary' => null, 'domains' => '{"tochter.de": null}']),
Snapshot::fromRow(['id' => str_repeat('c', 64), 'short_id' => 'cccccccc', 'time' => 500, 'hostname' => 'other', 'tags' => '',
'summary' => null, 'domains' => null]),
];
}
private function service(?Context $context = null, string $timezone = 'Europe/Berlin'): JobService
{
$context ??= FakeContext::admin();
return new JobService($this->jobs, $this->snapshots, new SettingsService($this->settingsRepo, $context), $context, $timezone);
}
private static function domainAdmin(array $acl = []): FakeContext
{
return FakeContext::domainAdmin(['anna@firma.de', 'bob@firma.de'], ['firma.de'], $acl);
}
/** A job requested at unix time 2000, restores by a domain admin unless $params say otherwise */
private static function job(int $id, string $type, string $state = 'queued', string $domain = '', array $params = [], int $created = 2000): Job
{
$job = new Job($type);
$job->id = $id;
$job->state = $state;
$job->domain = $domain;
$job->params = $params + (($type === 'restore') ? ['requested_role' => 'domainadmin'] : []);
$job->created = $created;
return $job;
}
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());
}
}
private static function restore(array $input = []): array
{
return $input + ['snapshot' => 'bbbbbbbb', 'scope' => 'mailbox', 'mailboxes' => ['anna@firma.de'], 'components' => ['mails', 'aliases']];
}
public function testAdminsQueueOneJobOfEachType(): void
{
$job = $this->service()->create(['type' => 'check']);
$this->assertSame(['check', 'queued', 'admin', 100], [$job->type, $job->state, $job->requestedBy, $job->id]);
$this->active = [self::job(1, 'check', 'running'), self::job(2, 'restore')];
$this->expectUserError(['backup_job_active', 'check'], fn() => $this->service()->create(['type' => 'check']));
$this->assertSame('prune', $this->service()->create(['type' => 'prune'])->type);
$this->expectUserError(['backup_job_type_invalid', 'restore'], fn() => $this->service()->create(['type' => 'restore']));
$this->expectUserError(['backup_job_type_invalid', '&lt;x&gt;'], fn() => $this->service()->create(['type' => '<x>']));
}
public function testJobsNeedAConfiguredRepository(): void
{
$this->settings['password_confirmed'] = '0';
$this->expectUserError('backup_password_not_confirmed', fn() => $this->service()->create(['type' => 'backup']));
// testing the connection needs no confirmation
$this->assertSame('init', $this->service()->create(['type' => 'init'])->type);
$this->settings['password'] = '';
$this->expectUserError('backup_not_configured', fn() => $this->service()->create(['type' => 'init']));
$this->expectUserError('backup_not_configured', fn() => $this->service()->restore(self::restore()));
}
public function testOnlyWritingAdminsQueueJobs(): void
{
foreach ([new Context('admin', 'API', [], false, 'ro'), self::domainAdmin(['syncjobs' => '1']), FakeContext::user('anna@firma.de'), Context::system()] as $context) {
$this->expectUserError('access_denied', fn() => $this->service($context)->create(['type' => 'backup']));
}
$this->assertSame([], $this->inserted);
}
public function testRestoreOfMailboxes(): void
{
$job = $this->service()->restore(self::restore(['mailboxes' => "Anna@firma.de\nbob@firma.de", 'components' => 'password, mails,aliases,mails',
'mail_target' => 'original']));
$this->assertSame(['restore', 'files', 'firma.de', 'admin'], [$job->type, $job->step, $job->domain, $job->requestedBy]);
$this->assertSame(['snapshot' => str_repeat('b', 64), 'snapshot_time' => 2000, 'scope' => 'mailbox', 'domain' => 'firma.de',
'mailboxes' => ['anna@firma.de', 'bob@firma.de'], 'components' => ['mails', 'aliases', 'password'], 'mail_target' => 'original',
'requested_role' => 'admin', 'create_domain' => 0, 'create_mailboxes' => []], $job->params);
}
public function testRestoreOfADomain(): void
{
// the snapshot's mailboxes of the domain; deleted ones are created
$job = $this->service()->restore(self::restore(['scope' => 'domain', 'domain' => 'Firma.de', 'mailboxes' => 'ignored@firma.de']));
$this->assertSame([['anna@firma.de', 'bob@firma.de', 'dora@firma.de'], ['dora@firma.de'], 0, 'folder'],
[$job->params['mailboxes'], $job->params['create_mailboxes'], $job->params['create_domain'], $job->params['mail_target']]);
// snapshots without content: the current mailboxes
$this->expectUserError('backup_mailboxes_empty', fn() => $this->service()->restore(self::restore(['snapshot' => 'aaaaaaaa', 'scope' => 'domain',
'domain' => 'tochter.de'])));
$this->expectUserError(['domain_not_found', 'nix.de'], fn() => $this->service()->restore(self::restore(['scope' => 'domain', 'domain' => 'nix.de'])));
}
public function testDeletedMailboxesAndDomainsAreCreated(): void
{
$job = $this->service()->restore(self::restore(['mailboxes' => ['anna@firma.de', 'dora@firma.de']]));
$this->assertSame([['dora@firma.de'], 0], [$job->params['create_mailboxes'], $job->params['create_domain']]);
$job = $this->service()->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de']));
$this->assertSame(['alt.de', ['x@alt.de'], ['x@alt.de'], 1], [$job->domain, $job->params['mailboxes'], $job->params['create_mailboxes'],
$job->params['create_domain']]);
$this->assertSame(1, $this->service()->restore(self::restore(['mailboxes' => ['x@alt.de']]))->params['create_domain']);
// only mailboxes the snapshot knows, and only from snapshots with content
$this->expectUserError(['backup_mailbox_not_found', 'dora@firma.de'], fn() => $this->service()->restore(self::restore(['snapshot' => 'dddddddd',
'mailboxes' => ['dora@firma.de']])));
$this->expectUserError(['domain_not_found', 'alt.de'], fn() => $this->service()->restore(self::restore(['snapshot' => 'aaaaaaaa', 'scope' => 'domain',
'domain' => 'alt.de'])));
// domain admins create mailboxes in their domains, never domains
$da = self::domainAdmin();
$this->assertSame(['dora@firma.de'], $this->service($da)->restore(self::restore(['mailboxes' => ['dora@firma.de']]))->params['create_mailboxes']);
$this->expectUserError(['domain_not_found', 'alt.de'], fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de'])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['x@alt.de']])));
}
public function testInvalidRestores(): void
{
$cases = [
[['snapshot' => 'ffff'], ['backup_snapshot_not_found', 'ffff']],
[['snapshot' => ''], ['backup_snapshot_not_found', '']],
[['scope' => 'server'], 'backup_scope_invalid'],
[['mailboxes' => []], 'backup_mailboxes_empty'],
[['mailboxes' => ['anna@firma.de', 'gone@firma.de']], ['backup_mailbox_not_found', 'gone@firma.de']],
[['mailboxes' => ['anna@firma.de', 'carl@tochter.de']], 'backup_mailboxes_one_domain'],
[['domain' => 'tochter.de'], 'backup_mailboxes_one_domain'],
[['mailboxes' => array_map(fn(int $i): string => "u$i@firma.de", range(1, 1001))], ['backup_entries_exceeded', 1000]],
[['components' => []], ['backup_components_invalid', '']],
[['components' => ['mails', 'everything']], ['backup_components_invalid', 'everything']],
[['components' => ['policies']], ['backup_components_invalid', 'policies']],
[['components' => ['sogo_public']], ['backup_components_invalid', 'sogo_public']],
[['components' => ['mails', 'sieve_activate']], ['backup_component_requires', 'sieve_activate', 'filters']],
[['mail_target' => 'elsewhere'], 'backup_mail_target_invalid'],
// the snapshot holds tochter.de only
[['snapshot' => 'aaaaaaaa'], ['backup_domain_not_in_snapshot', 'firma.de']],
];
foreach ($cases as [$input, $error]) {
$this->expectUserError($error, fn() => $this->service()->restore(self::restore($input)));
}
// snapshots not made by mailcow have no domain list: the runner reports what is missing
$this->assertNotNull($this->service()->restore(self::restore(['snapshot' => 'cccccccc'])));
}
public function testAMailboxIsRestoredOnceAtATime(): void
{
$this->active = [self::job(1, 'restore', 'running', 'firma.de', ['mailboxes' => ['bob@firma.de']]), self::job(2, 'backup', 'running')];
$this->expectUserError(['backup_restore_active', 'bob@firma.de'], fn() => $this->service()->restore(self::restore(['mailboxes' => ['anna@firma.de', 'bob@firma.de']])));
$this->assertCount(1, [$this->service()->restore(self::restore())]);
}
public function testDomainAdminsRestoreTheirMailboxesWithinTheirAcl(): void
{
$da = self::domainAdmin(['syncjobs' => '1', 'protocol_access' => '1']);
$job = $this->service($da)->restore(self::restore(['components' => ['mails', 'sogo', 'syncjobs', 'settings']]));
$this->assertSame(['domainadmin', 'da', 1, 0, 0], [$job->params['requested_role'], $job->requestedBy, $job->params['acl']['syncjobs'],
$job->params['acl']['unlimited_quota'], $job->params['acl']['app_passwds']]);
$this->assertSame([1, ['firma.de']], [$job->params['acl']['protocol_access'], $job->params['allowed_domains']]);
$this->assertSame('firma.de', $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'firma.de']))->domain);
// components he could not manage himself
$this->expectUserError(['backup_component_denied', 'app_passwords'], fn() => $this->service($da)->restore(self::restore(['components' => ['app_passwords']])));
$this->expectUserError(['backup_component_denied', 'groups'], fn() => $this->service($da)->restore(self::restore(['components' => ['groups']])));
$this->expectUserError(['backup_component_denied', 'sieve_activate'], fn() => $this->service($da)->restore(self::restore(['components' => ['sieve_activate']])));
$retention = self::domainAdmin(['retention' => '1']);
$this->assertNotNull($this->service($retention)->restore(self::restore(['components' => ['retention_rules']])));
$this->expectUserError(['backup_component_denied', 'mail_policies'], fn() => $this->service($retention)->restore(self::restore(['components' => ['mail_policies']])));
// snapshots made before his domain was created hold a former domain of that name
$this->expectUserError(['backup_snapshot_not_found', 'dddddddd'], fn() => $this->service($da)->restore(self::restore(['snapshot' => 'dddddddd'])));
// mailboxes and domains of others
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['carl@tochter.de']])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'tochter.de'])));
}
public function testUsersAndReadOnlyKeysCannotRestore(): void
{
foreach ([FakeContext::user('anna@firma.de'), new Context('admin', 'API', [], false, 'ro'), Context::system()] as $context) {
$this->expectUserError('access_denied', fn() => $this->service($context)->restore(self::restore()));
}
$this->assertSame([], $this->inserted);
}
public function testVisibilityOfJobs(): void
{
$this->stored = [1 => self::job(1, 'backup', 'done'), 2 => self::job(2, 'restore', 'done', 'firma.de', ['mailboxes' => ['anna@firma.de']]),
3 => self::job(3, 'restore', 'done', 'tochter.de'), 4 => self::job(4, 'restore', 'done', 'firma.de', [], 1400)];
$da = self::domainAdmin();
$this->assertNotNull($this->service()->get(1));
$this->assertNotNull($this->service(new Context('admin', 'API', [], false, 'ro'))->get(3));
$this->assertSame([null, 2, null], [$this->service($da)->get(1), $this->service($da)->get(2)?->id, $this->service($da)->get(3)]);
// a restore of a former domain of the name (before firma.de was created at 1500)
$this->assertNull($this->service($da)->get(4));
$this->assertSame(4, $this->service()->get(4)?->id);
$this->assertNull($this->service(FakeContext::user('anna@firma.de'))->get(2));
// the list is filtered in SQL: the repository gets the caller and the limit
$this->jobs->expects($this->exactly(2))->method('visible')->willReturnCallback(function (Context $context, int $limit): array {
$this->assertContains($limit, [100, 1000]);
return [];
});
$this->service($da)->list();
$this->service()->list(5000);
}
public function testQueuedAndRunningJobsCanBeCancelled(): void
{
$mails = self::job(5, 'restore', 'running', 'firma.de');
$mails->step = 'mails_running';
$files = self::job(6, 'restore', 'running', 'firma.de');
$files->step = 'files';
$this->stored = [1 => self::job(1, 'backup'), 2 => self::job(2, 'backup', 'running'), 3 => self::job(3, 'restore', 'queued', 'firma.de'),
4 => self::job(4, 'restore', 'queued', 'tochter.de'), 5 => $mails, 6 => $files, 7 => self::job(7, 'check', 'done'),
8 => self::job(8, 'restore', 'queued', 'firma.de', ['requested_role' => 'admin'])];
$this->jobs->method('cancel')->willReturn(true);
$this->assertSame('cancelled', $this->service()->cancel(1)->state);
$this->assertSame('cancelled', $this->service()->cancel(2)->state);
$this->assertSame('cancelled', $this->service()->cancel(6)->state);
// the dovecot step runs to its end
$this->expectUserError(['backup_job_not_cancellable', 5], fn() => $this->service()->cancel(5));
$this->expectUserError(['backup_job_not_cancellable', 7], fn() => $this->service()->cancel(7));
$this->assertSame('cancelled', $this->service(self::domainAdmin())->cancel(3)->state);
$this->expectUserError('access_denied', fn() => $this->service(self::domainAdmin())->cancel(4));
// a restore an admin requested is not the domain admin's to cancel
$this->expectUserError('access_denied', fn() => $this->service(self::domainAdmin())->cancel(8));
$this->assertSame('cancelled', $this->service()->cancel(8)->state);
$this->expectUserError('access_denied', fn() => $this->service(self::domainAdmin())->cancel(1));
$this->expectUserError('access_denied', fn() => $this->service(new Context('admin', 'API', [], false, 'ro'))->cancel(1));
$this->expectUserError('access_denied', fn() => $this->service()->cancel(99));
}
public function testTheRunnerMayHaveFinishedMeanwhile(): void
{
$this->stored = [1 => self::job(1, 'backup', 'running')];
$this->jobs->method('cancel')->willReturn(false);
$this->expectUserError(['backup_job_not_cancellable', 1], fn() => $this->service()->cancel(1));
}
public function testContentOfASnapshot(): void
{
$this->assertSame(['snapshot' => str_repeat('b', 64), 'time' => 2000, 'available' => 1, 'domains' => [
['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => 3, 'missing' => 1],
['domain' => 'tochter.de', 'exists' => 1, 'mailboxes' => 1, 'missing' => 0],
['domain' => 'alt.de', 'exists' => 0, 'mailboxes' => 1, 'missing' => 1],
]], $this->service()->content('bbbbbbbb'));
$this->assertSame(['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => [['username' => 'anna@firma.de', 'name' => 'Anna', 'exists' => 1],
['username' => 'bob@firma.de', 'name' => 'Bob', 'exists' => 1], ['username' => 'dora@firma.de', 'name' => 'Dora', 'exists' => 0]]],
$this->service()->domainContent(str_repeat('b', 64), 'Firma.de'));
$this->assertSame([['username' => 'x@alt.de', 'name' => 'X', 'exists' => 0]], $this->service()->domainContent('bbbbbbbb', 'alt.de')['mailboxes']);
// without content: the manifest's domains with their current mailboxes
$this->assertSame(['snapshot' => str_repeat('a', 64), 'time' => 1000, 'available' => 0, 'domains' => [
['domain' => 'tochter.de', 'exists' => 1, 'mailboxes' => 1, 'missing' => 0]]], $this->service()->content('aaaaaaaa'));
$this->assertSame([['username' => 'carl@tochter.de', 'name' => 'Carl', 'exists' => 1]], $this->service()->domainContent('aaaaaaaa', 'tochter.de')['mailboxes']);
$this->assertSame([], $this->service()->content('nope'));
$this->assertSame([], $this->service()->domainContent('bbbbbbbb', 'nix.de'));
}
public function testSearchInASnapshot(): void
{
$names = fn(array $result): array => array_map(fn(array $domain): array => [$domain['domain'], $domain['exists'],
array_map(fn(array $m): string => $m['username'] . ($m['exists'] ? '' : ' (deleted)'), $domain['mailboxes'])], $result['domains']);
// local part, address and name; a matching domain comes with all its mailboxes
$this->assertSame([['firma.de', 1, ['dora@firma.de (deleted)']]], $names($this->service()->search('bbbbbbbb', 'DOR')));
$this->assertSame([['firma.de', 1, ['bob@firma.de']]], $names($this->service()->search('bbbbbbbb', 'Bob')));
$this->assertSame([['alt.de', 0, ['x@alt.de (deleted)']]], $names($this->service()->search('bbbbbbbb', 'alt')));
// "x@": local part only, "@d": the mailboxes of matching domains, "x@d": the address
$this->assertSame([['firma.de', 1, ['anna@firma.de']]], $names($this->service()->search('bbbbbbbb', 'ann@')));
$this->assertSame([['tochter.de', 1, ['carl@tochter.de']]], $names($this->service()->search('bbbbbbbb', '@tochter')));
$this->assertSame([['firma.de', 1, ['anna@firma.de']]], $names($this->service()->search('bbbbbbbb', 'na@firma.de')));
$this->assertSame([], $names($this->service()->search('bbbbbbbb', 'x@firma')));
$this->assertSame(['domains' => [], 'truncated' => 0], $this->service()->search('bbbbbbbb', '@'));
$this->assertSame(['domains' => [], 'truncated' => 0], $this->service()->search('bbbbbbbb', ''));
$this->assertSame(0, $this->service()->search('bbbbbbbb', 'a')['truncated']);
// snapshots without content: the current mailboxes
$this->assertSame([['tochter.de', 1, ['carl@tochter.de']]], $names($this->service()->search('aaaaaaaa', 'carl')));
// domain admins: their domains of newer snapshots only
$da = self::domainAdmin();
$this->assertSame([['firma.de', 1, ['anna@firma.de', 'bob@firma.de', 'dora@firma.de (deleted)']]], $names($this->service($da)->search('bbbbbbbb', '.de')));
$this->assertSame([], $this->service($da)->search('dddddddd', 'anna'));
$this->assertSame([], $this->service(FakeContext::user('anna@firma.de'))->search('bbbbbbbb', 'anna'));
$this->assertSame([], $this->service()->search('nope', 'anna'));
}
public function testSearchStopsAt500Mailboxes(): void
{
$mailboxes = [];
foreach (range(1, 600) as $i) {
$mailboxes["user$i@gross.de"] = "User $i";
}
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturn(Snapshot::fromRow(['id' => str_repeat('e', 64), 'short_id' => 'eeeeeeee', 'time' => 3000,
'domains' => '{"gross.de": null, "z.de": null}', 'content' => gzencode(json_encode(['domains' => ['gross.de' => ['created' => null, 'mailboxes' => $mailboxes],
'z.de' => ['created' => null, 'mailboxes' => ['user@z.de' => 'Z']]]]))]));
$result = $this->service()->search('eeeeeeee', 'user');
$this->assertSame([1, 1, 500], [$result['truncated'], count($result['domains']), count($result['domains'][0]['mailboxes'])]);
}
public function testJobsOfOneType(): void
{
$types = [];
$this->jobs->method('visible')->willReturnCallback(function (Context $context, int $limit, ?string $type) use (&$types): array {
$types[] = [$limit, $type];
return [];
});
$this->service()->list(0, 'restore');
$this->service()->list(20, '');
$this->service()->list(20, null);
$this->assertSame([], $this->service()->list(20, 'everything'));
$this->assertSame([[100, 'restore'], [20, null], [20, null]], $types);
}
public function testDomainAdminsSeeTheirDomainsOfNewerSnapshotsOnly(): void
{
$da = self::domainAdmin();
$this->assertSame([['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => 3, 'missing' => 1]], $this->service($da)->content('bbbbbbbb')['domains']);
$this->assertSame([], $this->service($da)->domainContent('bbbbbbbb', 'alt.de'));
$this->assertSame([], $this->service($da)->domainContent('bbbbbbbb', 'tochter.de'));
$this->assertSame([], $this->service($da)->content('dddddddd'));
$this->assertSame([], $this->service(FakeContext::user('anna@firma.de'))->content('bbbbbbbb'));
}
public function testSnapshotsForDomainAdminsShowOnlyTheirDomains(): void
{
$this->assertCount(4, $this->service()->snapshots());
$this->assertSame(['data_added' => 5], $this->service()->snapshots()[0]['summary']);
// snapshot d holds a former firma.de (older than the domain)
$this->assertSame([['id' => str_repeat('b', 64), 'short_id' => 'bbbbbbbb', 'time' => 2000, 'domains' => ['firma.de'], 'mode' => 'all']],
$this->service(self::domainAdmin())->snapshots());
$this->assertSame([], $this->service(FakeContext::user('anna@firma.de'))->snapshots());
}
public function testStatusAndNextRun(): void
{
$daily = '[{"days":[0,1,2,3,4,5,6],"time":"02:30"}]';
$this->settings += ['enabled' => '1', 'schedule' => $daily, 'state_runner_seen' => '1000', 'state_repo_size' => '4096',
'state_schedule_since' => '1794000000', 'state_schedule' => $daily];
$this->jobs->method('latestOfEachType')->willReturn(['backup' => self::job(7, 'backup', 'done')]);
$scheduled = [];
$this->jobs->method('scheduledSince')->willReturnCallback(function (int $since) use (&$scheduled): bool {
return in_array($since, $scheduled, true);
});
// 2026-11-08 01:00 Berlin, armed since 2026-11-06: yesterday's run (2026-11-07 02:30) is due until it was made
$status = $this->service()->status(1794096000);
$this->assertSame(1794015000, $status['next_run']);
$this->assertSame([1, 1, 1794095000, 0, 4096, 4, 2000, 7, null, null], [$status['enabled'], $status['configured'], $status['runner_age'],
$status['runner_ok'], $status['repo_size'], $status['snapshots'], $status['latest_snapshot'], $status['jobs']['backup']['id'],
$status['jobs']['check'], $status['secrets_error']]);
$scheduled[] = 1794015000;
$this->assertSame(1794101400, $this->service()->status(1794096000)['next_run']);
// 03:00: today's run is due until the schedule made it, then tomorrow
$this->assertSame(1794101400, $this->service()->status(1794103200)['next_run']);
$scheduled[] = 1794101400;
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
// switched on (armed) after today's time: no catch-up, tomorrow
$this->settings['state_schedule_since'] = '1794102000';
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
// not armed yet, or armed for another time: the next schedule time
$this->settings['state_schedule_since'] = '';
$this->assertSame(1794101400, $this->service()->status(1794096000)['next_run']);
$this->settings['state_schedule_since'] = '1794000000';
$this->settings['state_schedule'] = '[{"days":[0,1,2,3,4,5,6],"time":"01:00"}]';
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
$this->settings['state_schedule'] = $daily;
$this->assertSame(86400, $this->service()->status(1794103200)['schedule_gap']);
// Monday, Wednesday and Friday at 02:30 and 14:00: from Sunday 2026-11-08 03:00 Monday 02:30 (due until made), then
// Monday 14:00, from Monday 15:00 Wednesday 02:30; the gap from Friday 14:00 to Monday 02:30 (60.5 hours)
$this->settings['schedule'] = '[{"days":[1,3,5],"time":"02:30"},{"days":[1,3,5],"time":"14:00"}]';
$this->settings['state_schedule'] = $this->settings['schedule'];
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
$this->assertSame(1794187800, $this->service()->status(1794189600)['next_run']);
$scheduled[] = 1794187800;
$this->assertSame(1794229200, $this->service()->status(1794189600)['next_run']);
$scheduled[] = 1794229200;
$this->assertSame(1794360600, $this->service()->status(1794232800)['next_run']);
$this->assertSame(217800, $this->service()->status(1794232800)['schedule_gap']);
// no entry: no run
$this->settings['schedule'] = '[]';
$this->assertNull($this->service()->status(1794232800)['next_run']);
$this->assertNull($this->service()->status(1794232800)['schedule_gap']);
// the schedule is off
$this->settings['enabled'] = '0';
$this->assertNull($this->service()->status(1794103200)['next_run']);
$this->assertSame([], $this->service(self::domainAdmin())->status());
// the runner cannot decrypt the credentials
$this->settings['state_secrets_error'] = 'credentials must be entered again';
$this->assertSame('credentials must be entered again', $this->service()->status(1794103200)['secrets_error']);
// RESTIC_BACKUP_MODE once the runner reported it
$this->assertNull($this->service()->status(1794103200)['mode']);
$this->settings['state_backup_mode'] = 'data';
$this->assertSame('data', $this->service()->status(1794103200)['mode']);
}
public function testDomainAdminsSeeJobsRestricted(): void
{
$job = self::job(9, 'restore', 'done', 'firma.de', ['requested_role' => 'admin']);
$job->result = ['mailboxes' => ['anna@firma.de' => ['aliases' => ['added' => 0, 'skipped' => 0,
'errors' => ['info@firma.de: goto bob@tochter.de dropped (no mailbox or alias)', 'x@firma.com: in use']]]]];
$job->error = 'restic: Fatal: unable to open repository at sftp:u1@backup.example.org:/srv/repo';
$job->log = "09:00 restore from s3:https://s3.example.org/bucket by admin@tochter.de\n09:01 anna@firma.de done";
$this->jobs->method('aliasDomainsOf')->willReturn(['firma.com']);
$view = $this->service(self::domainAdmin())->view($job, true);
$this->assertSame(['info@firma.de: goto … dropped (no mailbox or alias)', 'x@firma.com: in use'],
$view['result']['mailboxes']['anna@firma.de']['aliases']['errors']);
$this->assertSame('restic: Fatal: unable to open repository at …', $view['error']);
$this->assertSame("09:00 restore from … by …\n09:01 anna@firma.de done", $view['log']);
// admins see everything
$this->assertSame($job->error, $this->service()->view($job, true)['error']);
}
public function testADomainAdminRestoresADomainOnceAtATime(): void
{
$this->active = [self::job(1, 'restore', 'running', 'firma.de', ['mailboxes' => ['bob@firma.de']])];
$da = self::domainAdmin();
$this->expectUserError(['backup_restore_domain_active', 'firma.de'], fn() => $this->service($da)->restore(self::restore()));
// admins may queue restores of other mailboxes of the domain
$this->assertSame('firma.de', $this->service()->restore(self::restore())->domain);
}
public function testSnapshotsRecordTheDomainsCreationTime(): void
{
// snapshot f holds the current firma.de (created 1500), snapshot g a former one; content without manifest entry
// and manifest without content (its export failed) both count
$content = fn(int $created): string => gzencode(json_encode(['domains' => ['firma.de' => ['created' => $created,
'mailboxes' => ['anna@firma.de' => 'Anna', 'old@firma.de' => 'Old']]]]));
$this->snapshots = $this->createMock(SnapshotRepository::class);
$list = [
Snapshot::fromRow(['id' => str_repeat('f', 64), 'short_id' => 'ffffffff', 'time' => 1000, 'domains' => '{"firma.de": null, "tochter.de": null}',
'content' => $content(1500)]),
Snapshot::fromRow(['id' => str_repeat('9', 64), 'short_id' => '99999999', 'time' => 3000, 'domains' => '{"firma.de": null}', 'content' => $content(900)]),
];
$this->snapshots->method('all')->willReturn($list);
$this->snapshots->method('find')->willReturnCallback(fn(string $id): ?Snapshot => array_values(array_filter($list, fn(Snapshot $s): bool => $s->shortId === $id))[0] ?? null);
$da = self::domainAdmin();
// the recorded time decides, not the snapshot time
$this->assertSame(['ffffffff'], array_column($this->service($da)->snapshots(), 'short_id'));
$this->assertSame([], $this->service($da)->content('99999999'));
$this->assertSame([['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => 2, 'missing' => 1],
['domain' => 'tochter.de', 'exists' => 1, 'mailboxes' => 1, 'missing' => 0]], $this->service()->content('ffffffff')['domains']);
$this->assertSame(['old@firma.de'], $this->service($da)->restore(self::restore(['snapshot' => 'ffffffff', 'mailboxes' => ['old@firma.de']]))->params['create_mailboxes']);
}
public function testTheSnapshotListNeedsNoContent(): void
{
// the runner writes {domain: creation time} into the row: a list of snapshots without their content is enough
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->expects($this->once())->method('all')->with(false)->willReturn([
Snapshot::fromRow(['id' => str_repeat('f', 64), 'short_id' => 'ffffffff', 'time' => 1000, 'domains' => '{"firma.de": 1500, "tochter.de": 99}']),
Snapshot::fromRow(['id' => str_repeat('9', 64), 'short_id' => '99999999', 'time' => 3000, 'domains' => '{"firma.de": 900}']),
]);
$list = $this->service(FakeContext::domainAdmin(['anna@firma.de'], ['firma.de', 'tochter.de']))->snapshots();
// firma.de of snapshot f is the current one, tochter.de is not (created 100 now); snapshot 9 holds a former firma.de
$this->assertSame([['ffffffff', ['firma.de']]], array_map(fn(array $s): array => [$s['short_id'], $s['domains']], $list));
$this->assertSame([1500, ['firma.de', 'tochter.de']], [Snapshot::fromRow(['id' => 'x', 'short_id' => 'x', 'time' => 1,
'domains' => '{"firma.de": 1500, "tochter.de": null}'])->createdOf('firma.de'), Snapshot::fromRow(['id' => 'x', 'short_id' => 'x',
'time' => 1, 'domains' => '{"firma.de": 1500, "tochter.de": null}'])->domains]);
}
public function testADeletedDomainWithoutMailboxesCanBeRestored(): void
{
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturn(Snapshot::fromRow(['id' => str_repeat('e', 64), 'short_id' => 'eeeeeeee', 'time' => 3000,
'domains' => '{"leer.de": null}', 'content' => gzencode(json_encode(['domains' => ['leer.de' => ['created' => 100, 'mailboxes' => []]]]))]));
$job = $this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'scope' => 'domain', 'domain' => 'leer.de', 'components' => ['aliases']]));
$this->assertSame([[], [], 1], [$job->params['mailboxes'], $job->params['create_mailboxes'], $job->params['create_domain']]);
}
public function testSnapshotsWithoutMailsRestoreNoMailData(): void
{
$content = gzencode(json_encode(['domains' => ['firma.de' => ['created' => 1500, 'mailboxes' => ['anna@firma.de' => 'Anna']]]]));
$snapshot = fn(string $tags): Snapshot => Snapshot::fromRow(['id' => str_repeat('e', 64), 'short_id' => 'eeeeeeee', 'time' => 3000,
'tags' => $tags, 'domains' => '{"firma.de": null}', 'content' => $content]);
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturnOnConsecutiveCalls($snapshot('mailcow,instance:x,mode:config'),
$snapshot('mailcow,instance:x,mode:config'), $snapshot('mailcow,instance:x,mode:data'));
$this->expectUserError(['backup_components_no_mails', 'mails, imap_acl'],
fn() => $this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'components' => ['mails', 'imap_acl', 'aliases']])));
$job = $this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'components' => ['aliases', 'filters', 'password']]));
$this->assertSame(['aliases', 'filters', 'password'], $job->params['components']);
// data snapshots lack the mailcow directory only
$this->assertNotNull($this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'mailboxes' => ['bob@firma.de']])));
$this->assertSame(['all', 'config', 'data'], [$this->snapshotList()[0]->mode(), $snapshot('mailcow,mode:config')->toArray()['mode'],
$snapshot('mode:data')->mode()]);
}
public function testTheMailComponentsOfTheUiMatch(): void
{
$script = (string)file_get_contents(__DIR__ . '/../../../../data/web/js/site/backup.js');
$this->assertSame(1, preg_match('/MAIL_COMPONENTS\s*=\s*\[([^\]]*)\]/', $script, $match), 'MAIL_COMPONENTS not found in backup.js');
preg_match_all("/'(\w+)'/", $match[1], $items);
$this->assertSame(JobService::MAIL_COMPONENTS, $items[1]);
}
public function testTheAclMapOfTheUiMatches(): void
{
$script = (string)file_get_contents(__DIR__ . '/../../../../data/web/js/site/backup.js');
$this->assertSame(1, preg_match('/COMPONENT_ACL\s*=\s*\{([^}]*)\}/', $script, $match), 'COMPONENT_ACL not found in backup.js');
preg_match_all("/(\w+)\s*:\s*'(\w+)'/", $match[1], $pairs, PREG_SET_ORDER);
$ui = array_column($pairs, 2, 1);
$php = (new \ReflectionClassConstant(JobService::class, 'COMPONENT_ACL'))->getValue();
ksort($ui);
ksort($php);
$this->assertSame($php, $ui);
}
}
@@ -0,0 +1,654 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Service\GroupMembers;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\Sogo\Service\SogoService;
use Mailcow\Tests\Backup\Support\FakeRestoreRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class RestoreServiceTest extends TestCase
{
private const ALL = ['mails', 'sogo', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'filters', 'sieve_activate',
'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'];
private const MIB = 1048576;
private FakeRestoreRepository $repo;
private JobRepository&MockObject $jobs;
private GroupMembers&MockObject $groups;
private LegacyDomain&MockObject $legacy;
/** @var array<string, string> rate limits in Redis */
private array $rateLimits = [];
private SogoService&MockObject $sogo;
private DovecotService&MockObject $dovecot;
private ?Job $job = null;
/** @var array[] memberships added through the group module: [role of the caller, group id, username] */
private array $groupUpdates = [];
private array $sogoUsers = [];
private array $authUsers = [];
protected function setUp(): void
{
$attributes = json_encode(['imap_access' => '1', 'pop3_access' => '1', 'relayhost' => '0', 'passwd_update' => '2026-01-01 10:00:00',
'mailbox_format' => 'maildir:', 'tls_enforce_in' => '0']);
$this->repo = new FakeRestoreRepository([
'domain' => [
['domain' => 'firma.de', 'aliases' => 400, 'mailboxes' => 10, 'quota' => 10240, 'maxquota' => 2048, 'active' => 1],
['domain' => 'other.de', 'aliases' => 400, 'quota' => 10240, 'maxquota' => 10240, 'active' => 1],
],
'alias_domain' => [['alias_domain' => 'firma.com', 'target_domain' => 'firma.de', 'active' => 1]],
'mailbox' => [
self::mailbox('anna@firma.de', ['attributes' => $attributes, 'quota' => 1024 * self::MIB]),
self::mailbox('bob@firma.de', ['quota' => 1024 * self::MIB]),
self::mailbox('room@firma.de', ['kind' => 'location']),
self::mailbox('carl@other.de'),
],
'alias' => [
['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 2, 'address' => 'bob@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 3, 'address' => 'info@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 4, 'address' => 'team@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1, 'group_id' => 7],
],
'mailbox_group' => [['id' => 7, 'domain' => 'firma.de', 'name' => 'Team'], ['id' => 9, 'domain' => 'firma.de', 'name' => 'Sales']],
'mail_policy' => [['id' => 3, 'domain' => 'firma.de', 'name' => 'Strict']],
'retention_rule' => [['id' => 5, 'domain' => 'firma.de', 'name' => 'Papierkorb']],
'imapsync_source' => [
['id' => 1, 'host1' => 'imap.old.de', 'port1' => 993, 'scope' => 'all'],
['id' => 2, 'host1' => 'imap.legacy.de', 'port1' => 143, 'scope' => 'domain'],
],
'imapsync_source_domain' => [['source_id' => 2, 'domain' => 'firma.de']],
'relayhosts' => [['id' => 1, 'hostname' => 'relay.example.org']],
'user_acl' => [['username' => 'anna@firma.de', 'spam_alias' => 1, 'syncjobs' => 0, 'pushover' => 1]],
]);
$this->jobs = $this->createMock(JobRepository::class);
$this->jobs->method('find')->willReturnCallback(fn(int $id): ?Job => ($this->job !== null && $this->job->id === $id) ? $this->job : null);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('add')->willReturnCallback(function (Context $context, int $id, string $username): void {
$this->groupUpdates[] = [$context->role, $id, $username];
});
$this->legacy = $this->createMock(LegacyDomain::class);
$this->legacy->method('rateLimits')->willReturnCallback(fn(array $objects): array => array_intersect_key($this->rateLimits, array_flip($objects)));
$this->legacy->method('setRateLimit')->willReturnCallback(function (string $object, string $value): void {
$this->rateLimits[$object] = $value;
});
$this->sogo = $this->createMock(SogoService::class);
$this->sogo->method('refreshStaticView')->willReturnCallback(function (array $users): void {
$this->sogoUsers = $users;
});
$this->dovecot = $this->createMock(DovecotService::class);
$this->dovecot->method('flushAuthCache')->willReturnCallback(function (array $users): void {
$this->authUsers = $users;
});
$this->job(self::ALL);
}
private static function mailbox(string $username, array $values = []): array
{
[$local, $domain] = explode('@', $username);
return $values + ['username' => $username, 'password' => 'HASH-NOW', 'name' => $local, 'quota' => 0, 'local_part' => $local,
'domain' => $domain, 'attributes' => '{}', 'custom_attributes' => '{}', 'kind' => '', 'authsource' => 'mailcow', 'active' => 1];
}
private function job(array $components, string $role = 'admin', array $acl = [], string $state = 'running', array $params = []): void
{
$this->job = new Job('restore');
$this->job->id = 12;
$this->job->state = $state;
$this->job->domain = 'firma.de';
$this->job->params = $params + ['snapshot' => 'abc', 'scope' => 'mailbox', 'domain' => 'firma.de', 'mailboxes' => ['anna@firma.de'],
'components' => $components, 'mail_target' => 'folder', 'requested_role' => $role] + ($role === 'admin' ? [] : ['acl' => $acl]);
}
/** A domain admin account of firma.de with these da_acl flags (all others 0) */
private function domainAdmin(string $username, array $acl): void
{
$this->repo->tables['admin'][] = ['username' => $username, 'superadmin' => 0, 'active' => 1];
$this->repo->tables['domain_admins'][] = ['id' => 1, 'username' => $username, 'domain' => 'firma.de', 'active' => 1];
$this->repo->tables['da_acl'][] = ['username' => $username] + $acl + array_fill_keys(array_diff($this->repo->columns('da_acl'), ['username']), 0);
}
private function service(bool $externalDisabled = false): RestoreService
{
return new RestoreService($this->repo, $this->jobs, $this->groups, $this->sogo, $this->dovecot, $this->legacy, $externalDisabled);
}
/** The mailbox section of anna's export, as the runner sends it */
private static function backup(array $override = []): array
{
$attributes = json_encode(['imap_access' => '0', 'pop3_access' => '1', 'relayhost' => '1', 'passwd_update' => '2025-05-05 05:05:05',
'mailbox_format' => 'sdbox:', 'tls_enforce_in' => '1']);
return $override + [
'mailbox' => self::mailbox('anna@firma.de', ['password' => 'HASH-OLD', 'quota' => 2048 * self::MIB, 'attributes' => $attributes,
'custom_attributes' => '{"dept": "IT"}']) + ['tags' => ['vip', 'it']],
'user_acl' => ['username' => 'anna@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 1, 'gone_column' => 1],
'pushover' => ['username' => 'anna@firma.de', 'key' => 'K', 'token' => 'T', 'attributes' => '{}', 'title' => 'Mail', 'active' => 1],
'ratelimit' => '20 / 1h',
'aliases' => [
['id' => 30, 'address' => 'info@firma.de', 'goto' => 'bob@firma.de,anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 31, 'address' => 'sales@firma.com', 'goto' => 'anna@firma.de,gone@firma.de,partner@example.org,room@firma.de',
'domain' => 'firma.com', 'active' => 1, 'public_comment' => 'Vertrieb', 'old_column' => 'x'],
['id' => 32, 'address' => 'team@firma.de', 'goto' => 'anna@firma.de,bob@firma.de', 'domain' => 'firma.de', 'group_id' => 7],
['id' => 33, 'address' => 'help@other.de', 'goto' => 'anna@firma.de', 'domain' => 'other.de', 'active' => 1],
],
'sender_acl' => [
['id' => 1, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'info@firma.de', 'external' => 0, 'group_id' => null],
['id' => 2, 'logged_in_as' => 'anna@firma.de', 'send_as' => '@firma.com', 'external' => 0, 'group_id' => null],
['id' => 3, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'gone@firma.de', 'external' => 0, 'group_id' => null],
['id' => 4, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'team@firma.de', 'external' => 0, 'group_id' => 7],
['id' => 5, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'anna@private.example', 'external' => 1, 'group_id' => null],
],
'sender_acl_granted' => [
['id' => 6, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'anna@firma.de', 'external' => 0, 'group_id' => null],
['id' => 7, 'logged_in_as' => 'ghost@firma.de', 'send_as' => 'anna@firma.de', 'external' => 0, 'group_id' => null],
],
'groups' => [
['id' => 7, 'name' => 'Team', 'domain' => 'firma.de', 'source' => 'manual'],
['id' => 99, 'name' => 'Sales', 'domain' => 'firma.de', 'source' => 'manual'],
['id' => 98, 'name' => 'Gone', 'domain' => 'firma.de', 'source' => 'manual'],
['id' => 97, 'name' => 'Synced', 'domain' => 'firma.de', 'source' => 'idp'],
],
'mail_policies' => [['id' => 3, 'name' => 'Strict', 'domain' => 'firma.de']],
'retention_rules' => [['id' => 77, 'name' => 'Papierkorb', 'domain' => 'firma.de'], ['id' => 78, 'name' => 'Weg', 'domain' => 'firma.de']],
'sieve_filters' => [
['id' => 4, 'username' => 'anna@firma.de', 'script_desc' => 'Spam', 'script_name' => 'active', 'script_data' => 'discard;', 'filter_type' => 'prefilter'],
['id' => 5, 'username' => 'anna@firma.de', 'script_desc' => 'Old', 'script_name' => 'inactive', 'script_data' => 'keep;', 'filter_type' => 'postfilter'],
],
'filterconf' => [
['prefid' => 1, 'object' => 'anna@firma.de', 'option' => 'whitelist_from', 'value' => 'boss@example.org'],
['prefid' => 2, 'object' => 'anna@firma.de', 'option' => 'highspamlevel', 'value' => '12'],
],
'spamalias' => [
['address' => 'abc123@firma.de', 'goto' => 'anna@firma.de', 'description' => 'Shop', 'validity' => time() + 3600, 'permanent' => 0],
['address' => 'old999@firma.de', 'goto' => 'anna@firma.de', 'description' => 'Old', 'validity' => time() - 3600, 'permanent' => 0],
['address' => 'bob@firma.de', 'goto' => 'anna@firma.de', 'description' => 'Taken', 'validity' => 0, 'permanent' => 1],
['address' => 'xyz@other.de', 'goto' => 'anna@firma.de', 'description' => 'Foreign', 'validity' => 0, 'permanent' => 1],
],
'bcc_maps' => [['id' => 1, 'local_dest' => 'anna@firma.de', 'bcc_dest' => 'archive@example.org', 'domain' => 'firma.de', 'type' => 'rcpt', 'active' => 1]],
'imapsync' => [
['id' => 40, 'user2' => 'anna@firma.de', 'source_id' => 1, 'user1' => 'anna@old.de', 'password1' => 'pw', 'maxage' => 30,
'source_host1' => 'imap.old.de', 'source_port1' => 993, 'is_running' => 1, 'last_run' => '2026-01-01 00:00:00', 'prio' => 9, 'active' => 1],
['id' => 41, 'user2' => 'anna@firma.de', 'source_id' => 55, 'user1' => 'anna@legacy.de', 'password1' => 'pw',
'source_host1' => 'imap.legacy.de', 'source_port1' => 143, 'active' => 1],
['id' => 42, 'user2' => 'anna@firma.de', 'source_id' => 56, 'user1' => 'anna@gone.de', 'password1' => 'pw',
'source_host1' => 'imap.gone.de', 'source_port1' => 993, 'active' => 1],
],
'app_passwd' => [['id' => 8, 'name' => 'Phone', 'mailbox' => 'anna@firma.de', 'domain' => 'firma.de', 'password' => 'APPHASH', 'imap_access' => 1, 'active' => 1]],
'tfa' => [['id' => 6, 'key_id' => 'Phone', 'username' => 'anna@firma.de', 'authmech' => 'totp', 'secret' => 'SECRET', 'active' => 1]],
'fido2' => [['username' => 'anna@firma.de', 'friendlyName' => 'Key', 'rpId' => 'mail.firma.de', 'credentialPublicKey' => 'pk',
'credentialId' => ['base64' => base64_encode("\x00\xff\x10")], 'AAGUID' => ['base64' => base64_encode("\xfe\x01")], 'active' => 1]],
];
}
private function merge(array $data = [], string $username = 'anna@firma.de'): array
{
return $this->service()->merge(12, $username, $data ?: self::backup());
}
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());
}
}
private function alias(string $address): ?array
{
return $this->repo->rows('alias', ['address' => $address])[0] ?? null;
}
public function testMissingThingsAreAddedAndNothingIsDeleted(): void
{
$this->repo->tables['sieve_filters'][] = ['id' => 1, 'username' => 'anna@firma.de', 'script_desc' => 'Mine', 'script_name' => 'active',
'script_data' => 'stop;', 'filter_type' => 'prefilter'];
$this->repo->tables['filterconf'][] = ['prefid' => 9, 'object' => 'anna@firma.de', 'option' => 'highspamlevel', 'value' => '20'];
$result = $this->merge();
$this->assertSame(['aliases', 'sender_acl', 'mail_policies', 'retention_rules', 'filters', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa',
'settings', 'password', 'groups'], array_keys($result));
// aliases: anna back into info@, sales@ created with the gotos that exist now, group address and foreign domain left alone
$this->assertSame('bob@firma.de,anna@firma.de', $this->alias('info@firma.de')['goto']);
$sales = $this->alias('sales@firma.com');
$this->assertSame(['anna@firma.de,partner@example.org', 'firma.com', 'Vertrieb', null], [$sales['goto'], $sales['domain'], $sales['public_comment'], $sales['group_id']]);
$this->assertSame('bob@firma.de', $this->alias('team@firma.de')['goto']);
$this->assertSame('anna@firma.de', $this->alias('help@other.de')['goto']);
$this->assertSame(['added' => 3, 'skipped' => 1], array_slice($result['aliases'], 0, 2));
$this->assertSame(['sales@firma.com: goto gone@firma.de dropped (no mailbox or alias)', 'sales@firma.com: goto room@firma.de dropped (no mailbox or alias)'],
$result['aliases']['errors']);
// sender_acl: both directions, only for what exists, group rows never
$this->assertEqualsCanonicalizing([['anna@firma.de', 'info@firma.de', 0], ['anna@firma.de', '@firma.com', 0], ['anna@firma.de', 'anna@private.example', 1],
['bob@firma.de', 'anna@firma.de', 0]], array_map(fn(array $r): array => [$r['logged_in_as'], $r['send_as'], $r['external']], $this->repo->tables['sender_acl']));
$this->assertSame(['gone@firma.de: address does not exist', 'ghost@firma.de: mailbox does not exist'], $result['sender_acl']['errors']);
// policies by id, else by name
$this->assertSame([3], array_column($this->repo->tables['mail_policy_member'], 'policy_id'));
$this->assertSame([5], array_column($this->repo->tables['retention_rule_member'], 'rule_id'));
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => []], $result['mail_policies']);
$this->assertSame(['Weg: does not exist'], $result['retention_rules']['errors']);
// filters: the mailbox has an active prefilter, so the restored one stays inactive
$this->assertSame([['Mine', 'active'], ['Spam', 'inactive'], ['Old', 'inactive']],
array_map(fn(array $f): array => [$f['script_desc'], $f['script_name']], $this->repo->tables['sieve_filters']));
// spam: the existing level stays, the list entry and the valid free spam alias are added
$this->assertSame([['highspamlevel', '20'], ['whitelist_from', 'boss@example.org']],
array_map(fn(array $f): array => [$f['option'], $f['value']], $this->repo->tables['filterconf']));
$this->assertSame(['abc123@firma.de'], array_column($this->repo->tables['spamalias'], 'address'));
// spam aliases only in the mailbox's domain and its alias domains, like the legacy form
$this->assertSame(['added' => 2, 'skipped' => 2, 'errors' => ['bob@firma.de: address is in use (mailbox)', 'xyz@other.de: not a domain of the mailbox']],
$result['spam']);
$this->assertSame(['archive@example.org'], array_column($this->repo->tables['bcc_maps'], 'bcc_dest'));
// sync jobs: inactive, source by id and host, else another usable source with the same host
$this->assertSame([['anna@old.de', 1, 0, null, null, 0], ['anna@legacy.de', 2, 0, null, null, 0]],
array_map(fn(array $j): array => [$j['user1'], $j['source_id'], $j['active'], $j['last_run'], $j['prio'], $j['is_running'] ?? 0], $this->repo->tables['imapsync']));
$this->assertSame(['anna@gone.de (imap.gone.de:993): sync source does not exist'], $result['syncjobs']['errors']);
$this->assertSame([['Phone', 'APPHASH', 'firma.de']], array_map(fn(array $a): array => [$a['name'], $a['password'], $a['domain']], $this->repo->tables['app_passwd']));
$this->assertSame('SECRET', $this->repo->tables['tfa'][0]['secret']);
$this->assertSame(["\x00\xff\x10", "\xfe\x01"], [$this->repo->tables['fido2'][0]['credentialId'], $this->repo->tables['fido2'][0]['AAGUID']]);
$this->assertSame(['added' => 2, 'skipped' => 0, 'errors' => []], $result['tfa']);
// groups through the group module, by id or name; IdP memberships are the IdP sync's
$this->assertSame([['admin', 7, 'anna@firma.de'], ['admin', 9, 'anna@firma.de']], $this->groupUpdates);
$this->assertSame(['added' => 2, 'skipped' => 1, 'errors' => ['Gone: does not exist']], $result['groups']);
// caches of the touched mailboxes
$this->assertEqualsCanonicalizing(['anna@firma.de', 'bob@firma.de'], $this->sogoUsers);
$this->assertSame(['anna@firma.de'], $this->authUsers);
}
public function testSettingsAndPasswordOverwrite(): void
{
$this->job(['settings', 'password']);
$this->repo->tables['tags_mailbox'][] = ['tag_name' => 'vip', 'username' => 'anna@firma.de'];
$result = $this->merge();
$anna = $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0];
$attributes = json_decode($anna['attributes'], true);
// overwritten except storage format; the password component sets passwd_update of the backup
$this->assertSame(['imap_access' => '0', 'pop3_access' => '1', 'relayhost' => '1', 'passwd_update' => '2025-05-05 05:05:05',
'mailbox_format' => 'maildir:', 'tls_enforce_in' => '1'], $attributes);
$this->assertSame([2048 * self::MIB, 'HASH-OLD', ['dept' => 'IT']], [$anna['quota'], $anna['password'], json_decode($anna['custom_attributes'], true)]);
$this->assertSame(['vip', 'it'], array_column($this->repo->tables['tags_mailbox'], 'tag_name'));
$this->assertSame(['username' => 'anna@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 1], $this->repo->tables['user_acl'][0]);
$this->assertSame('Mail', $this->repo->tables['pushover'][0]['title']);
$this->assertSame(['anna@firma.de' => '20 / 1h'], $this->rateLimits);
// quota, 3 attributes, custom attributes, a tag, 2 user ACL values, Pushover, rate limit
$this->assertSame(['added' => 10, 'skipped' => 3, 'errors' => []], $result['settings']);
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => []], $result['password']);
$this->assertSame(['anna@firma.de'], $this->authUsers);
}
public function testQuotaStaysWithinTheDomainLimits(): void
{
$this->job(['settings']);
$tooBig = self::backup();
$tooBig['mailbox']['quota'] = 4096 * self::MIB;
$this->assertSame(['quota: exceeds the maximum quota of a mailbox in the domain'], $this->merge($tooBig)['settings']['errors']);
$this->repo->update('mailbox', ['username' => 'bob@firma.de'], ['quota' => 9000 * self::MIB]);
$this->assertSame(['quota: exceeds the quota left in the domain'], $this->merge()['settings']['errors']);
$this->assertSame(1024 * self::MIB, $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0]['quota']);
}
public function testDomainAdminRestoresKeepToHisAcl(): void
{
$this->job(['aliases', 'sender_acl', 'settings', 'mail_policies', 'retention_rules', 'groups'], 'domainadmin', ['alias_external_goto' => 0,
'extend_sender_acl' => 0, 'protocol_access' => 0, 'mailbox_relayhost' => 1, 'unlimited_quota' => 0, 'pushover' => 0, 'mail_policies' => 1,
'retention' => 0, 'ratelimit' => 0, 'tls_policy' => 0, 'mailbox_groups' => 1]);
$this->job->requestedBy = 'da@firma.de';
$this->domainAdmin('da@firma.de', ['mailbox_relayhost' => 1, 'mail_policies' => 1, 'mailbox_groups' => 1]);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('add')->willReturnCallback(function (Context $context, int $id): void {
$this->groupUpdates[] = [$context->role, $context->username, $context->hasAcl('mailbox_groups')];
// the group module refuses groups that are read-only for him
if ($id === 9) {
throw new AccessDenied();
}
});
$data = self::backup();
$data['mailbox']['quota'] = 0;
$data['sender_acl'][] = ['id' => 9, 'logged_in_as' => 'anna@firma.de', 'send_as' => '*', 'external' => 0, 'group_id' => null];
$data['sender_acl'][] = ['id' => 10, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'carl@other.de', 'external' => 0, 'group_id' => null];
$result = $this->merge($data);
// the alias of another domain is not his; external gotos are not allowed for him
$this->assertSame('anna@firma.de', $this->alias('sales@firma.com')['goto']);
$this->assertContains('help@other.de: outside the domain of the restore', $result['aliases']['errors']);
$this->assertContains('sales@firma.com: goto partner@example.org dropped (external gotos are not allowed)', $result['aliases']['errors']);
$this->assertSame(['gone@firma.de: address does not exist', 'anna@private.example: not allowed', '*: only admins grant *',
'carl@other.de: outside the domain of the restore', 'ghost@firma.de: mailbox does not exist'], $result['sender_acl']['errors']);
// user ACLs he does not have himself keep their value (syncjobs; pushover is equal)
$this->assertSame(['quota: unlimited quota not allowed', 'imap_access: not allowed', 'tls_enforce_in: not allowed', 'user ACL syncjobs: not allowed',
'pushover: not allowed', 'rate limit: not allowed'], $result['settings']['errors']);
$this->assertSame([0, 0, 1], [$this->repo->tables['user_acl'][0]['spam_alias'], $this->repo->tables['user_acl'][0]['syncjobs'],
$this->repo->tables['user_acl'][0]['pushover']]);
// groups as the domain admin with his ACL of the request
$this->assertSame([['domainadmin', 'da@firma.de', true], ['domainadmin', 'da@firma.de', true]], $this->groupUpdates);
$this->assertSame(['added' => 1, 'skipped' => 1, 'errors' => ['Sales: not allowed', 'Gone: does not exist']], $result['groups']);
$anna = $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0];
$this->assertSame(['1', '1'], [json_decode($anna['attributes'], true)['imap_access'], json_decode($anna['attributes'], true)['relayhost']]);
$this->assertSame([], $this->repo->tables['pushover']);
$this->assertSame(['not allowed'], $result['retention_rules']['errors']);
$this->assertCount(1, $this->repo->tables['mail_policy_member']);
$this->assertSame([], $this->repo->tables['retention_rule_member']);
}
public function testExternalGotosCanBeDisabledGlobally(): void
{
$this->job(['aliases']);
$result = $this->service(true)->merge(12, 'anna@firma.de', self::backup());
$this->assertSame('anna@firma.de', $this->alias('sales@firma.com')['goto']);
$this->assertContains('sales@firma.com: goto partner@example.org dropped (external gotos are not allowed)', $result['aliases']['errors']);
}
public function testASecondMergeAddsNothing(): void
{
$this->merge();
$this->groupUpdates = [];
$this->repo->tables['mailbox_group_member'] = [['group_id' => 7, 'username' => 'anna@firma.de', 'source' => 'manual'],
['group_id' => 9, 'username' => 'anna@firma.de', 'source' => 'manual']];
$tables = $this->repo->tables;
$result = $this->merge();
$this->assertSame($tables, $this->repo->tables);
$this->assertSame([], $this->groupUpdates);
foreach ($result as $component => $counts) {
$this->assertSame(0, $counts['added'], $component);
}
}
public function testOnlyTheComponentsOfTheJobAreMerged(): void
{
$this->job(['mails', 'sogo', 'bcc']);
$this->assertSame(['bcc'], array_keys($this->merge()));
$this->assertSame([], $this->repo->tables['tfa']);
$this->assertSame([], $this->groupUpdates);
}
public function testTheAliasesOfADomainRestoreComeInParts(): void
{
$this->job(['aliases'], 'admin', [], 'running', ['scope' => 'domain', 'mailboxes' => ['anna@firma.de', 'bob@firma.de']]);
$domainAliases = [
['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
['id' => 50, 'address' => '@firma.com', 'goto' => 'bob@firma.de', 'domain' => 'firma.com', 'active' => 1],
['id' => 51, 'address' => 'news@firma.de', 'goto' => 'null@localhost', 'domain' => 'firma.de', 'active' => 1],
// existing aliases get their mailboxes back with the mailboxes' merge
['id' => 3, 'address' => 'info@firma.de', 'goto' => 'bob@firma.de,anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
];
$result = $this->service()->mergeAliases(12, $domainAliases);
$this->assertSame(['aliases' => ['added' => 2, 'skipped' => 1, 'errors' => []]], $result);
$this->assertSame(['bob@firma.de', 'null@localhost', 'bob@firma.de'], [$this->alias('@firma.com')['goto'], $this->alias('news@firma.de')['goto'],
$this->alias('info@firma.de')['goto']]);
$this->assertSame(['bob@firma.de'], $this->sogoUsers);
// mailboxes the restore creates count as existing gotos
$this->job(['aliases'], 'admin', [], 'running', ['scope' => 'domain', 'mailboxes' => ['anna@firma.de', 'dora@firma.de'],
'create_mailboxes' => ['dora@firma.de']]);
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => ['x@firma.de: goto gone@firma.de dropped (no mailbox or alias)']],
$this->service()->mergeAliases(12, [['address' => 'x@firma.de', 'goto' => 'dora@firma.de,gone@firma.de', 'domain' => 'firma.de']])['aliases']);
$this->assertSame('dora@firma.de', $this->alias('x@firma.de')['goto']);
$this->expectUserError(['backup_entries_exceeded', 500], fn() => $this->service()->mergeAliases(12, array_fill(0, 501, [])));
$this->expectUserError('backup_export_invalid', fn() => $this->service()->mergeAliases(12, 'x'));
// only for domain restores with aliases
$this->job(['aliases']);
$this->expectUserError('access_denied', fn() => $this->service()->mergeAliases(12, $domainAliases));
$this->job(['mails'], 'admin', [], 'running', ['scope' => 'domain']);
$this->expectUserError('access_denied', fn() => $this->service()->mergeAliases(12, $domainAliases));
}
public function testADeletedMailboxIsCreatedFromTheBackup(): void
{
$this->job(['aliases', 'settings'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup();
$data['mailbox'] = self::mailbox('dora@firma.de', ['password' => 'HASH-DORA', 'quota' => 512 * self::MIB, 'name' => 'Dora',
'attributes' => '{"imap_access": "1"}', 'created' => '2020-01-01 00:00:00']) + ['tags' => ['vip']];
$data['user_acl'] = ['username' => 'dora@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 0];
$data['aliases'] = [['address' => 'info@firma.de', 'goto' => 'bob@firma.de,dora@firma.de', 'domain' => 'firma.de']];
$result = $this->merge($data, 'dora@firma.de');
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => [], 'created' => 1], $result['mailbox']);
$dora = $this->repo->rows('mailbox', ['username' => 'dora@firma.de'])[0];
$this->assertSame(['HASH-DORA', 'Dora', 512 * self::MIB, 'dora', 'firma.de', '2020-01-01 00:00:00'], [$dora['password'], $dora['name'], $dora['quota'],
$dora['local_part'], $dora['domain'], $dora['created']]);
// the side effects of the legacy mailbox add
$this->assertSame('dora@firma.de', $this->alias('dora@firma.de')['goto']);
$this->assertSame([['username' => 'dora@firma.de', 'bytes' => 0, 'messages' => 0]], $this->repo->tables['quota2']);
$this->assertSame([0, 1], [$this->repo->rows('user_acl', ['username' => 'dora@firma.de'])[0]['spam_alias'],
$this->repo->rows('user_acl', ['username' => 'dora@firma.de'])[0]['syncjobs']]);
$this->assertSame(['vip'], array_column($this->repo->rows('tags_mailbox', ['username' => 'dora@firma.de']), 'tag_name'));
// then the components as for an existing mailbox
$this->assertSame('bob@firma.de,dora@firma.de', $this->alias('info@firma.de')['goto']);
// quota, attributes, user ACL and tags came with the mailbox: only Pushover and the rate limit are new
$this->assertSame(2, $result['settings']['added']);
$this->assertContains('dora@firma.de', $this->sogoUsers);
$this->assertContains('dora@firma.de', $this->authUsers);
// a second request finds it
$this->assertSame(['added' => 0, 'skipped' => 1, 'errors' => [], 'created' => 0], $this->merge($data, 'dora@firma.de')['mailbox']);
}
public function testACreatedMailboxGetsItsForwardingBack(): void
{
$this->job(['aliases'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup(['aliases' => []]);
$data['mailbox'] = self::mailbox('dora@firma.de');
// forwarding with a copy, one goto no longer valid
$data['own_alias'] = ['id' => 60, 'address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example,gone@firma.de', 'domain' => 'firma.de'];
$result = $this->merge($data, 'dora@firma.de');
$this->assertSame(1, $result['mailbox']['created']);
$this->assertSame('dora@firma.de,fwd@ext.example', $this->alias('dora@firma.de')['goto']);
$this->assertSame(['dora@firma.de: goto gone@firma.de dropped (no mailbox or alias)'], $result['aliases']['errors']);
// forwarding without a copy
$this->repo->tables['mailbox'] = array_values(array_filter($this->repo->tables['mailbox'], fn(array $row): bool => $row['username'] !== 'dora@firma.de'));
$this->repo->tables['alias'] = array_values(array_filter($this->repo->tables['alias'], fn(array $row): bool => $row['address'] !== 'dora@firma.de'));
$data['own_alias']['goto'] = 'fwd@ext.example';
$this->merge($data, 'dora@firma.de');
$this->assertSame('fwd@ext.example', $this->alias('dora@firma.de')['goto']);
// an existing mailbox keeps its own alias as it is now
$this->job(['aliases']);
$this->merge(self::backup(['aliases' => [], 'own_alias' => ['address' => 'anna@firma.de', 'goto' => 'anna@firma.de,fwd@ext.example']]));
$this->assertSame('anna@firma.de', $this->alias('anna@firma.de')['goto']);
}
public function testTheAliasLeftByADeletedForwardingMailboxIsTakenBack(): void
{
// deleting dora (forwarding with a copy) left dora@ -> fwd@ext.example
$this->repo->tables['alias'][] = ['id' => 70, 'address' => 'dora@firma.de', 'goto' => 'fwd@ext.example', 'domain' => 'firma.de', 'active' => 1];
$this->job(['aliases'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup(['aliases' => []]);
$data['mailbox'] = self::mailbox('dora@firma.de');
$data['own_alias'] = ['address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example', 'domain' => 'firma.de'];
$this->assertSame(1, $this->merge($data, 'dora@firma.de')['mailbox']['created']);
$this->assertSame([70, 'dora@firma.de,fwd@ext.example'], [$this->alias('dora@firma.de')['id'], $this->alias('dora@firma.de')['goto']]);
// an alias with other targets (added since) still blocks
$this->repo->tables['mailbox'] = array_values(array_filter($this->repo->tables['mailbox'], fn(array $row): bool => $row['username'] !== 'dora@firma.de'));
$this->repo->update('alias', ['id' => 70], ['goto' => 'bob@firma.de']);
$this->assertSame(['the address is in use (alias)'], $this->merge($data, 'dora@firma.de')['mailbox']['errors']);
}
public function testTheOwnAliasOfADeletedMailboxDoesNotBlockIt(): void
{
// a domain restore: dora is created with her mailbox, gone@ stays deleted; neither becomes a plain alias
$this->job(['aliases'], 'admin', [], 'running', ['scope' => 'domain', 'mailboxes' => ['anna@firma.de', 'dora@firma.de'],
'create_mailboxes' => ['dora@firma.de']]);
$result = $this->service()->mergeAliases(12, [
['address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example', 'domain' => 'firma.de', 'active' => 1],
['address' => 'gone@firma.de', 'goto' => 'gone@firma.de,anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
]);
$this->assertSame(['added' => 0, 'skipped' => 1, 'errors' => []], $result['aliases']);
$this->assertSame([null, null], [$this->alias('dora@firma.de'), $this->alias('gone@firma.de')]);
$data = self::backup(['aliases' => []]);
$data['mailbox'] = self::mailbox('dora@firma.de');
$data['own_alias'] = ['address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example', 'domain' => 'firma.de'];
$this->assertSame(1, $this->merge($data, 'dora@firma.de')['mailbox']['created']);
$this->assertSame('dora@firma.de,fwd@ext.example', $this->alias('dora@firma.de')['goto']);
// a mailbox restore: the own alias of a deleted mailbox naming anna is not created as an alias
$this->job(['aliases']);
$this->merge(self::backup(['aliases' => [['address' => 'gone@firma.de', 'goto' => 'gone@firma.de,anna@firma.de', 'domain' => 'firma.de']]]));
$this->assertNull($this->alias('gone@firma.de'));
}
public function testTheAliasLimitCountsTheAliasesOfTheRequest(): void
{
$this->job(['aliases']);
// info@ and team@ use 2 of 3
$this->repo->update('domain', ['domain' => 'firma.de'], ['aliases' => 3]);
$result = $this->merge(self::backup(['aliases' => [
['address' => 'one@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
['address' => 'two@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
]]));
$this->assertSame([1, ['two@firma.de: alias limit of the domain reached']], [$result['aliases']['added'], $result['aliases']['errors']]);
}
public function testAMailboxIsCreatedWithinTheDomainLimits(): void
{
$this->job(['aliases'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de', 'eve@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup();
$data['mailbox'] = self::mailbox('dora@firma.de', ['quota' => 4096 * self::MIB]);
$this->assertSame(['mailbox' => ['added' => 0, 'skipped' => 0, 'errors' => ['quota: exceeds the maximum quota of a mailbox in the domain'], 'created' => 0]],
$this->merge($data, 'dora@firma.de'));
$data['mailbox']['quota'] = 0;
$this->repo->update('domain', ['domain' => 'firma.de'], ['mailboxes' => 2]);
$this->assertSame(['mailbox limit of the domain reached'], $this->merge($data, 'dora@firma.de')['mailbox']['errors']);
$this->repo->update('domain', ['domain' => 'firma.de'], ['mailboxes' => 10]);
$this->repo->tables['alias'][] = ['id' => 60, 'address' => 'dora@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1];
$this->assertSame(['the address is in use (alias)'], $this->merge($data, 'dora@firma.de')['mailbox']['errors']);
$this->assertSame([], $this->repo->rows('mailbox', ['username' => 'dora@firma.de']));
// a mailbox missing now that was not missing when the restore was requested is not created
$eve = self::backup();
$eve['mailbox'] = self::mailbox('eve@firma.de');
$this->expectUserError(['backup_mailbox_not_found', 'eve@firma.de'], fn() => $this->merge($eve, 'eve@firma.de'));
}
public function testARestoreStopsWhenTheDomainAdminLostHisRights(): void
{
$revoked = ['mailbox' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked'], 'created' => 0]];
$this->job(['aliases', 'groups'], 'domainadmin', ['mailbox_groups' => 1, 'filters' => 0]);
$this->job->requestedBy = 'da@firma.de';
// no account
$this->assertSame($revoked, $this->merge());
// an ACL of the request taken away
$this->domainAdmin('da@firma.de', ['mailbox_groups' => 0]);
$this->assertSame($revoked, $this->merge());
$this->repo->update('da_acl', ['username' => 'da@firma.de'], ['mailbox_groups' => 1]);
$this->assertSame(['aliases', 'groups'], array_keys($this->merge()));
// no longer administers the domain, or the account is disabled
$this->repo->update('domain_admins', ['username' => 'da@firma.de'], ['active' => 0]);
$this->assertSame($revoked, $this->merge());
$this->repo->update('domain_admins', ['username' => 'da@firma.de'], ['active' => 1]);
$this->repo->update('admin', ['username' => 'da@firma.de'], ['active' => 0]);
$this->assertSame($revoked, $this->merge());
$this->job(['aliases'], 'domainadmin', ['mailbox_groups' => 1], 'running', ['scope' => 'domain']);
$this->job->requestedBy = 'da@firma.de';
$this->assertSame(['aliases' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked']]], $this->service()->mergeAliases(12, []));
// only the merge with all rights reached the group module
$this->assertCount(2, $this->groupUpdates);
}
public function testAMailboxCreatedByADomainAdminGetsDefaultsForAclsHeLacks(): void
{
$this->job(['aliases'], 'domainadmin', ['app_passwds' => 1], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$this->job->requestedBy = 'da@firma.de';
$this->domainAdmin('da@firma.de', ['app_passwds' => 1]);
$data = self::backup();
$data['mailbox'] = self::mailbox('dora@firma.de', ['quota' => 512 * self::MIB]);
$data['user_acl'] = ['username' => 'dora@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 1, 'app_passwds' => 1];
$this->assertSame(1, $this->merge($data, 'dora@firma.de')['mailbox']['created']);
// null: the column's default in the table
$this->assertSame(['username' => 'dora@firma.de', 'spam_alias' => 0, 'syncjobs' => null, 'pushover' => null, 'quarantine' => null, 'app_passwds' => 1],
$this->repo->rows('user_acl', ['username' => 'dora@firma.de'])[0]);
}
public function testARetriedRequestWaitsForTheFirst(): void
{
$this->repo->busyLocks = ['12:anna@firma.de'];
$this->expectUserError('backup_job_busy', fn() => $this->merge());
$this->assertSame([], $this->groupUpdates);
}
public function testAliasLimitAndAddressesInUse(): void
{
$this->job(['aliases']);
$this->repo->tables['spamalias'][] = ['address' => 'shop@firma.de', 'goto' => 'bob@firma.de', 'validity' => time() + 60, 'permanent' => 0];
$data = self::backup(['aliases' => [
['address' => 'shop@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
['address' => 'bob@firma.com', 'goto' => 'anna@firma.de', 'domain' => 'firma.com'],
['address' => 'x@unknown.de', 'goto' => 'anna@firma.de', 'domain' => 'unknown.de'],
]]);
$this->assertSame(['shop@firma.de: address is in use (spamalias)', 'bob@firma.com: address is in use (mailbox)', 'x@unknown.de: domain does not exist'],
$this->merge($data)['aliases']['errors']);
$this->repo->update('domain', ['domain' => 'firma.de'], ['aliases' => 2]);
$this->assertSame(['sales@firma.com: alias limit of the domain reached'], array_slice($this->merge()['aliases']['errors'], 0, 1));
}
public function testADatabaseErrorDropsOnlyItsComponent(): void
{
$this->job(['bcc', 'tfa', 'app_passwords']);
$this->repo->failingTables = ['fido2'];
$result = $this->merge();
$this->assertSame(['added' => 0, 'skipped' => 0, 'errors' => ['database error 23000']], $result['tfa']);
// the 2FA entry written before the failing FIDO2 key was rolled back with it
$this->assertSame([[], []], [$this->repo->tables['tfa'], $this->repo->tables['fido2']]);
$this->assertSame(1, $result['bcc']['added']);
$this->assertCount(1, $this->repo->tables['app_passwd']);
}
public function testPasswordOnlyForMailcowLogins(): void
{
$this->job(['password']);
$this->repo->update('mailbox', ['username' => 'anna@firma.de'], ['authsource' => 'keycloak']);
$this->assertSame(['the mailbox logs in through an identity provider'], $this->merge()['password']['errors']);
$this->assertSame('HASH-NOW', $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0]['password']);
$this->assertSame([], $this->authUsers);
}
public function testGroupErrorsAreReported(): void
{
$this->job(['groups']);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('add')->willThrowException(new UserError('group_member_invalid', ['anna@firma.de']));
$this->assertSame(['Team: group_member_invalid', 'Sales: group_member_invalid', 'Gone: does not exist'], $this->merge()['groups']['errors']);
}
public function testTheJobDecidesWhatMayBeMerged(): void
{
$this->expectUserError('backup_job_not_running', fn() => $this->service()->merge(13, 'anna@firma.de', self::backup()));
$this->expectUserError('backup_job_not_running', fn() => $this->service()->merge('x', 'anna@firma.de', self::backup()));
$this->job(self::ALL, 'admin', [], 'done');
$this->expectUserError('backup_job_not_running', fn() => $this->merge());
$this->job(self::ALL);
$this->expectUserError('access_denied', fn() => $this->merge([], 'bob@firma.de'));
$this->expectUserError('backup_export_invalid', fn() => $this->merge(['mailbox' => ['username' => 'bob@firma.de']]));
$this->expectUserError('backup_export_invalid', fn() => $this->service()->merge(12, 'Anna@firma.de', 'text'));
$this->repo->tables['mailbox'] = array_values(array_filter($this->repo->tables['mailbox'], fn(array $m): bool => $m['username'] !== 'anna@firma.de'));
$this->expectUserError(['backup_mailbox_not_found', 'anna@firma.de'], fn() => $this->merge());
$this->assertSame([], $this->groupUpdates);
}
}
@@ -0,0 +1,341 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SettingsServiceTest extends TestCase
{
private SettingsRepository&MockObject $repo;
/** @var array<string, string> what the repository holds */
private array $stored = [];
/** @var array[] values passed to save() */
private array $saved = [];
/** Key pair of the runner (sealed boxes) */
private string $keypair;
protected function setUp(): void
{
$this->keypair = sodium_crypto_box_keypair();
$this->stored = ['state_secrets_pubkey' => base64_encode(sodium_crypto_box_publickey($this->keypair))];
$this->repo = $this->createMock(SettingsRepository::class);
$this->repo->method('load')->willReturnCallback(fn(): array => $this->stored);
$this->repo->method('save')->willReturnCallback(function (array $values): void {
$this->saved[] = $values;
$this->stored = array_merge($this->stored, $values);
});
}
private function service(?Context $context = null): SettingsService
{
return new SettingsService($this->repo, $context ?? FakeContext::admin());
}
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());
}
}
/** A configured S3 target as the runner left it */
private function s3(array $values = []): void
{
$this->stored = $values + ['target' => 's3', 's3_bucket' => 'backups', 's3_access_key' => 'AKIA1', 's3_secret_key' => 'SECRET',
'password' => 'repo-password', 'password_confirmed' => '1', 'state_initialized' => '1', 'sftp_host_key' => 'ssh-ed25519 AAAA',
'state_secrets_pubkey' => base64_encode(sodium_crypto_box_publickey($this->keypair))];
}
/** What the runner decrypts from a stored secret */
private function open(string $stored): string|false
{
$this->assertStringStartsWith('sealed:v1:', $stored);
return sodium_crypto_box_seal_open((string)base64_decode(substr($stored, strlen('sealed:v1:')), true), $this->keypair);
}
public function testSecretsNeverLeaveTheApi(): void
{
$this->s3(['state_repo_stats' => '{"total_size": 5}', 'state_runner_seen' => '1700000000', 'unknown' => 'x']);
$settings = $this->service()->get()->toArray();
$this->assertSame([1, 1, 0], [$settings['password_set'], $settings['s3_secret_key_set'], $settings['rest_password_set']]);
$this->assertArrayNotHasKey('password', $settings);
$this->assertArrayNotHasKey('s3_secret_key', $settings);
$this->assertArrayNotHasKey('unknown', $settings);
$this->assertSame([7, 1700000000, null, ['total_size' => 5], 1], [$settings['keep_daily'], $settings['state_runner_seen'],
$settings['state_repo_size'], $settings['state_repo_stats'], $settings['configured']]);
$this->assertStringNotContainsString('SECRET', json_encode($settings));
}
public function testValuesAreNormalized(): void
{
$settings = $this->service()->save([
'target' => 'sftp', 'sftp_host' => 'u123.your-storagebox.de', 'sftp_port' => '23', 'sftp_user' => 'u123-sub1', 'sftp_path' => 'backups/mailcow/',
's3_path' => '/mailcow/', 'schedule' => [['days' => [5, '1', 3, 1], 'time' => '3:05'], ['days' => [0], 'time' => '14:00'], ['days' => [1, 3, 5], 'time' => '03:05']], 'max_unused' => '5', 'notify_email' => "a@example.org; b@example.org\nA@example.org",
'enabled' => ['0', '1'], 'include_fts' => false, 'keep_yearly' => 3, 'password' => 'long enough',
]);
$this->assertSame(['sftp', 'backups/mailcow', 'mailcow', '[{"days":[1,3,5],"time":"03:05"},{"days":[0],"time":"14:00"}]', '5%',
'a@example.org,b@example.org,A@example.org', '1', '0', '3'],
array_map(fn(string $name): string => $settings->get($name),
['target', 'sftp_path', 's3_path', 'schedule', 'max_unused', 'notify_email', 'enabled', 'include_fts', 'keep_yearly']));
$this->assertSame([[0, 14, 0], [1, 3, 5], [3, 3, 5], [5, 3, 5]], $settings->scheduleSlots());
$this->assertSame([['days' => [1, 3, 5], 'time' => '03:05'], ['days' => [0], 'time' => '14:00']], $settings->toArray()['schedule']);
// as JSON as well
$this->assertSame('[{"days":[2],"time":"23:59"}]', $this->service()->save(['schedule' => '[{"days":[2],"time":"23:59"}]'])->get('schedule'));
// secrets are stored sealed for the runner
$this->assertSame('long enough', $this->open($this->stored['password']));
// secrets as typed, spaces at the ends included
$this->service()->save(['password' => ' spaced password ', 's3_secret_key' => ' key ']);
$this->assertSame([' spaced password ', ' key '], [$this->open($this->stored['password']), $this->open($this->stored['s3_secret_key'])]);
// only changed values are written
$this->assertArrayNotHasKey('keep_daily', $this->saved[0]);
}
public static function invalidInput(): array
{
return [
'target' => [['target' => 'ftp'], 'target'],
'endpoint without scheme' => [['s3_endpoint' => 's3.example.org'], 's3_endpoint'],
'endpoint with credentials' => [['s3_endpoint' => 'https://key:secret@s3.example.org'], 's3_endpoint'],
'endpoint ftp' => [['s3_endpoint' => 'ftp://s3.example.org'], 's3_endpoint'],
'endpoint with a path' => [['s3_endpoint' => 'https://s3.example.org/bucket'], 's3_endpoint'],
'rest url with credentials' => [['rest_url' => 'https://user:pw@backup.example.org:8000/mailcow'], 'rest_url'],
'rest url with query' => [['rest_url' => 'https://backup.example.org/?x=1'], 'rest_url'],
'bucket' => [['s3_bucket' => 'my bucket'], 's3_bucket'],
'bucket too long' => [['s3_bucket' => str_repeat('b', 64)], 's3_bucket'],
's3 path up' => [['s3_path' => 'a/../b'], 's3_path'],
'region' => [['s3_region' => 'eu central'], 's3_region'],
'access key with space' => [['s3_access_key' => 'AK IA'], 's3_access_key'],
'sftp host option' => [['sftp_host' => '-oProxyCommand=x'], 'sftp_host'],
'sftp host with user' => [['sftp_host' => 'u1@host.example.org'], 'sftp_host'],
'sftp port' => [['sftp_port' => '70000'], 'sftp_port'],
'sftp user option' => [['sftp_user' => '-l'], 'sftp_user'],
// the runner's character set
'sftp user with @' => [['sftp_user' => 'u1@host'], 'sftp_user'],
'sftp user with +' => [['sftp_user' => 'u1+x'], 'sftp_user'],
'local path with ~' => [['local_path' => 'mailcow/~old'], 'local_path'],
'local path with space' => [['local_path' => 'mail cow'], 'local_path'],
'sftp path up' => [['sftp_path' => '/srv/../etc'], 'sftp_path'],
'sftp path root' => [['sftp_path' => '/'], 'sftp_path'],
'rest user with colon' => [['rest_user' => 'a:b'], 'rest_user'],
'local path absolute' => [['local_path' => '/etc'], 'local_path'],
'local path up' => [['local_path' => '../mailcow'], 'local_path'],
'local path empty' => [['local_path' => ''], 'local_path'],
'schedule time' => [['schedule' => [['days' => [1], 'time' => '24:00']]], 'schedule'],
'schedule without time' => [['schedule' => [['days' => [1]]]], 'schedule'],
'schedule day' => [['schedule' => [['days' => [1, 7], 'time' => '02:30']]], 'schedule'],
'schedule without day' => [['schedule' => [['days' => [], 'time' => '02:30']]], 'schedule'],
'schedule days as text' => [['schedule' => [['days' => '1,2', 'time' => '02:30']]], 'schedule'],
'schedule nested day' => [['schedule' => [['days' => [[1]], 'time' => '02:30']]], 'schedule'],
'schedule not a list' => [['schedule' => ['a' => ['days' => [1], 'time' => '02:30']]], 'schedule'],
'schedule broken json' => [['schedule' => '[{"days":'], 'schedule'],
'too many schedule entries' => [['schedule' => array_map(fn(int $h): array => ['days' => [1], 'time' => sprintf('%02d:00', $h % 24) . ''], range(0, 24))], 'schedule'],
'keep' => [['keep_daily' => '1001'], 'keep_daily'],
'keep hourly' => [['keep_hourly' => '1001'], 'keep_hourly'],
'keep negative' => [['keep_weekly' => '-1'], 'keep_weekly'],
'weekday' => [['prune_weekday' => '7'], 'prune_weekday'],
'max unused' => [['max_unused' => '101%'], 'max_unused'],
'max unused size' => [['max_unused' => '5G'], 'max_unused'],
'check subset' => [['check_subset' => '366'], 'check_subset'],
'limit upload' => [['limit_upload' => '-5'], 'limit_upload'],
'notify' => [['notify_email' => 'a@example.org, nobody'], 'notify_email'],
'flag' => [['enabled' => 'maybe'], 'enabled'],
'list as text' => [['sftp_host' => ['a', 'b']], 'sftp_host'],
'control character' => [['s3_secret_key' => "abc\ndef"], 's3_secret_key'],
];
}
#[DataProvider('invalidInput')]
public function testInvalidInput(array $input, string $name): void
{
$this->expectUserError(['backup_setting_invalid', $name], fn() => $this->service()->save($input));
$this->assertSame([], $this->saved);
}
public function testPruneCanBeOff(): void
{
$settings = $this->service()->save(['prune_weekday' => '']);
$this->assertSame(['', ''], [$settings->get('prune_weekday'), $settings->toArray()['prune_weekday']]);
$this->assertSame(3, $this->service()->save(['prune_weekday' => '3'])->toArray()['prune_weekday']);
$this->assertSame(0, (new Settings())->toArray()['prune_weekday']);
}
public function testSecretsNeedTheKeyOfTheRunner(): void
{
$this->stored = [];
$this->expectUserError('backup_secrets_key_missing', fn() => $this->service()->save(['keep_daily' => '9', 'password' => 'long enough']));
$this->assertSame([], $this->saved);
// everything else can be saved before the backup container ran
$this->assertSame('9', $this->service()->save(['keep_daily' => '9', 'password' => ''])->get('keep_daily'));
$this->stored['state_secrets_pubkey'] = base64_encode('too short');
$this->expectUserError('backup_secrets_key_missing', fn() => $this->service()->save(['rest_password' => 'x']));
}
public function testEverySecretIsSealedAnew(): void
{
$this->s3();
$this->service()->save(['s3_secret_key' => 'NEW-SECRET', 'rest_password' => 'rest pw']);
$this->assertSame(['NEW-SECRET', 'rest pw'], [$this->open($this->stored['s3_secret_key']), $this->open($this->stored['rest_password'])]);
$this->assertStringNotContainsString('NEW-SECRET', json_encode($this->saved));
$this->assertSame([1, 1], [$this->service()->get()->toArray()['s3_secret_key_set'], $this->service()->get()->toArray()['rest_password_set']]);
}
public function testATargetChangeIsReportedToTheAddressesOfBefore(): void
{
$this->s3(['notify_email' => 'old@example.org,boss@example.org', 's3_endpoint' => 'https://s3.example.org']);
$this->service()->save(['target' => 'local', 'local_path' => 'elsewhere', 'notify_email' => 'new@example.org']);
$change = json_decode($this->stored['state_target_changed'], true);
$this->assertSame(['target' => 's3', 's3_endpoint' => 'https://s3.example.org', 's3_bucket' => 'backups', 's3_path' => '', 's3_region' => '',
's3_access_key' => 'AKIA1', 's3_path_style' => '0'], $change['old_target']);
$this->assertSame([['old@example.org', 'boss@example.org'], ['target', 'notify_email'], 'admin'],
[$change['old_addresses'], $change['changed'], $change['changed_by']]);
$this->assertIsInt($change['time']);
$this->assertSame($change, $this->service()->get()->toArray()['state_target_changed']);
// a second change before the runner mailed the first keeps the older state
$this->service()->save(['local_path' => 'again']);
$this->assertSame($change, json_decode($this->stored['state_target_changed'], true));
}
public function testNewAddressesAreReportedToTheOldOnes(): void
{
$this->s3(['notify_email' => 'old@example.org']);
// new addresses first, then a new target: the original addresses hear about both
$this->service()->save(['notify_email' => 'attacker@example.net']);
$first = json_decode($this->stored['state_target_changed'], true);
$this->service()->save(['target' => 'local']);
$change = json_decode($this->stored['state_target_changed'], true);
$this->assertSame([['old@example.org'], ['notify_email']], [$first['old_addresses'], $first['changed']]);
$this->assertSame([['old@example.org'], ['notify_email', 'target'], 's3', $first['time']],
[$change['old_addresses'], $change['changed'], $change['old_target']['target'], $change['time']]);
}
public function testWhatIsNoTargetChange(): void
{
// other settings, a value of another target
$this->s3(['notify_email' => 'old@example.org']);
$this->service()->save(['keep_daily' => '3', 'rest_url' => 'https://r.example.org', 'notify_email' => 'old@example.org']);
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
// a new secret of the target is a change
$this->service()->save(['s3_secret_key' => 'NEW']);
$this->assertSame([['old@example.org'], ['target']], array_values(array_intersect_key(json_decode($this->stored['state_target_changed'], true),
['old_addresses' => 1, 'changed' => 1])));
// the first addresses of a backup
$this->s3();
$this->service()->save(['notify_email' => 'first@example.org']);
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
// a target that was not configured, or nobody to tell
$this->stored = ['state_secrets_pubkey' => $this->stored['state_secrets_pubkey'], 'notify_email' => 'old@example.org'];
$this->service()->save(['target' => 'local']);
$this->s3();
$this->service()->save(['target' => 'local']);
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
// the runner's key cannot be set through the API
$this->service()->save(['state_target_changed' => '', 'state_secrets_pubkey' => 'x']);
$this->assertNotSame('x', $this->stored['state_secrets_pubkey']);
}
public function testShortRepositoryPassword(): void
{
$this->expectUserError(['backup_password_too_short', 8], fn() => $this->service()->save(['password' => 'short']));
}
public function testEmptySecretsAndRunnerValuesKeepTheStoredOnes(): void
{
$this->s3();
$settings = $this->service()->save(['password' => '', 's3_secret_key' => '', 'state_initialized' => '0', 'sftp_public_key' => 'evil',
'password_set' => 0, 'keep_daily' => '10']);
$this->assertSame(['repo-password', 'SECRET', '1', 'ssh-ed25519 AAAA', '', '10'], array_map(fn(string $name): string => $settings->get($name),
['password', 's3_secret_key', 'state_initialized', 'sftp_host_key', 'sftp_public_key', 'keep_daily']));
$this->assertSame([['keep_daily' => '10']], $this->saved);
}
public function testANewConnectionMustBeTestedAgain(): void
{
$this->s3();
// values of another target do not touch the connection
$this->assertSame('1', $this->service()->save(['rest_url' => 'https://backup.example.org:8000/mailcow'])->get('state_initialized'));
$this->assertSame('0', $this->service()->save(['s3_secret_key' => 'NEW'])->get('state_initialized'));
$this->s3();
$this->assertSame('0', $this->service()->save(['s3_path_style' => '1'])->get('state_initialized'));
$this->s3();
$this->assertSame('0', $this->service()->save(['target' => 'local'])->get('state_initialized'));
}
public function testANewSftpHostDropsTheKnownHostKey(): void
{
$this->s3(['target' => 'sftp', 'sftp_host' => 'old.example.org', 'sftp_user' => 'u1', 'sftp_path' => 'repo']);
$this->assertSame('ssh-ed25519 AAAA', $this->service()->save(['sftp_path' => 'repo2'])->get('sftp_host_key'));
$this->assertSame('', $this->service()->save(['sftp_port' => '23'])->get('sftp_host_key'));
$this->s3(['target' => 'sftp', 'sftp_host' => 'old.example.org', 'sftp_user' => 'u1', 'sftp_path' => 'repo']);
$this->assertSame('', $this->service()->save(['sftp_host' => '192.0.2.10'])->get('sftp_host_key'));
}
public function testANewRepositoryPasswordNeedsANewConfirmation(): void
{
$this->s3();
$settings = $this->service()->save(['password' => 'another password']);
$this->assertSame(['0', '0'], [$settings->get('password_confirmed'), $settings->get('state_initialized')]);
$this->s3();
$this->assertSame('1', $this->service()->save(['password' => 'another password', 'password_confirmed' => '1'])->get('password_confirmed'));
}
public function testTheScheduleNeedsAConfiguredTarget(): void
{
$this->expectUserError('backup_not_configured', fn() => $this->service()->save(['enabled' => '1']));
$this->s3();
$this->assertSame('1', $this->service()->save(['enabled' => '1'])->get('enabled'));
$this->expectUserError('backup_not_configured', fn() => $this->service()->save(['s3_bucket' => '']));
// and at least one time
$this->expectUserError(['backup_setting_invalid', 'schedule'], fn() => $this->service()->save(['schedule' => []]));
$this->assertSame('[]', $this->service()->save(['enabled' => '0', 'schedule' => []])->get('schedule'));
}
public function testOnlyAdminsReadAndOnlyWritingAdminsSave(): void
{
$this->s3();
$readOnly = new Context('admin', 'API', [], false, 'ro');
$this->assertNotNull($this->service($readOnly)->get());
$this->assertNull($this->service(FakeContext::domainAdmin([], ['firma.de']))->get());
$this->assertNull($this->service(FakeContext::user('anna@firma.de'))->get());
foreach ([$readOnly, FakeContext::domainAdmin([], ['firma.de']), FakeContext::user('anna@firma.de'), Context::system()] as $context) {
$this->expectUserError('access_denied', fn() => $this->service($context)->save(['keep_daily' => '1']));
}
$this->assertSame([], $this->saved);
}
public function testConfiguredPerTarget(): void
{
$settings = Settings::fromStored(['password' => 'x', 'target' => 'local']);
$this->assertTrue($settings->configured());
$this->assertFalse(Settings::fromStored(['target' => 'local'])->configured());
$this->assertFalse(Settings::fromStored(['password' => 'x', 'target' => 'rest'])->configured());
$this->assertTrue(Settings::fromStored(['password' => 'x', 'target' => 'rest', 'rest_url' => 'https://r.example.org'])->configured());
$this->assertFalse(Settings::fromStored(['password' => 'x', 'target' => 'sftp', 'sftp_host' => 'h', 'sftp_user' => 'u'])->configured());
}
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Support;
use Mailcow\Backup\Repository\RestoreRepository;
use PDOException;
use Throwable;
/**
* RestoreRepository on in-memory tables: the merge rules are tested against table contents instead of SQL. Strings
* compare case-insensitively like the MariaDB collation; transactions and savepoints restore a copy of the tables.
*/
class FakeRestoreRepository extends RestoreRepository
{
/** @var array<string, array[]> table => rows */
public array $tables = [];
/** Inserts into these tables fail with a database error */
public array $failingTables = [];
/** Named locks that are taken by another request */
public array $busyLocks = [];
private const SCHEMA = [
'domain' => ['domain', 'description', 'aliases', 'mailboxes', 'defquota', 'maxquota', 'quota', 'relayhost', 'backupmx', 'gal', 'created', 'modified', 'active'],
'tags_domain' => ['tag_name', 'domain'],
'domain_wide_footer' => ['domain', 'html', 'plain', 'mbox_exclude', 'alias_domain_exclude', 'skip_replies'],
'mta_sts' => ['id', 'domain', 'version', 'mode', 'mx', 'max_age', 'active'],
'domain_admins' => ['id', 'username', 'domain', 'created', 'active'],
'admin' => ['username', 'superadmin', 'active'],
'da_acl' => ['username', 'syncjobs', 'quarantine', 'app_passwds', 'pushover', 'spam_policy', 'filters', 'mailbox_groups', 'mail_policies',
'retention', 'protocol_access', 'mailbox_relayhost', 'unlimited_quota', 'ratelimit', 'extend_sender_acl', 'alias_external_goto'],
'quota2' => ['username', 'bytes', 'messages'],
'alias_domain' => ['alias_domain', 'target_domain', 'active'],
'mailbox' => ['username', 'password', 'name', 'quota', 'local_part', 'domain', 'attributes', 'custom_attributes', 'kind', 'authsource', 'created', 'modified', 'active'],
'alias' => ['id', 'address', 'goto', 'domain', 'created', 'modified', 'private_comment', 'public_comment', 'sogo_visible', 'internal',
'sender_allowed', 'active', 'group_id'],
'sender_acl' => ['id', 'logged_in_as', 'send_as', 'external', 'group_id'],
'spamalias' => ['address', 'goto', 'description', 'created', 'modified', 'validity', 'permanent'],
'filterconf' => ['prefid', 'object', 'option', 'value', 'created', 'modified'],
'sieve_filters' => ['id', 'username', 'script_desc', 'script_name', 'script_data', 'filter_type', 'created', 'modified'],
'bcc_maps' => ['id', 'local_dest', 'bcc_dest', 'domain', 'type', 'created', 'modified', 'active'],
'app_passwd' => ['id', 'name', 'mailbox', 'domain', 'password', 'created', 'modified', 'imap_access', 'smtp_access', 'active'],
'tfa' => ['id', 'key_id', 'username', 'authmech', 'secret', 'keyHandle', 'publicKey', 'counter', 'certificate', 'active'],
'fido2' => ['username', 'friendlyName', 'rpId', 'credentialPublicKey', 'AAGUID', 'credentialId', 'created', 'modified', 'active'],
'tags_mailbox' => ['tag_name', 'username'],
'user_acl' => ['username', 'spam_alias', 'syncjobs', 'pushover', 'quarantine', 'app_passwds'],
'pushover' => ['username', 'key', 'token', 'attributes', 'title', 'active'],
'imapsync' => ['id', 'user2', 'source_id', 'user1', 'password1', 'maxage', 'created', 'modified', 'is_running', 'returned_text',
'last_run', 'success', 'exit_status', 'prio', 'active'],
'imapsync_source' => ['id', 'host1', 'port1', 'scope'],
'imapsync_source_domain' => ['source_id', 'domain'],
'imapsync_source_user' => ['source_id', 'username'],
'mailbox_group' => ['id', 'domain', 'name', 'address'],
'mailbox_group_member' => ['group_id', 'username', 'source'],
'mail_policy' => ['id', 'domain', 'name', 'send_mode', 'send_allow', 'send_block', 'receive_mode', 'receive_allow', 'receive_block', 'created'],
'mail_policy_member' => ['id', 'policy_id', 'username', 'group_id'],
'retention_rule' => ['id', 'domain', 'name', 'folders', 'days', 'created'],
'retention_rule_member' => ['id', 'rule_id', 'username', 'group_id'],
'relayhosts' => ['id', 'hostname'],
];
public function __construct(array $tables = [])
{
$this->tables = array_fill_keys(array_keys(self::SCHEMA), []);
foreach ($tables as $table => $rows) {
foreach ($rows as $row) {
$this->tables[$table][] = $row;
}
}
}
public function inTransaction(callable $work): mixed
{
return $this->savepoint('transaction', $work);
}
public function savepoint(string $name, callable $work): mixed
{
$copy = $this->tables;
try {
return $work();
} catch (Throwable $e) {
$this->tables = $copy;
throw $e;
}
}
public function locked(string $key, callable $work, int $timeout = 120): mixed
{
return in_array($key, $this->busyLocks, true) ? null : $work();
}
public function resetQuota(string $username): void
{
$this->tables['quota2'] = array_values(array_filter($this->tables['quota2'], fn(array $row): bool => $row['username'] !== $username));
$this->tables['quota2'][] = ['username' => $username, 'bytes' => 0, 'messages' => 0];
}
public function mailboxCount(string $domain): int
{
return count($this->rows('mailbox', ['domain' => $domain, 'kind' => '']));
}
public function dropExternalSenderAcl(string $domain): void
{
$this->tables['sender_acl'] = array_values(array_filter($this->tables['sender_acl'], fn(array $row): bool =>
!((int)$row['external'] === 1 && str_ends_with(strtolower((string)$row['send_as']), '@' . strtolower($domain)))));
}
public function columns(string $table): array
{
return self::SCHEMA[$table];
}
public function rows(string $table, array $where): array
{
return array_values(array_filter($this->tables[$table], fn(array $row): bool => self::matches($row, $where)));
}
public function insert(string $table, array $values): int
{
if (in_array($table, $this->failingTables, true)) {
throw new PDOException('SQLSTATE[23000]: duplicate', 23000);
}
$row = array_fill_keys(self::SCHEMA[$table], null);
foreach (array_intersect_key($values, $row) as $column => $value) {
$row[$column] = $value;
}
$id = 0;
foreach (['id', 'prefid'] as $key) {
if (array_key_exists($key, $row)) {
$id = $row[$key] = 1 + max([0, ...array_map(fn(array $r): int => (int)$r[$key], $this->tables[$table])]);
}
}
$this->tables[$table][] = $row;
return $id;
}
public function update(string $table, array $where, array $values): int
{
$count = 0;
foreach ($this->tables[$table] as &$row) {
if (self::matches($row, $where)) {
$row = array_merge($row, array_intersect_key($values, array_flip(self::SCHEMA[$table])));
$count++;
}
}
return $count;
}
public function domainExists(string $domain): bool
{
return $this->primaryDomain($domain) !== null;
}
public function primaryDomain(string $domain): ?string
{
if (!empty($this->rows('domain', ['domain' => $domain]))) {
return strtolower($domain);
}
$alias = $this->rows('alias_domain', ['alias_domain' => $domain])[0] ?? null;
return ($alias === null) ? null : (string)$alias['target_domain'];
}
public function isLocalDomain(string $domain): bool
{
return !empty($this->rows('domain', ['domain' => $domain, 'active' => 1])) || !empty($this->rows('alias_domain', ['alias_domain' => $domain, 'active' => 1]));
}
public function aliasDomainsOf(string $domain): array
{
return array_map(fn(array $row): string => (string)$row['alias_domain'], $this->rows('alias_domain', ['target_domain' => $domain]));
}
public function addressKind(string $address): ?string
{
$mailbox = $this->rows('mailbox', ['username' => $address])[0] ?? null;
if ($mailbox !== null) {
return preg_match('/location|thing|group/', (string)$mailbox['kind']) ? 'resource' : 'mailbox';
}
[$local, $domain] = array_pad(explode('@', $address, 2), 2, '');
foreach ($this->rows('alias_domain', ['alias_domain' => $domain]) as $aliasDomain) {
if (!empty($this->rows('mailbox', ['username' => $local . '@' . $aliasDomain['target_domain']]))) {
return 'mailbox';
}
}
if (!empty($this->rows('alias', ['address' => $address]))) {
return 'alias';
}
return empty($this->rows('spamalias', ['address' => $address])) ? null : 'spamalias';
}
public function aliasesLeft(string $domain): int
{
$domains = array_merge([$domain], $this->aliasDomainsOf($domain));
$used = array_filter($this->tables['alias'], fn(array $alias): bool => in_array(strtolower((string)$alias['domain']), $domains, true)
&& empty($this->rows('mailbox', ['username' => $alias['address']])));
return (int)($this->rows('domain', ['domain' => $domain])[0]['aliases'] ?? 0) - count($used);
}
public function quotaInUse(string $domain, string $exceptUsername): int
{
return array_sum(array_map(fn(array $m): int => (int)$m['quota'], array_filter($this->rows('mailbox', ['domain' => $domain, 'kind' => '']),
fn(array $m): bool => strtolower((string)$m['username']) !== strtolower($exceptUsername))));
}
public function syncJobsOf(string $username): array
{
return array_map(function (array $job): array {
$source = $this->rows('imapsync_source', ['id' => $job['source_id']])[0] ?? [];
return ['user1' => $job['user1'], 'host1' => $source['host1'] ?? null, 'port1' => $source['port1'] ?? null];
}, $this->rows('imapsync', ['user2' => $username]));
}
public function syncSourcesFor(string $host, int $port, string $username, string $domain): array
{
$ids = [];
foreach ($this->rows('imapsync_source', ['host1' => $host, 'port1' => $port]) as $source) {
if ($source['scope'] === 'all'
|| ($source['scope'] === 'domain' && !empty($this->rows('imapsync_source_domain', ['source_id' => $source['id'], 'domain' => $domain])))
|| ($source['scope'] === 'user' && !empty($this->rows('imapsync_source_user', ['source_id' => $source['id'], 'username' => $username])))) {
$ids[] = (int)$source['id'];
}
}
sort($ids);
return $ids;
}
private static function matches(array $row, array $where): bool
{
foreach ($where as $column => $value) {
$actual = $row[$column] ?? null;
if ($value === null ? $actual !== null : ($actual === null || strtolower((string)$actual) !== strtolower((string)$value))) {
return false;
}
}
return true;
}
}
+51
View File
@@ -0,0 +1,51 @@
# Shared helpers of the backup runner tests: fake database rows and a settable clock
import datetime
import os
import time
class Cursor:
def __init__(self, rows):
self.rows = rows
self.rowcount = len(rows)
self.lastrowid = 1
def fetchone(self):
return self.rows[0] if self.rows else None
def fetchall(self):
return self.rows
def __iter__(self):
return iter(self.rows)
class FakeDatetime(datetime.datetime):
current = None
@classmethod
def now(cls, tz=None):
return cls.current
class Clock:
# datetime module for a runner: datetime.now() returns the set time
timedelta = datetime.timedelta
date = datetime.date
datetime = FakeDatetime
# Local time zone of the process for one test (cleaned up with the test case); the fake clock is reset as well
def use_timezone(test, name):
old = os.environ.get('TZ')
def restore():
if old is None:
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = old
time.tzset()
FakeDatetime.current = None
test.addCleanup(restore)
os.environ['TZ'] = name
time.tzset()
+344
View File
@@ -0,0 +1,344 @@
# Pure functions of the backup runner (data/Dockerfiles/restic/restic_runner.py)
import datetime
import gzip
import json
import os
import tempfile
import time
import types
import unittest
import urllib.parse
import nacl.public
import restic_runner as rr
import support
GiB = 1 << 30
class Patch(unittest.TestCase):
# replaces module attributes of the runner for one test
def patch(self, **values):
for name, value in values.items():
old = getattr(rr, name)
self.addCleanup(setattr, rr, name, old)
setattr(rr, name, value)
class Globs(unittest.TestCase):
def test_glob_escape(self):
self.assertEqual(rr.glob_escape('a*b?c[d]e\\f'), 'a\\*b\\?c\\[d\\]e\\\\f')
self.assertEqual(rr.glob_escape('user.name+tag'), 'user.name+tag')
def test_includes_per_component(self):
homes = {'/var/vmail/x.de/a': 100, '/var/vmail/x.de/b*': 50}
ready = ['a@x.de', 'b*@x.de', 'new@x.de']
includes, need = rr.batch_includes(ready, homes, {'mails'})
self.assertEqual((includes, need), (['/var/vmail/x.de/a', '/var/vmail/x.de/b\\*'], 150))
includes, need = rr.batch_includes(['a@x.de'], homes, {'imap_acl', 'filters'})
self.assertEqual(includes, ['/var/vmail/x.de/a/**/dovecot-acl', '/var/vmail/x.de/a/sieve', '/var/vmail/sieve/a@x.de.sieve'])
self.assertEqual(need, 0)
self.assertEqual(rr.batch_includes(['a@x.de'], homes, {'sogo', 'aliases'})[0], ['/sogo_backup/a@x.de'])
# no home in the snapshot (never got mail): only the active Sieve link
self.assertEqual(rr.batch_includes(['new@x.de'], homes, {'mails', 'sieve_activate'})[0], ['/var/vmail/sieve/new@x.de.sieve'])
self.assertEqual(rr.batch_includes(['a@x.de'], homes, {'aliases', 'settings'}), ([], 0))
class Modes(Patch):
def test_paths(self):
s = dict(rr.defaults)
paths, excludes = rr.backup_paths(s, 'all')
self.assertIn('/mailcow', paths)
self.assertIn('/var/vmail_index', paths)
self.assertEqual(excludes, [])
# data: of the mailcow directory only mailcow.conf
paths, _ = rr.backup_paths(s, 'data')
self.assertEqual([p for p in paths if p == '/mailcow' or p.startswith('/mailcow/install')], ['/mailcow/install/mailcow.conf'])
self.assertTrue({'/var/vmail', '/mailcow/mysql', '/mailcow/export', '/mailcow/manifest.json'} <= set(paths))
# config: no mails
paths, _ = rr.backup_paths(s, 'config')
self.assertEqual(sorted(paths), sorted(['/mail_crypt', '/redis', '/mailcow/mysql', '/mailcow/export',
'/mailcow/manifest.json', '/mailcow/install/mailcow.conf', '/mailcow/install/data/conf']))
s.update(include_index='1', include_fts='0')
self.assertEqual(rr.backup_paths(s, 'data')[1], ['--exclude', '/var/vmail_index/**/fts-flatcurve'])
def test_mode(self):
self.addCleanup(os.environ.pop, 'RESTIC_BACKUP_MODE', None)
for value, mode in (('', 'all'), (' Data ', 'data'), ('config', 'config')):
os.environ['RESTIC_BACKUP_MODE'] = value
self.assertEqual(rr.backup_mode(), mode)
os.environ['RESTIC_BACKUP_MODE'] = 'everything'
self.assertRaises(rr.JobError, rr.backup_mode)
del os.environ['RESTIC_BACKUP_MODE']
self.assertEqual(rr.backup_mode(), 'all')
class Retention(unittest.TestCase):
def test_keep_args(self):
s = dict(rr.defaults, keep_hourly='24', keep_daily='7', keep_weekly='0', keep_monthly='x', keep_yearly='2')
self.assertEqual(rr.keep_args(s), ['--keep-hourly', '24', '--keep-daily', '7', '--keep-yearly', '2'])
self.assertEqual(rr.keep_args(dict(rr.defaults, keep_daily='0', keep_weekly='0', keep_monthly='0')), [])
def test_max_unused(self):
for value, expected in (('10%', '10%'), ('2.5%', '2.5%'), ('500M', '500M'), ('unlimited', 'unlimited'),
('1; rm', '10%'), ('', '10%'), ('--x', '10%')):
self.assertEqual(rr.max_unused(dict(rr.defaults, max_unused=value)), expected)
class Space(Patch):
def test_needed(self):
self.assertEqual(rr.local_space_needed(100 << 20, 0, 0, 0), GiB)
self.assertEqual(rr.local_space_needed(3 * GiB, 0, 0, 0), 6 * GiB)
self.assertEqual(rr.local_space_needed(None, 5 * GiB, GiB, GiB), 8 * GiB)
def test_reserve(self):
self.assertEqual(rr.disk_reserve(10 * GiB), 2 * GiB)
self.assertEqual(rr.disk_reserve(200 * GiB), 10 * GiB)
def check(self, free, total, added=None, mails=0, db=0):
rows = {'backup_snapshot': [{'summary': json.dumps({'data_added': added})}] if added is not None else [],
'quota2': [{'bytes': mails}], 'information_schema': [{'bytes': db}], 'backup_job': []}
query = lambda sql, args=None: support.Cursor(next(v for k, v in rows.items() if k in sql))
self.patch(query=query, instance_id=lambda: '0' * 16)
stat = types.SimpleNamespace(f_bavail=free // 4096, f_frsize=4096, f_blocks=total // 4096)
statvfs = os.statvfs
os.statvfs = lambda path: stat
self.addCleanup(setattr, os, 'statvfs', statvfs)
logs = []
rr.check_local_space(types.SimpleNamespace(log=logs.append), dict(rr.defaults, target='local'))
return logs
def test_check(self):
self.assertIn('50.0 GiB free, about 1.0 GiB needed plus 5.0 GiB reserve', self.check(50 * GiB, 100 * GiB, added=1)[0])
with self.assertRaisesRegex(rr.JobError, 'not enough free space for the local repository: 10.0 GiB free'):
self.check(10 * GiB, 100 * GiB, added=3 * GiB)
with self.assertRaises(rr.JobError):
self.check(7 * GiB, 40 * GiB, mails=5 * GiB, db=GiB // 2)
self.assertEqual(rr.check_local_space(None, dict(rr.defaults, target='s3')), None)
# The schedule setting: entries of (weekdays, time)
def plan(*entries):
return json.dumps([{'days': days, 'time': time} for days, time in entries])
EVERY_DAY = [0, 1, 2, 3, 4, 5, 6]
class Schedule(Patch):
def setUp(self):
support.use_timezone(self, 'Europe/Berlin')
self.written, self.jobs, self.queued = {}, [], []
# jobs: (type, created) of scheduled jobs
query = lambda sql, args=None: support.Cursor([{'1': 1}] if any(job_type == args[0] and created >= args[1]
for job_type, created in self.jobs) else [])
self.patch(datetime=support.Clock, query=query, set_setting=lambda k, v: self.written.__setitem__(k, str(v)),
queue=lambda job_type, params=None: self.queued.append(job_type))
self.s = dict(rr.defaults, enabled='1', password_confirmed='1', target='local', local_path='mailcow',
password='x' * 8, schedule=plan((EVERY_DAY, '02:30')))
def at(self, *when):
support.FakeDatetime.current = support.FakeDatetime(*when)
return support.FakeDatetime.current
def test_arming(self):
self.at(2026, 11, 8, 15, 0)
self.assertFalse(rr.schedule_due(self.s))
self.assertIn('state_schedule_since', self.written)
# armed after today's time: the next day
self.s.update(state_schedule_since=self.written['state_schedule_since'], state_schedule=self.s['schedule'])
self.assertFalse(rr.schedule_due(self.s))
self.at(2026, 11, 9, 2, 31)
self.assertTrue(rr.schedule_due(self.s))
self.jobs.append(('backup', '2026-11-09 02:31:00'))
self.assertFalse(rr.schedule_due(self.s))
def test_catch_up_and_time_change(self):
armed = self.at(2026, 11, 1, 12, 0)
self.s.update(state_schedule_since=str(int(armed.timestamp())), state_schedule=self.s['schedule'])
self.at(2026, 11, 8, 1, 0)
self.assertTrue(rr.schedule_due(self.s))
self.written.clear()
self.assertFalse(rr.schedule_due(dict(self.s, schedule=plan((EVERY_DAY, '01:00')))))
self.assertEqual(self.written.get('state_schedule'), plan((EVERY_DAY, '01:00')))
def test_dst(self):
# 02:30 does not exist on 28 March 2027 in Berlin, the backup still runs that night
armed = self.at(2027, 3, 27, 12, 0)
self.s.update(state_schedule_since=str(int(armed.timestamp())), state_schedule=self.s['schedule'])
self.at(2027, 3, 28, 1, 59)
self.jobs.append(('backup', '2027-03-27 02:30:00'))
self.assertFalse(rr.schedule_due(self.s))
self.at(2027, 3, 28, 3, 1)
self.assertTrue(rr.schedule_due(self.s))
# 31 October 2027: 02:30 happens twice, one backup
self.jobs.append(('backup', '2027-10-31 02:31:00'))
self.at(2027, 10, 31, 2, 45)
self.assertFalse(rr.schedule_due(self.s))
def test_disabled(self):
self.at(2026, 11, 8, 15, 0)
self.assertFalse(rr.schedule_due(dict(self.s, enabled='0', state_schedule_since='1')))
self.assertEqual(self.written.get('state_schedule_since'), '')
def test_entries(self):
# Monday and Wednesday at 12:00, Friday to Sunday at 18:00, Friday at 04:00; armed on Sunday 8 November 2026 19:00
armed = self.at(2026, 11, 8, 19, 0)
self.s.update(schedule=plan(([1, 3], '12:00'), ([5, 6, 0], '18:00'), ([5], '04:00')), state_schedule_since=str(int(armed.timestamp())))
self.s['state_schedule'] = self.s['schedule']
self.at(2026, 11, 9, 11, 0)
self.assertFalse(rr.schedule_due(self.s))
self.at(2026, 11, 9, 12, 1)
self.assertTrue(rr.schedule_due(self.s))
self.jobs.append(('backup', '2026-11-09 12:01:00'))
# Tuesday: no entry
self.at(2026, 11, 10, 15, 0)
self.assertFalse(rr.schedule_due(self.s))
self.jobs.append(('backup', '2026-11-11 12:01:00'))
self.at(2026, 11, 13, 4, 1)
self.assertTrue(rr.schedule_due(self.s))
self.jobs.append(('backup', '2026-11-13 04:01:00'))
self.at(2026, 11, 13, 17, 0)
self.assertFalse(rr.schedule_due(self.s))
self.at(2026, 11, 13, 18, 1)
self.assertTrue(rr.schedule_due(self.s))
# no entry, broken or wrong values: never
for schedule in ('[]', 'not json', plan(([7], '02:30')), plan((EVERY_DAY, '25:00')), '[{"days": "1", "time": "02:30"}]'):
self.assertFalse(rr.schedule_due(dict(self.s, schedule=schedule, state_schedule=schedule)), schedule)
def test_gap(self):
self.assertEqual(rr.schedule_gap(rr.schedule_slots(self.s)), 86400)
self.assertEqual(rr.schedule_gap(rr.schedule_slots(dict(self.s, schedule=plan((EVERY_DAY, '02:30'), (EVERY_DAY, '10:00'), (EVERY_DAY, '18:00'))))), 8.5 * 3600)
self.assertEqual(rr.schedule_gap(rr.schedule_slots(dict(self.s, schedule=plan(([0], '02:30'))))), 7 * 86400)
# Monday 12:00 to Wednesday 12:00
self.assertEqual(rr.schedule_gap(rr.schedule_slots(dict(self.s, schedule=plan(([1, 3], '12:00'), ([5, 6, 0], '18:00'), ([5], '04:00'))))), 2 * 86400)
# the same time twice is one slot
self.assertEqual(rr.schedule_slots(dict(self.s, schedule=plan(([1], '12:00'), ([1, 2], '12:00')))), [(1, 12, 0), (2, 12, 0)])
self.assertIsNone(rr.schedule_gap(rr.schedule_slots(dict(self.s, schedule='[]'))))
def test_after_scheduled_backup(self):
# Sunday prune, backups Monday, Wednesday, Friday: the first backup from Sunday on prunes, a check once a day
self.s.update(prune_weekday='0', check_subset='30')
self.at(2026, 11, 9, 2, 35)
rr.after_scheduled_backup(self.s)
self.assertEqual(self.queued, ['prune', 'check'])
self.jobs += [('prune', '2026-11-09 02:35:00'), ('check', '2026-11-09 02:35:00')]
self.queued.clear()
self.at(2026, 11, 9, 14, 5)
rr.after_scheduled_backup(self.s)
self.assertEqual(self.queued, [])
self.at(2026, 11, 11, 2, 35)
rr.after_scheduled_backup(self.s)
self.assertEqual(self.queued, ['check'])
# never prune, no check
self.queued.clear()
self.at(2026, 11, 16, 2, 35)
rr.after_scheduled_backup(dict(self.s, prune_weekday='', check_subset='0'))
self.assertEqual(self.queued, [])
class Export(Patch):
def test_pages_merged(self):
pages = {
None: {'version': 1, 'domain': 'a.de', 'domain_data': {'domain': {'domain': 'a.de'}}, 'aliases': [{'address': 'x@a.de'}],
'mailboxes': {'u1@a.de': {'mailbox': {'name': 'Ü One'}}, 'u2@a.de': {'mailbox': {'name': 'Two'}}}, 'next_after': 'u2@a.de'},
'u2@a.de': {'mailboxes': {'u3@a.de': {'mailbox': {'name': None}}}, 'next_after': None},
}
calls = []
def retried(url, body=None):
q = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)
calls.append(q.get('after', [None])[0])
return json.dumps(pages[calls[-1]]).encode()
self.patch(retried=retried, check_cancel=lambda: None)
with tempfile.TemporaryDirectory() as folder:
names = rr.export_domain(folder + '/a.de.json', 'a.de')
with open(folder + '/a.de.json') as f:
data = json.load(f)
mode = os.stat(folder + '/a.de.json').st_mode & 0o777
self.assertEqual(calls, [None, 'u2@a.de'])
self.assertEqual(set(data['mailboxes']), {'u1@a.de', 'u2@a.de', 'u3@a.de'})
self.assertEqual((data['aliases'], data['domain_data']), (pages[None]['aliases'], pages[None]['domain_data']))
self.assertNotIn('next_after', data)
self.assertEqual(names, {'u1@a.de': 'Ü One', 'u2@a.de': 'Two', 'u3@a.de': ''})
self.assertEqual(mode, 0o600)
def test_target_summary(self):
self.assertEqual(rr.target_summary({'target': 'sftp', 'sftp_host': 'h', 'sftp_port': 22, 'sftp_user': 'u', 'sftp_path': '/p',
'password': 'secret'}), 'sftp, sftp_host=h, sftp_port=22, sftp_user=u, sftp_path=/p')
self.assertEqual(rr.target_summary({'target': 's3', 's3_endpoint': 'https://e', 's3_bucket': 'b', 's3_secret_key': 'x'}),
's3, s3_endpoint=https://e, s3_bucket=b')
def test_content_format(self):
rows = {'FROM domain': [{'domain': 'a.de', 'created': 1700000000}, {'domain': 'b.de', 'created': 1700000001}],
'FROM mailbox': [{'username': 'v@b.de', 'name': None}]}
self.patch(query=lambda sql, args=None: support.Cursor(next(v for k, v in rows.items() if k in sql)))
content = rr.snapshot_content({'a.de': {'u@a.de': 'U'}})
self.assertEqual(content, {'domains': {'a.de': {'created': 1700000000, 'mailboxes': {'u@a.de': 'U'}},
'b.de': {'created': 1700000001, 'mailboxes': {'v@b.de': ''}}}})
self.assertEqual(json.loads(gzip.decompress(gzip.compress(json.dumps(content).encode()))), content)
def test_section_aliases(self):
# the aliases naming mailboxes come with the first page and go into the sections when the export is read
export = {'aliases': [], 'mailbox_aliases': {'u@a.de': [{'id': 2}], 'gone@a.de': [{'id': 3}]},
'mailboxes': {'u@a.de': {'aliases': [{'id': 1}]}, 'v@a.de': {'aliases': []}}}
rr.section_aliases(export)
self.assertNotIn('mailbox_aliases', export)
self.assertEqual(export['mailboxes'], {'u@a.de': {'aliases': [{'id': 1}, {'id': 2}]}, 'v@a.de': {'aliases': []}})
def test_own_aliases_come_with_the_mailboxes(self):
posted = []
self.patch(post=lambda body: posted.append(body['aliases']) or {'aliases': {'added': len(body['aliases'])}})
job = types.SimpleNamespace(id=1, result={}, save=lambda: None, log=lambda text: None)
export = {'mailboxes': {'u@a.de': {}}, 'aliases': [{'address': 'U@a.de', 'goto': 'u@a.de,x@ext.example'},
{'address': 'info@a.de', 'goto': 'u@a.de'}]}
self.patch(check_cancel=lambda: None)
rr.restore_aliases(job, export)
self.assertEqual(posted, [[{'address': 'info@a.de', 'goto': 'u@a.de'}]])
self.assertEqual(job.result['aliases']['added'], 1)
def test_snapshot_domains(self):
# the manifest's creation times go into the row
manifest = {'domains': ['b.de', 'a.de'], 'domains_created': {'b.de': 20, 'a.de': 10}}
self.patch(restic=lambda job, repo, args: (0, json.dumps(manifest).encode(), b''))
self.assertEqual(rr.snapshot_domains(None, None, {'id': 'x', 'tags': ['mailcow']}), '{"a.de": 10, "b.de": 20}')
self.assertIsNone(rr.snapshot_domains(None, None, {'id': 'x', 'tags': []}))
self.patch(restic=lambda job, repo, args: (1, b'', b'no such file'))
self.assertIsNone(rr.snapshot_domains(None, None, {'id': 'x', 'tags': ['mailcow']}))
class Secrets(Patch):
def test_seal_roundtrip_and_errors(self):
key = nacl.public.PrivateKey.generate()
sealed = rr.seal(key.public_key, 'pässwörd')
self.assertTrue(sealed.startswith(rr.sealed_prefix))
self.assertEqual(rr.unseal(key, sealed), 'pässwörd')
rows = [{'name': 'password', 'value': sealed}, {'name': 's3_secret_key', 'value': rr.seal(nacl.public.PrivateKey.generate().public_key, 'x')}]
self.patch(query=lambda sql, args=None: support.Cursor(rows), secrets_private_key=lambda: key)
s = rr.settings()
self.assertEqual(s['password'], 'pässwörd')
self.assertEqual(s['s3_secret_key'], '')
self.assertEqual(s['_secrets_error'], rr.secrets_error)
with self.assertRaisesRegex(rr.JobError, 'cannot be decrypted'):
rr.Repository(s)
class Errors(unittest.TestCase):
def test_error_text(self):
err = ['Stat(<config/>) returned error, retrying after 1.2s: Head "http://user:***@h:8000/r/config": no such host',
'Stat(<config/>) returned error, retrying after 2.4s: Head "http://user:***@h:8000/r/config": no such host',
'{"message_type":"exit_error","code":1,"message":"Fatal: unable to open config file"}']
text = rr.error_text(err)
self.assertEqual(text.count('no such host'), 1)
self.assertNotIn('user', text)
self.assertIn('Fatal: unable to open config file', text)
def test_local_time(self):
support.use_timezone(self, 'Europe/Berlin')
self.assertEqual(rr.local_time('2026-11-08T02:44:44.050341463+01:00'), '2026-11-08 02:44:44')
self.assertEqual(rr.local_time('2026-07-01T00:00:00Z'), '2026-07-01 02:00:00')
self.assertIsNone(rr.local_time('garbage'))
if __name__ == '__main__':
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
# Pure functions of the dovecot restore runner (data/Dockerfiles/dovecot/restore_runner.py)
import os
import tempfile
import unittest
import restic_runner
import restore_runner as rr
class FolderNames(unittest.TestCase):
def test_maildir_plus_plus(self):
self.assertEqual(rr.folder_name(''), 'INBOX')
self.assertEqual(rr.folder_name('.Sent'), 'Sent')
self.assertEqual(rr.folder_name('.A.B.C'), 'A/B/C')
def test_listescape_dot(self):
self.assertEqual(rr.folder_name('.a\\2eb'), 'a.b')
self.assertEqual(rr.folder_name('.rt\\2ea\\2eb.Kind'), 'rt.a.b/Kind')
def test_modified_utf7(self):
self.assertEqual(rr.folder_name('.Entw&APw-rfe'), 'Entwürfe')
self.assertEqual(rr.folder_name('.Tom &- Jerry'), 'Tom & Jerry')
self.assertEqual(rr.mutf7_decode('&ZeVnLIqe-'), '日本語')
def test_subscriptions_file(self):
with tempfile.TemporaryDirectory() as folder:
with open(os.path.join(folder, 'subscriptions'), 'w') as f:
f.write('V\t2\n\nSent\nEntw&APw-rfe\tUnter\\2ePunkt\nA\tB\n')
self.assertEqual(rr.backup_subscriptions(folder), ['Sent', 'Entwürfe/Unter.Punkt', 'A/B'])
def test_subscriptions_version_1(self):
with tempfile.TemporaryDirectory() as folder:
with open(os.path.join(folder, 'subscriptions'), 'w') as f:
f.write('Sent\nA.B\n')
self.assertEqual(rr.backup_subscriptions(folder), ['Sent', 'A/B'])
class Acl(unittest.TestCase):
def test_entries(self):
with tempfile.NamedTemporaryFile('w', delete=False) as f:
f.write('user=a@x.de lr\n-user=b@x.de l\ngroup=mcg_1 lrwstipekxa\nuser=c@y.de lr :custom\nbroken\n')
try:
self.assertEqual(rr.acl_entries(f.name), [
('user=a@x.de', ['lookup', 'read']),
('group=mcg_1', ['lookup', 'read', 'write', 'write-seen', 'write-deleted', 'insert', 'post', 'expunge', 'create',
'delete', 'admin']),
('user=c@y.de', ['lookup', 'read', ':custom'])])
finally:
os.unlink(f.name)
def test_scope(self):
acl = rr.AclRestore(('Shared/',), {'x.de'})
self.assertTrue(acl.in_scope('a@x.de'))
self.assertFalse(acl.in_scope('a@y.de'))
self.assertEqual(acl.shown('user=a@y.de'), 'user=(other domain)')
self.assertEqual(acl.shown('user=a@x.de'), 'user=a@x.de')
self.assertTrue(rr.AclRestore(('Shared/',), None).in_scope('a@y.de'))
class Messages(unittest.TestCase):
def test_message_key(self):
self.assertEqual(rr.message_key({'hdr.message-id': ' <abc@x>\n', 'date.sent': 'd', 'size.physical': '1'}), 'id:abc@x')
self.assertEqual(rr.message_key({'hdr.message-id': '', 'date.sent': '2026-11-08 01:00:00 (+0100)', 'size.physical': '159'}),
'date:2026-11-08 01:00:00 (+0100):159')
def test_present_matching(self):
present = {rr.message_key(r) for r in ({'hdr.message-id': '<a@x>'}, {'hdr.message-id': '', 'date.sent': 'd', 'size.physical': '5'})}
staged = [{'uid': '1', 'hdr.message-id': '<a@x>'}, {'uid': '2', 'hdr.message-id': '<b@x>'},
{'uid': '3', 'hdr.message-id': '', 'date.sent': 'd', 'size.physical': '5'},
{'uid': '4', 'hdr.message-id': '', 'date.sent': 'd', 'size.physical': '6'}]
self.assertEqual([r['uid'] for r in staged if rr.message_key(r) not in present], ['2', '4'])
def test_uid_chunks(self):
self.assertEqual(rr.uid_chunks([5, 1, 2, 3, 9]), ['1:3,5,9'])
self.assertEqual(rr.uid_chunks([]), [])
chunks = rr.uid_chunks(range(1, 2 * rr.uid_ranges * 2, 2))
self.assertEqual(len(chunks), 2)
self.assertLess(max(len(c) for c in chunks), 128 * 1024)
class Space(unittest.TestCase):
GiB = 1 << 30
def test_reserve(self):
self.assertEqual(rr.disk_reserve(10 * self.GiB), 2 * self.GiB)
self.assertEqual(rr.disk_reserve(100 * self.GiB), 5 * self.GiB)
def test_import(self):
self.assertTrue(rr.import_space_ok(1 * self.GiB, 10 * self.GiB, 100 * self.GiB))
self.assertFalse(rr.import_space_ok(5 * self.GiB, 10 * self.GiB, 100 * self.GiB))
self.assertFalse(rr.import_space_ok(0, 1 * self.GiB, 10 * self.GiB))
class SameAsBackupRunner(unittest.TestCase):
# helpers both runners have (separate images): they must give the same results
def test_helpers(self):
for total in (0, 10 << 30, 100 << 30, 1 << 40):
self.assertEqual(rr.disk_reserve(total), restic_runner.disk_reserve(total))
for size in (0, 1, 1048576, 5 << 30):
self.assertEqual(rr.mib(size), restic_runner.mib(size))
result = {'domain': {'errors': ['a']}, 'aliases': {'errors': ['b', 'c']}, 'mailboxes': {
'u@d': {'error': 'gone', 'mails': {'errors': ['x']}, 'filters': {'errors': []}}, 'v@d': {'mails': {'added': 1}}}}
self.assertEqual(rr.count_errors(result), 5)
self.assertEqual(rr.count_errors(result), restic_runner.count_errors(result))
if __name__ == '__main__':
unittest.main()
+23 -11
View File
@@ -67,6 +67,7 @@ source _modules/scripts/core.sh
source _modules/scripts/ipv6_controller.sh
source _modules/scripts/new_options.sh
source _modules/scripts/migrate_options.sh
source _modules/scripts/backup.sh
############## End Function Section ##############
@@ -146,6 +147,9 @@ while (($#)); do
--skip-ping-check)
SKIP_PING_CHECK=y
;;
--backup)
BACKUP=y
;;
--stable)
CURRENT_BRANCH="$(cd "${SCRIPT_DIR}"; git rev-parse --abbrev-ref HEAD)"
NEW_BRANCH="master"
@@ -176,7 +180,7 @@ while (($#)); do
NEW_BRANCH="legacy"
;;
--help|-h)
echo './update.sh [-c|--check, --check-tags, --ours, --gc, --nightly, --prefetch, --skip-start, --skip-ping-check, --stable, --legacy, -f|--force, -d|--dev, -h|--help]
echo './update.sh [-c|--check, --check-tags, --ours, --gc, --nightly, --prefetch, --skip-start, --skip-ping-check, --backup, --stable, --legacy, -f|--force, -d|--dev, -h|--help]
-c|--check - Check for updates and exit (exit codes => 0: update available, 3: no updates)
--check-tags - Check for newer tags and exit (exit codes => 0: newer tag available, 3: no newer tag)
@@ -186,9 +190,10 @@ while (($#)); do
--prefetch - Only prefetch new images and exit (useful to prepare updates)
--skip-start - Do not start mailcow after update
--skip-ping-check - Skip ICMP Check to public DNS resolvers (Use it only if you'\''ve blocked any ICMP Connections to your mailcow machine)
--backup - Create a backup before the update without asking (when the backup is set up in the mailcow UI), also with --force
--stable - Switch your mailcow updates to the stable (master) branch. Default unless you changed it with --nightly or --legacy.
--legacy - Switch your mailcow updates to the legacy branch. The legacy branch will only receive security updates until February 2026.
-f|--force - Force update, do not ask questions
-f|--force - Force update, do not ask questions (no backup before the update unless --backup is given)
-d|--dev - Enables Developer Mode (No Checkout of update.sh for tests)
'
exit 0
@@ -239,6 +244,8 @@ else
fi
fi
backup_decide
if ! [ "$NEW_BRANCH" ]; then
echo -e "\e[33mDetecting which build your mailcow runs on...\e[0m"
sleep 1
@@ -281,6 +288,7 @@ elif [ "$NEW_BRANCH" == "master" ] && [ "$CURRENT_BRANCH" != "master" ]; then
echo "OK. If you prepared yourself for that please run the update.sh Script with the --stable parameter again to trigger this process here."
exit 0
fi
backup_run
BRANCH="$NEW_BRANCH"
DIFF_DIRECTORY=update_diffs
DIFF_FILE="${DIFF_DIRECTORY}/diff_before_upgrade_to_master_$(date +"%Y-%m-%d-%H-%M-%S")"
@@ -306,6 +314,7 @@ elif [ "$NEW_BRANCH" == "nightly" ] && [ "$CURRENT_BRANCH" != "nightly" ]; then
echo "OK. If you prepared yourself for that please run the update.sh Script with the --nightly parameter again to trigger this process here."
exit 0
fi
backup_run
BRANCH=$NEW_BRANCH
DIFF_DIRECTORY=update_diffs
DIFF_FILE=${DIFF_DIRECTORY}/diff_before_upgrade_to_nightly_$(date +"%Y-%m-%d-%H-%M-%S")
@@ -329,6 +338,7 @@ elif [ "$NEW_BRANCH" == "legacy" ] && [ "$CURRENT_BRANCH" != "legacy" ]; then
echo "OK. If you prepared yourself for that please run the update.sh Script with the --legacy parameter again to trigger this process here."
exit 0
fi
backup_run
BRANCH=$NEW_BRANCH
DIFF_DIRECTORY=update_diffs
DIFF_FILE=${DIFF_DIRECTORY}/diff_before_upgrade_to_legacy_$(date +"%Y-%m-%d-%H-%M-%S")
@@ -343,6 +353,17 @@ elif [ "$NEW_BRANCH" == "legacy" ] && [ "$CURRENT_BRANCH" != "legacy" ]; then
git checkout -f "${BRANCH}"
fi
if [ ! "$FORCE" ]; then
read -r -p "Are you sure you want to update mailcow: dockerized? All containers will be stopped. [y/N] " response
if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "OK, exiting."
exit 0
fi
detect_major_update
fi
backup_run
if [ ! "$DEV" ]; then
EXIT_COUNT=0
echo -e "\e[32mChecking for newer update script...\e[0m"
@@ -356,15 +377,6 @@ if [ ! "$DEV" ]; then
fi
fi
if [ ! "$FORCE" ]; then
read -r -p "Are you sure you want to update mailcow: dockerized? All containers will be stopped. [y/N] " response
if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "OK, exiting."
exit 0
fi
detect_major_update
fi
echo -e "\e[32mValidating docker-compose stack configuration...\e[0m"
sed -i 's/HTTPS_BIND:-:/HTTPS_BIND:-/g' docker-compose.yml
sed -i 's/HTTP_BIND:-:/HTTP_BIND:-/g' docker-compose.yml