From 6cf0390d5829f793fbe96d6668ec3e49a599941c Mon Sep 17 00:00:00 2001
From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com>
Date: Sun, 8 Nov 2026 22:56:27 +0100
Subject: [PATCH] [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
---
_modules/scripts/backup.sh | 71 +
_modules/scripts/new_options.sh | 15 +
data/Dockerfiles/dockerapi/main.py | 3 +
.../dockerapi/modules/DockerApi.py | 35 +
data/Dockerfiles/dovecot/Dockerfile | 1 +
data/Dockerfiles/dovecot/docker-entrypoint.sh | 1 +
data/Dockerfiles/dovecot/restore_runner.py | 702 ++++++
data/Dockerfiles/restic/Dockerfile | 28 +
data/Dockerfiles/restic/restic_runner.py | 1941 +++++++++++++++++
data/Dockerfiles/restic/sogo_restore.py | 118 +
data/conf/nginx/templates/nginx.conf.j2 | 9 +
data/conf/rspamd/dynmaps/backup.php | 97 +
data/web/admin/restore.php | 8 +
data/web/admin/system.php | 3 +
data/web/api/openapi.yaml | 933 ++++++++
data/web/css/site/admin.css | 216 +-
data/web/css/themes/mailcow-darkmode.css | 14 +-
data/web/domainadmin/restore.php | 8 +
data/web/inc/footer.inc.php | 1 +
data/web/inc/init_db.inc.php | 61 +
.../inc/lib/mailcow/Backup/BackupModule.php | 87 +
.../Backup/Controller/BackupApiController.php | 176 ++
.../mailcow/Backup/Legacy/LegacyDomain.php | 84 +
.../lib/mailcow/Backup/Legacy/NoTemplates.php | 19 +
data/web/inc/lib/mailcow/Backup/Model/Job.php | 129 ++
.../lib/mailcow/Backup/Model/RestoreRun.php | 116 +
.../inc/lib/mailcow/Backup/Model/Settings.php | 189 ++
.../inc/lib/mailcow/Backup/Model/Snapshot.php | 142 ++
.../Backup/Repository/ExportRepository.php | 169 ++
.../Backup/Repository/JobRepository.php | 176 ++
.../Backup/Repository/RestoreRepository.php | 237 ++
.../Backup/Repository/SettingsRepository.php | 31 +
.../Backup/Repository/SnapshotRepository.php | 31 +
.../Backup/Service/DomainRestoreService.php | 320 +++
.../mailcow/Backup/Service/ExportService.php | 227 ++
.../mailcow/Backup/Service/GroupMembers.php | 48 +
.../lib/mailcow/Backup/Service/JobService.php | 653 ++++++
.../mailcow/Backup/Service/RestoreService.php | 996 +++++++++
.../Backup/Service/SettingsService.php | 327 +++
data/web/inc/restore.inc.php | 18 +
data/web/js/site/backup.js | 1809 +++++++++++++++
data/web/json_api.php | 43 +
data/web/lang/lang.de-de.json | 369 ++++
data/web/lang/lang.en-gb.json | 369 ++++
data/web/templates/admin.twig | 6 +
.../templates/admin/tab-config-backup.twig | 378 ++++
data/web/templates/base.twig | 2 +
data/web/templates/modals/backup-job.twig | 19 +
data/web/templates/modals/backup-restore.twig | 112 +
data/web/templates/restore.twig | 81 +
docker-compose.yml | 59 +-
generate_config.sh | 11 +
.../dev_tests/run_python_unit_tests.sh | 23 +
helper-scripts/restic.sh | 717 ++++++
.../Controller/BackupApiControllerTest.php | 136 ++
tests/php/Backup/Legacy/NoTemplatesTest.php | 24 +
.../Service/DomainRestoreServiceTest.php | 197 ++
.../php/Backup/Service/ExportServiceTest.php | 140 ++
tests/php/Backup/Service/JobServiceTest.php | 604 +++++
.../php/Backup/Service/RestoreServiceTest.php | 654 ++++++
.../Backup/Service/SettingsServiceTest.php | 341 +++
.../Backup/Support/FakeRestoreRepository.php | 239 ++
tests/python/backup/support.py | 51 +
tests/python/backup/test_restic_runner.py | 344 +++
tests/python/backup/test_restore_runner.py | 109 +
update.sh | 34 +-
66 files changed, 15214 insertions(+), 97 deletions(-)
create mode 100644 _modules/scripts/backup.sh
create mode 100644 data/Dockerfiles/dovecot/restore_runner.py
create mode 100644 data/Dockerfiles/restic/Dockerfile
create mode 100644 data/Dockerfiles/restic/restic_runner.py
create mode 100644 data/Dockerfiles/restic/sogo_restore.py
create mode 100644 data/conf/rspamd/dynmaps/backup.php
create mode 100644 data/web/admin/restore.php
create mode 100644 data/web/domainadmin/restore.php
create mode 100644 data/web/inc/lib/mailcow/Backup/BackupModule.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Model/Job.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Model/Settings.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Model/Snapshot.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Service/ExportService.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Service/JobService.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Service/RestoreService.php
create mode 100644 data/web/inc/lib/mailcow/Backup/Service/SettingsService.php
create mode 100644 data/web/inc/restore.inc.php
create mode 100644 data/web/js/site/backup.js
create mode 100644 data/web/templates/admin/tab-config-backup.twig
create mode 100644 data/web/templates/modals/backup-job.twig
create mode 100644 data/web/templates/modals/backup-restore.twig
create mode 100644 data/web/templates/restore.twig
create mode 100755 helper-scripts/dev_tests/run_python_unit_tests.sh
create mode 100755 helper-scripts/restic.sh
create mode 100644 tests/php/Backup/Controller/BackupApiControllerTest.php
create mode 100644 tests/php/Backup/Legacy/NoTemplatesTest.php
create mode 100644 tests/php/Backup/Service/DomainRestoreServiceTest.php
create mode 100644 tests/php/Backup/Service/ExportServiceTest.php
create mode 100644 tests/php/Backup/Service/JobServiceTest.php
create mode 100644 tests/php/Backup/Service/RestoreServiceTest.php
create mode 100644 tests/php/Backup/Service/SettingsServiceTest.php
create mode 100644 tests/php/Backup/Support/FakeRestoreRepository.php
create mode 100644 tests/python/backup/support.py
create mode 100644 tests/python/backup/test_restic_runner.py
create mode 100644 tests/python/backup/test_restore_runner.py
diff --git a/_modules/scripts/backup.sh b/_modules/scripts/backup.sh
new file mode 100644
index 000000000..e80d30d29
--- /dev/null
+++ b/_modules/scripts/backup.sh
@@ -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
+}
diff --git a/_modules/scripts/new_options.sh b/_modules/scripts/new_options.sh
index 7d67799ba..db4080cb5 100644
--- a/_modules/scripts/new_options.sh
+++ b/_modules/scripts/new_options.sh
@@ -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
;;
diff --git a/data/Dockerfiles/dockerapi/main.py b/data/Dockerfiles/dockerapi/main.py
index bf197bd61..c2d48558e 100644
--- a/data/Dockerfiles/dockerapi/main.py
+++ b/data/Dockerfiles/dockerapi/main.py
@@ -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))
diff --git a/data/Dockerfiles/dockerapi/modules/DockerApi.py b/data/Dockerfiles/dockerapi/modules/DockerApi.py
index 3de02ac3f..414096c64 100644
--- a/data/Dockerfiles/dockerapi/modules/DockerApi.py
+++ b/data/Dockerfiles/dockerapi/modules/DockerApi.py
@@ -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
/ in a
+ # restore batch of the backup (/restore//b/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//...), or unsubscribes them
def container_post__exec__doveadm__subscribe_shared(self, request_json, **kwargs):
diff --git a/data/Dockerfiles/dovecot/Dockerfile b/data/Dockerfiles/dovecot/Dockerfile
index 4f7582608..98c13afa4 100644
--- a/data/Dockerfiles/dovecot/Dockerfile
+++ b/data/Dockerfiles/dovecot/Dockerfile
@@ -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
diff --git a/data/Dockerfiles/dovecot/docker-entrypoint.sh b/data/Dockerfiles/dovecot/docker-entrypoint.sh
index c35151e9e..9af208fc8 100755
--- a/data/Dockerfiles/dovecot/docker-entrypoint.sh
+++ b/data/Dockerfiles/dovecot/docker-entrypoint.sh
@@ -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 \
diff --git a/data/Dockerfiles/dovecot/restore_runner.py b/data/Dockerfiles/dovecot/restore_runner.py
new file mode 100644
index 000000000..47dc25d7b
--- /dev/null
+++ b/data/Dockerfiles/dovecot/restore_runner.py
@@ -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//b and sets the job to step 'mails'. Per mailbox this imports the mails folder by folder
+# (into "Restored " 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///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 "
+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_ 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()
diff --git a/data/Dockerfiles/restic/Dockerfile b/data/Dockerfiles/restic/Dockerfile
new file mode 100644
index 000000000..b24ba87c0
--- /dev/null
+++ b/data/Dockerfiles/restic/Dockerfile
@@ -0,0 +1,28 @@
+FROM alpine:3.24
+
+LABEL maintainer="The Infrastructure Company GmbH "
+
+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"]
diff --git a/data/Dockerfiles/restic/restic_runner.py b/data/Dockerfiles/restic/restic_runner.py
new file mode 100644
index 000000000..093b8678d
--- /dev/null
+++ b/data/Dockerfiles/restic/restic_runner.py
@@ -0,0 +1,1941 @@
+#!/usr/bin/python3
+# Backup runner of the restic container (daemon, tini is PID 1): runs the jobs of backup_job one at a time (backup,
+# check, prune, init and restores), queues the daily backup of the schedule in backup_setting and keeps the state and
+# the snapshot cache (backup_snapshot) up to date. backup.php (nginx:8081) exports the domains and merges the restored
+# settings (and creates deleted domains and mailboxes), sogo_restore.py merges the SOGo data, restore_runner.py in the
+# dovecot container restores mails, IMAP ACLs and Sieve scripts batch by batch.
+
+import base64
+import collections
+import datetime
+import fcntl
+import gzip
+import hashlib
+import hmac
+import json
+import os
+import platform
+import re
+import secrets
+import shutil
+import signal
+import smtplib
+import socket
+import stat
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import traceback
+import urllib.error
+import urllib.parse
+import urllib.request
+from email.message import EmailMessage
+from email.utils import formatdate, make_msgid
+import MySQLdb
+import MySQLdb.cursors
+import nacl.exceptions
+import nacl.public
+import sogo_restore
+
+poll_interval = 10
+lock_file = '/cache/runner.lock'
+cache_dir = '/cache/restic'
+ssh_dir = '/cache/ssh'
+ssh_key = ssh_dir + '/id_ed25519'
+known_hosts = ssh_dir + '/known_hosts'
+# random id of this installation (in the volume, not the database: a database restored on another server gets a new
+# one); snapshots carry it as tag instance:, forget and prune only touch the own snapshots
+instance_file = '/cache/instance_id'
+# X25519 key pair of the stored secrets: PHP seals them with the public key (state_secrets_pubkey), only this
+# container can open them; a restored database on another server needs the secrets entered again
+secrets_key_file = '/cache/secrets_key'
+secret_keys = ('password', 's3_secret_key', 'rest_password')
+sealed_prefix = 'sealed:v1:'
+secrets_error = 'the stored credentials cannot be decrypted by this backup container (another server or a new backup ' \
+ 'volume): enter the repository password and the target credentials again in the backup settings'
+# the mailcow directory (read-only, with .git) is /mailcow/install; dump, export and manifest are written next to it
+# in the container (restic stores absolute paths)
+staging_dir = '/mailcow'
+install_dir = staging_dir + '/install'
+dump_dir = staging_dir + '/mysql'
+export_dir = staging_dir + '/export'
+# what a backup holds (RESTIC_BACKUP_MODE in mailcow.conf): all; data without the mailcow directory (its configuration
+# may hold keys of an operator); config without mails (database, exports, Redis, mail crypt keys, mailcow.conf and
+# data/conf). Snapshots of data and config carry the tag mode:
+backup_modes = ('all', 'data', 'config')
+# restore components that need the mails (not in config snapshots)
+mail_components = {'mails', 'imap_acl', 'sogo', 'sieve_activate'}
+# a local repository inside the mailcow directory is not backed up: this file marks it (only with its header, which
+# holds the instance id, so a file of the same name elsewhere excludes nothing)
+repo_marker = '.mailcow-restic-repository'
+restore_dir = '/restore'
+repository_root = '/repository'
+backup_url = 'http://nginx:8081/backup.php'
+max_log = 1000000
+keep_jobs_days = 180
+# requests to backup.php are tried this often
+tries = 3
+# unreadable files listed in the result of a backup
+max_errors = 50
+# a progress line in the job log at most this often (seconds)
+progress_interval = 300
+# restic retries an unreachable repository for many minutes, the connection test gives up earlier (seconds)
+connect_timeout = 120
+# mailboxes per batch of a restore: the staging of a batch is deleted before the next one
+restore_batch = 20
+# aliases per restore_aliases request
+alias_chunk = 500
+# a batch waits this long for the dovecot runner to take it, whose heartbeat may then be this old (seconds)
+pickup_timeout = 1800
+dovecot_timeout = 900
+# free space kept besides what a restore needs
+space_reserve = 256 << 20
+# uid and gid of sogo in the sogo container (sogo-tool reads the staged backup files)
+sogo_uid = 999
+# a mail when the last successful backup is older than the longest gap of the schedule plus this (seconds), once a day
+stale_margin = 12 * 3600
+# lines of restic and ssh errors that tell the reason
+error_hints = ('Fatal:', 'Host key', 'Permission denied', 'Connection refused', 'timed out', 'no such host',
+ 'Could not resolve', 'No route to host', 'Access Denied', 'AccessDenied', 'Unauthorized', 'NoSuchBucket')
+# restore errors domain admins see instead of repository details
+repo_error_generic = 'the backup repository could not be read (details in the log of the restic container)'
+
+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_user': '',
+ 'sftp_path': '', 'sftp_port': '22', 'sftp_public_key': '', 'sftp_host_key': '', 'rest_url': '', 'rest_user': '',
+ 'rest_password': '', 'local_path': 'mailcow', 'password': '', 'password_confirmed': '0',
+ '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': '',
+}
+# restore components merged by backup.php, sogo_restore.py and the dovecot runner (filters: backup.php and dovecot)
+php_components = {'filters', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'policies', 'spam',
+ 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'}
+dovecot_components = {'mails', 'imap_acl', 'filters', 'sieve_activate'}
+
+domain_re = re.compile(r'[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+')
+local_re = re.compile(r'[^@/\s\x00]+')
+
+hostname = os.environ.get('MAILCOW_HOSTNAME') or socket.getfqdn()
+token = hmac.new(os.environ.get('DBPASS', '').encode(), b'backup', hashlib.sha256).hexdigest()
+
+conn = None
+stopping = False
+# the running job and its process, for the heartbeat, a cancel and a stop of the container
+current = {'job': None, 'proc': None, 'interrupt': None}
+last_cleanup = 0
+
+
+class JobError(Exception):
+ pass
+
+# restic or the repository failed: details are not shown to domain admins
+class RepoError(JobError):
+ pass
+
+# the job was ended by someone else (the dovecot runner failed it)
+class JobGone(Exception):
+ pass
+
+# the container stops while the dovecot runner works on a batch of the job
+class JobDetached(Exception):
+ pass
+
+
+def num(value):
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return 0
+
+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 (jobs 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
+
+def secrets_private_key():
+ try:
+ return nacl.public.PrivateKey(base64.b64decode(read_private(secrets_key_file).strip()))
+ except (OSError, ValueError, nacl.exceptions.CryptoError):
+ return None
+
+# Creates the key pair on the first start of the container
+def secrets_setup():
+ key = secrets_private_key()
+ if key is None:
+ key = nacl.public.PrivateKey.generate()
+ write_private(secrets_key_file, base64.b64encode(bytes(key)) + b'\n')
+ return key
+
+def seal(public_key, value):
+ return sealed_prefix + base64.b64encode(nacl.public.SealedBox(public_key).encrypt(value.encode())).decode()
+
+def unseal(private_key, value):
+ return nacl.public.SealedBox(private_key).decrypt(base64.b64decode(value[len(sealed_prefix):])).decode()
+
+# The settings with defaults and opened secrets; secrets this container cannot open are empty and _secrets_error is set
+def settings():
+ s = dict(defaults)
+ for row in query("SELECT name, value FROM backup_setting"):
+ s[row['name']] = row['value'] or ''
+ key = None
+ for name in secret_keys:
+ if not s[name].startswith(sealed_prefix):
+ continue
+ key = key or secrets_private_key()
+ try:
+ if key is None:
+ raise ValueError('no key')
+ s[name] = unseal(key, s[name])
+ except (ValueError, TypeError, UnicodeDecodeError, nacl.exceptions.CryptoError):
+ s[name] = ''
+ s['_secrets_error'] = secrets_error
+ return s
+
+def set_setting(name, value):
+ query("INSERT INTO backup_setting (name, value) VALUES (%s, %s) ON DUPLICATE KEY UPDATE value = VALUES(value)",
+ (name, str(value)))
+
+def instance_id():
+ try:
+ with open(instance_file) as f:
+ value = f.read().strip()
+ if re.fullmatch(r'[0-9a-f]{16}', value):
+ return value
+ except OSError:
+ pass
+ value = secrets.token_hex(8)
+ write_private(instance_file, value.encode())
+ return value
+
+def instance_tag():
+ return 'instance:' + instance_id()
+
+# Content of the marker file of the local repository; restic excludes a folder only with this header
+def marker_header():
+ return 'mailcow restic repository ' + instance_id()
+
+def configured_mode():
+ return os.environ.get('RESTIC_BACKUP_MODE', '').strip().lower()[:32] or 'all'
+
+def backup_mode():
+ mode = configured_mode()
+ if mode not in backup_modes:
+ raise JobError('RESTIC_BACKUP_MODE in mailcow.conf must be all, data or config, not "%s"' % mode)
+ return mode
+
+def mailbox_valid(username):
+ local, _, domain = username.rpartition('@')
+ return bool(local_re.fullmatch(local)) and local not in ('.', '..') and bool(domain_re.fullmatch(domain))
+
+# restic include patterns are globs
+def glob_escape(text):
+ return re.sub(r'([*?\[\]\\])', r'\\\1', text)
+
+def local_time(value):
+ # restic times are RFC 3339 with up to nanoseconds, stored in the local time zone like the other DATETIME columns
+ m = re.match(r'(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.\d+)?(Z|[+-]\d\d:\d\d)$', value or '')
+ if not m:
+ return None
+ stamp = datetime.datetime.fromisoformat(m.group(1) + m.group(2).replace('Z', '+00:00'))
+ return stamp.astimezone().strftime('%Y-%m-%d %H:%M:%S')
+
+def tail(lines, size=1000):
+ return '\n'.join(line for line in lines if line.strip())[-size:]
+
+def mib(size):
+ return '%.1f MiB' % (size / 1048576)
+
+def gib(size):
+ return '%.1f GiB' % (size / 1073741824)
+
+
+class Job:
+ def __init__(self, row):
+ self.id, self.type, self.requested_by = row['id'], row['type'], row['requested_by']
+ try:
+ self.params = json.loads(row['params'] or '{}')
+ except ValueError:
+ self.params = {}
+ if not isinstance(self.params, dict):
+ self.params = {}
+ self.result = {}
+ self.warnings = []
+ # restores of domain admins: no repository details in their log, errors and results
+ self.restricted = self.type == 'restore' and self.params.get('requested_role', 'admin') != 'admin'
+ # called with every heartbeat while restic runs, returns a line for the log or None
+ self.progress = None
+
+ def log(self, text):
+ print('backup job %d (%s): %s' % (self.id, self.type, 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, self.id))
+
+ def step(self, name):
+ query("UPDATE backup_job SET step = %s WHERE id = %s", (name, self.id))
+
+ def warn(self, text):
+ self.warnings.append(text)
+ self.log('warning: ' + text)
+
+ def save(self):
+ if self.warnings:
+ self.result['warnings'] = self.warnings
+ query("UPDATE backup_job SET result = %s WHERE id = %s", (json.dumps(self.result), self.id))
+
+
+# Repository string, options and environment of a target; secrets only go to the environment of restic
+class Repository:
+ def __init__(self, s):
+ if s.get('_secrets_error'):
+ raise JobError(s['_secrets_error'])
+ if not s['password']:
+ raise JobError('no repository password is set')
+ self.env = base_env()
+ self.env.update(RESTIC_PASSWORD=s['password'], RESTIC_CACHE_DIR=cache_dir)
+ self.options = []
+ self.sftp = False
+ target = s['target']
+ if target == 's3':
+ endpoint = s['s3_endpoint'].strip().rstrip('/')
+ bucket, path = s['s3_bucket'].strip().strip('/'), s['s3_path'].strip().strip('/')
+ if not re.fullmatch(r'https?://[^/\s@]+', endpoint):
+ raise JobError('the S3 endpoint must be a URL like https://s3.example.com')
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', bucket):
+ raise JobError('the S3 bucket is missing or invalid')
+ if '..' in path.split('/'):
+ raise JobError('the S3 path must not contain ..')
+ if not s['s3_access_key'] or not s['s3_secret_key']:
+ raise JobError('the S3 access key or secret key is missing')
+ self.name = 's3:%s/%s' % (endpoint, bucket) + ('/' + path if path else '')
+ self.env.update(AWS_ACCESS_KEY_ID=s['s3_access_key'], AWS_SECRET_ACCESS_KEY=s['s3_secret_key'])
+ if s['s3_region'].strip():
+ self.options += ['-o', 's3.region=' + s['s3_region'].strip()]
+ if s['s3_path_style'] == '1':
+ self.options += ['-o', 's3.bucket-lookup=path']
+ elif target == 'sftp':
+ host, user, path = s['sftp_host'].strip(), s['sftp_user'].strip(), s['sftp_path'].strip()
+ port = num(s['sftp_port'])
+ # these go into the ssh command line: no spaces, nothing ssh could take for an option
+ if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*', host):
+ raise JobError('the SFTP host is missing or invalid')
+ if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._-]*', user):
+ raise JobError('the SFTP user is missing or invalid')
+ if not 0 < port < 65536:
+ raise JobError('the SFTP port is invalid')
+ if not path or '..' in path.split('/'):
+ raise JobError('the SFTP path is missing or contains ..')
+ self.sftp = True
+ self.strict = bool(s['sftp_host_key'].strip())
+ self.name = 'sftp:%s@%s:%s' % (user, '[%s]' % host if ':' in host else host, path)
+ command = ['ssh', '-F', '/dev/null', '-i', ssh_key, '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes',
+ '-o', 'UserKnownHostsFile=' + known_hosts, '-o', 'GlobalKnownHostsFile=/dev/null',
+ '-o', 'StrictHostKeyChecking=' + ('yes' if self.strict else 'accept-new'), '-o', 'LogLevel=ERROR',
+ '-o', 'ConnectTimeout=30', '-o', 'ServerAliveInterval=60', '-o', 'ServerAliveCountMax=5',
+ '-p', str(port), '-l', user, '-s', '--', host, 'sftp']
+ self.options += ['-o', 'sftp.command=' + ' '.join(command)]
+ elif target == 'rest':
+ url = s['rest_url'].strip()
+ parts = urllib.parse.urlsplit(url)
+ if parts.scheme not in ('http', 'https') or not parts.hostname:
+ raise JobError('the REST server URL must look like https://host:8000/path')
+ if parts.username or parts.password:
+ raise JobError('the REST server URL must not contain credentials, they belong into user and password')
+ self.name = 'rest:' + url
+ if s['rest_user']:
+ self.env.update(RESTIC_REST_USERNAME=s['rest_user'], RESTIC_REST_PASSWORD=s['rest_password'])
+ elif target == 'local':
+ path = s['local_path'].strip().strip('/')
+ if not re.fullmatch(r'[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*', path) or '..' in path.split('/'):
+ raise JobError('the local path must be a subdirectory of the repository folder without ..')
+ self.name = repository_root + '/' + path
+ else:
+ raise JobError('no backup target is configured')
+ self.env['RESTIC_REPOSITORY'] = self.name
+ if num(s['limit_upload']) > 0:
+ self.options += ['--limit-upload', str(num(s['limit_upload']))]
+
+ # known_hosts comes from the settings: empty = the next connection accepts the key of the host
+ def prepare(self, s):
+ if self.sftp:
+ with open(known_hosts, 'w') as f:
+ f.write(s['sftp_host_key'].strip() + '\n' if self.strict else '')
+
+ # store the host key accepted on the first connection
+ def remember_host(self, s):
+ if self.sftp and not self.strict:
+ try:
+ with open(known_hosts) as f:
+ key = f.read().strip()
+ except OSError:
+ key = ''
+ if key:
+ set_setting('sftp_host_key', key)
+ s['sftp_host_key'] = key
+ self.strict = True
+
+
+def base_env():
+ env = {k: os.environ[k] for k in ('PATH', 'TZ', 'LANG', 'LC_ALL') if k in os.environ}
+ env['HOME'] = '/root'
+ return env
+
+# state_runner_seen every poll_interval, in its own thread and connection: also while a call blocks the runner
+class Heartbeat(threading.Thread):
+ def __init__(self):
+ super().__init__(daemon=True)
+
+ def run(self):
+ db = None
+ while not stopping:
+ try:
+ if db is None:
+ db = connect()
+ db.cursor().execute("""INSERT INTO backup_setting (name, value) VALUES ('state_runner_seen', %s)
+ ON DUPLICATE KEY UPDATE value = VALUES(value)""", (str(int(time.time())),))
+ except MySQLdb.Error:
+ db = None
+ time.sleep(poll_interval)
+
+# Checks while a job runs (cancel, stop, progress) and the cleanup of the restore staging; returns why the running
+# job has to stop
+def tick():
+ global last_cleanup
+ try:
+ if time.monotonic() - last_cleanup >= 60:
+ last_cleanup = time.monotonic()
+ clean_restore()
+ job = current['job']
+ if job:
+ row = query("SELECT state FROM backup_job WHERE id = %s", (job.id,)).fetchone()
+ if row and row['state'] == 'cancelled':
+ return 'cancelled'
+ if job.progress:
+ line = job.progress()
+ if line:
+ job.log(line)
+ except MySQLdb.Error as e:
+ print('backup runner: database error: %s' % e, file=sys.stderr)
+ return 'stopped' if stopping else None
+
+def interrupted_error():
+ return JobError('cancelled' if current['interrupt'] == 'cancelled' else 'interrupted: the container was stopped')
+
+# Last heartbeat of the dovecot runner (unix time, 0 before the first)
+def restore_seen():
+ row = query("SELECT value FROM backup_setting WHERE name = 'state_restore_seen'").fetchone()
+ return num(row['value']) if row else 0
+
+# Heartbeat in long phases without restic: stops the job when it was cancelled or the container stops
+def check_cancel():
+ reason = tick()
+ if reason:
+ current['interrupt'] = current['interrupt'] or reason
+ raise interrupted_error()
+
+# The reason in the error output of restic (and ssh): hint lines first, retry lines only once
+def error_text(err):
+ lines = []
+ for line in err:
+ line = line.strip()
+ if line.startswith('{'):
+ try:
+ message = json.loads(line)
+ line = (message.get('message') or '') if isinstance(message, dict) else line
+ except ValueError:
+ pass
+ line = re.sub(r', retrying after [^:]*:', ':', re.sub(r'^subprocess ssh: ', '', line))
+ # user names in URLs (the passwords are masked by restic)
+ line = re.sub(r'//[^/@\s]+@', '//', line)
+ if line and line not in lines:
+ lines.append(line)
+ hints = [line for line in lines if any(hint in line for hint in error_hints)]
+ return '\n'.join((hints or lines)[-6:])[:1000]
+
+# Run a command, heartbeat every poll_interval. stdout lines go to on_line or are returned, stderr lines to on_err and
+# the end of stderr is kept. timeout: interrupt it after that many seconds (RepoError)
+def execute(cmd, env, on_line=None, capture=True, timeout=None, on_err=None):
+ proc = subprocess.Popen(cmd, env=env, stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE if on_line or capture else subprocess.DEVNULL, stderr=subprocess.PIPE)
+ out, err = [], collections.deque(maxlen=40)
+
+ def read_out():
+ for line in proc.stdout:
+ if on_line:
+ on_line(line)
+ else:
+ out.append(line)
+
+ def read_err():
+ for line in proc.stderr:
+ if on_err:
+ on_err(line)
+ err.append(line.decode('utf-8', 'replace').rstrip())
+
+ readers = [threading.Thread(target=read_err, daemon=True)]
+ if proc.stdout:
+ readers.append(threading.Thread(target=read_out, daemon=True))
+ for reader in readers:
+ reader.start()
+ current['proc'] = proc
+ signalled, deadline = None, time.monotonic() + timeout if timeout else None
+ try:
+ while True:
+ try:
+ proc.wait(timeout=poll_interval if not deadline else max(1, min(poll_interval, deadline - time.monotonic())))
+ break
+ except subprocess.TimeoutExpired:
+ pass
+ reason = tick()
+ if not reason and deadline and time.monotonic() >= deadline:
+ reason = 'timeout'
+ if reason and not signalled:
+ # restic removes its lock on SIGINT
+ if reason != 'timeout':
+ current['interrupt'] = current['interrupt'] or reason
+ signalled = time.monotonic()
+ proc.send_signal(signal.SIGINT)
+ elif signalled and time.monotonic() - signalled > 60:
+ proc.kill()
+ finally:
+ current['proc'] = None
+ for reader in readers:
+ reader.join()
+ if current['interrupt']:
+ raise interrupted_error()
+ if signalled:
+ raise RepoError('no answer within %d s: %s' % (timeout, error_text(err)))
+ return proc.returncode, b''.join(out), list(err)
+
+def restic_error(rc, err):
+ known = {10: 'the repository does not exist', 11: 'the repository is locked by another process',
+ 12: 'wrong repository password'}
+ text = error_text(err)
+ return known.get(rc, 'restic failed with exit code %d' % rc) + (': ' + text if text else '')
+
+# restic with the repository; a lock error removes stale locks once and tries again
+def restic(job, repo, args, on_line=None, capture=True, env=None, timeout=None, on_err=None):
+ cmd = ['restic'] + repo.options + args
+ run_env = dict(repo.env, **(env or {}))
+ for attempt in (1, 2):
+ rc, out, err = execute(cmd, run_env, on_line, capture, timeout, on_err)
+ if rc != 11 or attempt == 2:
+ break
+ job.log('the repository is locked, removing stale locks')
+ execute(['restic'] + repo.options + ['unlock'], repo.env)
+ return rc, out, err
+
+def restic_json(job, repo, args):
+ rc, out, err = restic(job, repo, args)
+ if rc != 0:
+ raise RepoError(restic_error(rc, err))
+ try:
+ return json.loads(out or b'null')
+ except ValueError:
+ raise RepoError('unexpected output of restic %s' % args[0])
+
+def restic_version():
+ try:
+ return subprocess.run(['restic', 'version'], capture_output=True, text=True, timeout=30).stdout.split()[1]
+ except (OSError, IndexError, subprocess.SubprocessError):
+ return ''
+
+# Use the repository: a changed target resets the state, create = initialize it if it does not exist yet
+def open_repository(job, s, repo, create):
+ if s.get('state_repository') != repo.name:
+ if s.get('state_repository'):
+ job.log('the repository changed, its state and the snapshot list are reset')
+ query("DELETE FROM backup_snapshot")
+ for name, value in (('state_initialized', 0), ('state_repo_size', ''), ('state_repo_stats', ''),
+ ('state_repo_id', ''), ('state_repository', repo.name)):
+ set_setting(name, value)
+ s[name] = str(value)
+ if s.get('state_initialized') == '1' and job.type != 'init':
+ return
+ job.step('connect')
+ rc, out, err = restic(job, repo, ['cat', 'config'], timeout=connect_timeout)
+ if rc == 10 and create:
+ job.log('the repository does not exist, creating it at %s' % repo.name)
+ job.step('init')
+ rc, out, err = restic(job, repo, ['init'], timeout=connect_timeout)
+ if rc != 0:
+ raise RepoError('the repository could not be created: ' + restic_error(rc, err))
+ job.result['created'] = True
+ rc, out, err = restic(job, repo, ['cat', 'config'])
+ if rc != 0:
+ raise RepoError(restic_error(rc, err))
+ try:
+ config = json.loads(out)
+ except ValueError:
+ raise RepoError('unexpected output of restic cat config')
+ repo.remember_host(s)
+ set_setting('state_initialized', 1)
+ set_setting('state_repo_id', config.get('id', ''))
+ s['state_initialized'] = '1'
+ if not job.restricted:
+ job.result['repository'] = {'id': config.get('id', ''), 'version': config.get('version')}
+ job.log('repository %s is ready' % ('' if job.restricted else '%s (format %s)' % (repo.name, config.get('version'))))
+
+def keep_args(s):
+ args = []
+ for period in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'):
+ count = num(s['keep_' + period])
+ if count > 0:
+ args += ['--keep-' + period, str(count)]
+ return args
+
+def max_unused(s):
+ value = s['max_unused'].strip()
+ return value if re.fullmatch(r'\d+(\.\d+)?%|\d+[kKmMgGtT]?|unlimited', value) else defaults['max_unused']
+
+def forget(job, s, repo, prune):
+ keep = keep_args(s)
+ if keep:
+ # only the snapshots of this installation, in one group (also after changes of hostname, paths or backup mode)
+ args = ['forget', '--tag', 'mailcow,' + instance_tag(), '--group-by', ''] + keep
+ elif prune:
+ args = ['prune']
+ else:
+ job.log('no retention policy, all snapshots are kept')
+ return
+ if prune:
+ args = ['--cleanup-cache'] + args + ['--max-unused', max_unused(s)] + (['--prune'] if keep else [])
+ rc, out, err = restic(job, repo, args)
+ if rc != 0:
+ raise RepoError(restic_error(rc, err))
+ job.result['output'] = out.decode('utf-8', 'replace').splitlines()[-30:]
+ job.log('prune finished')
+ return
+ groups = restic_json(job, repo, args + ['--json']) or []
+ removed = sum(len(group.get('remove') or []) for group in groups)
+ job.result['forget'] = {'removed': removed}
+ job.log('forget: %d snapshots removed by the retention policy' % removed)
+
+# The domains of a snapshot made by mailcow with their creation time, from its manifest: {domain: unix time}
+def snapshot_domains(job, repo, snapshot):
+ if 'mailcow' not in (snapshot.get('tags') or []):
+ return None
+ rc, out, err = restic(job, repo, ['dump', snapshot['id'], staging_dir + '/manifest.json'])
+ try:
+ created = json.loads(out)['domains_created'] if rc == 0 else None
+ return json.dumps({domain: created[domain] for domain in sorted(created)}) if isinstance(created, dict) else None
+ except (ValueError, KeyError, TypeError):
+ return None
+
+# Replaces the snapshot cache; content (gzip JSON {domain: {username: name}}) of known snapshots is kept
+def sync_snapshots(job, repo):
+ snapshots = restic_json(job, repo, ['snapshots', '--json']) or []
+ known = {row['id']: row['domains'] for row in query("SELECT id, domains FROM backup_snapshot")}
+ for snapshot in snapshots:
+ sid = snapshot['id']
+ domains = known[sid] if sid in known else snapshot_domains(job, repo, snapshot)
+ query("""INSERT INTO backup_snapshot (id, short_id, time, hostname, tags, summary, domains)
+ VALUES (%s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE short_id = VALUES(short_id), time = VALUES(time),
+ hostname = VALUES(hostname), tags = VALUES(tags), summary = VALUES(summary), domains = VALUES(domains)""",
+ (sid, snapshot.get('short_id') or sid[:8], local_time(snapshot.get('time')), snapshot.get('hostname') or '',
+ ','.join(snapshot.get('tags') or [])[:255],
+ json.dumps(snapshot['summary']) if snapshot.get('summary') else None, domains))
+ gone = set(known) - {snapshot['id'] for snapshot in snapshots}
+ for sid in gone:
+ query("DELETE FROM backup_snapshot WHERE id = %s", (sid,))
+ job.result['snapshots'] = len(snapshots)
+
+def repo_stats(job, repo):
+ try:
+ stats = restic_json(job, repo, ['stats', '--json', '--mode', 'raw-data']) or {}
+ except JobError as e:
+ job.warn('the repository size could not be read: %s' % e)
+ return
+ set_setting('state_repo_size', num(stats.get('total_size')))
+ set_setting('state_repo_stats', json.dumps(stats))
+
+def unlink(path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+def clean_staging():
+ shutil.rmtree(dump_dir, ignore_errors=True)
+ shutil.rmtree(export_dir, ignore_errors=True)
+ unlink(staging_dir + '/manifest.json')
+
+def write_private(path, data):
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
+ with os.fdopen(fd, 'wb') as f:
+ f.write(data)
+
+def read_private(path):
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
+ with os.fdopen(fd, 'rb') as f:
+ return f.read()
+
+def dump_database(job):
+ os.makedirs(dump_dir, mode=0o700, exist_ok=True)
+ os.chmod(dump_dir, 0o700)
+ # credentials in an option file, not in argv
+ fd, cnf = tempfile.mkstemp(prefix='client.', suffix='.cnf')
+ quote = lambda value: '"%s"' % value.replace('\\', '\\\\').replace('"', '\\"')
+ try:
+ with os.fdopen(fd, 'w') as f:
+ f.write('[client]\nuser=%s\npassword=%s\nsocket=/var/run/mysqld/mysqld.sock\nskip-ssl\n' % (
+ quote(os.environ['DBUSER']), quote(os.environ['DBPASS'])))
+ started = time.monotonic()
+ db = os.environ['DBNAME']
+ dump = ['mariadb-dump', '--defaults-extra-file=' + cnf, '--single-transaction', '--hex-blob',
+ '--default-character-set=utf8mb4']
+ # the service API keys of mailcow.conf stay out (php-fpm adds them again at every start)
+ rc, _, err = execute(dump + ['--routines', '--triggers', '--events', '--ignore-table=%s.api' % db,
+ '--result-file=' + dump_dir + '/mailcow.sql', db], base_env(), capture=False)
+ if rc == 0:
+ rc, _, err = execute(dump + ['--where=service = 0', '--result-file=' + dump_dir + '/api.sql', db, 'api'], base_env(),
+ capture=False)
+ if rc == 0:
+ with open(dump_dir + '/mailcow.sql', 'ab') as out, open(dump_dir + '/api.sql', 'rb') as part:
+ shutil.copyfileobj(part, out)
+ finally:
+ unlink(cnf)
+ unlink(dump_dir + '/api.sql')
+ if rc != 0:
+ raise JobError('the database dump failed: ' + tail(err))
+ size = os.path.getsize(dump_dir + '/mailcow.sql')
+ job.result['dump_bytes'] = size
+ job.log('database dump: %d bytes in %d s' % (size, time.monotonic() - started))
+
+def request(url, body=None):
+ data = None if body is None else json.dumps(body).encode()
+ req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json', 'X-Backup-Token': token},
+ method='GET' if body is None else 'POST')
+ with urllib.request.urlopen(req, timeout=600) as response:
+ return response.read()
+
+# Retries on connection errors and server errors, not on refused requests
+def retried(url, body=None):
+ for attempt in range(1, tries + 1):
+ try:
+ return request(url, body)
+ except urllib.error.HTTPError as e:
+ if e.code < 500 or attempt == tries:
+ raise
+ except (OSError, ValueError):
+ if attempt == tries:
+ raise
+ time.sleep(5 * attempt)
+
+def post(body):
+ answer = json.loads(retried(backup_url, body))
+ return answer.get('result') if isinstance(answer, dict) and isinstance(answer.get('result'), dict) else {}
+
+def http_error(e):
+ if isinstance(e, urllib.error.HTTPError):
+ try:
+ detail = e.read(300).decode('utf-8', 'replace').strip()
+ except OSError:
+ detail = ''
+ return 'HTTP %d%s' % (e.code, ': ' + detail if detail else '')
+ return str(e)
+
+# Export of one domain, page by page (after the last username of the previous page), into one file with all mailboxes;
+# returns {username: name}
+def export_domain(path, domain):
+ names, pages, opened, query_args = {}, 0, False, {'export': domain}
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
+ while query_args is not None:
+ check_cancel()
+ data = json.loads(retried(backup_url + '?' + urllib.parse.urlencode(query_args)))
+ if not isinstance(data, dict):
+ raise ValueError('no JSON object')
+ if not opened:
+ # version, domain, domain_data, aliases of the first page, then the mailboxes of all pages
+ head = json.dumps({k: v for k, v in data.items() if k not in ('mailboxes', 'next_after')})
+ f.write(head[:-1] + (', ' if len(head) > 2 else '') + '"mailboxes": {')
+ opened = True
+ for username, section in (data.get('mailboxes') or {}).items():
+ if username in names:
+ continue
+ f.write((', ' if names else '') + json.dumps(username) + ': ' + json.dumps(section))
+ mailbox = section.get('mailbox') if isinstance(section, dict) else None
+ names[username] = str((mailbox or {}).get('name') or '') if isinstance(mailbox, dict) else ''
+ pages += 1
+ after = data.get('next_after')
+ if isinstance(after, str) and after and after != query_args.get('after') and pages < 100000:
+ query_args = {'export': domain, 'after': after}
+ else:
+ query_args = None
+ f.write('}}')
+ return names
+
+# Export of every domain by backup.php (/mailcow/export/.json); failures are warnings, the mails are saved anyway.
+# Returns {domain: {username: name}} of the exported domains
+def export_domains(job):
+ folder = export_dir
+ os.makedirs(folder, mode=0o700, exist_ok=True)
+ content = {}
+ try:
+ listed = json.loads(retried(backup_url + '?domains=1'))['domains']
+ except Exception as e:
+ job.warn('the domain list for the export could not be fetched, the snapshot has no export: %s' % http_error(e))
+ job.result['export'] = {'domains': 0, 'failed': ['*']}
+ return content
+ exported, failed = 0, []
+ for domain in listed:
+ if not isinstance(domain, str) or not domain_re.fullmatch(domain):
+ failed.append(str(domain))
+ continue
+ try:
+ content[domain] = export_domain('%s/%s.json' % (folder, domain), domain)
+ exported += 1
+ except JobError:
+ raise
+ except Exception as e:
+ failed.append(domain)
+ unlink('%s/%s.json' % (folder, domain))
+ job.log('export of %s failed: %s' % (domain, http_error(e)))
+ if failed:
+ job.warn('the export failed for %d domains: %s' % (len(failed), ', '.join(failed[:20])))
+ job.result['export'] = {'domains': exported, 'failed': failed}
+ job.log('export: %d domains' % exported)
+ return content
+
+# Content of a snapshot for the restore page: {"domains": {domain: {"created": unix time, "mailboxes": {username: name}}}};
+# mailboxes of domains without export come from the database
+def snapshot_content(exported):
+ domains = {}
+ for row in query("SELECT domain, UNIX_TIMESTAMP(created) AS created FROM domain").fetchall():
+ domain = row['domain']
+ mailboxes = exported.get(domain)
+ if mailboxes is None:
+ mailboxes = {m['username']: m['name'] or '' for m in query("SELECT username, name FROM mailbox WHERE domain = %s",
+ (domain,)).fetchall()}
+ domains[domain] = {'created': num(row['created']), 'mailboxes': mailboxes}
+ return {'domains': domains}
+
+# A fresh dump.rdb: BGSAVE and wait for LASTSAVE to change; the older dump is backed up if that fails
+def save_redis(job):
+ try:
+ with socket.create_connection(('redis', 6379), timeout=30) as sock:
+ stream = sock.makefile('rwb')
+
+ def command(*args):
+ stream.write(b'*%d\r\n' % len(args) + b''.join(b'$%d\r\n%s\r\n' % (len(a), a) for a in (x.encode() for x in args)))
+ stream.flush()
+ reply = stream.readline()
+ if reply[:1] == b'-':
+ raise RuntimeError(reply[1:].strip().decode('utf-8', 'replace'))
+ return reply[1:].strip()
+
+ if os.environ.get('REDISPASS'):
+ command('AUTH', os.environ['REDISPASS'])
+ last = command('LASTSAVE')
+ try:
+ command('BGSAVE')
+ except RuntimeError as e:
+ # a save in progress also ends with a new LASTSAVE
+ if 'progress' not in str(e):
+ raise
+ deadline = time.monotonic() + 600
+ while command('LASTSAVE') == last:
+ if time.monotonic() > deadline:
+ raise RuntimeError('timeout')
+ check_cancel()
+ time.sleep(1)
+ except JobError:
+ raise
+ except Exception as e:
+ job.warn('redis did not save a fresh dump, the last one is backed up: %s' % e)
+
+# Version of the mailcow directory: git commit, branch, locally changed files, image tags
+def install_info():
+ info = {}
+
+ def git(*args):
+ result = subprocess.run(['git', '-c', 'safe.directory=*', '--no-optional-locks', '-C', install_dir] + list(args),
+ capture_output=True, text=True, timeout=300)
+ return result.stdout if result.returncode == 0 else None
+
+ try:
+ info['commit'] = (git('rev-parse', 'HEAD') or '').strip()
+ info['branch'] = (git('rev-parse', '--abbrev-ref', 'HEAD') or '').strip()
+ status = git('status', '--porcelain', '--untracked-files=all')
+ if status is not None:
+ changed = [line for line in status.splitlines() if line.strip()]
+ info['changed'] = changed[:1000]
+ info['changed_count'] = len(changed)
+ except (OSError, subprocess.SubprocessError):
+ pass
+ try:
+ with open(install_dir + '/data/web/inc/app_info.inc.php', encoding='utf-8', errors='replace') as f:
+ m = re.search(r'\$MAILCOW_GIT_VERSION="([^"]*)"', f.read())
+ if m:
+ info['version'] = m.group(1)
+ except OSError:
+ pass
+ images, service = {}, None
+ try:
+ with open(install_dir + '/docker-compose.yml', encoding='utf-8', errors='replace') as f:
+ for line in f:
+ m = re.match(r'^ ([A-Za-z0-9_.-]+):\s*$', line)
+ if m:
+ service = m.group(1)
+ m = re.match(r'^\s+image:\s*(\S+)', line)
+ if m and service:
+ images[service] = m.group(1)
+ except OSError:
+ pass
+ info['images'] = images
+ return info
+
+def db_version():
+ row = query("SELECT version FROM versions WHERE application = 'db_schema'").fetchone()
+ return row['version'] if row else ''
+
+def write_manifest(job, mode):
+ rows = query("SELECT domain, UNIX_TIMESTAMP(created) AS created FROM domain").fetchall()
+ domains = sorted(row['domain'] for row in rows)
+ install = install_info()
+ # without the directory only its version
+ if mode == 'data':
+ install = {key: install[key] for key in ('commit', 'branch', 'version') if key in install}
+ manifest = {'created': int(time.time()), 'hostname': hostname, 'db_version': db_version(), 'restic': restic_version(),
+ 'domains': domains, 'domains_created': {row['domain']: num(row['created']) for row in rows}, 'arch': platform.machine(),
+ 'instance': instance_id(), 'mode': mode, 'install': install}
+ write_private(staging_dir + '/manifest.json', json.dumps(manifest).encode())
+ job.result['domains'] = len(domains)
+
+# What a backup of the mode holds: paths and excludes
+def backup_paths(s, mode):
+ staged = [dump_dir, export_dir, staging_dir + '/manifest.json']
+ if mode == 'config':
+ return ['/mail_crypt', '/redis'] + staged + [install_dir + '/mailcow.conf', install_dir + '/data/conf'], []
+ paths, excludes = ['/var/vmail'], []
+ if s['include_index'] == '1':
+ paths.append('/var/vmail_index')
+ if s['include_fts'] != '1':
+ excludes += ['--exclude', '/var/vmail_index/**/fts-flatcurve']
+ # data: of the mailcow directory only mailcow.conf (data/conf may hold keys of the operator)
+ paths += ['/mail_crypt', '/redis', '/rspamd', '/sogo_backup'] + ([staging_dir] if mode == 'all' else staged + [install_dir + '/mailcow.conf'])
+ return paths, excludes
+
+def run_backup(job, s, repo, mode):
+ args = ['backup', '--json', '--host', hostname, '--tag', 'mailcow', '--tag', instance_tag()] + \
+ ([] if mode == 'all' else ['--tag', 'mode:' + mode]) + [
+ '--exclude', '/var/vmail/_garbage',
+ # the mailcow directory without the local repository, the template cache and editor files
+ '--exclude-if-present', '%s:%s' % (repo_marker, marker_header()),
+ '--exclude', install_dir + '/data/web/templates/cache/*', '--exclude', '!' + install_dir + '/data/web/templates/cache/.gitkeep',
+ '--exclude', install_dir + '/**/*.swp', '--exclude', install_dir + '/**/*~']
+ local = os.environ.get('RESTIC_LOCAL_PATH', '').strip()
+ if local and not local.startswith('/') and '..' not in local.split('/'):
+ args += ['--exclude', os.path.normpath(os.path.join(install_dir, local))]
+ paths, excludes = backup_paths(s, mode)
+ args += excludes
+ state = {'summary': None, 'errors': [], 'count': 0, 'status': None, 'logged': time.monotonic()}
+
+ # runs in the reader threads, no database access here; errors come on stderr
+ def on_line(line):
+ try:
+ message = json.loads(line)
+ except ValueError:
+ return
+ kind = message.get('message_type') if isinstance(message, dict) else None
+ if kind == 'summary':
+ state['summary'] = message
+ elif kind == 'status':
+ state['status'] = message
+ elif kind == 'error':
+ state['count'] += 1
+ if len(state['errors']) < max_errors:
+ state['errors'].append({'item': message.get('item', ''), 'error': (message.get('error') or {}).get('message', '')})
+
+ def progress():
+ status = state['status']
+ if not status or time.monotonic() - state['logged'] < progress_interval:
+ return None
+ state['logged'] = time.monotonic()
+ return 'backup: %d%% done, %d of %d files, %d MiB of %d MiB' % (100 * (status.get('percent_done') or 0),
+ status.get('files_done', 0), status.get('total_files', 0), status.get('bytes_done', 0) >> 20,
+ status.get('total_bytes', 0) >> 20)
+
+ job.progress = progress
+ try:
+ rc, _, err = restic(job, repo, args + paths, on_line=on_line, env={'RESTIC_PROGRESS_FPS': '0.1'}, on_err=on_line)
+ finally:
+ job.progress = None
+ summary = state['summary']
+ if rc not in (0, 3) or not summary:
+ raise RepoError(restic_error(rc, err))
+ job.result['snapshot'] = summary.get('snapshot_id')
+ job.result['summary'] = summary
+ if state['errors']:
+ job.result['file_errors'] = state['errors']
+ if rc == 3:
+ job.warn('%d files could not be read, the snapshot misses them' % max(state['count'], 1))
+ job.log('snapshot %s: %d new and %d changed files, %d MiB added, %d MiB processed' % (
+ (summary.get('snapshot_id') or '')[:8], summary.get('files_new', 0), summary.get('files_changed', 0),
+ summary.get('data_added', 0) >> 20, summary.get('total_bytes_processed', 0) >> 20))
+
+# Free space for a local repository (the default volume shares the disk with the mail data, a full disk stops mail):
+# what the backup adds (twice the data added by the last snapshot of this installation, else an estimate of everything)
+# must leave a reserve of max(2 GiB, 5 % of the filesystem)
+def local_space_needed(added, mails, database, dump):
+ # twice the data the last snapshot added; for the first backup everything plus 1 GiB
+ if added is not None:
+ return max(1 << 30, 2 * added)
+ return mails + database + dump + (1 << 30)
+
+# as in restore_runner.py of the dovecot image (also mib and count_errors; tests/python/backup compares them)
+def disk_reserve(total):
+ return max(2 << 30, total // 20)
+
+def check_local_space(job, s):
+ if s['target'] != 'local':
+ return
+ fs = os.statvfs(repository_root)
+ free, total = fs.f_bavail * fs.f_frsize, fs.f_blocks * fs.f_frsize
+ added, mails, database, dump = None, 0, 0, 0
+ row = query("""SELECT summary FROM backup_snapshot WHERE FIND_IN_SET(%s, tags) AND summary IS NOT NULL
+ ORDER BY time DESC LIMIT 1""", (instance_tag(),)).fetchone()
+ if row:
+ try:
+ added = int(json.loads(row['summary']).get('data_added'))
+ except (ValueError, TypeError, AttributeError):
+ added = None
+ if added is None:
+ # first backup: mails, database, the last dump if there is one
+ mails = num(query("SELECT IFNULL(SUM(bytes), 0) AS bytes FROM quota2").fetchone()['bytes'])
+ database = num(query("""SELECT IFNULL(SUM(data_length + index_length), 0) AS bytes FROM information_schema.tables
+ WHERE table_schema = DATABASE()""").fetchone()['bytes'])
+ for row in query("""SELECT result FROM backup_job WHERE type = 'backup' AND state = 'done' AND result IS NOT NULL
+ ORDER BY id DESC LIMIT 1"""):
+ try:
+ dump = num(json.loads(row['result']).get('dump_bytes'))
+ except (ValueError, AttributeError):
+ pass
+ needed = local_space_needed(added, mails, database, dump)
+ reserve = disk_reserve(total)
+ text = '%s free, about %s needed plus %s reserve' % (gib(free), gib(needed), gib(reserve))
+ if free - needed < reserve:
+ raise JobError('not enough free space for the local repository: ' + text)
+ job.log('local repository: ' + text)
+
+def job_backup(job, s, repo):
+ set_setting('state_last_backup', job.id)
+ mode = backup_mode()
+ check_local_space(job, s)
+ open_repository(job, s, repo, create=True)
+ try:
+ job.step('dump')
+ dump_database(job)
+ job.step('export')
+ exported = export_domains(job)
+ save_redis(job)
+ write_manifest(job, mode)
+ job.step('backup')
+ run_backup(job, s, repo, mode)
+ finally:
+ clean_staging()
+ # the data is safe from here on: the snapshot gets its row with the content at once, forget and the snapshot list
+ # are warnings when they fail
+ set_setting('state_last_success', int(time.time()))
+ store_snapshot(job, snapshot_content(exported), mode)
+ job.save()
+ try:
+ job.step('forget')
+ forget(job, s, repo, prune=False)
+ except RepoError as e:
+ job.warn('forget failed, the snapshot is saved: %s' % e)
+ try:
+ job.step('snapshots')
+ sync_snapshots(job, repo)
+ except RepoError as e:
+ job.warn('the snapshot list could not be refreshed: %s' % e)
+ # the exact size is read after prunes; here the data the snapshot added is counted
+ if not s.get('state_repo_size'):
+ job.step('stats')
+ repo_stats(job, repo)
+ else:
+ set_setting('state_repo_size', num(s['state_repo_size']) + num((job.result.get('summary') or {}).get('data_added_packed')
+ or (job.result.get('summary') or {}).get('data_added')))
+
+# Row of the new snapshot with its content (the restore page lists domains and mailboxes from it); the snapshot sync
+# fills in the rest and keeps the content
+def store_snapshot(job, content, mode):
+ summary = job.result.get('summary') or {}
+ sid = job.result.get('snapshot')
+ if not sid:
+ return
+ query("""INSERT INTO backup_snapshot (id, short_id, time, hostname, tags, summary, domains, content)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE content = VALUES(content)""",
+ (sid, sid[:8], local_time(summary.get('backup_start')) or time.strftime('%Y-%m-%d %H:%M:%S'), hostname,
+ ','.join(['mailcow', instance_tag()] + ([] if mode == 'all' else ['mode:' + mode])), json.dumps(summary),
+ json.dumps({domain: content['domains'][domain].get('created') for domain in sorted(content['domains'])}),
+ gzip.compress(json.dumps(content).encode())))
+
+def job_init(job, s, repo):
+ open_repository(job, s, repo, create=True)
+ job.step('snapshots')
+ sync_snapshots(job, repo)
+ job.step('stats')
+ repo_stats(job, repo)
+
+def job_check(job, s, repo):
+ open_repository(job, s, repo, create=False)
+ job.step('check')
+ parts = num(s['check_subset'])
+ args = ['check', '--with-cache']
+ if parts > 0:
+ # each run reads the next part of the data
+ part = num(s.get('state_check_part')) % parts + 1
+ args.append('--read-data-subset=%d/%d' % (part, parts))
+ job.result['subset'] = '%d/%d' % (part, parts)
+ rc, out, err = restic(job, repo, args)
+ job.result['output'] = out.decode('utf-8', 'replace').splitlines()[-30:]
+ if rc != 0:
+ raise RepoError(restic_error(rc, err))
+ if parts > 0:
+ set_setting('state_check_part', part)
+ job.log('check finished' + (' (part %s)' % job.result['subset'] if parts > 0 else ''))
+
+def job_prune(job, s, repo):
+ open_repository(job, s, repo, create=False)
+ job.step('prune')
+ forget(job, s, repo, prune=True)
+ job.step('snapshots')
+ sync_snapshots(job, repo)
+ job.step('stats')
+ repo_stats(job, repo)
+
+# Restore staging: /restore/ belongs to root and only lets others pass; vmail and sogo get their own subtrees
+def make_dir(path, uid=0, gid=0, mode=0o711):
+ os.mkdir(path, mode)
+ os.chown(path, uid, gid, follow_symlinks=False)
+ os.chmod(path, mode)
+
+def component_error(text):
+ return {'added': 0, 'skipped': 0, 'errors': [text]}
+
+def free_space(job, path, need, what):
+ free = shutil.disk_usage(path).free
+ if need + space_reserve > free:
+ raise JobError('not enough free disk space for %s: %s needed, %s free' % (what, mib(need + space_reserve), mib(free)))
+
+# Sizes of the files below the given directories of a snapshot (bytes per directory); listed directories that do not
+# exist are missing in the result
+def snapshot_sizes(job, repo, snap_id, dirs):
+ sizes = {}
+ if not dirs:
+ return sizes
+ wanted = set(dirs)
+
+ def on_line(line):
+ try:
+ node = json.loads(line)
+ except ValueError:
+ return
+ path = node.get('path') if isinstance(node, dict) else None
+ if not isinstance(path, str):
+ return
+ # the directory given on the command line the path is in
+ parts = path.split('/')
+ for depth in range(len(parts), 1, -1):
+ prefix = '/'.join(parts[:depth])
+ if prefix in wanted:
+ sizes[prefix] = sizes.get(prefix, 0) + (num(node.get('size')) if node.get('type') == 'file' else 0)
+ break
+
+ rc, _, err = restic(job, repo, ['ls', '--json', '--recursive', snap_id] + dirs, on_line=on_line)
+ if rc != 0:
+ raise RepoError('the snapshot could not be listed: ' + restic_error(rc, err))
+ return sizes
+
+# Names of the entries of a directory of a snapshot
+def snapshot_names(job, repo, snap_id, folder):
+ names = set()
+
+ def on_line(line):
+ try:
+ node = json.loads(line)
+ except ValueError:
+ return
+ path = node.get('path') if isinstance(node, dict) else None
+ if isinstance(path, str) and path.startswith(folder + '/'):
+ names.add(path[len(folder) + 1:].split('/')[0])
+
+ rc, _, err = restic(job, repo, ['ls', '--json', snap_id, folder], on_line=on_line)
+ if rc != 0:
+ raise RepoError('the snapshot could not be listed: ' + restic_error(rc, err))
+ return names
+
+def restic_restore(job, repo, snap_id, target, includes, need, what):
+ free_space(job, restore_dir, need, what)
+ args = []
+ for include in includes:
+ args += ['--include', include]
+ rc, _, err = restic(job, repo, ['restore', snap_id, '--target', target] + args, capture=False)
+ if rc != 0:
+ raise RepoError('the files could not be restored: ' + restic_error(rc, err))
+
+# The exports of the domains (password hashes): read into memory, the files are deleted at once
+def load_exports(job, repo, snap_id, target, domains):
+ paths = ['/mailcow/export/%s.json' % d for d in domains]
+ sizes = snapshot_sizes(job, repo, snap_id, ['/mailcow/export'])
+ folder = target + '/export'
+ make_dir(folder, mode=0o700)
+ exports = {}
+ try:
+ restic_restore(job, repo, snap_id, folder, [glob_escape(p) for p in paths], sizes.get('/mailcow/export', 0) * 2,
+ 'the export of the snapshot')
+ for domain, path in zip(domains, paths):
+ try:
+ data = json.loads(read_private(folder + path))
+ exports[domain] = data if isinstance(data, dict) else None
+ except (OSError, ValueError):
+ exports[domain] = None
+ if exports[domain]:
+ section_aliases(exports[domain])
+ finally:
+ shutil.rmtree(folder, ignore_errors=True)
+ return exports
+
+# The aliases naming the mailboxes come once with the first page of an export: into the sections, as backup.php expects them
+def section_aliases(export):
+ sections = export.get('mailboxes') if isinstance(export.get('mailboxes'), dict) else {}
+ for username, rows in (export.pop('mailbox_aliases', None) or {}).items():
+ if isinstance(sections.get(username), dict) and isinstance(rows, list):
+ sections[username]['aliases'] = (sections[username].get('aliases') or []) + rows
+
+# The domain (with alias domains, DKIM and its settings) before its mailboxes; without it the restore fails
+def restore_domain(job, domain, export, mailboxes, results):
+ data = (export or {}).get('domain_data')
+ if not isinstance(data, dict):
+ job.result['domain'] = component_error('the snapshot has no data of the domain')
+ else:
+ try:
+ job.result['domain'] = post({'action': 'restore_domain', 'job': job.id, 'data': data}).get('domain') or \
+ component_error('no result')
+ except Exception as e:
+ job.result['domain'] = component_error('the restore request failed: %s' % http_error(e))
+ if query("SELECT 1 FROM domain WHERE domain = %s", (domain,)).fetchone():
+ job.log('domain %s: %s' % (domain, 'created' if job.result['domain'].get('created') else 'exists'))
+ return
+ for username in mailboxes:
+ results[username] = {'error': 'the domain could not be created'}
+ job.save()
+ raise JobError('the domain %s could not be created: %s' % (domain, '; '.join(job.result['domain'].get('errors') or [])))
+
+# Aliases of the domain (scope domain), in chunks; the own alias of a mailbox comes with the mailbox
+def restore_aliases(job, export):
+ mailboxes = (export or {}).get('mailboxes') or {}
+ aliases = [a for a in (export or {}).get('aliases') or [] if not (isinstance(a, dict) and str(a.get('address') or '').lower() in mailboxes)]
+ total = {'added': 0, 'skipped': 0, 'errors': []}
+ for i in range(0, len(aliases), alias_chunk):
+ check_cancel()
+ try:
+ part = post({'action': 'restore_aliases', 'job': job.id, 'aliases': aliases[i:i + alias_chunk]}).get('aliases') or {}
+ except Exception as e:
+ part = component_error('the restore request failed: %s' % http_error(e))
+ total['added'] += num(part.get('added'))
+ total['skipped'] += num(part.get('skipped'))
+ total['errors'] += [str(e) for e in part.get('errors') or []]
+ job.result['aliases'] = total
+ job.save()
+ job.log('aliases of the domain: %d added, %d skipped, %d errors' % (total['added'], total['skipped'], len(total['errors'])))
+
+# backup.php creates a missing mailbox and merges the settings of each mailbox of a batch
+def restore_settings(job, batch, sections, components, results):
+ create = set(job.params.get('create_mailboxes') or [])
+ for username in batch:
+ result = results[username]
+ if result.get('error') or not (components or username in create):
+ continue
+ check_cancel()
+ section = sections.get(username)
+ if not isinstance(section, dict):
+ for component in components:
+ result[component] = component_error('the snapshot has no export of this mailbox')
+ if username in create:
+ result['mailbox'] = component_error('the snapshot has no export of this mailbox, it cannot be created')
+ job.log('%s: no export in the snapshot' % username)
+ continue
+ try:
+ for component, value in post({'action': 'restore', 'job': job.id, 'username': username, 'data': section}).items():
+ result[component] = value
+ job.log('%s: %s' % (username, 'created and settings restored' if (result.get('mailbox') or {}).get('created')
+ else 'settings restored'))
+ except Exception as e:
+ for component in components:
+ result[component] = component_error('the restore request failed: %s' % http_error(e))
+ job.log('%s: the restore request failed: %s' % (username, http_error(e)))
+ job.save()
+
+# sogo_restore.py resets calendars, address books and SOGo preferences of each mailbox to the backup (sogo-tool
+# restore in the sogo container through dockerapi)
+def restore_sogo(job, sogo_dir, batch, results):
+ for username in batch:
+ check_cancel()
+ db = connect()
+ try:
+ results[username]['sogo'] = sogo_restore.restore(db, sogo_dir, username,
+ lambda text: job.log('%s: %s' % (username, text)))
+ except Exception as e:
+ results[username]['sogo'] = component_error('the SOGo restore failed: %s' % e)
+ finally:
+ db.close()
+ job.save()
+
+# sogo-tool backup files are world-readable and hold password hashes: only the sogo user reads them, no symlinks
+def protect_sogo_files(folder):
+ os.chown(folder, sogo_uid, sogo_uid, follow_symlinks=False)
+ os.chmod(folder, 0o700)
+ for name in os.listdir(folder):
+ path = os.path.join(folder, name)
+ mode = os.lstat(path).st_mode
+ if stat.S_ISREG(mode):
+ os.chown(path, sogo_uid, sogo_uid, follow_symlinks=False)
+ os.chmod(path, 0o600)
+ elif stat.S_ISDIR(mode):
+ shutil.rmtree(path, ignore_errors=True)
+ else:
+ unlink(path)
+
+# Hands a batch to the dovecot runner (step 'mails') and waits until it is done with it ('mails_done')
+def handover(job, batch_dir, acl_dir, mailboxes):
+ job.result['batch'] = {'dir': batch_dir, 'acl_dir': acl_dir, 'mailboxes': mailboxes}
+ job.result['handover'] = int(time.time())
+ job.save()
+ query("UPDATE backup_job SET step = 'mails' WHERE id = %s AND state = 'running'", (job.id,))
+ job.log('mails, IMAP ACLs and Sieve scripts of %d mailboxes are restored by the dovecot container' % len(mailboxes))
+ started = time.monotonic()
+ while True:
+ sleep(5)
+ tick()
+ if stopping:
+ # the dovecot runner finishes the batch, the job fails after the restart
+ raise JobDetached()
+ row = query("SELECT state, step, result FROM backup_job WHERE id = %s", (job.id,)).fetchone()
+ if not row or row['state'] != 'running':
+ raise JobGone()
+ if row['step'] == 'mails_done':
+ job.result = json.loads(row['result'] or '{}')
+ break
+ if row['step'] == 'mails' and time.monotonic() - started > pickup_timeout and query("""UPDATE backup_job SET step = 'files'
+ WHERE id = %s AND step = 'mails'""", (job.id,)).rowcount == 1:
+ raise JobError('the dovecot container did not take over the restore (ofelia job dovecot_restore_runner)')
+ if row['step'] == 'mails_running' and time.time() - restore_seen() > dovecot_timeout:
+ raise JobError('the restore of the mails in the dovecot container stopped answering')
+ for key in ('batch', 'handover'):
+ job.result.pop(key, None)
+ job.warnings = job.result.get('warnings') or job.warnings
+ job.step('files')
+
+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
+
+# What restic restores for a batch: the home only when mails are restored, else only the files of the other
+# components; returns (include patterns, bytes of the homes)
+def batch_includes(ready, homes, components):
+ includes, need = [], 0
+ for username in ready:
+ local, _, domain = username.rpartition('@')
+ home = '/var/vmail/%s/%s' % (domain, local)
+ if home in homes:
+ if 'mails' in components:
+ includes.append(glob_escape(home))
+ need += homes[home]
+ else:
+ if 'imap_acl' in components:
+ includes.append(glob_escape(home) + '/**/dovecot-acl')
+ if components & {'filters', 'sieve_activate'}:
+ includes.append(glob_escape(home) + '/sieve')
+ if components & {'filters', 'sieve_activate'}:
+ includes.append('/var/vmail/sieve/%s.sieve' % glob_escape(username))
+ if 'sogo' in components:
+ includes.append('/sogo_backup/%s' % glob_escape(username))
+ return includes, need
+
+# A restore: deleted domains and mailboxes are created by backup.php first, then the mailboxes are restored in
+# batches: restic restores only what the components need into /restore//b, backup.php merges the settings,
+# sogo_restore.py the SOGo data, the dovecot runner mails, IMAP ACLs and Sieve scripts; the staging of a batch is
+# deleted before the next one
+def job_restore(job, s, repo):
+ params = job.params
+ snapshot = str(params.get('snapshot') or '')
+ if not re.fullmatch(r'[0-9a-f]{8,64}', snapshot):
+ raise JobError('invalid snapshot id')
+ mailboxes = params.get('mailboxes') if isinstance(params.get('mailboxes'), list) else []
+ create_domain = bool(params.get('create_domain'))
+ # a deleted domain without mailboxes: domain data and aliases only
+ if (not mailboxes and not create_domain) or not all(isinstance(m, str) and mailbox_valid(m) for m in mailboxes):
+ raise JobError('no or invalid mailboxes')
+ components = {c for c in params.get('components') or [] if isinstance(c, str)}
+ if not components:
+ raise JobError('no components to restore')
+ domains = sorted({m.rpartition('@')[2] for m in mailboxes} or {str(params.get('domain') or '')})
+ if len(domains) != 1 or not domain_re.fullmatch(domains[0]):
+ raise JobError('invalid domain')
+ allowed = params.get('allowed_domains') if isinstance(params.get('allowed_domains'), list) else None
+ if allowed is not None and not all(isinstance(d, str) and domain_re.fullmatch(d) for d in allowed):
+ raise JobError('invalid allowed domains')
+ open_repository(job, s, repo, create=False)
+ job.step('files')
+ found = restic_json(job, repo, ['snapshots', '--json', snapshot]) or []
+ if not found:
+ raise JobError('snapshot %s not found' % snapshot)
+ snap = found[0]
+ snap_id = snap['id']
+ if 'mode:config' in (snap.get('tags') or []) and components & mail_components:
+ raise JobError('the snapshot holds no mails (RESTIC_BACKUP_MODE=config): ' + ', '.join(sorted(components & mail_components)))
+ job.result['snapshot'] = {'id': snap_id, 'short_id': snap.get('short_id') or snap_id[:8],
+ 'time': (local_time(snap.get('time')) or '')[:16]}
+ target = '%s/%d' % (restore_dir, job.id)
+ shutil.rmtree(target, ignore_errors=True)
+ make_dir(target)
+ results = job.result['mailboxes'] = {m: {} for m in mailboxes}
+ create = set(params.get('create_mailboxes') or [])
+ scope_domain = params.get('scope') == 'domain'
+ # the export also tells which mailboxes the snapshot has
+ exports = load_exports(job, repo, snap_id, target, domains)
+ sogo_files = snapshot_names(job, repo, snap_id, '/sogo_backup') if 'sogo' in components else set()
+ sections = {}
+ for domain in domains:
+ sections.update((exports.get(domain) or {}).get('mailboxes') or {})
+ job.save()
+ if create_domain:
+ job.step('domain')
+ restore_domain(job, domains[0], exports.get(domains[0]), mailboxes, results)
+ if scope_domain and 'aliases' in components:
+ job.step('aliases')
+ restore_aliases(job, exports.get(domains[0]))
+ exports = None
+ acl_dir = None
+ if 'imap_acl' in components and mailboxes:
+ # the other side: rights other mailboxes granted to the restored ones (domain admins: only their domains)
+ acl_dir = target + '/acl'
+ make_dir(acl_dir)
+ owners = ['/var/vmail/%s/**/dovecot-acl' % glob_escape(d) for d in allowed] if allowed is not None \
+ else ['/var/vmail/**/dovecot-acl']
+ restic_restore(job, repo, snap_id, acl_dir, owners, 0, 'the IMAP ACLs')
+ homes_needed = bool(components & {'mails', 'imap_acl', 'filters', 'sieve_activate'})
+ batches = [mailboxes[i:i + restore_batch] for i in range(0, len(mailboxes), restore_batch)]
+ for number, batch in enumerate(batches, 1):
+ check_cancel()
+ job.step('files')
+ homes = {}
+ if homes_needed:
+ homes = snapshot_sizes(job, repo, snap_id, ['/var/vmail/%s/%s' % (m.rpartition('@')[2], m.rpartition('@')[0])
+ for m in batch])
+ for username in batch:
+ local, _, domain = username.rpartition('@')
+ # a mailbox that never got mail has no home yet, the export still knows it
+ if '/var/vmail/%s/%s' % (domain, local) not in homes and username not in sections and username not in sogo_files:
+ results[username] = {'error': 'the mailbox is not in the snapshot'}
+ job.log('%s: not in the snapshot' % username)
+ job.step('settings')
+ restore_settings(job, batch, sections, components & php_components, results)
+ # mailboxes that exist now (created or not) get the rest
+ live = {row['username'] for row in query("SELECT username FROM mailbox WHERE username IN (%s)" % ','.join(['%s'] * len(batch)),
+ tuple(batch))}
+ ready = []
+ for username in batch:
+ if results[username].get('error'):
+ continue
+ if username not in live:
+ results[username]['error'] = 'the mailbox does not exist' + (' and could not be created' if username in create else '')
+ continue
+ ready.append(username)
+ job.save()
+ batch_dir = '%s/b%d' % (target, number)
+ includes, need = batch_includes(ready, homes, components)
+ if includes:
+ make_dir(batch_dir)
+ job.step('files')
+ started = time.monotonic()
+ restic_restore(job, repo, snap_id, batch_dir, includes, need, 'the files of the restore')
+ job.log('files of %d mailboxes restored in %d s (batch %d of %d)' % (len(ready), time.monotonic() - started, number,
+ len(batches)))
+ detached = False
+ try:
+ if 'sogo' in components and ready:
+ job.step('sogo')
+ sogo_dir = batch_dir + '/sogo_backup'
+ if os.path.isdir(sogo_dir) and not os.path.islink(sogo_dir):
+ protect_sogo_files(sogo_dir)
+ restore_sogo(job, sogo_dir, ready, results)
+ shutil.rmtree(sogo_dir, ignore_errors=True)
+ if components & dovecot_components and ready:
+ job.result['errors'] = count_errors(job.result)
+ handover(job, batch_dir, acl_dir, ready)
+ results = job.result['mailboxes']
+ except JobDetached:
+ detached = True
+ raise
+ finally:
+ # the dovecot runner may still read the batch of a detached job
+ if not detached:
+ shutil.rmtree(batch_dir, ignore_errors=True)
+ job.result['errors'] = count_errors(job.result)
+ job.save()
+ shutil.rmtree(target, ignore_errors=True)
+ job.result['errors'] = count_errors(job.result)
+ if results and not any(not entry.get('error') for entry in results.values()):
+ raise JobError('none of the mailboxes could be restored')
+
+handlers = {'backup': job_backup, 'init': job_init, 'check': job_check, 'prune': job_prune, 'restore': job_restore}
+
+# Mail to notify_email through postfix (internal network, port 588)
+def append_log(job_id, text):
+ print('backup job %d: %s' % (job_id, text), flush=True)
+ query("UPDATE backup_job SET log = RIGHT(CONCAT(IFNULL(log, ''), %s), %s) WHERE id = %s",
+ ('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), text), max_log, job_id))
+
+# Mail through postfix (internal network, port 588) to notify_email or the given addresses; refused recipients and
+# errors go to the container log and the log of the job
+def notify(s, subject, text, job_id=None, addresses=None):
+ if addresses is None:
+ addresses = s['notify_email'].split(',')
+ rcpts = [r.strip() for r in addresses if re.fullmatch(r'[^@\s<>,]+@[^@\s<>,]+', str(r).strip())]
+ if not rcpts:
+ return
+ msg = EmailMessage()
+ msg['From'] = 'backup@' + hostname
+ msg['To'] = ', '.join(rcpts)
+ msg['Subject'] = 'mailcow backup on %s: %s' % (hostname, subject)
+ msg['Date'] = formatdate(localtime=True)
+ msg['Message-ID'] = make_msgid(domain=hostname)
+ msg.set_content(text + ('\n\nThe job log is in the backup section of the mailcow UI.\n' if job_id else '\n'))
+ problem = None
+ try:
+ with smtplib.SMTP('postfix', 588, hostname, timeout=60) as smtp:
+ refused = smtp.send_message(msg)
+ if refused:
+ problem = 'the notification was refused for %s' % ', '.join('%s (%s %s)' % (r, code, detail.decode('utf-8', 'replace')
+ if isinstance(detail, bytes) else detail) for r, (code, detail) in refused.items())
+ except smtplib.SMTPRecipientsRefused as e:
+ problem = 'the notification was refused for %s' % ', '.join(e.recipients)
+ except (OSError, smtplib.SMTPException) as e:
+ problem = 'the notification could not be sent: %s' % e
+ if problem:
+ if job_id:
+ try:
+ append_log(job_id, problem)
+ except MySQLdb.Error:
+ pass
+ else:
+ print('backup runner: %s: %s' % (subject, problem), file=sys.stderr, flush=True)
+
+def notify_failed(s, job_id, job_type, error):
+ notify(s, '%s job %d failed' % (job_type, job_id), 'The %s job %d failed:\n\n%s' % (job_type, job_id, error), job_id)
+
+def queue(job_type, params=None):
+ query("INSERT INTO backup_job (type, params, requested_by) VALUES (%s, %s, 'schedule')",
+ (job_type, json.dumps(params or {})))
+
+def finish(job, state, error):
+ try:
+ job.save()
+ except MySQLdb.Error as e:
+ # the state matters more than the result
+ print('backup job %d: the result could not be saved: %s' % (job.id, e), file=sys.stderr)
+ query("""UPDATE backup_job SET state = IF(state = 'cancelled', state, %s), error = %s, finished = NOW(),
+ step = IF(%s = 'done', NULL, step) WHERE id = %s""", (state, error, state, job.id))
+ job.log('%s job %s%s' % (job.type, state, ': ' + error if error else ''))
+
+def run_job(row):
+ job = Job(row)
+ current.update(job=job, interrupt=None)
+ s = settings()
+ state, error = 'done', None
+ try:
+ job.log('%s job started (requested by %s)' % (job.type, job.requested_by or 'unknown'))
+ repo = Repository(s)
+ repo.prepare(s)
+ handlers[job.type](job, s, repo)
+ repo.remember_host(s)
+ except JobGone:
+ state = None
+ except JobDetached:
+ # the dovecot runner still imports the batch: its staging stays, the job fails after the restart (fail_orphans)
+ current['job'] = None
+ return
+ except JobError as e:
+ state, error = 'failed', str(e)[:2000]
+ if isinstance(e, RepoError) and job.restricted:
+ print('backup job %d: %s' % (job.id, error), file=sys.stderr, flush=True)
+ error = repo_error_generic
+ except MySQLdb.Error as e:
+ state, error = 'failed', 'database error: %s' % e
+ except Exception as e:
+ traceback.print_exc()
+ state, error = 'failed', 'internal error: %s' % e
+ finally:
+ current['job'] = None
+ if job.type == 'restore':
+ shutil.rmtree('%s/%d' % (restore_dir, job.id), ignore_errors=True)
+ if state is None:
+ # failed by the dovecot runner, which also sent the notification
+ return
+ if current['interrupt'] == 'cancelled':
+ state, error = 'cancelled', None
+ finish(job, state, error)
+ if state == 'failed':
+ notify_failed(s, job.id, job.type, error)
+ elif state == 'done' and job.warnings and job.type == 'backup':
+ notify(s, 'backup job %d finished with warnings' % job.id, 'The backup job %d finished with warnings:\n\n%s' % (
+ job.id, '\n'.join('- ' + w for w in job.warnings)), job.id)
+ if state == 'done' and job.type == 'backup' and job.requested_by == 'schedule':
+ after_scheduled_backup(s)
+
+# After the first scheduled backup of a day: prune once a week from the prune weekday on (0 = Sunday, '' = never), then
+# check the next part of the data
+def after_scheduled_backup(s):
+ today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ weekday = s['prune_weekday'].strip()
+ if weekday.isdigit() and not scheduled_since('prune', today - datetime.timedelta(days=(weekday_of(today) - int(weekday)) % 7)):
+ queue('prune')
+ if num(s['check_subset']) > 0 and not scheduled_since('check', today):
+ queue('check')
+
+def scheduled_since(job_type, since):
+ return query("""SELECT 1 FROM backup_job WHERE type = %s AND requested_by = 'schedule' AND created >= %s LIMIT 1""",
+ (job_type, since.strftime('%Y-%m-%d %H:%M:%S'))).fetchone() is not None
+
+# 0 = Sunday, as prune_weekday and the days of the schedule
+def weekday_of(day):
+ return (day.weekday() + 1) % 7
+
+# The schedule (JSON list of {days: [0 = Sunday ...], time: "HH:MM"}) as sorted (weekday, hour, minute) slots
+def schedule_slots(s):
+ try:
+ entries = json.loads(s['schedule'])
+ except ValueError:
+ return []
+ slots = set()
+ for entry in entries if isinstance(entries, list) else []:
+ m = re.fullmatch(r'(\d{1,2}):(\d{2})', str(entry.get('time', '')) if isinstance(entry, dict) else '')
+ if not m or int(m.group(1)) > 23 or int(m.group(2)) > 59 or not isinstance(entry.get('days'), list):
+ continue
+ slots |= {(day, int(m.group(1)), int(m.group(2))) for day in entry['days'] if isinstance(day, int) and 0 <= day <= 6}
+ return sorted(slots)
+
+# The newest schedule time up to now, None without slots
+def last_slot(slots, now):
+ for back in range(8):
+ day = now - datetime.timedelta(days=back)
+ for weekday, hour, minute in reversed(slots):
+ if weekday == weekday_of(day):
+ slot = day.replace(hour=hour, minute=minute, second=0, microsecond=0)
+ if slot <= now:
+ return slot
+ return None
+
+# The longest time between two schedule times of a week (seconds), None without slots
+def schedule_gap(slots):
+ times = [day * 86400 + hour * 3600 + minute * 60 for day, hour, minute in slots]
+ if not times:
+ return None
+ return max([b - a for a, b in zip(times, times[1:])] + [times[0] + 7 * 86400 - times[-1]])
+
+# Scheduled backup: enabled, password confirmed, target complete, a schedule time passed since the schedule was
+# switched on (or changed) and no scheduled backup since that time
+def schedule_due(s):
+ if s['enabled'] != '1' or s['password_confirmed'] != '1':
+ if s.get('state_schedule_since'):
+ set_setting('state_schedule_since', '')
+ return False
+ try:
+ Repository(s)
+ except JobError:
+ return False
+ slots = schedule_slots(s)
+ if not slots:
+ return False
+ now = datetime.datetime.now()
+ if not s.get('state_schedule_since') or s.get('state_schedule') != s['schedule']:
+ set_setting('state_schedule_since', int(now.timestamp()))
+ set_setting('state_schedule', s['schedule'])
+ return False
+ due = last_slot(slots, now)
+ if due is None or num(s['state_schedule_since']) > due.timestamp():
+ return False
+ return not scheduled_since('backup', due)
+
+# A mail when backups are on but the last success is older than the longest gap of the schedule plus stale_margin
+# (36 hours for one backup a day), once a day
+def check_last_success(s):
+ if s['enabled'] != '1':
+ return
+ limit = (schedule_gap(schedule_slots(s)) or 86400) + stale_margin
+ last = num(s.get('state_last_success')) or num(s.get('state_schedule_since'))
+ today = time.strftime('%Y-%m-%d')
+ if not last or time.time() - last < limit or s.get('state_stale_notified') == today:
+ return
+ set_setting('state_stale_notified', today)
+ notify(s, 'no successful backup for %d hours' % ((time.time() - last) // 3600),
+ 'Backups are enabled, but the last successful backup is older than %d hours.' % (limit // 3600))
+
+# Target and connection values of settings, without secrets
+def target_summary(values):
+ fields = {'s3': ('s3_endpoint', 's3_bucket', 's3_path', 's3_region'), 'sftp': ('sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'),
+ 'rest': ('rest_url', 'rest_user'), 'local': ('local_path',)}
+ target = str(values.get('target') or '')
+ return target + ''.join(', %s=%s' % (key, values.get(key)) for key in fields.get(target, ()) if values.get(key) not in (None, ''))
+
+# Mail to the notify addresses that were set before the target or its connection changed (backup.php stores them
+# with the change); then the marker is removed, unless it changed meanwhile
+def target_changed(s):
+ marker = s['state_target_changed']
+ try:
+ change = json.loads(marker)
+ if not isinstance(change, dict):
+ raise ValueError()
+ except ValueError:
+ query("UPDATE backup_setting SET value = '' WHERE name = 'state_target_changed' AND value = %s", (marker,))
+ return
+ addresses = change.get('old_addresses') or []
+ if isinstance(addresses, str):
+ addresses = addresses.split(',')
+ when = change.get('time')
+ when = time.strftime('%Y-%m-%d %H:%M', time.localtime(int(when))) if str(when).isdigit() else str(when or '')
+ changed = change.get('changed') if isinstance(change.get('changed'), list) else []
+ old = change.get('old_target')
+ notify(s, 'backup settings changed', 'The backup settings of mailcow on %s were changed%s%s%s.\n\nTarget before: %s\n'
+ 'Target now: %s\nNotification addresses now: %s\n\nIf you did not make this change, check the backup settings and '
+ 'the API keys of mailcow.' % (hostname, ' by %s' % change['changed_by'] if change.get('changed_by') else '',
+ ' at %s' % when if when else '', ' (%s)' % ', '.join(str(c) for c in changed) if changed else '',
+ target_summary(old) if isinstance(old, dict) else str(old or 'unknown'), target_summary(s), s['notify_email'] or 'none'),
+ addresses=addresses)
+ query("UPDATE backup_setting SET value = '' WHERE name = 'state_target_changed' AND value = %s", (marker,))
+
+# Restore staging of jobs that do not run anymore (finished, failed, crashed or deleted)
+def clean_restore():
+ job = current['job']
+ for name in os.listdir(restore_dir):
+ if not name.isdigit() or (job and job.id == int(name)):
+ continue
+ row = query("SELECT state FROM backup_job WHERE id = %s", (int(name),)).fetchone()
+ if row and row['state'] == 'running':
+ continue
+ shutil.rmtree(os.path.join(restore_dir, name), ignore_errors=True)
+
+# Restores waiting for a runner that no longer waits (restart): a batch the dovecot runner finished, did not take or
+# stopped working on
+def fail_orphans(s):
+ seen = num(s.get('state_restore_seen'))
+ for row in query("""SELECT id, step FROM backup_job WHERE type = 'restore' AND state = 'running'
+ AND step IN ('mails', 'mails_running', 'mails_done')""").fetchall():
+ if current['job'] and current['job'].id == row['id']:
+ continue
+ if row['step'] == 'mails_running' and time.time() - seen < dovecot_timeout:
+ continue
+ error = 'interrupted: the backup runner was restarted'
+ if query("UPDATE backup_job SET state = 'failed', error = %s, finished = NOW() WHERE id = %s AND state = 'running'",
+ (error, row['id'])).rowcount == 1:
+ notify_failed(s, row['id'], 'restore', error)
+
+# After a restart: jobs this runner was running are lost; a scheduled backup is queued again once
+def recover():
+ stale = query("""SELECT id, type, params, requested_by FROM backup_job WHERE state = 'running'
+ AND (type <> 'restore' OR step IS NULL OR step <> 'mails_running')""").fetchall()
+ s = settings()
+ for row in stale:
+ error = 'interrupted: the backup runner was restarted'
+ query("UPDATE backup_job SET state = 'failed', error = %s, finished = NOW() WHERE id = %s AND state = 'running'",
+ (error, row['id']))
+ notify_failed(s, row['id'], row['type'], error)
+ try:
+ retry = bool(json.loads(row['params'] or '{}').get('retry_of'))
+ except (ValueError, AttributeError):
+ retry = True
+ if row['type'] == 'backup' and row['requested_by'] == 'schedule' and not retry:
+ queue('backup', {'retry_of': row['id']})
+ clean_restore()
+ clean_staging()
+
+def ssh_setup():
+ os.makedirs(ssh_dir, mode=0o700, exist_ok=True)
+ if not os.path.exists(ssh_key):
+ subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'mailcow-backup@' + hostname, '-f', ssh_key],
+ check=True, stdin=subprocess.DEVNULL)
+ if not os.path.exists(ssh_key + '.pub'):
+ public = subprocess.run(['ssh-keygen', '-y', '-f', ssh_key], check=True, capture_output=True, text=True).stdout
+ with open(ssh_key + '.pub', 'w') as f:
+ f.write(public)
+ with open(ssh_key + '.pub') as f:
+ return f.read().strip()
+
+def stop(signum, frame):
+ global stopping
+ stopping = True
+ proc = current['proc']
+ if proc and proc.poll() is None:
+ current['interrupt'] = current['interrupt'] or 'stopped'
+ try:
+ proc.send_signal(signal.SIGINT)
+ except OSError:
+ pass
+
+def sleep(seconds):
+ end = time.monotonic() + seconds
+ while not stopping and time.monotonic() < end:
+ time.sleep(min(1, end - time.monotonic()))
+
+def main():
+ global conn
+ # dumps, exports and keys are for root only
+ os.umask(0o077)
+ 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("backup runner: can't lock " + lock_file)
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
+ signal.signal(sig, stop)
+ if os.environ.get('MASTER', 'y') != 'y':
+ print('backup runner: MASTER is not y, idle', flush=True)
+ while not stopping:
+ sleep(3600)
+ return
+ os.makedirs(cache_dir, mode=0o700, exist_ok=True)
+ # /restore and /restore/: only passable for vmail and sogo, the subtrees decide who reads them
+ os.chown(restore_dir, 0, 0)
+ os.chmod(restore_dir, 0o711)
+ # marks the folder of the local repository, also when it lies inside the mailcow directory
+ try:
+ marker = repository_root + '/' + repo_marker
+ content = (marker_header() + '\n').encode()
+ if not os.path.exists(marker) or read_private(marker) != content:
+ write_private(marker, content)
+ except OSError as e:
+ print('backup runner: %s: %s' % (repository_root, e), file=sys.stderr)
+ public_key = ssh_setup()
+ secrets_key = secrets_setup()
+ secrets_public = base64.b64encode(bytes(secrets_key.public_key)).decode()
+ print('backup runner: started (restic %s, %s)' % (restic_version(), instance_tag()), flush=True)
+ Heartbeat().start()
+ recovered, last_housekeeping = False, 0
+ while not stopping:
+ try:
+ if not recovered:
+ if settings().get('state_secrets_pubkey') != secrets_public:
+ set_setting('state_secrets_pubkey', secrets_public)
+ recover()
+ recovered = True
+ tick()
+ s = settings()
+ if s['sftp_public_key'] != public_key:
+ set_setting('sftp_public_key', public_key)
+ if s.get('_secrets_error', '') != s.get('state_secrets_error', ''):
+ set_setting('state_secrets_error', s.get('_secrets_error', ''))
+ if s.get('state_backup_mode', '') != configured_mode():
+ set_setting('state_backup_mode', configured_mode())
+ if s.get('state_target_changed'):
+ target_changed(s)
+ fail_orphans(s)
+ if time.monotonic() - last_housekeeping >= 3600:
+ last_housekeeping = time.monotonic()
+ query("""DELETE FROM backup_job WHERE state IN ('done', 'failed', 'cancelled')
+ AND IFNULL(finished, created) < NOW() - INTERVAL %s DAY""", (keep_jobs_days,))
+ check_last_success(s)
+ if schedule_due(s):
+ queue('backup')
+ row = query("""SELECT id, type, params, requested_by FROM backup_job WHERE state = 'queued'
+ ORDER BY id LIMIT 1""").fetchone()
+ if row and query("UPDATE backup_job SET state = 'running', started = NOW() WHERE id = %s AND state = 'queued'",
+ (row['id'],)).rowcount == 1:
+ run_job(row)
+ continue
+ except MySQLdb.Error as e:
+ print('backup runner: database error: %s' % e, file=sys.stderr, flush=True)
+ conn = None
+ except Exception:
+ traceback.print_exc()
+ sleep(poll_interval)
+
+# Command line for update.sh: --configured (exit 0 when backups are set up, 2 when they are not, 1 on errors such as
+# credentials this container cannot open), --backup (queue a backup, wait for it and print its log; exit 0 when it is
+# done)
+def cli(argv):
+ global conn
+ conn = connect()
+ s = settings()
+ reason = None
+ if s.get('_secrets_error'):
+ print(s['_secrets_error'], file=sys.stderr)
+ return 1
+ try:
+ backup_mode()
+ except JobError as e:
+ print(e, file=sys.stderr)
+ return 1
+ try:
+ repo = Repository(s)
+ if s['password_confirmed'] != '1':
+ reason = 'the repository password is not confirmed'
+ elif s.get('state_initialized') != '1' or s.get('state_repository') != repo.name:
+ reason = 'the repository is not initialized'
+ except JobError as e:
+ reason = str(e)
+ if argv[1] == '--configured':
+ print(reason or 'backups are set up')
+ return 0 if reason is None else 2
+ if argv[1] != '--backup':
+ print('usage: %s --configured | --backup' % argv[0], file=sys.stderr)
+ return 2
+ if reason:
+ print('no backup: %s' % reason, file=sys.stderr)
+ return 2
+ if time.time() - num(s.get('state_runner_seen')) > 120:
+ print('no backup: the backup runner does not run', file=sys.stderr)
+ return 2
+ job_id = query("INSERT INTO backup_job (type, params, requested_by) VALUES ('backup', '{}', 'update')").lastrowid
+ print('backup job %d queued' % job_id, flush=True)
+ printed, waiting = 0, None
+ try:
+ while True:
+ row = query("SELECT state, error, log, result FROM backup_job WHERE id = %s", (job_id,)).fetchone()
+ if not row:
+ print('the backup job was deleted', file=sys.stderr)
+ return 1
+ if row['state'] == 'queued':
+ other = query("SELECT id, type FROM backup_job WHERE state = 'running' ORDER BY id LIMIT 1").fetchone()
+ if other and other['id'] != waiting:
+ waiting = other['id']
+ print('waiting for the running %s job %d' % (other['type'], other['id']), flush=True)
+ log = row['log'] or ''
+ if len(log) > printed:
+ print(log[printed:], end='', flush=True)
+ printed = len(log)
+ if row['state'] in ('done', 'failed', 'cancelled'):
+ break
+ time.sleep(2)
+ except KeyboardInterrupt:
+ print('\nthe backup job %d goes on in the background' % job_id, file=sys.stderr)
+ return 130
+ if row['state'] == 'done':
+ result = json.loads(row['result'] or '{}')
+ print('backup done: snapshot %s%s' % ((result.get('snapshot') or '')[:8],
+ ', %d warnings' % len(result['warnings']) if result.get('warnings') else ''))
+ return 0
+ print('backup %s%s' % (row['state'], ': ' + row['error'] if row['error'] else ''), file=sys.stderr)
+ return 1
+
+if __name__ == '__main__':
+ sys.exit(cli(sys.argv)) if len(sys.argv) > 1 else main()
diff --git a/data/Dockerfiles/restic/sogo_restore.py b/data/Dockerfiles/restic/sogo_restore.py
new file mode 100644
index 000000000..5648e41ec
--- /dev/null
+++ b/data/Dockerfiles/restic/sogo_restore.py
@@ -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 ` and `sogo-tool restore -p ` 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 (/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 / (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
diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2
index d97bb8b99..82a557183 100644
--- a/data/conf/nginx/templates/nginx.conf.j2
+++ b/data/conf/nginx/templates/nginx.conf.j2
@@ -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)(/.+)$;
diff --git a/data/conf/rspamd/dynmaps/backup.php b/data/conf/rspamd/dynmaps/backup.php
new file mode 100644
index 000000000..50485f8f2
--- /dev/null
+++ b/data/conf/rspamd/dynmaps/backup.php
@@ -0,0 +1,97 @@
+[&after=]: 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/.json of the snapshot.
+// POST {"action": "restore_domain", "job": , "data": }: recreates a deleted domain
+// POST {"action": "restore_aliases", "job": , "aliases": [...]}: a part (at most 500) of the aliases of a domain restore
+// POST {"action": "restore", "job": , "username": , "data": }: one mailbox
+// (created first when it was deleted); all answer {"result": {: {"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);
+}
diff --git a/data/web/admin/restore.php b/data/web/admin/restore.php
new file mode 100644
index 000000000..5715979a3
--- /dev/null
+++ b/data/web/admin/restore.php
@@ -0,0 +1,8 @@
+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'])
];
diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml
index cafde2456..1455699f9 100644
--- a/data/web/api/openapi.yaml
+++ b/data/web/api/openapi.yaml
@@ -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
+ "; "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:): 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
diff --git a/data/web/css/site/admin.css b/data/web/css/site/admin.css
index b54148026..465404cab 100644
--- a/data/web/css/site/admin.css
+++ b/data/web/css/site/admin.css
@@ -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;
-}
\ No newline at end of file
+.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;
+}
diff --git a/data/web/css/themes/mailcow-darkmode.css b/data/web/css/themes/mailcow-darkmode.css
index 901a6bcc6..e61368bc6 100644
--- a/data/web/css/themes/mailcow-darkmode.css
+++ b/data/web/css/themes/mailcow-darkmode.css
@@ -495,4 +495,16 @@ input::placeholder {
}
.btn-light, .btn-light:hover {
background-image: none;
-}
\ No newline at end of file
+}
+/* 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);
+}
diff --git a/data/web/domainadmin/restore.php b/data/web/domainadmin/restore.php
new file mode 100644
index 000000000..a51b2b11e
--- /dev/null
+++ b/data/web/domainadmin/restore.php
@@ -0,0 +1,8 @@
+ 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'],
diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php
index beaffeb3a..e1cb8640c 100644
--- a/data/web/inc/init_db.inc.php
+++ b/data/web/inc/init_db.inc.php
@@ -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",
diff --git a/data/web/inc/lib/mailcow/Backup/BackupModule.php b/data/web/inc/lib/mailcow/Backup/BackupModule.php
new file mode 100644
index 000000000..c7fc3eedd
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/BackupModule.php
@@ -0,0 +1,87 @@
+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);
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php b/data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php
new file mode 100644
index 000000000..38cd4b764
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Controller/BackupApiController.php
@@ -0,0 +1,176 @@
+_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/[/]: 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//: 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[/[/]]: 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/: 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=[&after=] @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": }: 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": }: 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();
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php b/data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php
new file mode 100644
index 000000000..946f87a4f
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Legacy/LegacyDomain.php
@@ -0,0 +1,84 @@
+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 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');
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php b/data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php
new file mode 100644
index 000000000..694cba2a6
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Legacy/NoTemplates.php
@@ -0,0 +1,19 @@
+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/) */
+ 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;
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php b/data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php
new file mode 100644
index 000000000..55cd13426
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Model/RestoreRun.php
@@ -0,0 +1,116 @@
+ */
+ 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));
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Model/Settings.php b/data/web/inc/lib/mailcow/Backup/Model/Settings.php
new file mode 100644
index 000000000..6d20c9f9d
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Model/Settings.php
@@ -0,0 +1,189 @@
+`) and never leave toArray(): the API gets `_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 $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
+ */
+ 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 _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;
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Model/Snapshot.php b/data/web/inc/lib/mailcow/Backup/Model/Snapshot.php
new file mode 100644
index 000000000..cdcb5b27f
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Model/Snapshot.php
@@ -0,0 +1,142 @@
+ domain => its creation time (unix) when the snapshot was made, from the snapshot row */
+ public array $created = [];
+ /**
+ * @var ?array}> 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": {"": {"created": , "mailboxes": {"": ""}}}}.
+ * 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 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:, 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(),
+ ];
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php
new file mode 100644
index 000000000..18991f069
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Repository/ExportRepository.php
@@ -0,0 +1,169 @@
+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');
+ }
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php
new file mode 100644
index 000000000..cbeb1b8e4
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Repository/JobRepository.php
@@ -0,0 +1,176 @@
+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 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, 1: array>} 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 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));
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php
new file mode 100644
index 000000000..cadf09193
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Repository/RestoreRepository.php
@@ -0,0 +1,237 @@
+ 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');
+ }
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php
new file mode 100644
index 000000000..b76b2364f
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Repository/SettingsRepository.php
@@ -0,0 +1,31 @@
+ 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]);
+ }
+ });
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php b/data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php
new file mode 100644
index 000000000..6a2e87e48
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Repository/SnapshotRepository.php
@@ -0,0 +1,31 @@
+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());
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php b/data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php
new file mode 100644
index 000000000..99fac8dfa
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Service/DomainRestoreService.php
@@ -0,0 +1,320 @@
+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 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: 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: 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'));
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Service/ExportService.php b/data/web/inc/lib/mailcow/Backup/Service/ExportService.php
new file mode 100644
index 000000000..42c5d7a38
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Service/ExportService.php
@@ -0,0 +1,227 @@
+.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 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 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;
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php b/data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php
new file mode 100644
index 000000000..52d38db88
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Service/GroupMembers.php
@@ -0,0 +1,48 @@
+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);
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Service/JobService.php b/data/web/inc/lib/mailcow/Backup/Service/JobService.php
new file mode 100644
index 000000000..8ff4b5005
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Service/JobService.php
@@ -0,0 +1,653 @@
+ '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 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/. `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//. 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//
+ *
+ * @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'));
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php b/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php
new file mode 100644
index 000000000..deedd545e
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Service/RestoreService.php
@@ -0,0 +1,996 @@
+ '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 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 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);
+ }
+}
diff --git a/data/web/inc/lib/mailcow/Backup/Service/SettingsService.php b/data/web/inc/lib/mailcow/Backup/Service/SettingsService.php
new file mode 100644
index 000000000..bff233dc7
--- /dev/null
+++ b/data/web/inc/lib/mailcow/Backup/Service/SettingsService.php
@@ -0,0 +1,327 @@
+ [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:
+ *
+ * @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]);
+ }
+}
diff --git a/data/web/inc/restore.inc.php b/data/web/inc/restore.inc.php
new file mode 100644
index 000000000..e35214495
--- /dev/null
+++ b/data/web/inc/restore.inc.php
@@ -0,0 +1,18 @@
+ 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';
diff --git a/data/web/js/site/backup.js b/data/web/js/site/backup.js
new file mode 100644
index 000000000..05af191c3
--- /dev/null
+++ b/data/web/js/site/backup.js
@@ -0,0 +1,1809 @@
+// Backup with restic. System > Configuration > Backup (admins): status, actions, settings, jobs and snapshots.
+// E-Mail > Restore (admins, domain admins): snapshot, a tree of its domains and mailboxes (also deleted ones), the
+// restore modal, the restores. Both pages share the job details modal. Texts from lang.backup. Loaded via $js_minifier.
+var mailcowBackup = (function($) {
+ 'use strict';
+
+ var POLL_MS = 5000;
+ var COMPONENTS = ['mails', 'imap_acl', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'filters',
+ 'sieve_activate', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'sogo', 'settings', 'password'];
+ // unchecked for existing targets, preselected when everything to restore was deleted
+ var RISKY = ['tfa', 'app_passwords', 'retention_rules', 'sieve_activate'];
+ // overwrite current values; for deleted targets SOGo is a normal item and settings/password are not sent
+ var OVERWRITE = ['sogo', 'settings', 'password'];
+ // existing targets: only the mails by default
+ var EXISTING_DEFAULT = ['mails'];
+ // items that need the mails, not in snapshots without them (RESTIC_BACKUP_MODE=config, as JobService::MAIL_COMPONENTS)
+ var MAIL_COMPONENTS = ['mails', 'imap_acl', 'sogo', 'sieve_activate'];
+ var BACKUP_MODES = ['all', 'data', 'config'];
+ // domain admin ACL an item needs (as JobService::COMPONENT_ACL)
+ var 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'};
+ // the restore of the dovecot container (mails, IMAP ACLs, Sieve) cannot be cancelled while it runs
+ var DOVECOT_STEPS = ['mails', 'mails_running'];
+ var SECRETS = ['password', 's3_secret_key', 'rest_password'];
+ // value of a password field while its secret is stored: shown as dots, never sent
+ var SECRET_MASK = '************';
+ var JOB_TYPES = ['backup', 'check', 'prune', 'init'];
+ var STATE_CLASS = {queued: 'bg-secondary', running: 'bg-info text-black', done: 'bg-success', failed: 'bg-danger', cancelled: 'bg-warning text-black'};
+ var RESULT_COUNTS = ['added', 'skipped', 'errors'];
+ // a scheduled backup is overdue this long after the longest gap of the schedule (26 hours for one backup a day)
+ var STALE_MARGIN = 2 * 3600;
+ // entries of the schedule (as SettingsService::MAX_SCHEDULE)
+ var MAX_SCHEDULE = 24;
+ // weekdays in the order of the form, 0 = Sunday
+ var WEEKDAYS = [1, 2, 3, 4, 5, 6, 0];
+ // errors of a job that could not open the repository
+ var REPO_ERROR = /unable to open|wrong (repository )?password|does not exist|host key|connection refused|no such host|could not resolve|timed? ?out|permission denied|repository/i;
+
+ // lang.backup of the page, '' if missing
+ function L(key) {
+ var src = (typeof lang !== 'undefined' && lang && lang.backup) ? lang.backup : {};
+ return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
+ }
+ function esc(s) {
+ return escapeHtml(s === null || s === undefined ? '' : String(s));
+ }
+ // replaces %s (and %1$s) one after the other, like vsprintf in the PHP alert box
+ function fill(text) {
+ var args = Array.prototype.slice.call(arguments, 1), next = 0;
+ return String(text).replace(/%(?:(\d+)\$)?s/g, function(match, pos) {
+ var value = pos ? args[pos - 1] : args[next++];
+ return (value === undefined || value === null) ? '' : String(value);
+ });
+ }
+ function pad(n) {
+ return (n < 10 ? '0' : '') + n;
+ }
+ function now() {
+ return Math.floor(Date.now() / 1000);
+ }
+ function pageLang() {
+ return document.documentElement.lang || undefined;
+ }
+ // the time zone of mailcow (TZ in mailcow.conf), so times match the schedule and the "Restored