Merge branch 'feat/backup' into nightly

This commit is contained in:
FreddleSpl0it committed 2026-11-09 15:44:47 +01:00
commit d475d1c593
66 files changed
+15359 -97

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
protect_route(['admin']);
$role = "admin";
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/restore.inc.php';
+3
View File
@@ -14,6 +14,7 @@ $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/archive.js');
$js_minifier->add('/web/js/site/backup.js');
// all domains
$domains = mailbox('get', 'domains');
@@ -123,6 +124,8 @@ $template_data = [
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
'iam_settings' => $iam_settings,
'mbox_templates' => $mbox_templates,
'backup_timezone' => getenv('TZ') ?: 'UTC',
'backup_hostname' => getenv('MAILCOW_HOSTNAME') ?: '',
'lang_admin' => json_encode($lang['admin']),
'lang_datatables' => json_encode($lang['datatables'])
];
+933
View File
@@ -1608,6 +1608,174 @@ paths:
type: boolean
type: object
summary: Create retention rule
/api/v1/add/backup-job:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- backup
- add
- job
- type: backup
msg:
- backup_job_added
- backup
- 11
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- warning
- error
type: string
type: object
description: OK
headers: {}
tags:
- Backup
description: >-
Queue a job for the backup container (admins): backup (database dump,
export of the domains, restic backup, forget by the keep policy), check
(restic check of the structure and 1/N of the data), prune (forget and
delete unused data) or init (test the connection, create the repository
if it does not exist). One job of each type at a time
(backup_job_active). Needs a complete target and repository password
(backup_not_configured); a backup also the confirmation that the
repository password is stored outside of mailcow
(backup_password_not_confirmed). Follow the job with
get/backup-job/{id}.
operationId: Create backup job
requestBody:
content:
application/json:
schema:
example:
type: backup
properties:
type:
description: backup, check, prune or init
type: string
type: object
summary: Create backup job
/api/v1/add/backup-restore:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- backup
- add
- restore
- snapshot: 3ee0b6f7
scope: mailbox
mailboxes:
- user@example.org
components:
- mails
- aliases
mail_target: folder
msg:
- backup_restore_added
- 12
- 1
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- warning
- error
type: string
type: object
description: OK
headers: {}
tags:
- Backup
description: >-
Queue a restore from a snapshot (admins; domain admins for their
domains). Scope "mailbox" restores the given mailboxes of one domain,
scope "domain" all mailboxes of the domain; admins can also restore a
deleted domain. Deleted mailboxes are created from the backup first.
The restore only adds what is missing, except "sogo", "settings" and
"password", which replace the current values with the backup's.
Components: mails (mail_target "folder": a copy below "Restored
<snapshot time>"; "original": only missing messages, into their
folders), imap_acl, aliases, groups, sender_acl, mail_policies,
retention_rules, filters, sieve_activate (needs filters), spam, bcc,
syncjobs, app_passwords, tfa, sogo, settings, password. Domain admins
need the matching ACL (else backup_component_denied). Snapshots made
with RESTIC_BACKUP_MODE=config hold no mails (backup_components_no_mails).
Results per mailbox and component: get/backup-job/{id}.
operationId: Create restore job
requestBody:
content:
application/json:
schema:
example:
snapshot: 3ee0b6f7
scope: mailbox
mailboxes:
- user@example.org
components:
- mails
- imap_acl
- aliases
- sender_acl
- filters
mail_target: folder
properties:
snapshot:
description: id or short id of the snapshot (get/backup-snapshots)
type: string
scope:
description: mailbox (the given mailboxes, default) or domain (all mailboxes of the domain)
type: string
mailboxes:
description: mailboxes of one domain, scope mailbox only, max. 1000; deleted ones from the snapshot's content list are created
type: array
domain:
description: the domain (required for scope domain, a deleted one for admins; for scope mailbox it must exist and match the mailboxes)
type: string
components:
description: what to restore, see above
type: array
mail_target:
description: folder (default) or original
type: string
type: object
summary: Create restore job
/api/v1/add/oauth2-client:
post:
responses:
@@ -3197,6 +3365,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:
@@ -5051,6 +5280,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:
@@ -7380,6 +7801,516 @@ paths:
mailboxes they manage.
operationId: Get effective retention rules
summary: Get effective retention rules
/api/v1/get/backup-settings:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
target: sftp
s3_endpoint: https://s3.amazonaws.com
s3_bucket: ""
s3_path: ""
s3_region: ""
s3_access_key: ""
s3_secret_key_set: 0
s3_path_style: 0
sftp_host: backup.example.org
sftp_port: 22
sftp_user: mailcow
sftp_path: backups/mailcow
sftp_public_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... mailcow-backup@mail.example.org
sftp_host_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
rest_url: ""
rest_user: ""
rest_password_set: 0
local_path: mailcow
password_set: 1
password_confirmed: 1
schedule:
- days: [0, 1, 2, 3, 4, 5, 6]
time: "02:30"
keep_hourly: 0
keep_daily: 14
keep_weekly: 4
keep_monthly: 12
keep_yearly: 0
prune_weekday: 0
max_unused: 10%
check_subset: 30
include_index: 1
include_fts: 1
limit_upload: 0
notify_email: admin@example.org
state_initialized: 1
state_runner_seen: 1794093697
state_last_backup: 6
state_last_success: 1794089278
state_repo_size: 84418594
state_repo_stats:
total_size: 84418594
snapshots_count: 2
configured: 1
description: OK
headers: {}
tags:
- Backup
description: >-
The backup settings (admins, also read-only API keys). Secrets are
never returned, password_set, s3_secret_key_set and rest_password_set
tell whether they are set. sftp_public_key (add it to the
authorized_keys of the SFTP user), sftp_host_key and the state_* values
are written by the backup container. configured: target and repository
password are complete. {} for everybody else.
operationId: Get backup settings
summary: Get backup settings
/api/v1/get/backup-status:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
target: local
configured: 1
password_confirmed: 1
initialized: 1
runner_seen: 1794094257
runner_age: 4
runner_ok: 1
last_success: 1794089278
repo_size: 84418594
repo_stats:
total_size: 84418594
snapshots_count: 2
snapshots: 2
latest_snapshot: 1794089277
next_run: 1794101400
schedule_gap: 86400
secrets_error: null
mode: all
jobs:
backup:
id: 26
type: backup
state: done
step: null
params: {}
result:
snapshot: f4f5a5fec226eec1cdd8ddd8f02b1fe8e6a1441ce130c507ca877f8b26a0bb3e
error: null
domain: ""
requested_by: schedule
created: 1794093646
started: 1794093646
finished: 1794093653
check: null
prune: null
init: null
restore: null
active: []
description: OK
headers: {}
tags:
- Backup
description: >-
State of the backup (admins): configuration, heartbeat of the backup
container (runner_ok: reported within the last 120 seconds), last
successful backup, repository size, number of snapshots, the next
scheduled run (null while the schedule is off or waits for the
password confirmation), schedule_gap (the longest time between two
scheduled backups in seconds; a last success older than that is
overdue), the newest job of each type and the queued and running jobs. secrets_error (null when fine): the backup container
cannot decrypt the stored secrets (e.g. after the database was restored on another
server); enter the repository password and the credentials again.
mode: RESTIC_BACKUP_MODE of mailcow.conf (all, data or config; null
until the backup container reported it). Times are unix timestamps.
operationId: Get backup status
summary: Get backup status
/api/v1/get/backup-snapshots:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
short_id: 3ee0b6f7
time: 1794089277
hostname: mail.example.org
tags:
- mailcow
summary:
files_new: 104
files_changed: 24
data_added: 4103499
total_bytes_processed: 92154276
domains:
- example.org
mode: all
description: OK
headers: {}
tags:
- Backup
description: >-
The snapshots in the repository, newest first, as of the last backup
or prune (admins). Domain admins get the snapshots that hold one of
their domains and were made after the domain was created, with id,
short_id, time, mode and those domains only. domains is null for
snapshots not made by mailcow. mode: what the backup holds, from
RESTIC_BACKUP_MODE (tag mode:<mode>): all, data (without the mailcow
directory) or config (without mails).
operationId: Get backup snapshots
summary: Get backup snapshots
"/api/v1/get/backup-snapshot-content/{snapshot}":
get:
parameters:
- description: id or short id of the snapshot
example: 3ee0b6f7
in: path
name: snapshot
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
time: 1794089277
available: 1
domains:
- domain: example.org
exists: 1
mailboxes: 42
missing: 2
- domain: old.example.org
exists: 0
mailboxes: 5
missing: 5
description: OK
headers: {}
tags:
- Backup
description: >-
The domains of a snapshot with their state now: exists (the domain
exists now), mailboxes (in the snapshot) and missing (of them deleted
now). Admins get all domains, domain admins the domains they
administer now (snapshots made after the domain was created). available
0: the snapshot has no content list (made before mailcow kept one), the
list then comes from its manifest and counts the mailboxes that exist
now; only those can be restored. {} if the snapshot does not exist or
is not visible.
operationId: Get backup snapshot content
summary: Get backup snapshot content
"/api/v1/get/backup-snapshot-content/{snapshot}/{domain}":
get:
parameters:
- description: id or short id of the snapshot
example: 3ee0b6f7
in: path
name: snapshot
required: true
schema:
type: string
- description: a domain of the snapshot
example: example.org
in: path
name: domain
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
domain: example.org
exists: 1
mailboxes:
- username: info@example.org
name: Info
exists: 1
- username: former@example.org
name: Former Colleague
exists: 0
description: OK
headers: {}
tags:
- Backup
description: >-
The mailboxes of a domain in a snapshot with their state now (exists 0:
deleted since, a restore creates it from the backup). Same visibility
as get/backup-snapshot-content/{snapshot}; {} if not visible.
operationId: Get backup snapshot domain content
summary: Get backup snapshot domain content
"/api/v1/get/backup-snapshot-search/{snapshot}/{term}":
get:
parameters:
- description: id or short id of the snapshot
example: 3ee0b6f7
in: path
name: snapshot
required: true
schema:
type: string
- description: search term, URL-encoded (path segment)
example: carol
in: path
name: term
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
domains:
- domain: example.org
exists: 0
mailboxes:
- username: carol@example.org
name: Carol
exists: 0
truncated: 0
description: OK
headers: {}
tags:
- Backup
description: >-
Search the domains and mailboxes of a snapshot (case-insensitive
"contains"). A plain term matches the domain name, the local part, the
full address or the display name; a matching domain comes with all its
mailboxes. "x@" matches the local part only, "@d" returns all mailboxes
of the matching domains (a matching domain also appears without
mailboxes), "x@d" matches the full address only. An empty term or "@"
returns no domains. At most 500 mailboxes, then truncated is 1. Same
visibility as get/backup-snapshot-content (domain admins: domains they
administer, snapshots made after the domain was created); {} when the
snapshot does not exist or is not visible. For snapshots without a
content list the current mailboxes of the snapshot's domains are
searched. The path is decoded before json_api.php reads it: encode the
term twice when it may contain "&", "+", "%" or "#" (the UI always
encodes it twice).
operationId: Search backup snapshot
summary: Search backup snapshot
"/api/v1/get/backup-jobs/{count}":
get:
parameters:
- description: Number of jobs to return, newest first (default 100 without this part, max. 1000)
example: 100
in: path
name: count
required: true
schema:
type: number
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 12
type: restore
state: done
step: null
params:
snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
snapshot_time: 1794089277
scope: mailbox
domain: example.org
mailboxes:
- user@example.org
components:
- mails
- aliases
mail_target: folder
requested_role: admin
create_domain: 0
create_mailboxes:
- user@example.org
result:
snapshot:
id: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
short_id: 3ee0b6f7
time: "2026-11-07 23:07"
mailboxes:
user@example.org:
mailbox:
added: 1
created: 1
mails:
added: 32
skipped: 0
errors: []
folder: Restored 2026-11-07 23:07
aliases:
added: 1
skipped: 2
errors: []
errors: 0
error: null
domain: example.org
requested_by: admin
created: 1794094015
started: 1794094016
finished: 1794094062
description: OK
headers: {}
tags:
- Backup
description: >-
Backup jobs without their log, newest first: admins all jobs, domain
admins the restores of their domains. state: queued, running, done,
failed or cancelled; step: what a running job does. The result of a
restore holds per mailbox and component the numbers of added and
already existing (skipped) entries and the errors; "mailbox" tells
whether a deleted mailbox was created, "domain" (next to "mailboxes")
the creation of a deleted domain. Finished jobs are kept for 180 days.
operationId: Get backup jobs
summary: Get backup jobs
"/api/v1/get/backup-jobs/{count}/{type}":
get:
parameters:
- description: Number of jobs to return, newest first; 0 = 100, max. 1000
example: 0
in: path
name: count
required: true
schema:
type: number
- description: only jobs of this type (backup, check, prune, init or restore); an unknown type returns []
example: restore
in: path
name: type
required: true
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 12
type: restore
state: done
step: null
params:
snapshot: 3ee0b6f795f9e7bebf4274758147ddc593f81f6925db82f6eaf0029f71dcc71f
scope: mailbox
domain: example.org
mailboxes:
- user@example.org
components:
- mails
mail_target: folder
result: null
error: null
domain: example.org
requested_by: admin
created: 1794094015
started: 1794094016
finished: 1794094062
description: OK
headers: {}
tags:
- Backup
description: >-
Backup jobs of one type without their log, newest first, with the
visibility of get/backup-jobs/{count} (domain admins: the restores of
their domains). E.g. get/backup-jobs/0/restore for the restores.
operationId: Get backup jobs of a type
summary: Get backup jobs of a type
"/api/v1/get/backup-job/{id}":
get:
parameters:
- description: id of the job
example: 12
in: path
name: id
required: true
schema:
type: number
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
id: 11
type: backup
state: done
step: null
params: {}
result:
dump_bytes: 667156
export:
domains: 1
failed: []
snapshot: f4f5a5fec226eec1cdd8ddd8f02b1fe8e6a1441ce130c507ca877f8b26a0bb3e
summary:
files_new: 1696
files_changed: 0
data_added: 93096838
total_bytes_processed: 95911108
forget:
removed: 0
snapshots: 3
error: null
domain: ""
requested_by: schedule
created: 1794093646
started: 1794093646
finished: 1794093653
log: "2026-11-08 02:30:01 backup job started (requested by schedule)\n2026-11-08 02:30:03 snapshot f4f5a5fe: 1696 new and 0 changed files, 88 MiB added, 91 MiB processed\n2026-11-08 02:30:07 backup job done"
description: OK
headers: {}
tags:
- Backup
description: >-
A backup job with its log (the same entry as in get/backup-jobs plus
"log"). {} if the job does not exist or the caller may not see it.
operationId: Get backup job
summary: Get backup job
/api/v1/get/identity-provider/sync-status:
get:
responses:
@@ -9090,6 +10021,8 @@ tags:
description: Whom mailboxes and groups may send to and receive from
- name: Retention rules
description: Delete mail from folders after a number of days
- name: Backup
description: Encrypted backups with restic, restore of mailboxes and domains
- name: Routing
description: Define your own email routes
- name: Resources
+133 -83
View File
@@ -1,83 +1,133 @@
.pagination a {
text-decoration: none !important;
}
.panel.panel-default {
overflow: visible !important;
}
.table-responsive {
overflow: visible !important;
}
.table-responsive {
overflow-x: scroll !important;
}
body {
overflow-y:scroll;
}
/* Fix modal moving content left */
body.modal-open {
overflow-y:scroll;
padding-right: inherit !important;
}
@media (min-width: 992px) {
.container {
width: 80%;
}
}
.mass-actions-admin {
user-select: none;
}
.inputMissingAttr {
border-color: #FF4136;
}
.rotate {
-moz-transition: all 0.3s linear;
-webkit-transition: all 0.3s linear;
transition: all 0.3s linear;
}
.rotate.animation {
-ms-transform:rotateX(180deg);
-moz-transform:rotateX(180deg);
-webkit-transform:rotateX(180deg);
transform:rotateX(180deg);
}
.anchor {
display: block;
height: 65px;
margin-top: -65px;
visibility: hidden;
}
.thumbnail img {
min-height:100px;
height:100px;
}
.nav-tabs>li>a {
z-index: 1;
}
.table-condensed .input-sm {
width: 100%!important;
}
.table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td {
padding: 3px;
}
table tbody tr td input[type="checkbox"] {
cursor: pointer;
}
#quarantine_template {
margin:20px;
}
.regex-input {
font-family: Consolas,monaco,monospace;
font-size: 1rem;
}
.label-keys {
font-size:100%;
margin: 0px !important;
white-space: normal !important;
}
.key-action {
font-weight:bold;
color:white !important;
}
.dkim-label {
margin: 0 0 8px !important;
}
.pagination a {
text-decoration: none !important;
}
.panel.panel-default {
overflow: visible !important;
}
.table-responsive {
overflow: visible !important;
}
.table-responsive {
overflow-x: scroll !important;
}
body {
overflow-y:scroll;
}
/* Fix modal moving content left */
body.modal-open {
overflow-y:scroll;
padding-right: inherit !important;
}
@media (min-width: 992px) {
.container {
width: 80%;
}
}
.mass-actions-admin {
user-select: none;
}
.inputMissingAttr {
border-color: #FF4136;
}
.rotate {
-moz-transition: all 0.3s linear;
-webkit-transition: all 0.3s linear;
transition: all 0.3s linear;
}
.rotate.animation {
-ms-transform:rotateX(180deg);
-moz-transform:rotateX(180deg);
-webkit-transform:rotateX(180deg);
transform:rotateX(180deg);
}
.anchor {
display: block;
height: 65px;
margin-top: -65px;
visibility: hidden;
}
.thumbnail img {
min-height:100px;
height:100px;
}
.nav-tabs>li>a {
z-index: 1;
}
.table-condensed .input-sm {
width: 100%!important;
}
.table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td {
padding: 3px;
}
table tbody tr td input[type="checkbox"] {
cursor: pointer;
}
#quarantine_template {
margin:20px;
}
.regex-input {
font-family: Consolas,monaco,monospace;
font-size: 1rem;
}
.label-keys {
font-size:100%;
margin: 0px !important;
white-space: normal !important;
}
.key-action {
font-weight:bold;
color:white !important;
}
.dkim-label {
margin: 0 0 8px !important;
}
/* backup schedule: the weekday toggles and the remove buttons of the entries react to the mouse, in every theme */
.backup-schedule-days .btn:hover {
background-image: linear-gradient(rgba(128, 128, 128, .3), rgba(128, 128, 128, .3));
}
.backup-schedule-remove {
opacity: .6;
}
.backup-schedule-remove:hover, .backup-schedule-remove:focus-visible {
opacity: 1;
color: var(--bs-danger) !important;
}
/* result and log of a backup job: the border of the theme in light and dark mode (Bootstrap's .border stays light) */
.backup-pre {
border: 1px solid var(--mc-border);
}
/* result of a restore job: an entry to open per mailbox, numbers in columns */
.backup-result {
border: 1px solid var(--mc-border);
}
.backup-result > details + details {
border-top: 1px solid var(--mc-border);
}
.backup-result summary {
list-style: none;
cursor: pointer;
}
.backup-result summary::-webkit-details-marker {
display: none;
}
.backup-result summary:hover {
background-image: linear-gradient(rgba(128, 128, 128, .12), rgba(128, 128, 128, .12));
}
.backup-result summary .bi-chevron-right {
transition: transform .15s;
}
.backup-result details[open] > summary .bi-chevron-right {
transform: rotate(90deg);
}
.backup-result-body {
padding-left: 2.6rem !important;
}
.backup-result-body .table {
--bs-table-bg: transparent;
}
.backup-result-body .num {
width: 8.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+13 -1
View File
@@ -495,4 +495,16 @@ input::placeholder {
}
.btn-light, .btn-light:hover {
background-image: none;
}
}
/* native time picker icon and popup in dark mode */
input[type="time"] {
color-scheme: dark;
}
/* switches while off: a light knob and a visible border (Bootstrap's dark knob is for light pages, the dark mode
does not set data-bs-theme) */
.form-switch .form-check-input:not(:checked):not(:focus) {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.6%29'/%3e%3c/svg%3e");
}
.form-switch .form-check-input:not(:checked) {
border-color: var(--mc-btn-outline-border);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
protect_route(['domainadmin']);
$role = "domainadmin";
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/restore.inc.php';
+1
View File
@@ -76,6 +76,7 @@ $globalVariables = [
'lang_groups' => json_encode($lang['groups']),
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
'lang_retention' => json_encode($lang['retention'] ?? []),
'lang_backup' => json_encode($lang['backup'] ?? []),
'docker_timeout' => $DOCKER_TIMEOUT,
'session_lifetime' => (int)$SESSION_LIFETIME,
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
+61
View File
@@ -682,6 +682,67 @@ function init_db_schema()
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"backup_setting" => array(
"cols" => array(
"name" => "VARCHAR(64) NOT NULL",
"value" => "TEXT NOT NULL",
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
),
"keys" => array(
"primary" => array(
"" => array("name")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"backup_job" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
"type" => "ENUM('backup', 'check', 'prune', 'init', 'restore') NOT NULL",
"state" => "ENUM('queued', 'running', 'done', 'failed', 'cancelled') NOT NULL DEFAULT 'queued'",
"step" => "VARCHAR(32) NULL DEFAULT NULL",
"params" => "MEDIUMTEXT NULL DEFAULT NULL",
"result" => "MEDIUMTEXT NULL DEFAULT NULL",
"log" => "MEDIUMTEXT NULL DEFAULT NULL",
"error" => "TEXT NULL DEFAULT NULL",
"domain" => "VARCHAR(255) NOT NULL DEFAULT ''",
"requested_by" => "VARCHAR(255) NOT NULL DEFAULT ''",
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
"started" => "DATETIME NULL DEFAULT NULL",
"finished" => "DATETIME NULL DEFAULT NULL"
),
"keys" => array(
"primary" => array(
"" => array("id")
),
"key" => array(
"state_type" => array("state", "type"),
"domain_created" => array("domain", "created")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"backup_snapshot" => array(
"cols" => array(
"id" => "VARCHAR(64) NOT NULL",
"short_id" => "VARCHAR(16) NOT NULL",
"time" => "DATETIME NOT NULL",
"hostname" => "VARCHAR(255) NOT NULL DEFAULT ''",
"tags" => "VARCHAR(255) NOT NULL DEFAULT ''",
"summary" => "TEXT NULL DEFAULT NULL",
"domains" => "MEDIUMTEXT NULL DEFAULT NULL",
"content" => "MEDIUMBLOB NULL DEFAULT NULL"
),
"keys" => array(
"primary" => array(
"" => array("id")
),
"key" => array(
"time" => array("time")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"mailbox_group_imap_grant" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup;
use Mailcow\Backup\Controller\BackupApiController;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Legacy\NoTemplates;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Repository\ExportRepository;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\RestoreRepository;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Backup\Repository\SnapshotRepository;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Backup\Service\GroupMembers;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use Mailcow\Dovecot\DovecotModule;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\MailboxGroup\Service\TemplateService;
use Mailcow\Sogo\SogoModule;
use PDO;
use Redis;
/**
* Composition root of the backup with restic: settings, jobs of the runners, snapshots, the export of a domain and the
* restore of mailboxes and domains. The runner in the restic container reaches the export and the restore actions
* through data/conf/rspamd/dynmaps/backup.php.
*
* process_get_return(BackupModule::fromGlobals()->api->getSettings());
* echo json_encode(BackupModule::create($pdo, Context::system(), $config, $redis)->api->export($domain, $page)); // backup.php
*
* $config: timezone (TZ of mailcow, for the schedule), alias_disable_external_domains ($ALIAS_DISABLE_EXTERNAL_DOMAINS).
* $redis: for DKIM keys, rate limits and the domain map (export and restore).
*/
class BackupModule
{
public function __construct(
public readonly SettingsService $settings,
public readonly JobService $jobs,
public readonly ExportService $export,
public readonly RestoreService $restore,
public readonly DomainRestoreService $domainRestore,
public readonly BackupApiController $api
) {
}
public static function create(PDO $pdo, Context $context, array $config = [], ?Redis $redis = null): self
{
$jobRepo = new JobRepository($pdo);
$restoreRepo = new RestoreRepository($pdo);
$legacy = new LegacyDomain($redis);
$settings = new SettingsService(new SettingsRepository($pdo), $context);
$jobs = new JobService($jobRepo, new SnapshotRepository($pdo), $settings, $context, (string)($config['timezone'] ?? 'UTC'));
$export = new ExportService(new ExportRepository($pdo), $legacy);
$sogo = SogoModule::create()->sogo;
$dovecot = DovecotModule::create()->dovecot;
// the group module without group templates: a restored mailbox keeps the settings of the backup
$groupRepo = new GroupRepository($pdo);
$lookup = new LookupRepository($pdo);
$templates = new TemplateService($lookup, new NoTemplates());
$effects = new ChangeEffects($templates, $sogo, $groupRepo, $dovecot);
$groups = new GroupMembers($groupRepo, $lookup, $templates, $effects, new SharingService($groupRepo, $dovecot, $sogo, $effects));
$restore = new RestoreService($restoreRepo, $jobRepo, $groups, $sogo, $dovecot, $legacy, (bool)($config['alias_disable_external_domains'] ?? false));
$domainRestore = new DomainRestoreService($restoreRepo, $jobRepo, $groups, $legacy);
return new self($settings, $jobs, $export, $restore, $domainRestore,
new BackupApiController($settings, $jobs, $export, $restore, $domainRestore, new ApiResponse(Settings::SECRETS)));
}
/** Legacy entry point for procedural callers: reads $pdo, $redis, $_SESSION, TZ and $ALIAS_DISABLE_EXTERNAL_DOMAINS */
public static function fromGlobals(?Context $context = null): self
{
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [
'timezone' => getenv('TZ') ?: 'UTC',
'alias_disable_external_domains' => ($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] ?? false) === true,
], ($GLOBALS['redis'] ?? null) instanceof Redis ? $GLOBALS['redis'] : null);
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Controller;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
/**
* HTTP API adapter for json_api.php (routes backup-settings, backup-status, backup-snapshots, backup-snapshot-content,
* backup-jobs, backup-job, backup-restore) and the runner's endpoint data/conf/rspamd/dynmaps/backup.php (domains,
* export pages, restore actions). Messages are logged as ['backup', action, object, data].
*/
class BackupApiController
{
public function __construct(private SettingsService $settings, private JobService $jobs, private ExportService $export,
private RestoreService $restore, private DomainRestoreService $domainRestore, private ApiResponse $response)
{
}
/** GET backup-settings: secrets as <key>_set; {} for non-admins */
public function getSettings(): array
{
return $this->settings->get()?->toArray() ?? [];
}
/** POST edit/backup-settings */
public function editSettings(array $attr): bool
{
return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
$this->settings->save($attr);
return $this->response->success('backup_settings_saved', $log);
});
}
/** GET backup-status */
public function getStatus(): array
{
return $this->jobs->status();
}
/** GET backup-snapshots */
public function getSnapshots(): array
{
return $this->jobs->snapshots();
}
/** GET backup-snapshot-content/<snapshot>[/<domain>]: domains of a snapshot, or the mailboxes of one, with their state now */
public function getSnapshotContent(mixed $snapshot, mixed $domain = null): array
{
return ($domain === null || $domain === '') ? $this->jobs->content($snapshot) : $this->jobs->domainContent($snapshot, $domain);
}
/** GET backup-snapshot-search/<snapshot>/<term>: domains and mailboxes of a snapshot matching the term */
public function getSnapshotSearch(mixed $snapshot, mixed $term): array
{
return $this->jobs->search($snapshot, $term);
}
/** GET backup-jobs[/<count>[/<type>]]: newest first, without logs; count 0 = default */
public function getJobs(int $limit = 0, mixed $type = null): array
{
return array_map(fn(Job $job): array => $this->jobs->view($job), $this->jobs->list($limit, $type));
}
/** GET backup-job/<id>: with its log */
public function getJob(mixed $id): array
{
$id = $this->toId($id);
$job = ($id === null) ? null : $this->jobs->get($id);
return ($job === null) ? [] : $this->jobs->view($job, true);
}
/** POST add/backup-job: {type: backup|check|prune|init} */
public function addJob(array $attr): bool
{
return $this->handle('add', 'job', $attr, function (array $log) use ($attr): bool {
$job = $this->jobs->create($attr);
return $this->response->success(['backup_job_added', $job->type, $job->id], $log);
});
}
/** POST add/backup-restore: {snapshot, scope, mailboxes | domain, components, mail_target} */
public function addRestore(array $attr): bool
{
return $this->handle('add', 'restore', $attr, function (array $log) use ($attr): bool {
$job = $this->jobs->restore($attr);
return $this->response->success(['backup_restore_added', $job->id, count($job->mailboxes())], $log);
});
}
/** POST delete/backup-job: cancels queued and running jobs, one message per id */
public function deleteJobs(array $items): bool
{
return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool {
foreach ($items as $id) {
try {
$job = $this->jobs->cancel($this->requireId($id));
$this->response->success(['backup_job_cancelled', $job->id], $log);
} catch (UserError $e) {
$this->response->error($e, $log);
}
}
return true;
});
}
/** backup.php ?domains=1 */
public function domains(): array
{
return ['domains' => $this->export->domains()];
}
/** backup.php ?export=<domain>[&after=<username>] @throws UserError */
public function export(string $domain, ?string $after = null): array
{
return $this->export->export($domain, $after);
}
/**
* backup.php POST of the runner, {"result": ...} of
* {"action": "restore_domain", "job": id, "data": <domain_data>}: creates a deleted domain
* {"action": "restore_aliases", "job": id, "aliases": [...]}: a part of the domain's aliases (scope domain)
* {"action": "restore", "job": id, "username": u, "data": <mailbox section>}: one mailbox
*
* @throws UserError
*/
public function post(array $body): array
{
return ['result' => match ($body['action'] ?? null) {
'restore' => $this->restore->merge($body['job'] ?? null, $body['username'] ?? null, $body['data'] ?? null),
'restore_aliases' => $this->restore->mergeAliases($body['job'] ?? null, $body['aliases'] ?? null),
'restore_domain' => $this->domainRestore->restore($body['job'] ?? null, $body['data'] ?? null),
default => throw new UserError('backup_action_invalid'),
}];
}
/**
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
*/
private function handle(string $action, string $object, mixed $data, callable $work): bool
{
$log = ['backup', $action, $object, $data];
try {
$result = $work($log);
} catch (UserError $e) {
$result = $this->response->error($e, $log);
} catch (PDOException $e) {
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
}
$this->response->flush();
return $result;
}
/** Integer id from request input ("12", 12); null for anything else */
private function toId(mixed $id): ?int
{
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
}
/** @throws AccessDenied for ids that cannot exist */
private function requireId(mixed $id): int
{
return $this->toId($id) ?? throw new AccessDenied();
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Legacy;
use Redis;
use RuntimeException;
/**
* What the legacy code keeps outside the database for domains and mailboxes: DKIM keys, rate limits and the domain
* map in Redis (functions.dkim/ratelimit/mailbox.inc.php), and the SOGo restart through dockerapi. Redis and docker()
* sit in protected methods so tests can replace them.
*/
class LegacyDomain
{
public function __construct(private ?Redis $redis = null)
{
}
/** @return ?array{selector: string, pubkey: string, privkey: string} the DKIM key of a domain */
public function dkim(string $domain): ?array
{
$pubkey = $this->redis()->hGet('DKIM_PUB_KEYS', $domain);
if (!is_string($pubkey) || $pubkey === '') {
return null;
}
$selector = (string)$this->redis()->hGet('DKIM_SELECTORS', $domain);
return ['selector' => $selector, 'pubkey' => $pubkey, 'privkey' => (string)$this->redis()->hGet('DKIM_PRIV_KEYS', $selector . '.' . $domain)];
}
/** Stores a DKIM key like dkim('import') does */
public function addDkim(string $domain, string $selector, string $pubkey, string $privkey): void
{
$this->redis()->hSet('DKIM_PUB_KEYS', $domain, $pubkey);
$this->redis()->hSet('DKIM_SELECTORS', $domain, $selector);
$this->redis()->hSet('DKIM_PRIV_KEYS', $selector . '.' . $domain, $privkey);
}
/**
* @param string[] $objects domains or mailboxes
* @return array<string, string> object => rate limit ("10 / 1h") of those with one
*/
public function rateLimits(array $objects): array
{
if (empty($objects)) {
return [];
}
$values = $this->redis()->hMGet('RL_VALUE', array_values($objects));
return array_filter(is_array($values) ? $values : [], fn(mixed $value): bool => is_string($value) && $value !== '');
}
public function setRateLimit(string $object, string $value): void
{
$this->redis()->hSet('RL_VALUE', $object, $value);
}
/** The domain is local for rspamd (legacy domain and alias domain add) */
public function addDomainMap(string $domain): void
{
$this->redis()->hSet('DOMAIN_MAP', $domain, 1);
}
/**
* SOGo reads its domains at start: restart it after a domain was added (like the "restart SOGo" option of the form)
*
* @return ?string null on success, else the error of dockerapi
*/
public function restartSogo(): ?string
{
$result = json_decode((string)$this->dockerRestart('sogo-mailcow'), true);
return (($result['type'] ?? '') === 'success') ? null : (string)($result['msg'] ?? 'dockerapi did not answer');
}
protected function redis(): Redis
{
return $this->redis ?? throw new RuntimeException('Redis is not connected');
}
protected function dockerRestart(string $service): mixed
{
return docker('post', $service, 'restart');
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Legacy;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
/**
* The group module's bridge to the mailbox code for restores: a membership restored from a backup never applies the
* group's mailbox template (the mailbox gets its settings from the backup).
*/
class NoTemplates extends LegacyMailbox
{
public function applyTemplate(string $username, string $name, string $template): ?string
{
return null;
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
/**
* A job of the backup runners (table `backup_job`): backup, check, prune and init run in the restic container; a
* restore runs there (step `files`, then the merge through backup.php) and, for mails, IMAP ACLs and Sieve
* scripts, in the dovecot container (step `mails`). Times are unix timestamps.
*/
class Job
{
public const TYPES = ['backup', 'check', 'prune', 'init', 'restore'];
/** Types an admin starts with add/backup-job */
public const MANUAL_TYPES = ['backup', 'check', 'prune', 'init'];
public const ACTIVE_STATES = ['queued', 'running'];
public ?int $id = null;
public string $type = 'backup';
public string $state = 'queued';
public ?string $step = null;
/**
* restore: snapshot, snapshot_time, scope, domain, mailboxes, components, mail_target, requested_role, create_domain (0|1),
* create_mailboxes (missing when requested), acl and allowed_domains (domain admins)
*/
public array $params = [];
public ?array $result = null;
/** Only loaded for a single job */
public ?string $log = null;
public ?string $error = null;
/** restore: the domain of its mailboxes, so domain admins see their restores */
public string $domain = '';
public string $requestedBy = '';
public ?int $created = null;
public ?int $started = null;
public ?int $finished = null;
public function __construct(string $type = 'backup')
{
$this->type = $type;
}
/** Row of `backup_job` with the times as unix timestamps */
public static function fromRow(array $row): self
{
$job = new self((string)$row['type']);
$job->id = (int)$row['id'];
$job->state = (string)$row['state'];
$job->step = isset($row['step']) ? (string)$row['step'] : null;
$params = is_string($row['params'] ?? null) ? json_decode($row['params'], true) : null;
$job->params = is_array($params) ? $params : [];
$result = is_string($row['result'] ?? null) ? json_decode($row['result'], true) : null;
$job->result = is_array($result) ? $result : null;
$job->log = isset($row['log']) ? (string)$row['log'] : null;
$job->error = isset($row['error']) ? (string)$row['error'] : null;
$job->domain = (string)($row['domain'] ?? '');
$job->requestedBy = (string)($row['requested_by'] ?? '');
foreach (['created', 'started', 'finished'] as $time) {
$job->$time = isset($row[$time]) ? (int)$row[$time] : null;
}
return $job;
}
/** Column values for a new row */
public function toRow(): array
{
return [
'type' => $this->type,
'state' => $this->state,
'step' => $this->step,
'params' => json_encode($this->params ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'domain' => $this->domain,
'requested_by' => $this->requestedBy,
];
}
/** @return string[] mailboxes of a restore */
public function mailboxes(): array
{
return array_values(array_filter((array)($this->params['mailboxes'] ?? []), 'is_string'));
}
/** @return string[] components of a restore */
public function components(): array
{
return array_values(array_filter((array)($this->params['components'] ?? []), 'is_string'));
}
/** @return string[] mailboxes of a restore that did not exist when it was requested */
public function createMailboxes(): array
{
return array_values(array_filter((array)($this->params['create_mailboxes'] ?? []), 'is_string'));
}
/** The restore creates its domain (requested by an admin while the domain did not exist) */
public function createsDomain(): bool
{
return (int)($this->params['create_domain'] ?? 0) === 1;
}
public function isActive(): bool
{
return in_array($this->state, self::ACTIVE_STATES, true);
}
/** Entry of the API; the log only with $withLog (get/backup-job/<id>) */
public function toArray(bool $withLog = false): array
{
$job = [
'id' => $this->id,
'type' => $this->type,
'state' => $this->state,
'step' => $this->step,
'params' => $this->params ?: new \stdClass(),
'result' => $this->result,
'error' => $this->error,
'domain' => $this->domain,
'requested_by' => $this->requestedBy,
'created' => $this->created,
'started' => $this->started,
'finished' => $this->finished,
];
if ($withLog) {
$job['log'] = (string)$this->log;
}
return $job;
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
use Mailcow\Core\Context;
/**
* The merge of one mailbox of a restore job: what the backup holds, who asked for the restore and what the merge
* did per component (added, skipped, errors), plus the caches to refresh afterwards.
*/
class RestoreRun
{
/** @var array<string, array{added: int, skipped: int, errors: string[]}> */
public array $results = [];
/** Mailboxes whose SOGo identities (aliases, send-as addresses) changed */
public array $sogoUsers = [];
/** Mailboxes whose userdb or passdb values changed (Dovecot auth cache) */
public array $authUsers = [];
/**
* @param array $mailbox the mailbox row as it is now
* @param array $params params of the restore job (requested_role, acl of a domain admin, scope, ...)
* @param array $data the mailbox's section of the domain export in the snapshot
* @param string[] $domains domains whose aliases and send-as addresses the restore may change, [] = all (admins)
* @param string $requestedBy who requested the restore
*/
/** The restore created the mailbox (its own alias gets the forwarding of the backup) */
public bool $created = false;
public function __construct(
public readonly string $username,
public readonly string $domain,
public readonly array $mailbox,
public readonly array $params,
public readonly array $data,
public readonly array $domains = [],
public readonly string $requestedBy = ''
) {
}
/**
* The caller the group module checks: an admin, or the domain admin who requested the restore with his ACL of then
* and his domains now
*/
public function requester(): Context
{
if ($this->byAdmin()) {
return new Context('admin', 'backup', [], true);
}
return new Context('domainadmin', $this->requestedBy, array_map(fn(mixed $flag): string => (string)(int)$flag, (array)($this->params['acl'] ?? [])));
}
public function start(string $component): void
{
$this->results[$component] = ['added' => 0, 'skipped' => 0, 'errors' => []];
}
public function added(string $component, int $count = 1): void
{
$this->results[$component]['added'] += $count;
}
public function skipped(string $component, int $count = 1): void
{
$this->results[$component]['skipped'] += $count;
}
public function error(string $component, string $error): void
{
$this->results[$component]['errors'][] = $error;
}
/** The part of a component was rolled back: nothing of it counts */
public function failed(string $component, string $error): void
{
$this->start($component);
$this->error($component, $error);
}
/** The restore was requested by an admin (domain admins are limited by their ACL and the job's domain) */
public function byAdmin(): bool
{
return ($this->params['requested_role'] ?? '') === 'admin';
}
/** Admins, or a domain admin with this ACL when he requested the restore */
public function may(string $acl): bool
{
return $this->byAdmin() || (int)($this->params['acl'][$acl] ?? 0) === 1;
}
public function mayChangeDomain(string $domain): bool
{
return $this->domains === [] || in_array(strtolower($domain), $this->domains, true);
}
/** Rows of a list in the backup (sieve_filters, tfa, ...) */
public function rows(string $key): array
{
return array_values(array_filter((array)($this->data[$key] ?? []), 'is_array'));
}
public function sogo(string ...$usernames): void
{
array_push($this->sogoUsers, ...$usernames);
$this->sogoUsers = array_values(array_unique($this->sogoUsers));
}
public function auth(string ...$usernames): void
{
array_push($this->authUsers, ...$usernames);
$this->authUsers = array_values(array_unique($this->authUsers));
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
/**
* Backup settings (table `backup_setting`, name/value pairs) over their defaults. Secrets are stored sealed for the
* runner (`sealed:v1:<base64>`) and never leave toArray(): the API gets `<key>_set` instead. The state_* values, the
* SSH public key and the SFTP host key are written by the runner in the restic container and cannot be set through
* the API (PHP writes state_target_changed when the target changes).
*/
class Settings
{
public const DEFAULTS = [
'enabled' => '0',
'target' => 's3',
's3_endpoint' => 'https://s3.amazonaws.com',
's3_bucket' => '',
's3_path' => '',
's3_region' => '',
's3_access_key' => '',
's3_secret_key' => '',
's3_path_style' => '0',
'sftp_host' => '',
'sftp_port' => '22',
'sftp_user' => '',
'sftp_path' => '',
'sftp_public_key' => '',
'sftp_host_key' => '',
'rest_url' => '',
'rest_user' => '',
'rest_password' => '',
'local_path' => 'mailcow',
'password' => '',
'password_confirmed' => '0',
// JSON list of the scheduled backups: {"days": [weekdays, 0 = Sunday], "time": "HH:MM"}
'schedule' => '[{"days":[0,1,2,3,4,5,6],"time":"01:00"}]',
'keep_hourly' => '0',
'keep_daily' => '7',
'keep_weekly' => '4',
'keep_monthly' => '12',
'keep_yearly' => '0',
'prune_weekday' => '0',
'max_unused' => '10%',
'check_subset' => '30',
'include_index' => '1',
'include_fts' => '1',
'limit_upload' => '0',
'notify_email' => '',
'state_initialized' => '0',
'state_runner_seen' => '',
'state_last_backup' => '',
'state_last_success' => '',
'state_repo_size' => '',
'state_repo_stats' => '',
'state_schedule_since' => '',
'state_schedule' => '',
// public key (base64, X25519) the secrets are sealed with; the private key stays in the restic container
'state_secrets_pubkey' => '',
'state_secrets_error' => '',
// JSON {old_target, old_addresses, changed, changed_by, time}: the runner mails the old addresses and clears it
'state_target_changed' => '',
// RESTIC_BACKUP_MODE of mailcow.conf as the runner sees it
'state_backup_mode' => '',
];
public const SECRETS = ['password', 's3_secret_key', 'rest_password'];
/** Written by the runner only */
public const RUNNER = ['sftp_public_key', 'sftp_host_key', 'state_initialized', 'state_runner_seen', 'state_last_backup',
'state_last_success', 'state_repo_size', 'state_repo_stats', 'state_schedule_since', 'state_schedule', 'state_secrets_pubkey',
'state_secrets_error', 'state_target_changed', 'state_backup_mode'];
/** Prefix of a sealed secret (sodium_crypto_box_seal with state_secrets_pubkey) */
public const SEALED = 'sealed:v1:';
public const TARGETS = ['s3', 'sftp', 'rest', 'local'];
/** The connection of each target: a change (or another target or repository password) resets state_initialized */
public const CONNECTION = [
's3' => ['s3_endpoint', 's3_bucket', 's3_path', 's3_region', 's3_access_key', 's3_secret_key', 's3_path_style'],
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
'rest' => ['rest_url', 'rest_user', 'rest_password'],
'local' => ['local_path'],
];
/** Values a target cannot work without (besides the repository password) */
public const REQUIRED = [
's3' => ['s3_endpoint', 's3_bucket', 's3_access_key', 's3_secret_key'],
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
'rest' => ['rest_url'],
'local' => ['local_path'],
];
/** Returned as numbers by the API ('' = not known yet: null; prune_weekday '' = never stays '') */
private const NUMBERS = ['enabled', 's3_path_style', 'sftp_port', 'password_confirmed', 'keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly',
'keep_yearly', 'prune_weekday', 'check_subset', 'include_index', 'include_fts', 'limit_upload', 'state_initialized',
'state_runner_seen', 'state_last_backup', 'state_last_success', 'state_repo_size', 'state_schedule_since'];
private const JSON = ['schedule', 'state_repo_stats', 'state_target_changed'];
/** @param array<string, string> $values every key of DEFAULTS */
public function __construct(public array $values = self::DEFAULTS)
{
}
/** Stored values (name => value) over the defaults; unknown names are dropped */
public static function fromStored(array $stored): self
{
$values = self::DEFAULTS;
foreach ($stored as $name => $value) {
if (array_key_exists($name, $values)) {
$values[$name] = (string)$value;
}
}
return new self($values);
}
public function get(string $name): string
{
return $this->values[$name] ?? '';
}
/** The target has its required values and the repository password is set */
public function configured(): bool
{
$required = self::REQUIRED[$this->get('target')] ?? null;
if ($required === null || $this->get('password') === '') {
return false;
}
foreach ($required as $name) {
if ($this->get($name) === '') {
return false;
}
}
return true;
}
/**
* The schedule as [weekday (0 = Sunday), hour, minute] slots, sorted and unique
*
* @return array<int, array{int, int, int}>
*/
public function scheduleSlots(): array
{
$entries = json_decode($this->get('schedule'), true);
$slots = [];
foreach (is_array($entries) ? $entries : [] as $entry) {
if (!is_array($entry) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', (string)($entry['time'] ?? ''), $time) || !is_array($entry['days'] ?? null)) {
continue;
}
foreach ($entry['days'] as $day) {
if (is_int($day) && $day >= 0 && $day <= 6) {
$slots[] = $day * 1440 + (int)$time[1] * 60 + (int)$time[2];
}
}
}
$slots = array_unique($slots);
sort($slots);
return array_map(fn(int $slot): array => [intdiv($slot, 1440), intdiv($slot % 1440, 60), $slot % 60], $slots);
}
/** The longest time between two scheduled backups of a week in seconds, null without a schedule */
public function scheduleGap(): ?int
{
$slots = array_map(fn(array $slot): int => $slot[0] * 86400 + $slot[1] * 3600 + $slot[2] * 60, $this->scheduleSlots());
if (empty($slots)) {
return null;
}
$gaps = [$slots[0] + 7 * 86400 - end($slots)];
for ($i = 1; $i < count($slots); $i++) {
$gaps[] = $slots[$i] - $slots[$i - 1];
}
return max($gaps);
}
/** Entry of get/backup-settings: every key, secrets as <key>_set, plus `configured` */
public function toArray(): array
{
$settings = [];
foreach ($this->values as $name => $value) {
if (in_array($name, self::SECRETS, true)) {
$settings[$name . '_set'] = (int)($value !== '');
} elseif (in_array($name, self::JSON, true)) {
$decoded = json_decode($value, true);
$settings[$name] = is_array($decoded) ? $decoded : null;
} elseif (in_array($name, self::NUMBERS, true)) {
$settings[$name] = ($value === '') ? (($name === 'prune_weekday') ? '' : null) : (int)$value;
} else {
$settings[$name] = $value;
}
}
$settings['configured'] = (int)$this->configured();
return $settings;
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Model;
/**
* A snapshot of the repository (table `backup_snapshot`, the runner's cache of `restic snapshots --json`).
*/
class Snapshot
{
public string $id = '';
public string $shortId = '';
/** unix timestamp */
public int $time = 0;
public string $hostname = '';
/** @var string[] */
public array $tags = [];
/** restic's summary of the backup (data_added, files_new, ...) */
public ?array $summary = null;
/** @var ?string[] domains of its manifest, null for snapshots not made by mailcow */
public ?array $domains = null;
/** @var array<string, ?int> domain => its creation time (unix) when the snapshot was made, from the snapshot row */
public array $created = [];
/**
* @var ?array<string, array{created: ?int, mailboxes: array<string, string>}> per domain its creation time (unix, null
* when not recorded) and username => name of its mailboxes; null = not known (snapshots this installation did
* not make itself, e.g. of another server in the same repository)
*/
public ?array $content = null;
/** Row of `backup_snapshot` with the time as unix timestamp */
public static function fromRow(array $row): self
{
$snapshot = new self();
$snapshot->id = (string)$row['id'];
$snapshot->shortId = (string)$row['short_id'];
$snapshot->time = (int)$row['time'];
$snapshot->hostname = (string)($row['hostname'] ?? '');
$tags = (string)($row['tags'] ?? '');
$decoded = json_decode($tags, true);
$tags = is_array($decoded) ? $decoded : explode(',', $tags);
$snapshot->tags = array_values(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', $tags), 'strlen'));
$summary = is_string($row['summary'] ?? null) ? json_decode($row['summary'], true) : null;
$snapshot->summary = is_array($summary) ? $summary : null;
// {domain: creation time}: lists of snapshots need no content
$domains = is_string($row['domains'] ?? null) ? json_decode($row['domains'], true) : null;
if (is_array($domains)) {
$snapshot->domains = [];
foreach ($domains as $domain => $created) {
$snapshot->domains[] = (string)$domain;
$snapshot->created[strtolower((string)$domain)] = is_numeric($created) ? (int)$created : null;
}
}
if (is_string($row['content'] ?? null) && $row['content'] !== '') {
$snapshot->content = self::decodeContent($row['content']);
}
return $snapshot;
}
/**
* `content` (gzip JSON) written by the runner: {"domains": {"<domain>": {"created": <unix>, "mailboxes": {"<username>": "<name>"}}}}.
* Keys lower case; null when broken.
*/
public static function decodeContent(string $gzip): ?array
{
$json = @gzdecode($gzip);
$data = is_string($json) ? json_decode($json, true) : null;
if (!is_array($data) || !is_array($data['domains'] ?? null)) {
return null;
}
$content = [];
foreach ($data['domains'] as $domain => $entry) {
$mailboxes = is_array($entry) ? ($entry['mailboxes'] ?? []) : [];
$created = is_numeric($entry['created'] ?? null) ? (int)$entry['created'] : null;
$names = [];
foreach (is_array($mailboxes) ? $mailboxes : [] as $username => $name) {
$names[strtolower((string)$username)] = is_scalar($name) ? (string)$name : '';
}
$content[strtolower((string)$domain)] = ['created' => $created, 'mailboxes' => $names];
}
return $content;
}
/** Domains of the snapshot: those of its content and of its manifest (an export may have failed) */
public function domainList(): array
{
return array_values(array_unique(array_merge(array_map('strval', array_keys($this->content ?? [])), array_map('strtolower', $this->domains ?? []))));
}
/** @return ?array<string, string> username => name of the domain's mailboxes, null when the content does not list the domain */
public function mailboxesOf(string $domain): ?array
{
return $this->content[strtolower($domain)]['mailboxes'] ?? null;
}
/** Creation time of the domain when the snapshot was made, null when not recorded */
public function createdOf(string $domain): ?int
{
return $this->content[strtolower($domain)]['created'] ?? $this->created[strtolower($domain)] ?? null;
}
public function hasDomain(string $domain): bool
{
return in_array(strtolower($domain), $this->domainList(), true);
}
/**
* RESTIC_BACKUP_MODE of the backup (tag mode:<mode>, none for all): all, data (without the mailcow directory) or
* config (without mails)
*/
public function mode(): string
{
foreach ($this->tags as $tag) {
if (str_starts_with($tag, 'mode:')) {
return substr($tag, 5);
}
}
return 'all';
}
/** Snapshots not made by mailcow know neither domains nor mailboxes */
public function knowsDomains(): bool
{
return $this->content !== null || $this->domains !== null;
}
/** Entry of the API for admins */
public function toArray(): array
{
return [
'id' => $this->id,
'short_id' => $this->shortId,
'time' => $this->time,
'hostname' => $this->hostname,
'tags' => $this->tags,
'summary' => $this->summary,
'domains' => $this->domains,
'mode' => $this->mode(),
];
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Core\Repository;
use PDO;
/**
* Reads what the export of a domain holds: the domain with its settings, its aliases and a page of its mailboxes with
* every row that belongs to a mailbox. Rows are read with all their columns (SELECT *).
*/
class ExportRepository extends Repository
{
/** @return string[] all domains */
public function domains(): array
{
return $this->query("SELECT `domain` FROM `domain` ORDER BY `domain`")->fetchAll(PDO::FETCH_COLUMN);
}
public function dbVersion(): string
{
return (string)$this->query("SELECT `version` FROM `versions` WHERE `application` = 'db_schema'")->fetchColumn();
}
/** @return array[] rows of a table matching column => value (table and column names from code) */
public function rows(string $table, string $column, string $value): array
{
self::identifier($table);
self::identifier($column);
return $this->query("SELECT * FROM `$table` WHERE `$column` = :value", ['value' => $value])->fetchAll();
}
/** @return string[] usernames of the domain's mailboxes */
public function usernames(string $domain): array
{
return $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain", ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN);
}
/** @return array[] the domain's mailbox rows after $after in username order (keyset paging), at most $limit */
public function mailboxesAfter(string $domain, string $after, int $limit): array
{
return $this->query("SELECT * FROM `mailbox` WHERE `domain` = :domain AND `username` > :after ORDER BY `username` LIMIT " . max(1, $limit),
['domain' => $domain, 'after' => $after])->fetchAll();
}
/** @return array[] alias rows of the domain and its alias domains */
public function aliases(string $domain): array
{
return $this->query("SELECT * FROM `alias` WHERE `domain` = :domain
OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target) ORDER BY `id`",
['domain' => $domain, 'target' => $domain])->fetchAll();
}
/** @return array[] alias rows of any domain whose goto may name a mailbox of the domain (the caller checks) */
public function aliasesPointingTo(string $domain): array
{
return $this->query("SELECT * FROM `alias` WHERE `goto` LIKE :pattern ORDER BY `id`",
['pattern' => '%@' . addcslashes($domain, '%_\\') . '%'])->fetchAll();
}
/**
* @param string $table table with rows of mailboxes
* @param string $column its column holding the username
* @param string[] $usernames
* @return array[] rows of these mailboxes
*/
public function rowsOfMailboxes(string $table, string $column, array $usernames): array
{
self::identifier($table);
self::identifier($column);
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
return $this->query("SELECT * FROM `$table` WHERE `$column` IN ($in)", $params)->fetchAll();
}
/** @return array[] username, id, name, domain, source of the group memberships of these mailboxes */
public function groupMemberships(array $usernames): array
{
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
return $this->query("SELECT `gm`.`username`, `g`.`id`, `g`.`name`, `g`.`domain`, `gm`.`source` FROM `mailbox_group_member` `gm`
JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` WHERE `gm`.`username` IN ($in) ORDER BY `g`.`id`", $params)->fetchAll();
}
/**
* @param string $table mail_policy or retention_rule
* @param string $member its member table
* @param string $key the member table's column with the id
* @return array[] username, id, name, domain of the direct assignments of these mailboxes
*/
public function assignments(string $table, string $member, string $key, array $usernames): array
{
self::identifier($table);
self::identifier($member);
self::identifier($key);
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
return $this->query("SELECT `a`.`username`, `p`.`id`, `p`.`name`, `p`.`domain` FROM `$member` `a`
JOIN `$table` `p` ON `p`.`id` = `a`.`$key` WHERE `a`.`username` IN ($in) ORDER BY `p`.`id`", $params)->fetchAll();
}
/** @return array[] sync jobs of these mailboxes (without their log) with host1, port1 and name of the source */
public function syncJobs(array $usernames): array
{
if (empty($usernames)) {
return [];
}
[$in, $params] = self::in($usernames);
$rows = $this->query("SELECT `i`.*, `s`.`host1` AS `source_host1`, `s`.`port1` AS `source_port1`, `s`.`name` AS `source_name`
FROM `imapsync` `i` LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` IN ($in) ORDER BY `i`.`id`", $params)->fetchAll();
foreach ($rows as &$row) {
unset($row['returned_text']);
}
return $rows;
}
/** @return array[] BCC maps of the whole domain (local_dest "@domain", older rows "domain") */
public function domainBccMaps(string $domain): array
{
return $this->query("SELECT * FROM `bcc_maps` WHERE `local_dest` IN (:domain, :at)", ['domain' => $domain, 'at' => '@' . $domain])->fetchAll();
}
/**
* @param string $table mail_policy or retention_rule
* @param string $member its member table
* @param string $key the member table's column with the id
* @return array[] rows of the domain, each with `group_members`: the names of the groups it is assigned to
*/
public function domainRules(string $table, string $member, string $key, string $domain): array
{
self::identifier($table);
self::identifier($member);
self::identifier($key);
$rows = $this->query("SELECT * FROM `$table` WHERE `domain` = :domain ORDER BY `id`", ['domain' => $domain])->fetchAll();
foreach ($rows as &$row) {
$row['group_members'] = $this->query("SELECT `g`.`name` FROM `$member` `a` JOIN `mailbox_group` `g` ON `g`.`id` = `a`.`group_id`
WHERE `a`.`$key` = :id ORDER BY `g`.`name`", ['id' => $row['id']])->fetchAll(PDO::FETCH_COLUMN);
}
return $rows;
}
/** @return array{0: string, 1: array} placeholders and parameters of an IN list */
private static function in(array $values): array
{
$names = [];
$params = [];
foreach (array_values($values) as $i => $value) {
$names[] = ':in' . $i;
$params['in' . $i] = $value;
}
return [implode(', ', $names), $params];
}
/** Table and column names come from code only; this keeps it that way */
private static function identifier(string $name): void
{
if (!preg_match('/^[a-z_0-9]+$/i', $name)) {
throw new \InvalidArgumentException('invalid identifier');
}
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Backup\Model\Job;
use Mailcow\Core\Context;
use Mailcow\Core\Repository;
use Mailcow\Core\UserError;
use PDO;
/**
* Table `backup_job` (the runners claim, log and finish the jobs) and the lookups a new restore job needs.
*/
class JobRepository extends Repository
{
private const COLUMNS = "`id`, `type`, `state`, `step`, `params`, `result`, `error`, `domain`, `requested_by`,
UNIX_TIMESTAMP(`created`) AS `created`, UNIX_TIMESTAMP(`started`) AS `started`, UNIX_TIMESTAMP(`finished`) AS `finished`";
/** Serializes the check for active jobs and the insert of a new one */
private const LOCK = 'mailcow_backup_job';
public function find(int $id, bool $withLog = false): ?Job
{
$row = $this->query("SELECT " . self::COLUMNS . ($withLog ? ", `log`" : "") . " FROM `backup_job` WHERE `id` = :id", ['id' => $id])->fetch();
return is_array($row) ? Job::fromRow($row) : null;
}
/**
* @param ?string $type only jobs of this type
* @return Job[] newest first: all jobs for admins, the restores of their domains (since the domain was created) for
* domain admins
*/
public function visible(Context $context, int $limit, ?string $type = null): array
{
if ($context->hasAdminRights()) {
[$where, $params] = ['1=1', []];
} elseif ($context->isDomainAdmin()) {
// restores of their domains, requested since the domain was created (not those of a former domain of that name)
[$where, $params] = ["`type` = 'restore' AND `domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :da)
AND `created` >= (SELECT `d`.`created` FROM `domain` `d` WHERE `d`.`domain` = `backup_job`.`domain`)", ['da' => $context->username]];
} else {
return [];
}
if ($type !== null) {
$where .= " AND `type` = :type";
$params['type'] = $type;
}
return array_map(fn(array $row): Job => Job::fromRow($row),
$this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE $where ORDER BY `id` DESC LIMIT " . max(1, $limit), $params)->fetchAll());
}
/** @return Job[] queued and running jobs */
public function active(): array
{
return array_map(fn(array $row): Job => Job::fromRow($row),
$this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `state` IN ('queued', 'running') ORDER BY `id`")->fetchAll());
}
/** @return array<string, Job> type => its newest job */
public function latestOfEachType(): array
{
$jobs = [];
foreach ($this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `id` IN (SELECT MAX(`id`) FROM `backup_job` GROUP BY `type`)")->fetchAll() as $row) {
$jobs[(string)$row['type']] = Job::fromRow($row);
}
return $jobs;
}
/** A backup job was created by the schedule since this unix time */
public function scheduledSince(int $since): bool
{
return (bool)$this->query("SELECT 1 FROM `backup_job` WHERE `type` = 'backup' AND `requested_by` = 'schedule' AND `created` >= FROM_UNIXTIME(:since) LIMIT 1",
['since' => $since])->fetchColumn();
}
/**
* Inserts the job after $check(active jobs) passed; $check throws a UserError on a conflict. A named lock keeps two
* requests from queueing the same work at once.
*
* @param callable(Job[]): void $check
* @throws UserError
*/
public function insertExclusive(Job $job, callable $check): int
{
if ((int)$this->query("SELECT GET_LOCK('" . self::LOCK . "', 10)")->fetchColumn() !== 1) {
throw new UserError('backup_job_active', [$job->type]);
}
try {
$check($this->active());
$job->id = $this->insertRow('backup_job', $job->toRow());
return $job->id;
} finally {
$this->query("SELECT RELEASE_LOCK('" . self::LOCK . "')");
}
}
/**
* Cancels a queued or running job; the restic runner stops at its next check. A restore in the dovecot step
* (mails, mails_running) cannot be cancelled. False when the job finished or reached that step meanwhile.
*/
public function cancel(int $id): bool
{
return $this->query("UPDATE `backup_job` SET `state` = 'cancelled', `finished` = NOW() WHERE `id` = :id
AND `state` IN ('queued', 'running') AND NOT (`type` = 'restore' AND COALESCE(`step`, '') IN ('mails', 'mails_running'))",
['id' => $id])->rowCount() > 0;
}
public function isDomain(string $domain): bool
{
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
}
/** Domain of a mailbox, null if there is none */
public function mailboxDomain(string $username): ?string
{
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username", ['username' => $username])->fetchColumn();
return is_string($domain) ? strtolower($domain) : null;
}
/** Unix time the domain was created, null if it does not exist */
public function domainCreated(string $domain): ?int
{
$created = $this->query("SELECT UNIX_TIMESTAMP(`created`) FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
return ($created === false || $created === null) ? null : (int)$created;
}
/**
* @param string[] $domains
* @return array{0: array<string, true>, 1: array<string, array<string, true>>} the domains that exist, and per domain
* the usernames of its mailboxes; one query each per 500 domains
*/
public function domainsAndUsernames(array $domains): array
{
$existing = [];
$usernames = [];
foreach (array_chunk(array_values(array_unique($domains)), 500) as $chunk) {
$params = [];
foreach ($chunk as $i => $domain) {
$params['d' . $i] = $domain;
}
$in = implode(', ', array_map(fn(string $name): string => ':' . $name, array_keys($params)));
foreach ($this->query("SELECT `domain` FROM `domain` WHERE `domain` IN ($in)", $params)->fetchAll(PDO::FETCH_COLUMN) as $domain) {
$existing[strtolower((string)$domain)] = true;
}
foreach ($this->query("SELECT `domain`, `username` FROM `mailbox` WHERE `domain` IN ($in)", $params)->fetchAll() as $row) {
$usernames[strtolower((string)$row['domain'])][strtolower((string)$row['username'])] = true;
}
}
return [$existing, $usernames];
}
/** @return array<string, string> username => name of the mailboxes of a domain */
public function mailboxNames(string $domain): array
{
$names = [];
foreach ($this->query("SELECT `username`, `name` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`", ['domain' => $domain])->fetchAll() as $row) {
$names[strtolower((string)$row['username'])] = (string)$row['name'];
}
return $names;
}
/** @return string[] all mailboxes of a domain */
public function mailboxesOf(string $domain): array
{
return array_map('strtolower', $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`",
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
}
/** @return string[] alias domains of a domain */
public function aliasDomainsOf(string $domain): array
{
return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain",
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
}
}
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Core\Repository;
use PDO;
use Throwable;
/**
* Reads and writes of the restore merge. Rows of a backup may come from an older schema, so inserts and updates only
* use the columns the table has now. Table and column names come from code, never from the backup.
*/
class RestoreRepository extends Repository
{
/** @var array<string, string[]> table => columns */
private array $columns = [];
/** Runs $work in one transaction */
public function inTransaction(callable $work): mixed
{
return $this->transaction($work);
}
/** Runs $work in a savepoint of the running transaction: an error undoes only its writes and is thrown on */
public function savepoint(string $name, callable $work): mixed
{
self::identifier($name);
$this->pdo->exec("SAVEPOINT `$name`");
try {
$result = $work();
$this->pdo->exec("RELEASE SAVEPOINT `$name`");
return $result;
} catch (Throwable $e) {
$this->pdo->exec("ROLLBACK TO SAVEPOINT `$name`");
throw $e;
}
}
/**
* Runs $work while holding a named lock, so a retried request of the runner waits for the first one instead of
* merging the same mailbox twice; null when the lock was not free within $timeout seconds
*/
public function locked(string $key, callable $work, int $timeout = 120): mixed
{
$name = 'mailcow_backup_' . md5($key);
if ((int)$this->query("SELECT GET_LOCK(:name, " . max(0, $timeout) . ")", ['name' => $name])->fetchColumn() !== 1) {
return null;
}
try {
return $work();
} finally {
$this->query("SELECT RELEASE_LOCK(:name)", ['name' => $name]);
}
}
/** Quota rows of a new mailbox (like the legacy mailbox add) */
public function resetQuota(string $username): void
{
foreach (['quota2', 'quota2replica'] as $table) {
$this->query("INSERT INTO `$table` (`username`, `bytes`, `messages`) VALUES (:username, 0, 0)
ON DUPLICATE KEY UPDATE `bytes` = 0, `messages` = 0", ['username' => $username]);
}
}
/** Regular mailboxes of a domain (the domain's mailbox limit counts these) */
public function mailboxCount(string $domain): int
{
return (int)$this->query("SELECT COUNT(*) FROM `mailbox` WHERE `domain` = :domain AND `kind` = ''", ['domain' => $domain])->fetchColumn();
}
/** External send-as rights for a domain that is local now (legacy domain and alias domain add) */
public function dropExternalSenderAcl(string $domain): void
{
$this->query("DELETE FROM `sender_acl` WHERE `external` = 1 AND `send_as` LIKE :domain", ['domain' => '%@' . addcslashes($domain, '%_\\')]);
}
/** @return string[] the columns of a table in the current schema */
public function columns(string $table): array
{
self::identifier($table);
return $this->columns[$table] ??= $this->query("SELECT `COLUMN_NAME` FROM `information_schema`.`COLUMNS`
WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = :table ORDER BY `ORDINAL_POSITION`", ['table' => $table])->fetchAll(PDO::FETCH_COLUMN);
}
/** @return array[] rows of a table matching every condition (column => value) */
public function rows(string $table, array $where): array
{
[$condition, $params] = $this->where($table, $where);
return $this->query("SELECT * FROM `$table` WHERE $condition", $params)->fetchAll();
}
/** Inserts a row with the values of the columns the table has now; returns the new id (0 without one) */
public function insert(string $table, array $values): int
{
return $this->insertRow($table, $this->existing($table, $values));
}
/** Updates the rows matching $where with the values of the columns the table has now */
public function update(string $table, array $where, array $values): int
{
$values = $this->existing($table, $values);
if (empty($values)) {
return 0;
}
[$condition, $params] = $this->where($table, $where);
$set = [];
foreach (array_keys($values) as $i => $column) {
$set[] = "`$column` = :v$i";
$params['v' . $i] = $values[$column];
}
return $this->query("UPDATE `$table` SET " . implode(', ', $set) . " WHERE $condition", $params)->rowCount();
}
/** A primary or alias domain */
public function domainExists(string $domain): bool
{
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias",
['domain' => $domain, 'alias' => $domain])->fetchColumn();
}
/** The primary domain of a domain or alias domain, null if mailcow has neither */
public function primaryDomain(string $domain): ?string
{
$primary = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain
UNION SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :alias", ['domain' => $domain, 'alias' => $domain])->fetchColumn();
return is_string($primary) ? strtolower($primary) : null;
}
/** An active primary or alias domain (like the legacy is_local_mailcow_domain()) */
public function isLocalDomain(string $domain): bool
{
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain AND `active` = 1
UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias AND `active` = 1", ['domain' => $domain, 'alias' => $domain])->fetchColumn();
}
/** @return string[] alias domains of a domain */
public function aliasDomainsOf(string $domain): array
{
return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain",
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
}
/**
* What an address is now: 'mailbox' (also local@alias-domain of a mailbox), 'resource' (a mailbox of kind
* location, thing or group), 'alias', 'spamalias' or null
*/
public function addressKind(string $address): ?string
{
$kind = $this->query("SELECT `kind` FROM `mailbox` WHERE `username` = :address", ['address' => $address])->fetchColumn();
if (is_string($kind)) {
return preg_match('/location|thing|group/', $kind) ? 'resource' : 'mailbox';
}
[$local, $domain] = array_pad(explode('@', $address, 2), 2, '');
if ((bool)$this->query("SELECT 1 FROM `mailbox` `m` JOIN `alias_domain` `ad` ON `ad`.`alias_domain` = :domain
WHERE `m`.`username` = CONCAT(:local, '@', `ad`.`target_domain`)", ['domain' => $domain, 'local' => $local])->fetchColumn()) {
return 'mailbox';
}
if ((bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) {
return 'alias';
}
if ((bool)$this->query("SELECT 1 FROM `spamalias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) {
return 'spamalias';
}
return null;
}
/** Aliases the domain may still get (domain limit minus its aliases and those of its alias domains, like the legacy code) */
public function aliasesLeft(string $domain): int
{
$limit = $this->query("SELECT `aliases` FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
if ($limit === false) {
return 0;
}
$used = (int)$this->query("SELECT COUNT(*) FROM `alias` WHERE (`domain` = :domain
OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target))
AND `address` NOT IN (SELECT `username` FROM `mailbox`)", ['domain' => $domain, 'target' => $domain])->fetchColumn();
return (int)$limit - $used;
}
/** Quota (bytes) the other mailboxes of the domain have */
public function quotaInUse(string $domain, string $exceptUsername): int
{
return (int)$this->query("SELECT COALESCE(SUM(`quota`), 0) FROM `mailbox` WHERE `domain` = :domain AND `kind` = '' AND `username` != :username",
['domain' => $domain, 'username' => $exceptUsername])->fetchColumn();
}
/** @return array[] user1, host1 and port1 of the mailbox's sync jobs */
public function syncJobsOf(string $username): array
{
return $this->query("SELECT `i`.`user1`, `s`.`host1`, `s`.`port1` FROM `imapsync` `i`
LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` = :username", ['username' => $username])->fetchAll();
}
/** @return int[] ids of the sync sources with this host and port the mailbox may use, oldest first */
public function syncSourcesFor(string $host, int $port, string $username, string $domain): array
{
return array_map('intval', $this->query("SELECT `s`.`id` FROM `imapsync_source` `s` WHERE `s`.`host1` = :host AND `s`.`port1` = :port
AND (`s`.`scope` = 'all'
OR (`s`.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` `d` WHERE `d`.`source_id` = `s`.`id` AND `d`.`domain` = :domain))
OR (`s`.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` `u` WHERE `u`.`source_id` = `s`.`id` AND `u`.`username` = :username)))
ORDER BY `s`.`id`", ['host' => $host, 'port' => $port, 'domain' => $domain, 'username' => $username])->fetchAll(PDO::FETCH_COLUMN));
}
/** @return array{0: string, 1: array} condition and parameters of column => value pairs */
private function where(string $table, array $where): array
{
self::identifier($table);
$parts = [];
$params = [];
foreach (array_keys($where) as $i => $column) {
self::identifier((string)$column);
if ($where[$column] === null) {
$parts[] = "`$column` IS NULL";
continue;
}
$parts[] = "`$column` = :w$i";
$params['w' . $i] = $where[$column];
}
return [empty($parts) ? '1=1' : implode(' AND ', $parts), $params];
}
/** The values of the columns the table has now */
private function existing(string $table, array $values): array
{
return array_intersect_key($values, array_flip($this->columns($table)));
}
/** Table, column and savepoint names come from code only; this keeps it that way */
private static function identifier(string $name): void
{
if (!preg_match('/^[a-z_0-9]+$/i', $name)) {
throw new \InvalidArgumentException('invalid identifier');
}
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Core\Repository;
use PDO;
/**
* Table `backup_setting` (name/value pairs; the runner writes its state_* values into it as well).
*/
class SettingsRepository extends Repository
{
/** @return array<string, string> name => value of the stored settings */
public function load(): array
{
return $this->query("SELECT `name`, `value` FROM `backup_setting`")->fetchAll(PDO::FETCH_KEY_PAIR);
}
/** Stores the given values (name => value) */
public function save(array $values): void
{
$this->transaction(function () use ($values): void {
foreach ($values as $name => $value) {
$this->query("INSERT INTO `backup_setting` (`name`, `value`) VALUES (:name, :value)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)", ['name' => (string)$name, 'value' => (string)$value]);
}
});
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Repository;
use Mailcow\Backup\Model\Snapshot;
use Mailcow\Core\Repository;
/**
* Table `backup_snapshot`: the runner's cache of the repository's snapshots (read only here).
*/
class SnapshotRepository extends Repository
{
private const COLUMNS = "`id`, `short_id`, UNIX_TIMESTAMP(`time`) AS `time`, `hostname`, `tags`, `summary`, `domains`";
/** By id or short id, with its content (the mailboxes in it) */
public function find(string $id): ?Snapshot
{
$row = $this->query("SELECT " . self::COLUMNS . ", `content` FROM `backup_snapshot` WHERE `id` = :id OR `short_id` = :short_id ORDER BY `id` = :exact DESC LIMIT 1",
['id' => $id, 'short_id' => $id, 'exact' => $id])->fetch();
return is_array($row) ? Snapshot::fromRow($row) : null;
}
/** @return Snapshot[] newest first; the content (the mailboxes in it) only with $withContent */
public function all(bool $withContent = false): array
{
return array_map(fn(array $row): Snapshot => Snapshot::fromRow($row),
$this->query("SELECT " . self::COLUMNS . ($withContent ? ", `content`" : "") . " FROM `backup_snapshot` ORDER BY `time` DESC, `id`")->fetchAll());
}
}
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\RestoreRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Throwable;
/**
* Action `restore_domain`: recreates a domain deleted since the snapshot from its export (`domain_data`), before its
* mailboxes are restored. Only for running restores an admin requested while the domain did not exist
* (params.create_domain).
*
* Creates the domain like the legacy domain add (row, tags, domain map, external send-as rights for it dropped, SOGo
* restarted so it knows the domain), unless it exists by now. Then adds what is missing: alias domains, DKIM keys, rate
* limit, spam settings, BCC maps, footer, MTA-STS, domain admin assignments of accounts that still exist, groups (no
* members, send-as rights or shares; members follow with the mailboxes), mail policies and retention rules with their
* group assignments (matched by name).
*/
class DomainRestoreService
{
private const NEW_ROW = ['id', 'created', 'modified'];
private const POLICY_LISTS = ['send_allow', 'send_block', 'receive_allow', 'receive_block'];
private const RATE_LIMIT = '/^\d+ \/ 1[smhd]$/';
public function __construct(private RestoreRepository $repo, private JobRepository $jobs, private GroupMembers $groups,
private LegacyDomain $legacy)
{
}
/**
* @return array{domain: array{added: int, skipped: int, errors: string[], created: int}}
* @throws UserError
*/
public function restore(mixed $jobId, mixed $data): array
{
$id = Input::intInRange($jobId, 1, PHP_INT_MAX);
$job = ($id === null) ? null : $this->jobs->find($id);
if ($job === null || $job->type !== 'restore' || $job->state !== 'running') {
throw new UserError('backup_job_not_running');
}
if (!$job->createsDomain() || ($job->params['requested_role'] ?? '') !== 'admin') {
throw new AccessDenied();
}
$domain = strtolower((string)$job->domain);
if (!is_array($data) || !is_array($data['domain'] ?? null) || strtolower((string)($data['domain']['domain'] ?? '')) !== $domain) {
throw new UserError('backup_export_invalid');
}
$result = $this->repo->locked($job->id . ':domain', fn(): array => $this->restoreDomain($job, $domain, $data));
return is_array($result) ? ['domain' => $result] : throw new UserError('backup_job_busy');
}
private function restoreDomain(Job $job, string $domain, array $data): array
{
$result = ['added' => 0, 'skipped' => 0, 'errors' => [], 'created' => 0];
if (!empty($this->repo->rows('alias_domain', ['alias_domain' => $domain]))) {
$result['errors'][] = "$domain is an alias domain now";
return $result;
}
if (!empty($this->repo->rows('domain', ['domain' => $domain]))) {
// created meanwhile: only what is missing is added
$result['skipped']++;
} else {
$this->createDomain($domain, $data, $result);
}
$this->repo->inTransaction(function () use ($domain, $data, &$result): void {
$this->aliasDomains($domain, $data, $result);
$this->domainSettings($domain, $data, $result);
});
$this->redisSettings($domain, $data, $result);
$groupIds = $this->createGroups($domain, $data, $result);
$this->repo->inTransaction(function () use ($domain, $data, $groupIds, &$result): void {
$this->rules($domain, $data, 'mail_policies', 'mail_policy', 'mail_policy_member', 'policy_id', $groupIds, $result);
$this->rules($domain, $data, 'retention_rules', 'retention_rule', 'retention_rule_member', 'rule_id', $groupIds, $result);
});
return $result;
}
/** The domain row, its tags and the effects of the legacy domain add */
private function createDomain(string $domain, array $data, array &$result): void
{
// the domain keeps its creation time: domain admins may use the snapshots made after it
$row = array_diff_key(RestoreService::decode($data['domain']), array_flip(['modified']));
$relayhost = (string)($row['relayhost'] ?? '0');
if ($relayhost !== '0' && $relayhost !== '' && empty($this->repo->rows('relayhosts', ['id' => (int)$relayhost]))) {
$row['relayhost'] = '0';
$result['errors'][] = 'relayhost: does not exist';
}
$this->repo->inTransaction(function () use ($domain, $row, $data): void {
$this->repo->insert('domain', ['domain' => $domain] + $row);
$this->repo->dropExternalSenderAcl($domain);
foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', (array)($data['tags'] ?? [])), 'strlen')) as $tag) {
$this->repo->insert('tags_domain', ['domain' => $domain, 'tag_name' => $tag]);
}
});
$result['added']++;
$result['created'] = 1;
$this->effect($result, 'domain map', fn() => $this->legacy->addDomainMap($domain));
$error = $this->legacy->restartSogo();
if ($error !== null) {
$result['errors'][] = 'SOGo restart failed: ' . $error;
}
}
private function aliasDomains(string $domain, array $data, array &$result): void
{
foreach (self::rows($data, 'alias_domains') as $row) {
$alias = strtolower(trim((string)($row['alias_domain'] ?? '')));
if ($alias === '' || $this->repo->domainExists($alias)) {
$result['skipped']++;
continue;
}
$this->repo->insert('alias_domain', ['alias_domain' => $alias, 'target_domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
$this->repo->dropExternalSenderAcl($alias);
$this->effect($result, "domain map of $alias", fn() => $this->legacy->addDomainMap($alias));
$result['added']++;
}
}
/** Spam settings, BCC maps, footer, MTA-STS and domain admins of the domain that are missing */
private function domainSettings(string $domain, array $data, array &$result): void
{
foreach (self::rows($data, 'filterconf') as $row) {
$option = (string)($row['option'] ?? '');
$value = (string)($row['value'] ?? '');
$current = $this->repo->rows('filterconf', ['object' => $domain]);
$present = in_array($option, ['whitelist_from', 'blacklist_from'], true)
? array_filter($current, fn(array $c): bool => in_array($c['option'], ['whitelist_from', 'blacklist_from'], true) && strtolower((string)$c['value']) === strtolower($value))
: array_filter($current, fn(array $c): bool => $c['option'] === $option && (in_array($option, ['lowspamlevel', 'highspamlevel'], true) || (string)$c['value'] === $value));
if ($option === '' || !empty($present)) {
$result['skipped']++;
continue;
}
$this->repo->insert('filterconf', ['object' => $domain, 'option' => $option, 'value' => $value]);
$result['added']++;
}
foreach (self::rows($data, 'bcc_maps') as $row) {
$localDest = strtolower((string)($row['local_dest'] ?? ''));
if (!in_array($localDest, [$domain, '@' . $domain], true) || !filter_var((string)($row['bcc_dest'] ?? ''), FILTER_VALIDATE_EMAIL)
|| !in_array($row['type'] ?? '', ['sender', 'rcpt'], true)) {
$result['errors'][] = "BCC map $localDest: invalid";
} elseif (!empty($this->repo->rows('bcc_maps', ['local_dest' => $localDest, 'type' => $row['type']]))) {
$result['skipped']++;
} else {
$this->repo->insert('bcc_maps', ['domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
$result['added']++;
}
}
foreach (['footer' => 'domain_wide_footer', 'mta_sts' => 'mta_sts'] as $key => $table) {
if (!is_array($data[$key] ?? null)) {
continue;
}
if (!empty($this->repo->rows($table, ['domain' => $domain]))) {
$result['skipped']++;
continue;
}
$this->repo->insert($table, ['domain' => $domain] + array_diff_key(RestoreService::decode($data[$key]), array_flip(['created', 'modified'])));
$result['added']++;
}
foreach (self::rows($data, 'domain_admins') as $row) {
$username = (string)($row['username'] ?? '');
if (empty($this->repo->rows('admin', ['username' => $username, 'superadmin' => 0]))) {
$result['errors'][] = "domain admin $username: does not exist";
} elseif (!empty($this->repo->rows('domain_admins', ['username' => $username, 'domain' => $domain]))) {
$result['skipped']++;
} else {
$this->repo->insert('domain_admins', ['username' => $username, 'domain' => $domain, 'active' => (int)($row['active'] ?? 1)]);
$result['added']++;
}
}
}
/** DKIM keys of the domain and its alias domains, and the rate limit of the domain (Redis), when missing */
private function redisSettings(string $domain, array $data, array &$result): void
{
$domains = array_merge([$domain], $this->repo->aliasDomainsOf($domain));
foreach (self::rows($data, 'dkim') as $key) {
$name = strtolower((string)($key['domain'] ?? ''));
$selector = (string)($key['selector'] ?? '');
if (!in_array($name, $domains, true)) {
continue;
}
if (!preg_match('/^[a-z0-9._-]{1,63}$/i', $selector) || (string)($key['pubkey'] ?? '') === '' || (string)($key['privkey'] ?? '') === '') {
$result['errors'][] = "DKIM key of $name: invalid";
continue;
}
$this->effect($result, "DKIM key of $name", function () use ($name, $selector, $key, &$result): void {
if ($this->legacy->dkim($name) !== null) {
$result['skipped']++;
return;
}
$this->legacy->addDkim($name, $selector, (string)$key['pubkey'], (string)$key['privkey']);
$result['added']++;
});
}
$rateLimit = $data['ratelimit'] ?? null;
if (is_string($rateLimit) && preg_match(self::RATE_LIMIT, $rateLimit)) {
$this->effect($result, 'rate limit', function () use ($domain, $rateLimit, &$result): void {
if (isset($this->legacy->rateLimits([$domain])[$domain])) {
$result['skipped']++;
return;
}
$this->legacy->setRateLimit($domain, $rateLimit);
$result['added']++;
});
}
}
/**
* Groups of the domain missing by name, through the group module (address, flags, priority, IdP link)
*
* @return array<int, int> id in the backup => id now, of all groups found by name
*/
private function createGroups(string $domain, array $data, array &$result): array
{
$ids = [];
$admin = new Context('admin', 'backup', [], true);
foreach (self::rows($data, 'groups') as $row) {
$name = (string)($row['name'] ?? '');
$current = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => $name])[0] ?? null;
if ($current !== null) {
$ids[(int)($row['id'] ?? 0)] = (int)$current['id'];
$result['skipped']++;
continue;
}
$input = ['domain' => $domain, 'name' => $name, 'description' => (string)($row['description'] ?? '')];
foreach (['sender_allowed', 'sogo_visible', 'internal', 'active', 'priority'] as $key) {
if (isset($row[$key])) {
$input[$key] = (int)$row[$key];
}
}
foreach (['address', 'idp_ref'] as $key) {
if (($row[$key] ?? null) !== null && $row[$key] !== '') {
$input[$key] = (string)$row[$key];
}
}
try {
$ids[(int)($row['id'] ?? 0)] = $this->groups->create($admin, $input);
$result['added']++;
} catch (UserError $e) {
$result['errors'][] = "group $name: " . $e->key;
}
}
return $ids;
}
/**
* Mail policies or retention rules missing by name, with their group assignments; group:<id> entries of policies
* follow the groups' new ids
*/
private function rules(string $domain, array $data, string $key, string $table, string $member, string $column, array $groupIds, array &$result): void
{
foreach (self::rows($data, $key) as $row) {
$name = (string)($row['name'] ?? '');
if (!empty($this->repo->rows($table, ['domain' => $domain, 'name' => $name]))) {
$result['skipped']++;
continue;
}
$values = array_diff_key($row, array_flip([...self::NEW_ROW, 'group_members']));
foreach (self::POLICY_LISTS as $list) {
if (isset($values[$list])) {
$values[$list] = json_encode($this->remapGroups((string)$values[$list], $groupIds, "$name ($list)", $result));
}
}
$id = $this->repo->insert($table, ['domain' => $domain, 'name' => $name] + $values);
foreach ((array)($row['group_members'] ?? []) as $groupName) {
$group = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => (string)$groupName])[0] ?? null;
if ($group === null) {
$result['errors'][] = "$name: group $groupName does not exist";
continue;
}
$this->repo->insert($member, [$column => $id, 'group_id' => (int)$group['id']]);
}
$result['added']++;
}
}
/** @return string[] entries of a JSON list with group:<id> of the backup turned into the group's id now */
private function remapGroups(string $json, array $groupIds, string $what, array &$result): array
{
$entries = [];
foreach ((array)json_decode($json, true) as $entry) {
if (!is_string($entry)) {
continue;
}
if (preg_match('/^group:(\d+)$/', $entry, $match)) {
if (!isset($groupIds[(int)$match[1]])) {
$result['errors'][] = "$what: $entry dropped (group does not exist)";
continue;
}
$entry = 'group:' . $groupIds[(int)$match[1]];
}
$entries[] = $entry;
}
return $entries;
}
/** A Redis or dockerapi step: a failure is reported and the rest goes on */
private function effect(array &$result, string $what, callable $work): void
{
try {
$work();
} catch (Throwable) {
$result['errors'][] = "$what: not stored (Redis)";
}
}
private static function rows(array $data, string $key): array
{
return array_values(array_filter((array)($data[$key] ?? []), 'is_array'));
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Repository\ExportRepository;
use Mailcow\Core\UserError;
/**
* Export of a domain for the backup (/mailcow/export/<domain>.json in a snapshot), in pages of mailboxes the runner
* merges into one file. The first page holds version, db_version, domain, domain_data (what recreating the domain
* needs), the aliases and `mailbox_aliases` (username => the aliases of any domain whose goto names the mailbox,
* without its own alias and group addresses; the runner moves them into the mailbox sections when it reads the file);
* every page a part of `mailboxes` and `next_after`, the last username of the page to ask for the next one with
* (keyset paging; null after the last page).
*
* Per mailbox every row a restore needs, with all columns. Password hashes, DKIM keys and the secrets of app
* passwords, 2FA and sync jobs are part of it on purpose; only the runner gets it (backup.php with the token). Binary
* values (fido2) are written as {"base64": "..."}. Beside the keys of the spec, a mailbox has `own_alias` (its alias
* row: forwarding with or without a copy), `aliases` (empty, see mailbox_aliases) and `ratelimit` (Redis).
*/
class ExportService
{
public const VERSION = 1;
public const PAGE_SIZE = 500;
/** Lists of a mailbox: key => [table, column with the username] */
private const MAILBOX_ROWS = [
'sender_acl' => ['sender_acl', 'logged_in_as'],
'sender_acl_granted' => ['sender_acl', 'send_as'],
'sieve_filters' => ['sieve_filters', 'username'],
'filterconf' => ['filterconf', 'object'],
'spamalias' => ['spamalias', 'goto'],
'bcc_maps' => ['bcc_maps', 'local_dest'],
'app_passwd' => ['app_passwd', 'mailbox'],
'tfa' => ['tfa', 'username'],
// FIDO2 keys of mailbox users (passwordless login on the user page), like those of admins
'fido2' => ['fido2', 'username'],
'identity_link' => ['identity_link', 'username'],
];
/** Single rows of a mailbox: key => [table, column with the username] */
private const MAILBOX_ROW = [
'user_acl' => ['user_acl', 'username'],
'pushover' => ['pushover', 'username'],
];
public function __construct(private ExportRepository $repo, private LegacyDomain $legacy)
{
}
/** @return string[] all domains */
public function domains(): array
{
return array_values(array_map('strval', $this->repo->domains()));
}
/**
* @param ?string $after the last username of the previous page (keyset paging), null for the first page
* @throws UserError domain_not_found
*/
public function export(string $domain, ?string $after = null, int $pageSize = self::PAGE_SIZE): array
{
$domain = strtolower(trim($domain));
$row = ($domain === '') ? null : ($this->repo->rows('domain', 'domain', $domain)[0] ?? null);
if ($row === null) {
throw new UserError('domain_not_found', [htmlspecialchars($domain)]);
}
$rows = $this->repo->mailboxesAfter($domain, strtolower($after ?? ''), $pageSize + 1);
$more = count($rows) > $pageSize;
$rows = array_slice($rows, 0, $pageSize);
$export = ['version' => self::VERSION, 'domain' => $domain];
if ($after === null) {
$export += [
'db_version' => $this->repo->dbVersion(),
'domain_data' => $this->domainData($domain, $row),
'aliases' => array_map(fn(array $alias): array => self::encode($alias), $this->repo->aliases($domain)),
'mailbox_aliases' => $this->mailboxAliases($domain) ?: new \stdClass(),
];
}
$export['mailboxes'] = $this->mailboxes($domain, $rows) ?: new \stdClass();
$export['next_after'] = ($more && !empty($rows)) ? strtolower((string)end($rows)['username']) : null;
return $export;
}
/** What recreating the domain needs: its row and settings, alias domains, DKIM keys, groups, policies, rules */
private function domainData(string $domain, array $row): array
{
$aliasDomains = $this->repo->rows('alias_domain', 'target_domain', $domain);
$dkim = [];
foreach (array_merge([$domain], array_map(fn(array $alias): string => strtolower((string)$alias['alias_domain']), $aliasDomains)) as $name) {
$key = $this->legacy->dkim($name);
if ($key !== null) {
$dkim[] = ['domain' => $name] + $key;
}
}
return [
'domain' => self::encode($row),
'tags' => array_map(fn(array $tag): string => (string)$tag['tag_name'], $this->repo->rows('tags_domain', 'domain', $domain)),
'alias_domains' => $aliasDomains,
'dkim' => $dkim,
'ratelimit' => $this->legacy->rateLimits([$domain])[$domain] ?? null,
'filterconf' => $this->repo->rows('filterconf', 'object', $domain),
'bcc_maps' => $this->repo->domainBccMaps($domain),
'footer' => $this->repo->rows('domain_wide_footer', 'domain', $domain)[0] ?? null,
'mta_sts' => $this->repo->rows('mta_sts', 'domain', $domain)[0] ?? null,
'domain_admins' => $this->repo->rows('domain_admins', 'domain', $domain),
'groups' => $this->repo->rows('mailbox_group', 'domain', $domain),
'mail_policies' => $this->repo->domainRules('mail_policy', 'mail_policy_member', 'policy_id', $domain),
'retention_rules' => $this->repo->domainRules('retention_rule', 'retention_rule_member', 'rule_id', $domain),
];
}
/** @return array<string, array> username => its section */
private function mailboxes(string $domain, array $rows): array
{
$mailboxes = [];
foreach ($rows as $row) {
$mailboxes[strtolower((string)$row['username'])] = [
'mailbox' => self::encode($row) + ['tags' => []],
'user_acl' => null,
'pushover' => null,
'ratelimit' => null,
'own_alias' => null,
'aliases' => [],
'groups' => [],
'mail_policies' => [],
'retention_rules' => [],
'imapsync' => [],
] + array_fill_keys(array_keys(self::MAILBOX_ROWS), []);
}
$usernames = array_map('strval', array_keys($mailboxes));
if (empty($usernames)) {
return [];
}
// the mailbox of the page a row belongs to, null for anything else
$owner = fn(array $row, string $column): ?string => isset($mailboxes[strtolower((string)$row[$column])]) ? strtolower((string)$row[$column]) : null;
foreach ($this->repo->rowsOfMailboxes('tags_mailbox', 'username', $usernames) as $row) {
if (($username = $owner($row, 'username')) !== null) {
$mailboxes[$username]['mailbox']['tags'][] = (string)$row['tag_name'];
}
}
foreach (self::MAILBOX_ROW as $key => [$table, $column]) {
foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) {
if (($username = $owner($row, $column)) !== null) {
$mailboxes[$username][$key] = self::encode($row);
}
}
}
foreach (self::MAILBOX_ROWS as $key => [$table, $column]) {
foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) {
if (($username = $owner($row, $column)) !== null) {
$mailboxes[$username][$key][] = self::encode($row);
}
}
}
foreach ($this->legacy->rateLimits($usernames) as $username => $value) {
if (isset($mailboxes[strtolower((string)$username)])) {
$mailboxes[strtolower((string)$username)]['ratelimit'] = $value;
}
}
foreach ($this->repo->groupMemberships($usernames) as $row) {
if (($username = $owner($row, 'username')) !== null) {
$mailboxes[$username]['groups'][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain'],
'source' => (string)$row['source']];
}
}
foreach (['mail_policies' => ['mail_policy', 'mail_policy_member', 'policy_id'], 'retention_rules' => ['retention_rule', 'retention_rule_member', 'rule_id']]
as $key => [$table, $member, $column]) {
foreach ($this->repo->assignments($table, $member, $column, $usernames) as $row) {
if (($username = $owner($row, 'username')) !== null) {
$mailboxes[$username][$key][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain']];
}
}
}
foreach ($this->repo->syncJobs($usernames) as $row) {
if (($username = $owner($row, 'user2')) !== null) {
$mailboxes[$username]['imapsync'][] = self::encode($row);
}
}
foreach ($this->repo->rowsOfMailboxes('alias', 'address', $usernames) as $row) {
if (($username = $owner($row, 'address')) !== null) {
$mailboxes[$username]['own_alias'] = self::encode($row);
}
}
return $mailboxes;
}
/**
* @return array<string, array[]> username => aliases of any domain whose goto names the mailbox (once per domain: the
* alias table is scanned, not once per page)
*/
private function mailboxAliases(string $domain): array
{
$usernames = array_fill_keys(array_map(fn(mixed $username): string => strtolower((string)$username), $this->repo->usernames($domain)), true);
$aliases = [];
foreach ($this->repo->aliasesPointingTo($domain) as $row) {
if ($row['group_id'] !== null) {
continue;
}
$address = strtolower((string)$row['address']);
foreach (array_unique(array_map('strtolower', self::gotos((string)$row['goto']))) as $goto) {
if ($goto !== $address && isset($usernames[$goto])) {
$aliases[$goto][] = self::encode($row);
}
}
}
return $aliases;
}
/** @return string[] addresses of a goto list */
public static function gotos(string $goto): array
{
return array_values(array_filter(array_map('trim', explode(',', $goto)), 'strlen'));
}
/** Binary values (not UTF-8) as {"base64": "..."}, so the row survives JSON */
public static function encode(array $row): array
{
foreach ($row as $column => $value) {
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
$row[$column] = ['base64' => base64_encode($value)];
}
}
return $row;
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\GroupService;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\MailboxGroup\Service\TemplateService;
/**
* Group writes of a restore through the group module (addresses, send-as rights, shares and caches follow), as the
* requester of the restore: an admin, or the domain admin with his rights now (GroupService::canEdit). The module is
* wired without group templates (Legacy\NoTemplates).
*/
class GroupMembers
{
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private TemplateService $templates,
private ChangeEffects $effects, private SharingService $sharing)
{
}
/** @throws UserError e.g. access_denied when the group is read-only for the requester */
public function add(Context $context, int $groupId, string $username): void
{
$this->service($context)->update($groupId, ['add_members' => [$username]]);
}
/**
* Creates a group (restore of a deleted domain); members follow with the mailboxes.
*
* @throws UserError
*/
public function create(Context $context, array $input): int
{
return (int)$this->service($context)->create($input)->id;
}
protected function service(Context $context): GroupService
{
return new GroupService($this->repo, $this->lookup, $this->templates, $this->effects, $this->sharing, $context);
}
}
@@ -0,0 +1,666 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use DateTimeImmutable;
use DateTimeZone;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Model\Snapshot;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\SnapshotRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
/**
* Jobs of the backup runners, the snapshots with their content and the status page.
*
* Backup, check, prune and init jobs: admins (not read-only API keys) queue them, one of each type at a time.
* Restores: admins, and domain admins for mailboxes or a domain they administer now. Mailboxes and domains deleted
* since the snapshot are created from it (deleted domains by admins only). A component a domain admin could not
* manage himself (e.g. sync jobs without the `syncjobs` ACL) is refused; his ACL and domains are stored with the job,
* so the merge and the runners apply the same limits.
* Reading: admins see every job and snapshot; domain admins the restores of their domains and, of each snapshot made
* after their domain was created, their domains in it.
*/
class JobService
{
/**
* Restore components in their order. mails, imap_acl, sieve_activate and the Sieve scripts of filters are restored by
* the dovecot runner, sogo by the restic runner, the others by RestoreService.
*/
public const COMPONENTS = ['mails', 'imap_acl', 'sogo', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules',
'filters', 'sieve_activate', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'];
/** Components that replace current data with the backup's (all others only add what is missing) */
public const OVERWRITE = ['sogo', 'settings', 'password'];
/** Components that need the mails: not in snapshots without them (RESTIC_BACKUP_MODE=config) */
public const MAIL_COMPONENTS = ['mails', 'imap_acl', 'sogo', 'sieve_activate'];
public const MAIL_TARGETS = ['folder', 'original'];
/** Domain admin ACL a component needs */
private const COMPONENT_ACL = [
'groups' => 'mailbox_groups',
'mail_policies' => 'mail_policies',
'retention_rules' => 'retention',
'filters' => 'filters',
'sieve_activate' => 'filters',
'spam' => 'spam_policy',
'bcc' => 'bcc_maps',
'syncjobs' => 'syncjobs',
'app_passwords' => 'app_passwds',
];
/** Components that only work together with another one */
private const REQUIRES = ['sieve_activate' => 'filters'];
/** Domain admin ACLs the merge respects, stored with the job */
private const RESTORE_ACL = ['filters', 'mailbox_groups', 'mail_policies', 'retention', 'spam_policy', 'bcc_maps', 'syncjobs',
'app_passwds', 'protocol_access', 'mailbox_relayhost', 'unlimited_quota', 'sogo_access', 'pushover', 'extend_sender_acl',
'alias_external_goto', 'ratelimit', 'tls_policy', 'quarantine', 'quarantine_notification', 'quarantine_category'];
private const MAX_MAILBOXES = 1000;
private const LIST_LIMIT = 100;
private const MAX_LIST = 1000;
/** Mailboxes a search returns at most */
private const SEARCH_LIMIT = 500;
/** The runner reports every 10 seconds */
private const RUNNER_TIMEOUT = 120;
/** @var array<string, array{bool, ?int}> domain => [access of the domain admin, creation time now], per request */
private array $domainState = [];
public function __construct(private JobRepository $jobs, private SnapshotRepository $snapshots, private SettingsService $settings,
private Context $context, private string $timezone = 'UTC')
{
}
/**
* @param mixed $type only jobs of this type ('' or null = all; an unknown type finds nothing)
* @return Job[] newest first, at most $limit (0 = LIST_LIMIT)
*/
public function list(int $limit = 0, mixed $type = null): array
{
$type = Input::text($type);
if ($type !== '' && !in_array($type, Job::TYPES, true)) {
return [];
}
return $this->jobs->visible($this->context, ($limit > 0) ? min($limit, self::MAX_LIST) : self::LIST_LIMIT, ($type === '') ? null : $type);
}
/**
* Entry of the API. Domain admins get it restricted: e-mail addresses outside the job's domain and its alias domains
* and repository details (URLs, restic repository strings, paths of the repository) are shown as "…".
*/
public function view(Job $job, bool $withLog = false): array
{
$entry = $job->toArray($withLog);
if ($this->context->hasAdminRights()) {
return $entry;
}
$domains = array_merge([strtolower($job->domain)], $this->jobs->aliasDomainsOf(strtolower($job->domain)));
foreach (['result', 'error', 'log'] as $key) {
if (isset($entry[$key])) {
$entry[$key] = self::restricted($entry[$key], $domains);
}
}
// the account of an admin who requested the restore stays hidden, the UI shows "Administrator"
if (($job->params['requested_role'] ?? '') !== 'domainadmin') {
$entry['requested_by'] = '';
if (isset($entry['log'])) {
$entry['log'] = str_replace('(requested by ' . $job->requestedBy . ')', '(requested by an administrator)', $entry['log']);
}
}
return $entry;
}
/** The job with its log, null when it does not exist or the caller may not see it */
public function get(int $id): ?Job
{
$job = $this->jobs->find($id, true);
return ($job !== null && $this->visible($job)) ? $job : null;
}
/**
* Queues a backup, check, prune or init job (admins).
*
* @throws UserError
*/
public function create(array $input): Job
{
$this->context->requireAdmin();
$type = Input::text($input['type'] ?? null);
if (!in_array($type, Job::MANUAL_TYPES, true)) {
throw new UserError('backup_job_type_invalid', [htmlspecialchars((string)$type)]);
}
$settings = $this->settings->load();
if (!$settings->configured()) {
throw new UserError('backup_not_configured');
}
if ($type === 'backup' && $settings->get('password_confirmed') !== '1') {
throw new UserError('backup_password_not_confirmed');
}
$job = new Job($type);
$job->requestedBy = (string)$this->context->username;
$this->jobs->insertExclusive($job, function (array $active) use ($type): void {
foreach ($active as $other) {
if ($other->type === $type) {
throw new UserError('backup_job_active', [$type]);
}
}
});
return $job;
}
/**
* Queues a restore: {snapshot, scope: mailbox|domain, mailboxes | domain, components, mail_target: folder|original}.
* All mailboxes belong to one domain. Mailboxes deleted since the snapshot are created from it (create_mailboxes);
* an admin may restore a deleted domain (create_domain). Scope domain restores the snapshot's mailboxes of the
* domain (the current ones for snapshots without content).
*
* @throws UserError
*/
public function restore(array $input): Job
{
$this->requireRestorer();
if (!$this->settings->load()->configured()) {
throw new UserError('backup_not_configured');
}
$snapshot = $this->snapshot($input['snapshot'] ?? null);
$scope = Input::text($input['scope'] ?? 'mailbox');
[$domain, $mailboxes] = match ($scope) {
'domain' => $this->domainScope($snapshot, $input['domain'] ?? null),
'mailbox' => $this->mailboxScope($snapshot, $input['mailboxes'] ?? null, $input['domain'] ?? null),
default => throw new UserError('backup_scope_invalid'),
};
$components = $this->components($input['components'] ?? null);
$missing = array_values(array_intersect($components, self::MAIL_COMPONENTS));
if ($snapshot->mode() === 'config' && !empty($missing)) {
throw new UserError('backup_components_no_mails', [htmlspecialchars(implode(', ', $missing))]);
}
$mailTarget = Input::text($input['mail_target'] ?? null) ?: 'folder';
if (!in_array($mailTarget, self::MAIL_TARGETS, true)) {
throw new UserError('backup_mail_target_invalid');
}
$job = new Job('restore');
$job->step = 'files';
$job->domain = $domain;
$job->requestedBy = (string)$this->context->username;
$job->params = [
'snapshot' => $snapshot->id,
'snapshot_time' => $snapshot->time,
'scope' => $scope,
'domain' => $domain,
'mailboxes' => $mailboxes,
'components' => $components,
'mail_target' => $mailTarget,
'requested_role' => $this->context->hasAdminRights() ? 'admin' : 'domainadmin',
'create_domain' => (int)!$this->jobs->isDomain($domain),
'create_mailboxes' => array_values(array_filter($mailboxes, fn(string $username): bool => $this->jobs->mailboxDomain($username) === null)),
];
if (!$this->context->hasAdminRights()) {
$job->params['acl'] = array_combine(self::RESTORE_ACL, array_map(fn(string $acl): int => (int)$this->context->hasAcl($acl), self::RESTORE_ACL));
$job->params['allowed_domains'] = array_values(array_unique(array_merge([$domain], $this->jobs->aliasDomainsOf($domain))));
}
$domainAdmin = !$this->context->hasAdminRights();
$this->jobs->insertExclusive($job, function (array $active) use ($mailboxes, $domain, $domainAdmin): void {
foreach ($active as $other) {
if ($other->type !== 'restore') {
continue;
}
// a domain admin has one restore per domain at a time
if ($domainAdmin && strtolower($other->domain) === $domain) {
throw new UserError('backup_restore_domain_active', [htmlspecialchars($domain)]);
}
$both = array_intersect($mailboxes, $other->mailboxes());
if (!empty($both)) {
throw new UserError('backup_restore_active', [htmlspecialchars((string)reset($both))]);
}
}
});
return $job;
}
/**
* Cancels a queued or running job (the restic runner stops at its next check): admins any, domain admins the
* restores of their domains that domain admins requested. A restore in the dovecot step (mails, mails_running) runs
* to its end.
*
* @throws UserError
*/
public function cancel(int $id): Job
{
if ($this->context->isReadOnlyApi()) {
throw new AccessDenied();
}
$job = $this->jobs->find($id);
// domain admins cancel only restores domain admins requested
if ($job === null || !$this->visible($job) || (!$this->context->hasAdminRights() && ($job->params['requested_role'] ?? '') !== 'domainadmin')) {
throw new AccessDenied();
}
if (!$job->isActive() || ($job->type === 'restore' && in_array($job->step, ['mails', 'mails_running'], true)) || !$this->jobs->cancel($id)) {
throw new UserError('backup_job_not_cancellable', [$id]);
}
$job->state = 'cancelled';
return $job;
}
/**
* Snapshots, newest first. Domain admins get those holding one of their domains (see mayUse()), with id, short_id,
* time and those domains.
*/
public function snapshots(): array
{
if ($this->context->hasAdminRights()) {
return array_map(fn(Snapshot $snapshot): array => $snapshot->toArray(), $this->snapshots->all());
}
if (!$this->context->isDomainAdmin()) {
return [];
}
$snapshots = [];
// the domains with their creation time come from the snapshot row, the content is not needed here
foreach ($this->snapshots->all() as $snapshot) {
$domains = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain)));
if (!empty($domains)) {
$snapshots[] = ['id' => $snapshot->id, 'short_id' => $snapshot->shortId, 'time' => $snapshot->time, 'domains' => $domains,
'mode' => $snapshot->mode()];
}
}
return $snapshots;
}
/**
* Domains of a snapshot with their state now: GET backup-snapshot-content/<snapshot>. `available` 0: the snapshot has
* no content (not made by this installation), the list comes from its manifest and only existing mailboxes can be restored. A domain
* of the manifest missing in the content (its export failed) lists its current mailboxes as well.
*
* @return array{snapshot: string, time: int, available: int, domains: array[]}|array{} [] when not found or not visible
*/
public function content(mixed $snapshotId): array
{
$snapshot = $this->visibleSnapshot($snapshotId);
if ($snapshot === null) {
return [];
}
$visible = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain)));
[$existing, $usernames] = $this->jobs->domainsAndUsernames($visible);
$domains = [];
foreach ($visible as $domain) {
$current = $usernames[$domain] ?? [];
$mailboxes = $snapshot->mailboxesOf($domain) ?? $current;
$domains[] = ['domain' => $domain, 'exists' => (int)isset($existing[$domain]), 'mailboxes' => count($mailboxes),
'missing' => count(array_diff_key($mailboxes, $current))];
}
return ['snapshot' => $snapshot->id, 'time' => $snapshot->time, 'available' => (int)($snapshot->content !== null), 'domains' => $domains];
}
/**
* Search in a snapshot: GET backup-snapshot-search/<snapshot>/<term>. Matches domain names and mailboxes by local
* part, address or name ("x@": local part, "@d": domain, "x@d": address), with the rules of content(); at most
* SEARCH_LIMIT mailboxes. A matching domain comes with all its mailboxes.
*
* @return array{domains: array[], truncated: int}|array{} [] when the snapshot is not found or not visible
*/
public function search(mixed $snapshotId, mixed $term): array
{
$snapshot = $this->visibleSnapshot($snapshotId);
$term = mb_strtolower((string)Input::text($term));
if ($snapshot === null) {
return [];
}
if (trim($term, '@') === '') {
return ['domains' => [], 'truncated' => 0];
}
$domains = [];
$hits = 0;
$truncated = 0;
[$local, $at, $domainTerm] = str_contains($term, '@') ? [strstr($term, '@', true), true, substr((string)strstr($term, '@'), 1)] : [$term, false, ''];
foreach ($snapshot->domainList() as $domain) {
if (!$this->mayUse($snapshot, $domain)) {
continue;
}
$domainMatch = $at ? ($domainTerm !== '' && str_contains($domain, $domainTerm)) : str_contains($domain, $term);
if ($at && $domainTerm !== '' && !$domainMatch) {
continue;
}
$current = $this->jobs->mailboxNames($domain);
$mailboxes = [];
foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) {
$username = (string)$username;
$part = (string)strstr($username, '@', true);
$match = match (true) {
// "x@d": the address, "x@": the local part, "@d": every mailbox of the matching domains
$at && $local !== '' && $domainTerm !== '' => str_contains($username, $term),
$at && $local !== '' => str_contains($part, $local),
$at => true,
default => $domainMatch || str_contains($username, $term) || str_contains(mb_strtolower($name), $term),
};
if (!$match) {
continue;
}
if ($hits >= self::SEARCH_LIMIT) {
$truncated = 1;
break;
}
$hits++;
$mailboxes[] = ['username' => $username, 'name' => $name, 'exists' => (int)isset($current[$username])];
}
// a domain without matching mailboxes only for a search by domain
if (!empty($mailboxes) || ($domainMatch && !($at && $local !== '') && !$truncated)) {
$domains[] = ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes];
}
if ($truncated) {
break;
}
}
return ['domains' => $domains, 'truncated' => $truncated];
}
/**
* Mailboxes of a domain in a snapshot with their state now: GET backup-snapshot-content/<snapshot>/<domain>
*
* @return array{domain: string, exists: int, mailboxes: array[]}|array{} [] when not found or not visible
*/
public function domainContent(mixed $snapshotId, mixed $domain): array
{
$snapshot = $this->visibleSnapshot($snapshotId);
$domain = strtolower((string)Input::text($domain));
if ($snapshot === null || !$snapshot->hasDomain($domain) || !$this->mayUse($snapshot, $domain)) {
return [];
}
$current = $this->jobs->mailboxNames($domain);
$mailboxes = [];
foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) {
$mailboxes[] = ['username' => (string)$username, 'name' => $name, 'exists' => (int)isset($current[$username])];
}
return ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes];
}
/**
* State of the backup for admins: settings state, runner heartbeat, repository, newest job of each type, queued and
* running jobs, the next scheduled run. [] for everybody else.
*/
public function status(?int $now = null): array
{
if (!$this->context->hasAdminRights()) {
return [];
}
$now ??= time();
$settings = $this->settings->load();
$seen = ($settings->get('state_runner_seen') === '') ? null : (int)$settings->get('state_runner_seen');
$snapshots = $this->snapshots->all();
$latest = $this->jobs->latestOfEachType();
$stats = json_decode($settings->get('state_repo_stats'), true);
return [
'enabled' => (int)$settings->get('enabled'),
'target' => $settings->get('target'),
'configured' => (int)$settings->configured(),
'password_confirmed' => (int)$settings->get('password_confirmed'),
'initialized' => (int)$settings->get('state_initialized'),
// the runner cannot decrypt the stored credentials (e.g. a database restored on another server): enter them again
'secrets_error' => ($settings->get('state_secrets_error') === '') ? null : $settings->get('state_secrets_error'),
// RESTIC_BACKUP_MODE (null until the runner reported it)
'mode' => ($settings->get('state_backup_mode') === '') ? null : $settings->get('state_backup_mode'),
'runner_seen' => $seen,
'runner_age' => ($seen === null) ? null : max(0, $now - $seen),
'runner_ok' => (int)($seen !== null && $now - $seen <= self::RUNNER_TIMEOUT),
'last_success' => ($settings->get('state_last_success') === '') ? null : (int)$settings->get('state_last_success'),
'repo_size' => ($settings->get('state_repo_size') === '') ? null : (int)$settings->get('state_repo_size'),
'repo_stats' => is_array($stats) ? $stats : null,
'snapshots' => count($snapshots),
'latest_snapshot' => empty($snapshots) ? null : $snapshots[0]->time,
'next_run' => $this->nextRun($settings, $now),
// the longest time between two scheduled backups (seconds): a later last success is overdue
'schedule_gap' => $settings->scheduleGap(),
'jobs' => array_map(fn(string $type): ?array => isset($latest[$type]) ? $latest[$type]->toArray() : null,
array_combine(Job::TYPES, Job::TYPES)),
'active' => array_map(fn(Job $job): array => $job->toArray(), $this->jobs->active()),
];
}
/**
* Unix time of the next scheduled backup, null while the schedule is off. Like the runner: the schedule is armed
* (state_schedule_since/state_schedule) when it is switched on or changed, a run is due at the first schedule time
* after that; a due run without a scheduled backup since its time is the next one (now).
*/
private function nextRun(Settings $settings, int $now): ?int
{
if ($settings->get('enabled') !== '1' || $settings->get('password_confirmed') !== '1' || !$settings->configured()) {
return null;
}
$slots = $settings->scheduleSlots();
try {
$zone = new DateTimeZone($this->timezone);
} catch (\Exception) {
$zone = new DateTimeZone('UTC');
}
$today = (new DateTimeImmutable('@' . $now))->setTimezone($zone);
// the last schedule time up to now, and the one after it
$due = self::slot($slots, $today, -1);
$next = self::slot($slots, $today, 1);
$since = $settings->get('state_schedule_since');
if ($due === null || $since === '' || $settings->get('state_schedule') !== $settings->get('schedule') || (int)$since > $due) {
return $next;
}
return $this->jobs->scheduledSince($due) ? $next : $due;
}
/**
* The newest schedule time up to $now ($direction -1) or the first one after it (1) as Unix time, null without
* slots ([weekday, hour, minute], sorted)
*/
private static function slot(array $slots, DateTimeImmutable $now, int $direction): ?int
{
for ($i = 0; $i <= 7; $i++) {
$day = $now->modify(sprintf('%+d day', $direction * $i));
foreach (($direction < 0) ? array_reverse($slots) : $slots as [$weekday, $hour, $minute]) {
if ($weekday !== (int)$day->format('w')) {
continue;
}
$slot = $day->setTime($hour, $minute)->getTimestamp();
if (($direction < 0) ? $slot <= $now->getTimestamp() : $slot > $now->getTimestamp()) {
return $slot;
}
}
}
return null;
}
/** @throws AccessDenied unless the caller may request restores */
private function requireRestorer(): void
{
if ($this->context->isReadOnlyApi() || !($this->context->hasAdminRights() || $this->context->isDomainAdmin())) {
throw new AccessDenied();
}
}
/** Admins every job; domain admins the restores of their domains requested after the domain was created */
private function visible(Job $job): bool
{
if ($this->context->hasAdminRights()) {
return true;
}
if (!$this->context->isDomainAdmin() || $job->type !== 'restore' || $job->domain === '' || !$this->context->canAccessDomain($job->domain)) {
return false;
}
$created = $this->jobs->domainCreated(strtolower($job->domain));
return $created !== null && (int)$job->created >= $created;
}
/**
* A value of a job as a domain admin sees it: e-mail addresses outside $domains and repository details become "…"
*
* @param string[] $domains
*/
private static function restricted(mixed $value, array $domains): mixed
{
if (is_array($value)) {
$restricted = [];
foreach ($value as $key => $item) {
$restricted[is_string($key) ? (string)self::restricted($key, $domains) : $key] = self::restricted($item, $domains);
}
return $restricted;
}
if (!is_string($value)) {
return $value;
}
$value = preg_replace(['~\b(?:s3|sftp|rest|b2|azure|gs|swift|rclone):\S+~i', '~\bhttps?://\S+~i', '~/repository\S*~'], '…', $value);
return preg_replace_callback('/[^\s@<>"\'(),;:\[\]]+@([a-z0-9.-]+\.[a-z]{2,})/i',
fn(array $match): string => in_array(strtolower($match[1]), $domains, true) ? $match[0] : '…', (string)$value);
}
/** @throws UserError backup_snapshot_not_found (also for snapshots a domain admin may not use) */
private function snapshot(mixed $value): Snapshot
{
$id = (string)Input::text($value);
$snapshot = ($id === '') ? null : $this->snapshots->find($id);
if ($snapshot === null || (!$this->context->hasAdminRights() && empty(array_filter($snapshot->domainList(),
fn(string $domain): bool => $this->mayUse($snapshot, $domain))))) {
throw new UserError('backup_snapshot_not_found', [htmlspecialchars($id)]);
}
return $snapshot;
}
private function visibleSnapshot(mixed $value): ?Snapshot
{
try {
return ($this->context->hasAdminRights() || $this->context->isDomainAdmin()) ? $this->snapshot($value) : null;
} catch (UserError) {
return null;
}
}
/**
* Admins every domain of the snapshot; domain admins the domains they administer now, when the snapshot holds this
* domain and not a former one of the same name: its recorded creation time is the domain's (snapshots without it:
* made after the domain was created)
*/
private function mayUse(Snapshot $snapshot, string $domain): bool
{
if ($this->context->hasAdminRights()) {
return true;
}
if (!$this->context->isDomainAdmin()) {
return false;
}
$this->domainState[$domain] ??= [$this->context->canAccessDomain($domain), $this->jobs->domainCreated($domain)];
[$access, $created] = $this->domainState[$domain];
if (!$access) {
return false;
}
$recorded = $snapshot->createdOf($domain);
return $created !== null && (($recorded !== null) ? $recorded === $created : $snapshot->time > $created);
}
/**
* @return array{0: string, 1: string[]} the domain and its mailboxes in the snapshot (now, for snapshots without content)
* @throws UserError
*/
private function domainScope(Snapshot $snapshot, mixed $value): array
{
$domain = strtolower((string)Input::text($value));
if ($domain !== '' && !$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
throw new AccessDenied();
}
$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) {
if (str_contains($username, '@') && !$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($username))) {
throw new AccessDenied();
}
// 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'));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace Mailcow\Backup\Service;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
/**
* Backup settings: admins read them (also read-only API keys) and save them. Values are checked strictly (at least as
* strictly as the runner), since the runner passes them to restic and ssh. Secrets are write-only and stored sealed
* with the public key the runner published (only the runner can decrypt them); an empty secret keeps the stored one,
* and without a published key no secret can be saved. A changed target, connection or repository password resets
* state_initialized (the runner tests the repository again); a changed SFTP host or port drops the known host key. A
* new repository password needs a new confirmation that it is stored outside mailcow. A change of a configured target,
* its connection or the notify addresses leaves state_target_changed for the runner, which mails the notify addresses
* of before the change.
*/
class SettingsService
{
private const FLAGS = ['enabled', 's3_path_style', 'password_confirmed', 'include_index', 'include_fts'];
/** Integer settings: name => [min, max] */
private const RANGES = [
'sftp_port' => [1, 65535],
'keep_hourly' => [0, 1000],
'keep_daily' => [0, 1000],
'keep_weekly' => [0, 1000],
'keep_monthly' => [0, 1000],
'keep_yearly' => [0, 1000],
'prune_weekday' => [0, 6],
'check_subset' => [0, 365],
'limit_upload' => [0, 100000000],
];
private const MIN_PASSWORD = 8;
private const MAX_LENGTH = 255;
private const MAX_NOTIFY = 20;
/** Entries of the schedule */
private const MAX_SCHEDULE = 24;
private const HOSTNAME = '/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i';
public function __construct(private SettingsRepository $repo, private Context $context)
{
}
/** The settings for admins, null for everybody else */
public function get(): ?Settings
{
return $this->context->hasAdminRights() ? $this->load() : null;
}
/** The settings without permission check (status, job rules) */
public function load(): Settings
{
return Settings::fromStored($this->repo->load());
}
/**
* Saves the given settings; missing ones keep their value, unknown and runner-owned ones are ignored.
*
* @throws UserError
*/
public function save(array $input): Settings
{
$this->context->requireAdmin();
$current = $this->load();
$values = $current->values;
foreach ($input as $name => $value) {
if (!is_string($name) || !array_key_exists($name, Settings::DEFAULTS) || in_array($name, Settings::RUNNER, true)) {
continue;
}
$value = $this->normalize($name, $value);
if (in_array($name, Settings::SECRETS, true)) {
if ($value === '') {
continue;
}
$value = self::seal($value, $current->get('state_secrets_pubkey'));
}
$values[$name] = $value;
}
if ($values['password'] !== $current->get('password')
&& !(array_key_exists('password_confirmed', $input) && $values['password_confirmed'] === '1')) {
$values['password_confirmed'] = '0';
}
foreach (array_merge(['target', 'password'], Settings::CONNECTION[$values['target']]) as $name) {
if ($values[$name] !== $current->get($name)) {
$values['state_initialized'] = '0';
break;
}
}
if ($values['sftp_host'] !== $current->get('sftp_host') || $values['sftp_port'] !== $current->get('sftp_port')) {
$values['sftp_host_key'] = '';
}
$settings = new Settings($values);
if ($values['enabled'] === '1' && !$settings->configured()) {
throw new UserError('backup_not_configured');
}
// switched on without a time
if ($values['enabled'] === '1' && empty($settings->scheduleSlots())) {
throw self::invalid('schedule');
}
$values['state_target_changed'] = $this->targetChange($current, $values);
$settings = new Settings($values);
$changed = array_diff_assoc($values, $current->values);
if (!empty($changed)) {
$this->repo->save($changed);
}
return $settings;
}
/**
* state_target_changed after a save: when the target, its connection or the notify addresses change, what the target
* was, whom to tell (the notify addresses before the change) and what changed ("target", "notify_email"). An earlier
* change the runner has not mailed yet keeps its state and addresses and only gets the new kinds of change, so new
* addresses followed by a new target still go to the original addresses. Nothing is recorded for a target that was
* not configured or without notify addresses.
*/
private function targetChange(Settings $current, array $values): string
{
$pending = json_decode($current->get('state_target_changed'), true);
if (!$current->configured() && !is_array($pending)) {
return $current->get('state_target_changed');
}
$old = $current->get('target');
$watched = array_unique(array_merge(['target'], Settings::CONNECTION[$old] ?? [], Settings::CONNECTION[$values['target']] ?? []));
$changed = [];
if (!empty(array_filter($watched, fn(string $name): bool => $values[$name] !== $current->get($name)))) {
$changed[] = 'target';
}
if ($values['notify_email'] !== $current->get('notify_email')) {
$changed[] = 'notify_email';
}
if (is_array($pending)) {
$pending['changed'] = array_values(array_unique(array_merge((array)($pending['changed'] ?? ['target']), $changed)));
return (string)json_encode($pending, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$addresses = array_values(array_filter(explode(',', $current->get('notify_email')), 'strlen'));
if (empty($changed) || empty($addresses)) {
return '';
}
$summary = ['target' => $old];
foreach (array_diff(Settings::CONNECTION[$old] ?? [], Settings::SECRETS) as $name) {
$summary[$name] = $current->get($name);
}
return (string)json_encode(['old_target' => $summary, 'old_addresses' => $addresses, 'changed' => $changed,
'changed_by' => (string)$this->context->username, 'time' => time()], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* A secret sealed for the runner: sealed:v1:<base64 of sodium_crypto_box_seal>
*
* @throws UserError backup_secrets_key_missing while the runner has not published its key
*/
private static function seal(string $secret, string $publicKey): string
{
$key = base64_decode($publicKey, true);
if ($key === false || strlen($key) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new UserError('backup_secrets_key_missing');
}
return Settings::SEALED . base64_encode(sodium_crypto_box_seal($secret, $key));
}
/**
* The value to store, '' for an empty optional value.
*
* @throws UserError backup_setting_invalid, backup_password_too_short
*/
private function normalize(string $name, mixed $value): string
{
if (in_array($name, self::FLAGS, true)) {
return self::flag($value) ?? throw self::invalid($name);
}
if ($name === 'notify_email' && is_string($value)) {
// one address per line is fine as well
$value = preg_replace('/[\r\n\t]+/', ',', $value);
}
if ($name === 'schedule') {
return self::schedule($value) ?? throw self::invalid($name);
}
// secrets as typed: spaces at the ends are part of a password
$text = (in_array($name, Settings::SECRETS, true) && is_string($value)) ? $value : Input::text($value);
if ($text === null || mb_strlen($text) > (($name === 'notify_email') ? self::MAX_LENGTH * self::MAX_NOTIFY : self::MAX_LENGTH) || preg_match('/[\x00-\x1f\x7f]/', $text) || !mb_check_encoding($text, 'UTF-8')) {
throw self::invalid($name);
}
// no scheduled prune (append-only or object-lock targets); a prune job still works
if ($name === 'prune_weekday' && $text === '') {
return '';
}
if (isset(self::RANGES[$name])) {
$int = Input::intInRange($text, ...self::RANGES[$name]);
return ($int === null) ? throw self::invalid($name) : (string)$int;
}
$valid = match ($name) {
'target' => in_array($text, Settings::TARGETS, true),
// like the runner: scheme, host and port only
's3_endpoint' => self::url($text) && in_array(parse_url($text, PHP_URL_PATH) ?? '', ['', '/'], true),
'rest_url' => $text === '' || self::url($text),
's3_bucket' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{1,62}$/i', $text),
's3_path' => $text === '' || self::path(trim($text, '/'), false),
's3_region' => $text === '' || preg_match('/^[a-z0-9-]{1,64}$/i', $text),
's3_access_key' => preg_match('/^[\x21-\x7e]*$/', $text),
's3_secret_key', 'rest_password' => true,
'sftp_host' => $text === '' || preg_match(self::HOSTNAME, $text) || filter_var($text, FILTER_VALIDATE_IP),
// the runner's character set (ssh -l)
'sftp_user' => $text === '' || preg_match('/^[a-z0-9_][a-z0-9._-]{0,63}$/i', $text),
'sftp_path' => $text === '' || self::path($text, true),
'rest_user' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,63}$/i', $text),
// the runner's character set, below the repository folder
'local_path' => preg_match('~^[a-z0-9._-]+(/[a-z0-9._-]+)*$~i', $text) && self::path($text, false),
'password' => true,
'max_unused' => preg_match('/^(\d{1,3})\s*%?$/', $text, $percent) && (int)$percent[1] <= 100,
'notify_email' => true,
default => false,
};
if (!$valid) {
throw self::invalid($name);
}
return match ($name) {
's3_path' => trim($text, '/'),
's3_endpoint' => rtrim($text, '/'),
'sftp_path' => rtrim($text, '/'),
'password' => self::password($text),
'max_unused' => (int)$text . '%',
'notify_email' => self::emails($text),
default => $text,
};
}
/**
* The schedule as stored: a list (or its JSON) of {days: weekdays 0 (Sunday) to 6, time: "H:MM"}, up to MAX_SCHEDULE
* entries, each with at least one weekday; days sorted, times as HH:MM, repeated entries dropped. Null when invalid.
*/
private static function schedule(mixed $value): ?string
{
if (is_string($value)) {
$value = json_decode($value, true);
}
if (!is_array($value) || !array_is_list($value) || count($value) > self::MAX_SCHEDULE) {
return null;
}
$entries = [];
foreach ($value as $entry) {
$days = $entry['days'] ?? null;
$time = is_array($entry) && is_string($entry['time'] ?? null) ? $entry['time'] : '';
if (!is_array($days) || empty($days) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $time, $match)) {
return null;
}
foreach ($days as $day) {
if (Input::intInRange($day, 0, 6) === null) {
return null;
}
}
$days = array_values(array_unique(array_map('intval', $days)));
sort($days);
$entries[] = ['days' => $days, 'time' => sprintf('%02d:%02d', $match[1], $match[2])];
}
return (string)json_encode(array_values(array_unique($entries, SORT_REGULAR)));
}
/** http(s) URL without credentials, query or fragment (rest_user/rest_password hold the credentials) */
private static function url(string $url): bool
{
$parts = parse_url($url);
return filter_var($url, FILTER_VALIDATE_URL) !== false && is_array($parts) && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
&& !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment'])
&& (preg_match(self::HOSTNAME, (string)($parts['host'] ?? '')) || filter_var(trim((string)($parts['host'] ?? ''), '[]'), FILTER_VALIDATE_IP));
}
/** A path of plain names separated by /, without . and .. parts; a leading / only where $absolute is allowed */
private static function path(string $path, bool $absolute): bool
{
if ($path === '' || (str_starts_with($path, '/') && !$absolute)) {
return false;
}
foreach (explode('/', trim($path, '/')) as $part) {
if ($part === '.' || $part === '..' || !preg_match('/^[a-z0-9_~+@][a-z0-9._~+@-]*$/i', $part)) {
return false;
}
}
return true;
}
/** @throws UserError backup_password_too_short ('' keeps the stored password) */
private static function password(string $password): string
{
if ($password !== '' && mb_strlen($password) < self::MIN_PASSWORD) {
throw new UserError('backup_password_too_short', [self::MIN_PASSWORD]);
}
return $password;
}
/** @throws UserError for an invalid address or too many */
private static function emails(string $text): string
{
$emails = array_values(array_unique(array_filter(preg_split('/[\s,;]+/', $text), 'strlen')));
if (count($emails) > self::MAX_NOTIFY) {
throw self::invalid('notify_email');
}
foreach ($emails as $email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw self::invalid('notify_email');
}
}
return implode(',', $emails);
}
/** Checkbox or API flag as '0'/'1'; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked; null = invalid */
private static function flag(mixed $value): ?string
{
if (is_array($value)) {
$value = end($value);
}
if (!is_scalar($value) && $value !== null) {
return null;
}
$flag = filter_var($value ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return ($flag === null) ? null : (string)(int)$flag;
}
private static function invalid(string $name): UserError
{
return new UserError('backup_setting_invalid', [$name]);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
// E-Mail > Restore of admins and domain admins (admin/restore.php, domainadmin/restore.php set $role after protect_route):
// restores from the snapshots of the backup, also of deleted mailboxes (and domains for admins)
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
$js_minifier->add('/web/js/site/backup.js');
$template = 'restore.twig';
$template_data = [
'acl' => $_SESSION['acl'],
'role' => $role,
'lang_datatables' => json_encode($lang['datatables']),
'backup_configured' => \Mailcow\Backup\BackupModule::fromGlobals()->settings->load()->configured(),
'backup_timezone' => getenv('TZ') ?: 'UTC',
];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
File diff suppressed because it is too large. Load diff
+43
View File
@@ -286,6 +286,12 @@ if (isset($_GET['query'])) {
case "retention-rule":
process_add_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->addRule($attr));
break;
case "backup-job":
process_add_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->addJob($attr));
break;
case "backup-restore":
process_add_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->addRestore($attr));
break;
case "domain":
switch ($object) {
case "template":
@@ -918,6 +924,37 @@ if (isset($_GET['query'])) {
}
break;
case "backup-settings":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSettings());
break;
case "backup-status":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getStatus());
break;
case "backup-snapshots":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSnapshots(), false);
break;
// backup-snapshot-content/<snapshot>[/<domain>]: domains of a snapshot, or the mailboxes of one, with their state now
case "backup-snapshot-content":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSnapshotContent($object, $extra));
break;
// backup-snapshot-search/<snapshot>/<term>: domains and mailboxes of a snapshot matching the term (at most 500)
case "backup-snapshot-search":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getSnapshotSearch($object, $extra));
break;
// backup-jobs[/<count>[/<type>]]: newest first, without logs; count 0 = default, type e.g. restore
case "backup-jobs":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getJobs(isset($object) ? intval(preg_replace('/[^\d]/', '', $object)) : 0, $extra), false);
break;
case "backup-job":
process_get_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->getJob($object));
break;
case "logs":
switch ($object) {
case "dovecot":
@@ -1857,6 +1894,9 @@ if (isset($_GET['query'])) {
case "retention-rule":
process_delete_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->deleteRules($items));
break;
case "backup-job":
process_delete_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->deleteJobs($items));
break;
case "app-passwd":
process_delete_return(app_passwd('delete', array('id' => $items)));
break;
@@ -2040,6 +2080,9 @@ if (isset($_GET['query'])) {
case "retention-rule":
process_edit_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->editRules($items, $attr));
break;
case "backup-settings":
process_edit_return(\Mailcow\Backup\BackupModule::fromGlobals()->api->editSettings($attr));
break;
case "recipient_map":
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
break;
+370
View File
@@ -555,6 +555,346 @@
"syncjob_max_kb_per_second": "Bandbreiten-Limit pro Prozess (KB/s)",
"syncjob_max_kb_per_second_info": "0 = unbegrenzt. Begrenzt jeden Sync-Prozess in KB/s; ein Job-Limit (Bytes/s) wird hierdurch zusätzlich begrenzt. Gesamt ≈ dieser Wert × parallele Prozesse."
},
"backup": {
"active_line": "%s #%s – %s",
"added": "Hinzugefügt",
"advanced": "Erweitert",
"advanced_info": "Bereinigen, ungenutzter Platz, Prüfung, Index, Upload-Limit",
"all_items": "Alle Bestandteile",
"all_ticked": "Alle Mailboxen gewählt –",
"and_more": "und %s weitere",
"backup_now": "Jetzt sichern",
"backup_now_info": "Sichert jetzt, unabhängig vom Zeitplan.",
"cancel": "Abbrechen",
"cancel_job": "Auftrag abbrechen",
"cancel_running_confirm": "Laufenden Auftrag abbrechen? Was er bisher wiederhergestellt hat, bleibt erhalten.",
"check_info": "Prüft das Repository und liest einen Teil der Daten, wie nach der ersten geplanten Sicherung eines Tages.",
"check_now": "Prüfen",
"check_subset": "Vollständige Prüfung in",
"check_subset_info": "Nach der ersten geplanten Sicherung eines Tages wird 1/N der Daten gelesen, nach N Sicherungstagen also alles einmal.",
"check_subset_unit": "Sicherungstagen (0 = aus)",
"clear_selection": "Auswahl aufheben",
"close": "Schließen",
"collapse": "Mailboxen von %s ausblenden",
"component": "Bestandteil",
"component_aliases": "Aliasse",
"component_app_passwords": "App-Passwörter",
"component_bcc": "BCC-Maps",
"component_domain": "Domain",
"component_filters": "Filter und Sieve-Skripte (inkl. Abwesenheitsnotiz)",
"component_groups": "Gruppenmitgliedschaften",
"component_imap_acl": "IMAP-Freigaben (Ordnerrechte)",
"component_mail_policies": "Mail-Richtlinien (Zuordnungen)",
"component_mailbox": "Mailbox",
"component_mails": "E-Mails mit Ordnern und Markierungen",
"component_password": "Mailbox-Passwort aus dem Backup",
"component_retention_rules": "Aufbewahrungsregeln (Zuordnungen)",
"component_sender_acl": "Absenderberechtigungen (Senden als)",
"component_settings": "Mailbox-Einstellungen",
"component_sieve_activate": "Sieve-Skript aus dem Backup aktivieren, wenn keines aktiv ist",
"component_sogo": "Kalender und Kontakte auf Backup-Stand zurücksetzen",
"component_sogo_new": "Kalender und Kontakte",
"component_spam": "Spam-Einstellungen (Deny/Allowlist, Spam-Score, Spam-Aliasse)",
"component_syncjobs": "Sync-Jobs",
"component_tfa": "Zwei-Faktor-Authentifizierung",
"components": "Bestandteile",
"connection_ok": "Verbindung OK",
"content_unavailable": "Dieser Snapshot hat keine Liste seiner Mailboxen (älter als diese Funktion): Nur Domains und Mailboxen, die es jetzt gibt, lassen sich wiederherstellen.",
"copy": "Kopieren",
"count_deleted": "%s (%s gelöscht)",
"create_note": "Gelöschte Mailboxen und Domains werden aus dem Backup neu angelegt, mit dem Passwort aus dem Backup.",
"created": "Erstellt",
"created_from_backup": "Aus dem Backup angelegt",
"data_added": "Hinzugefügt",
"deleted_domain_children": "Die Domain wurde gelöscht: Nur die ganze Domain lässt sich wiederherstellen.",
"details": "Details",
"domains": "Domains",
"duration": "Dauer",
"enabled": "Zeitplan",
"enabled_label": "Aktiv",
"error": "Fehler",
"errors": "Fehler",
"expand": "Mailboxen von %s anzeigen",
"failed_line": "%s #%s fehlgeschlagen",
"finished": "Beendet",
"first_run": "Einrichtung",
"first_run_1": "Backup-Ziel wählen und ausfüllen",
"first_run_2": "Repository-Passwort erzeugen und außerhalb von mailcow aufbewahren",
"first_run_3": "Änderungen speichern",
"first_run_4": "„Verbindung testen“ (legt das Repository an)",
"first_run_5": "Zeitplan einschalten und speichern",
"group_access": "Zugänge",
"group_addresses": "Adressen und Mitgliedschaften",
"group_calendar": "Kalender und Kontakte",
"group_filters": "Filter und Spam",
"group_mail": "E-Mails",
"hostname": "Host",
"id": "ID",
"include_fts": "Einschließlich des Volltext-Suchindex",
"include_index": "Dovecot-Index sichern (schnellere Wiederherstellung, er lässt sich auch neu aufbauen)",
"intro": "Verschlüsselte tägliche Backups mit restic. Wiederherstellen:",
"intro_restore_link": "E-Mail › Wiederherstellung",
"job": "Auftrag #%s",
"jobs": "Aufträge",
"jobs_none": "Noch keine Aufträge",
"keep": "Snapshots behalten",
"keep_daily": "tägl.",
"keep_hourly": "stündl.",
"keep_info": "Wie viele stündliche, tägliche, wöchentliche, monatliche und jährliche Snapshots erhalten bleiben; ältere werden nach jeder Sicherung verworfen. Von mehreren Sicherungen am Tag bleibt als tägliche nur die letzte, „stündl.“ behält auch die anderen.",
"keep_monthly": "monatl.",
"keep_weekly": "wöch.",
"keep_yearly": "jährl.",
"limit_upload": "Upload-Limit",
"limit_upload_info": "0 = unbegrenzt",
"load_error": "Die Backup-Daten konnten nicht geladen werden",
"local_path": "Verzeichnis",
"local_path_info": "Unterverzeichnis von RESTIC_LOCAL_PATH aus mailcow.conf (leer: das Docker-Volume restic-repo-vol-1). Schützt nicht vor dem Verlust des Servers.",
"log": "Protokoll",
"log_empty": "Noch keine Protokolleinträge",
"mail_target": "E-Mails",
"mail_target_folder": "in einen eigenen Ordner „%s“",
"mail_target_folder_name": "Restored <Datum des Snapshots>",
"mail_target_folder_named": "eigener Ordner „%s“",
"mail_target_folder_short": "eigener Ordner",
"mail_target_original": "in die ursprünglichen Ordner (nur fehlende E-Mails)",
"mail_target_original_short": "ursprüngliche Ordner",
"mailboxes": "Mailboxen",
"maintenance": "Wartung",
"manual_by": "manuell: %s",
"max_unused": "Max. ungenutzter Platz",
"max_unused_info": "Die Bereinigung darf bis zu diesem Anteil ungenutzter Daten im Repository lassen und schreibt dafür weniger um (restic --max-unused).",
"minutes_ago": "vor %s min",
"modal_title_domain": "Domain %s wiederherstellen",
"modal_title_mailbox": "%s wiederherstellen",
"modal_title_mailboxes": "%s Mailboxen in %s wiederherstellen",
"mode_config": "Ohne Mails (RESTIC_BACKUP_MODE=config)",
"mode_data": "Vom mailcow-Verzeichnis nur mailcow.conf (RESTIC_BACKUP_MODE=data)",
"mode_invalid": "RESTIC_BACKUP_MODE in mailcow.conf ist ungültig (%s), erlaubt sind all, data und config. Sicherungen schlagen fehl.",
"more": "Mehr",
"more_items": "+ %s weitere",
"name": "Name",
"nav": "Wiederherstellung",
"no_components": "Wählen Sie mindestens einen Bestandteil aus.",
"no_domains": "Der Snapshot enthält keine Domains, die Sie wiederherstellen können.",
"no_mail_data": "Dieser Snapshot enthält keine Mails (RESTIC_BACKUP_MODE=config): E-Mails, IMAP-Freigaben, Kalender und Kontakte und die Sieve-Aktivierung sind nicht wiederherstellbar.",
"no_mailboxes": "Der Snapshot enthält keine Mailboxen dieser Domain.",
"no_matches": "Nichts passt zur Suche.",
"no_snapshots_any": "Es gibt noch keine Snapshots.",
"no_snapshots_backup_now": "Jetzt sichern (System › Konfiguration › Backup)",
"not_configured": "Backups sind nicht eingerichtet.",
"not_configured_da": "Backups sind nicht eingerichtet. Bitte wenden Sie sich an den Administrator.",
"note_app_passwords": "Alte App-Passwörter ermöglichen wieder den Zugang.",
"note_inactive": "Inaktive Mailboxen erhalten keine E-Mails, IMAP-Freigaben und Sieve-Skripte zurück.",
"note_retention_rules": "Eine Aufbewahrungsregel kann ältere wiederhergestellte E-Mails bei ihrem nächsten Lauf löschen.",
"note_settings": "Quota, Protokolle, Attribute, Berechtigungen, Pushover, Relayhost (Tags werden ergänzt)",
"note_sieve_activate": "Es kann wieder E-Mails weiterleiten oder Abwesenheitsnotizen senden.",
"note_sogo": "Bestehende Kalender, Adressbücher und SOGo-Einstellungen erhalten den Stand des Backups.",
"note_sogo_more": "Fehlende Kalender, Adressbücher und Einträge kommen zurück. Namen und Freigaben bestehender Kalender und Adressbücher sowie alle SOGo-Einstellungen (Signaturen, Identitäten, Abonnements, Ansicht) werden durch die aus dem Backup ersetzt. Seitdem geänderte Einträge bleiben erhalten.",
"note_tfa": "Die Anmeldung braucht wieder den alten zweiten Faktor.",
"notify_email": "Bei Fehlern benachrichtigen",
"notify_email_info": "Adressen durch Kommas getrennt; sie erhalten eine E-Mail, wenn ein Auftrag fehlschlägt.",
"only_deleted": "Nur gelöschte",
"optional": "optional",
"overwrite": "Überschreibt aktuelle Werte",
"overwrite_confirm": "%s werden für %s Mailbox(en) mit dem Stand des Backups überschrieben. Fortfahren?",
"overwrite_info": "Diese Bestandteile ersetzen die aktuellen Werte durch die aus dem Backup.",
"overwrite_short_password": "Passwort",
"overwrite_short_settings": "Einstellungen",
"overwrite_short_sogo": "Kalender und Kontakte",
"password": "Repository-Passwort",
"password_change": "Ein neues Passwort gilt nur für ein neues, leeres Repository: Ein bestehendes behält sein Passwort.",
"password_confirmed": "Ich habe das Repository-Passwort außerhalb von mailcow aufbewahrt. Ohne das Passwort lässt sich das Backup nicht wiederherstellen.",
"password_confirmed_short": "Außerhalb von mailcow aufbewahrt (Pflicht)",
"password_generate": "Erzeugen",
"password_info": "Mind. 8 Zeichen, wird nicht wieder angezeigt.",
"password_unconfirmed": "Sicherungen laufen erst, wenn Sie unten bestätigen, dass das Repository-Passwort außerhalb von mailcow aufbewahrt ist.",
"prune_confirm": "Jetzt bereinigen? Die Daten nicht mehr behaltener Snapshots werden aus dem Repository gelöscht.",
"prune_info": "Löscht die Daten nicht mehr behaltener Snapshots.",
"prune_now": "Bereinigen",
"prune_weekday": "Bereinigen am",
"prune_weekday_info": "Einmal pro Woche, nach der ersten geplanten Sicherung ab diesem Tag, werden nicht mehr benötigte Daten aus dem Repository gelöscht. „Nie“ passt zu Zielen mit Append-only oder Object Lock.",
"queued_inline": "Wiederherstellungsauftrag #%s eingereiht.",
"refresh": "Neu laden",
"repo_not_tested": "Das Repository ist noch nicht getestet: „Verbindung testen“ verwenden.",
"requested_admin": "Administrator",
"requested_by": "Angefordert von",
"requested_by_short": "von %s",
"requested_schedule": "Zeitplan",
"rest_password": "Passwort",
"rest_url": "URL",
"rest_url_info": "z. B. https://backup.example.org:8000/mailcow, ohne Benutzer und Passwort.",
"rest_user": "Benutzer",
"restore": "Wiederherstellen",
"restore_added_many": "Wiederherstellungsauftrag #%s für %s Mailboxen eingereiht",
"restore_added_one": "Wiederherstellungsauftrag #%s für %s Mailbox eingereiht",
"restore_background": "Die Wiederherstellung läuft im Hintergrund. Den Fortschritt zeigt die Liste „Bisherige Wiederherstellungen“.",
"restore_card": "Aus Backup wiederherstellen",
"restore_info": "Ergänzt nur, was fehlt. Nichts Vorhandenes wird überschrieben oder gelöscht, außer den unten markierten Bestandteilen.",
"restore_item": "%s wiederherstellen",
"restore_overwrite": "Wiederherstellen und überschreiben",
"restore_target_domain": "Alle Mailboxen der Domain %s",
"restore_target_domain_short": "Domain %s",
"restore_target_mailbox": "Mailbox %s",
"restore_target_mailboxes": "%s Mailboxen: %s",
"restore_target_mailboxes_of": "%s Mailboxen von %s",
"restore_title": "Aus Backup wiederherstellen",
"restore_what": "Auswahl",
"restores": "Bisherige Wiederherstellungen",
"restores_info": "Wiederherstellungen der letzten 180 Tage. „Details“ zeigt das Ergebnis je Mailbox und Bestandteil. Laufende lassen sich abbrechen, bis die E-Mails an der Reihe sind.",
"restores_none": "Noch keine Wiederherstellungen",
"result": "Ergebnis",
"result_activated": "Aktiviertes Skript",
"result_details": "Details",
"result_empty": "kein Ergebnis",
"result_errors": "%s Fehler",
"result_failed": "mit Fehlern: %s",
"result_folder": "Ordner",
"result_folders": "Ordner",
"result_folders_created": "Angelegte Ordner",
"result_folders_failed": "Ordner mit Fehlern",
"result_mailboxes": "Mailboxen: %s",
"result_none": "noch kein Ergebnis",
"result_note_no_mails": "Der Snapshot enthält keine E-Mails dieser Mailbox.",
"result_note_no_sogo_data": "Der Snapshot enthält keine SOGo-Daten dieser Mailbox.",
"result_subscribed": "Hinzugefügte Abonnements",
"result_summary": "%s hinzugefügt, %s bereits vorhanden",
"result_unchanged": "Ohne Änderung: %s",
"runner_missing": "Der Backup-Container (restic-mailcow) hat sich seit %s nicht gemeldet. Sicherungen und Wiederherstellungen laufen erst wieder, wenn er läuft.",
"runner_never": "Der Backup-Container (restic-mailcow) hat sich noch nicht gemeldet. Sicherungen und Wiederherstellungen laufen erst, wenn er läuft.",
"s3_access_key": "Access Key",
"s3_bucket": "Bucket",
"s3_endpoint": "Endpunkt",
"s3_endpoint_info": "URL mit https://, z. B. https://s3.eu-central-1.amazonaws.com oder die URL eines S3-kompatiblen Speichers.",
"s3_path": "Pfad im Bucket",
"s3_path_style": "Pfad-Adressierung (für manche S3-kompatiblen Speicher nötig, z. B. MinIO)",
"s3_region": "Region",
"s3_secret_key": "Secret Key",
"save": "Änderungen speichern",
"schedule": "Zeitplan",
"schedule_add": "Hinzufügen",
"schedule_at": "um",
"schedule_daily": "täglich",
"schedule_days": "Wochentage",
"schedule_entry": "%s – %s Uhr",
"schedule_none": "Keine Zeiten eingetragen.",
"schedule_remove": "Entfernen",
"schedule_time": "Uhrzeit",
"schedule_time_info": "Zeitzone: %s (TZ in mailcow.conf).",
"scope": "Umfang",
"scope_all": "Alles",
"scope_all_in": "E-Mails, Datenbank, Redis, Rspamd, Kalender und Kontakte, das komplette mailcow-Verzeichnis",
"scope_config": "Nur Konfiguration",
"scope_config_in": "Datenbank, Redis, Mail-Crypt-Schlüssel, mailcow.conf, data/conf",
"scope_config_out": "E-Mails, Rspamd, Kalender und Kontakte",
"scope_data": "Nur Daten",
"scope_data_in": "E-Mails, Datenbank, Redis, Rspamd, Kalender und Kontakte, mailcow.conf",
"scope_data_out": "Übriges mailcow-Verzeichnis, z. B. data/conf und data/web",
"scope_domain": "ganze Domain",
"scope_info": "Festgelegt mit RESTIC_BACKUP_MODE in mailcow.conf (all, data oder config), in der Oberfläche nicht änderbar. Nach einer Änderung: docker compose up -d.",
"scope_mailbox": "ausgewählte Mailboxen",
"scope_unknown": "Noch nicht bekannt: Der Backup-Container hat sich noch nicht gemeldet.",
"search_placeholder": "Domain oder Mailbox suchen (ab 2 Zeichen)",
"search_truncated": "Mehr als 500 Treffer: Suche verfeinern.",
"seconds_ago": "vor %s s",
"secret_keep": "Gespeichert. Leer lassen, um es zu behalten.",
"secrets_error": "Die gespeicherten Zugangsdaten lassen sich nicht entschlüsseln (z. B. nach einer Wiederherstellung auf einem anderen Server). Bitte Repository-Passwort und Zugangsdaten neu eingeben.",
"select_whole_domain": "ganze Domain wählen",
"selected_domain": "Ganze Domain %s",
"selected_mailbox_in": "%s Mailbox in %s",
"selected_mailboxes_in": "%s Mailboxen in %s",
"selection_none": "Nichts ausgewählt",
"selection_replaced": "Eine Wiederherstellung umfasst eine Domain: Die Auswahl in %s wurde ersetzt.",
"selection_reset": "Die Liste wurde nach einer abgeschlossenen Wiederherstellung aktualisiert; die Auswahl wurde aufgehoben.",
"settings": "Einstellungen",
"sftp_host": "Host",
"sftp_host_key": "Host-Schlüssel des Servers",
"sftp_host_key_info": "Wird bei der ersten Verbindung übernommen und danach geprüft. Er wird verworfen, wenn sich Host oder Port ändern.",
"sftp_path": "Pfad",
"sftp_path_info": "Verzeichnis des Repositorys auf dem Server, relativ zum Home-Verzeichnis des Benutzers oder absolut.",
"sftp_port": "Port",
"sftp_public_key": "Öffentlicher Schlüssel von mailcow",
"sftp_public_key_info": "mailcow meldet sich mit diesem Schlüssel an. Tragen Sie ihn auf dem SFTP-Server in ~/.ssh/authorized_keys des Benutzers ein.",
"sftp_public_key_missing": "Der Backup-Container erzeugt den Schlüssel beim Start.",
"sftp_user": "Benutzer",
"show_more": "%s weitere anzeigen (noch %s)",
"show_more_domains": "%s weitere Domains anzeigen (noch %s)",
"size": "Datenmenge",
"skipped": "Bereits vorhanden",
"snapshot": "Snapshot",
"snapshot_label": "Sicherungsstand (Snapshot)",
"snapshot_mode_config": "ohne Mails",
"snapshot_mode_data": "nur Daten",
"snapshots": "Snapshots",
"snapshots_none": "Noch keine Snapshots",
"started": "Gestartet",
"state": "Status",
"state_cancelled": "Abgebrochen",
"state_done": "Fertig",
"state_failed": "Fehlgeschlagen",
"state_queued": "Eingereiht",
"state_running": "Läuft",
"status_deleted": "gelöscht",
"status_last": "Letzte Sicherung %s (%s)",
"status_never": "Noch keine Sicherung",
"status_next": "nächste %s",
"status_restoring": "wird wiederhergestellt",
"status_schedule_off": "Zeitplan aus",
"status_snapshot": "%s Snapshot",
"status_snapshots": "%s Snapshots",
"status_stale": "Sicherung laut Zeitplan überfällig (zuletzt erfolgreich %s).",
"step_aliases": "Wiederherstellen der Aliasse",
"step_backup": "Upload",
"step_check": "Prüfung",
"step_connect": "Verbindungsaufbau",
"step_domain": "Anlegen der Domain",
"step_dump": "Datenbank-Dump",
"step_export": "Export der Domains",
"step_files": "Zurückholen der Dateien",
"step_forget": "Verwerfen alter Snapshots",
"step_init": "Anlegen des Repositorys",
"step_mails": "Warten auf den Mail-Container",
"step_mails_done": "E-Mails wiederhergestellt, Abschluss",
"step_mails_running": "Wiederherstellen der E-Mails",
"step_prune": "Bereinigung",
"step_settings": "Wiederherstellen der Einstellungen",
"step_snapshots": "Lesen der Snapshots",
"step_sogo": "Wiederherstellen von Kalendern und Kontakten",
"step_stats": "Ermitteln der Repository-Größe",
"summary_backup": "Snapshot %s, %s hinzugefügt",
"summary_check": "Teil %s geprüft",
"summary_created": "Repository angelegt",
"summary_snapshots": "Snapshots: %s",
"tab": "Backup",
"target": "Backup-Ziel",
"target_count": "%s Mailboxen im Snapshot, davon %s gelöscht",
"target_count_all": "%s Mailboxen im Snapshot",
"target_count_one": "1 Mailbox im Snapshot",
"target_count_one_deleted": "1 Mailbox im Snapshot, gelöscht",
"target_local": "Lokales Verzeichnis",
"target_rest": "restic-REST-Server",
"target_s3": "S3 (Amazon S3 oder S3-kompatibel)",
"target_sftp": "SFTP",
"test_connection": "Verbindung testen",
"test_connection_info": "Prüft die Verbindung und legt das Repository an, falls es noch nicht existiert.",
"time": "Zeit",
"to_backup_settings": "Zu den Backup-Einstellungen",
"type": "Art",
"type_backup": "Sicherung",
"type_check": "Prüfung",
"type_init": "Verbindungstest",
"type_prune": "Bereinigung",
"type_restore": "Wiederherstellung",
"warnings": "Warnungen",
"weekday_0": "Sonntag",
"weekday_1": "Montag",
"weekday_2": "Dienstag",
"weekday_3": "Mittwoch",
"weekday_4": "Donnerstag",
"weekday_5": "Freitag",
"weekday_6": "Samstag",
"weekday_never": "nie (nur manuell)",
"will_be_created": "wird angelegt"
},
"danger": {
"access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
"alias_domain_invalid": "Alias-Domain %s ist ungültig",
@@ -584,6 +924,32 @@
"archive_tls_invalid": "Ungültiger TLS-Modus",
"archive_username_invalid": "Ungültiger SMTP-Benutzername",
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
"backup_action_invalid": "Ungültige Aktion für den Backup-Endpunkt",
"backup_component_denied": "Sie dürfen %s nicht wiederherstellen",
"backup_component_requires": "%s geht nur zusammen mit %s",
"backup_components_invalid": "Ungültiger oder fehlender Bestandteil der Wiederherstellung: %s",
"backup_components_no_mails": "Der Snapshot enthält keine Mails (RESTIC_BACKUP_MODE=config), nicht wiederherstellbar: %s",
"backup_domain_not_in_snapshot": "Der Snapshot enthält die Domain %s nicht",
"backup_entries_exceeded": "Zu viele Einträge (höchstens %s)",
"backup_export_invalid": "Der Export im Snapshot fehlt oder ist ungültig",
"backup_job_active": "Ein Auftrag der Art „%s“ ist bereits eingereiht oder läuft",
"backup_job_busy": "Der Auftrag wird gerade von einer anderen Anfrage bearbeitet, bitte erneut versuchen",
"backup_job_not_cancellable": "Auftrag #%s ist nicht mehr eingereiht und kann nicht abgebrochen werden",
"backup_job_not_running": "Der Wiederherstellungsauftrag läuft nicht",
"backup_job_type_invalid": "Ungültiger Auftragstyp %s",
"backup_mail_target_invalid": "Ungültiges Ziel für die E-Mails (Ordner oder Original)",
"backup_mailbox_not_found": "Mailbox %s existiert nicht",
"backup_mailboxes_empty": "Keine Mailboxen zum Wiederherstellen",
"backup_mailboxes_one_domain": "Alle Mailboxen einer Wiederherstellung müssen zu einer Domain gehören",
"backup_not_configured": "Backup-Ziel und Repository-Passwort sind nicht vollständig eingerichtet",
"backup_password_not_confirmed": "Bitte zuerst bestätigen, dass das Repository-Passwort außerhalb von mailcow aufbewahrt wird",
"backup_password_too_short": "Das Repository-Passwort muss mindestens %s Zeichen lang sein",
"backup_restore_active": "Eine Wiederherstellung von %s ist bereits eingereiht oder läuft",
"backup_restore_domain_active": "In %s ist bereits eine Wiederherstellung eingereiht oder läuft, Domain-Administratoren können je Domain eine Wiederherstellung gleichzeitig starten",
"backup_scope_invalid": "Ungültiger Umfang der Wiederherstellung (Mailbox oder Domain)",
"backup_secrets_key_missing": "Der Backup-Container hat seinen Schlüssel noch nicht bereitgestellt: Bitte den Container (restic-mailcow) starten und erneut speichern",
"backup_setting_invalid": "Ungültiger Wert für %s",
"backup_snapshot_not_found": "Snapshot %s nicht gefunden",
"bcc_empty": "BCC-Ziel darf nicht leer sein",
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
"bcc_must_be_email": "BCC-Ziel %s ist keine gültige E-Mail-Adresse",
@@ -1517,6 +1883,10 @@
"archive_settings_saved": "Archivierungseinstellungen gespeichert",
"archive_test_ok": "Verbindungstest erfolgreich: Der Archivserver akzeptiert die Archivadresse",
"archive_testmail_queued": "Test-E-Mail an das Archiv in die Queue gestellt (Queue-ID %s)",
"backup_job_added": "Auftrag #%2$s (%1$s) eingereiht",
"backup_job_cancelled": "Auftrag #%s abgebrochen",
"backup_restore_added": "Wiederherstellungsauftrag #%s für %s Mailbox(en) eingereiht",
"backup_settings_saved": "Backup-Einstellungen gespeichert",
"bcc_deleted": "BCC-Map-Einträge gelöscht: %s",
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
+370
View File
@@ -555,6 +555,346 @@
"syncjob_max_kb_per_second": "Bandwidth limit per process (KB/s)",
"syncjob_max_kb_per_second_info": "0 = unlimited. Caps every sync process in KB/s; a per-job limit (bytes/s) is capped by this. Total ≈ this × max parallel."
},
"backup": {
"active_line": "%s #%s – %s",
"added": "Added",
"advanced": "Advanced",
"advanced_info": "prune, unused space, check, index, upload limit",
"all_items": "All items",
"all_ticked": "All mailboxes selected –",
"and_more": "and %s more",
"backup_now": "Back up now",
"backup_now_info": "Backs up now, independent of the schedule.",
"cancel": "Cancel",
"cancel_job": "Cancel job",
"cancel_running_confirm": "Cancel the running job? Anything restored so far is kept.",
"check_info": "Verifies the repository and reads a part of the data, as after the first scheduled backup of a day.",
"check_now": "Check",
"check_subset": "Full check in",
"check_subset_info": "After the first scheduled backup of a day 1/N of the data is read, so all of it once in N backup days.",
"check_subset_unit": "backup days (0 = off)",
"clear_selection": "Clear selection",
"close": "Close",
"collapse": "Hide the mailboxes of %s",
"component": "Item",
"component_aliases": "Aliases",
"component_app_passwords": "App passwords",
"component_bcc": "BCC maps",
"component_domain": "Domain",
"component_filters": "Filters and Sieve scripts (incl. vacation reply)",
"component_groups": "Group memberships",
"component_imap_acl": "IMAP shares (folder permissions)",
"component_mail_policies": "Mail policies (assignments)",
"component_mailbox": "Mailbox",
"component_mails": "Mails with folders and flags",
"component_password": "Mailbox password from the backup",
"component_retention_rules": "Retention rules (assignments)",
"component_sender_acl": "Sender permissions (send as)",
"component_settings": "Mailbox settings",
"component_sieve_activate": "Activate the Sieve script of the backup if none is active",
"component_sogo": "Reset calendars and contacts to the backup",
"component_sogo_new": "Calendars and contacts",
"component_spam": "Spam settings (deny/allowlist, spam score, spam aliases)",
"component_syncjobs": "Sync jobs",
"component_tfa": "Two-factor authentication",
"components": "Items",
"connection_ok": "Connection OK",
"content_unavailable": "This snapshot has no list of its mailboxes (made before mailcow kept one): only domains and mailboxes that exist now can be restored.",
"copy": "Copy",
"count_deleted": "%s (%s deleted)",
"create_note": "Deleted mailboxes and domains are created again from the backup, with the password from the backup.",
"created": "Created",
"created_from_backup": "Created from the backup",
"data_added": "Added",
"deleted_domain_children": "The domain was deleted: only the whole domain can be restored.",
"details": "Details",
"domains": "Domains",
"duration": "Duration",
"enabled": "Schedule",
"enabled_label": "Active",
"error": "Error",
"errors": "Errors",
"expand": "Show the mailboxes of %s",
"failed_line": "%s #%s failed",
"finished": "Finished",
"first_run": "Setup",
"first_run_1": "Choose and fill in the backup target",
"first_run_2": "Generate the repository password and store it outside of mailcow",
"first_run_3": "Save the changes",
"first_run_4": "\"Test connection\" (creates the repository)",
"first_run_5": "Switch the schedule on and save",
"group_access": "Access",
"group_addresses": "Addresses and memberships",
"group_calendar": "Calendars and contacts",
"group_filters": "Filters and spam",
"group_mail": "Mails",
"hostname": "Host",
"id": "ID",
"include_fts": "Including the full-text search index",
"include_index": "Back up the Dovecot index (faster restore, it can be rebuilt as well)",
"intro": "Encrypted daily backups with restic. Restore:",
"intro_restore_link": "E-Mail › Restore",
"job": "Job #%s",
"jobs": "Jobs",
"jobs_none": "No jobs yet",
"keep": "Keep snapshots",
"keep_daily": "daily",
"keep_hourly": "hourly",
"keep_info": "How many hourly, daily, weekly, monthly and yearly snapshots are kept; older ones are forgotten after each backup. Of several backups a day only the last one is kept as daily, \"hourly\" keeps the others too.",
"keep_monthly": "monthly",
"keep_weekly": "weekly",
"keep_yearly": "yearly",
"limit_upload": "Upload limit",
"limit_upload_info": "0 = unlimited",
"load_error": "The backup data could not be loaded",
"local_path": "Directory",
"local_path_info": "Subdirectory below RESTIC_LOCAL_PATH of mailcow.conf (empty: the Docker volume restic-repo-vol-1). It does not protect against the loss of the server.",
"log": "Log",
"log_empty": "No log entries yet",
"mail_target": "Mails",
"mail_target_folder": "into a separate folder \"%s\"",
"mail_target_folder_name": "Restored <date of the snapshot>",
"mail_target_folder_named": "separate folder \"%s\"",
"mail_target_folder_short": "separate folder",
"mail_target_original": "into the original folders (missing mails only)",
"mail_target_original_short": "original folders",
"mailboxes": "Mailboxes",
"maintenance": "Maintenance",
"manual_by": "manual: %s",
"max_unused": "Max. unused space",
"max_unused_info": "The prune may leave up to this share of unused data in the repository and rewrites less (restic --max-unused).",
"minutes_ago": "%s min ago",
"modal_title_domain": "Restore domain %s",
"modal_title_mailbox": "Restore %s",
"modal_title_mailboxes": "Restore %s mailboxes in %s",
"mode_config": "Without mails (RESTIC_BACKUP_MODE=config)",
"mode_data": "Of the mailcow directory only mailcow.conf (RESTIC_BACKUP_MODE=data)",
"mode_invalid": "RESTIC_BACKUP_MODE in mailcow.conf is invalid (%s), allowed are all, data and config. Backups fail.",
"more": "More",
"more_items": "+ %s more",
"name": "Name",
"nav": "Restore",
"no_components": "Choose at least one item.",
"no_domains": "The snapshot contains no domains you can restore.",
"no_mail_data": "This snapshot holds no mails (RESTIC_BACKUP_MODE=config): mails, IMAP shares, calendars and contacts and the Sieve activation cannot be restored.",
"no_mailboxes": "The snapshot contains no mailboxes of this domain.",
"no_matches": "Nothing matches the search.",
"no_snapshots_any": "There are no snapshots yet.",
"no_snapshots_backup_now": "Back up now (System › Configuration › Backup)",
"not_configured": "Backups are not set up.",
"not_configured_da": "Backups are not set up. Please contact the administrator.",
"note_app_passwords": "Old app passwords give access again.",
"note_inactive": "Inactive mailboxes do not get mails, IMAP shares and Sieve scripts back.",
"note_retention_rules": "A retention rule can delete older restored mails on its next run.",
"note_settings": "Quota, protocols, attributes, permissions, Pushover, relayhost (tags are added)",
"note_sieve_activate": "It may forward mails or send vacation replies again.",
"note_sogo": "Existing calendars, address books and SOGo settings get the state of the backup.",
"note_sogo_more": "Missing calendars, address books and entries come back. Names and shares of existing calendars and address books and all SOGo settings (signatures, identities, subscriptions, view) are replaced by those of the backup. Entries changed since are kept.",
"note_tfa": "The login needs the old second factor again.",
"notify_email": "Notify on errors",
"notify_email_info": "Addresses separated by commas; they get a mail when a job fails.",
"only_deleted": "Deleted only",
"optional": "optional",
"overwrite": "Overwrites current values",
"overwrite_confirm": "%s will be overwritten with the state of the backup for %s mailbox(es). Continue?",
"overwrite_info": "These items replace the current values with those of the backup.",
"overwrite_short_password": "password",
"overwrite_short_settings": "settings",
"overwrite_short_sogo": "calendars and contacts",
"password": "Repository password",
"password_change": "A new password only works for a new, empty repository: an existing one keeps its password.",
"password_confirmed": "I have stored the repository password outside of mailcow. Without it the backup cannot be restored.",
"password_confirmed_short": "Stored outside of mailcow (required)",
"password_generate": "Generate",
"password_info": "At least 8 characters, it is not shown again.",
"password_unconfirmed": "Backups do not run before you confirm below that the repository password is stored outside of mailcow.",
"prune_confirm": "Prune now? The data of snapshots that are no longer kept is deleted from the repository.",
"prune_info": "Deletes the data of snapshots that are no longer kept.",
"prune_now": "Prune",
"prune_weekday": "Prune on",
"prune_weekday_info": "Once a week, after the first scheduled backup from this day on, data no longer needed is deleted from the repository. \"Never\" suits append-only or object-lock targets.",
"queued_inline": "Restore job #%s queued.",
"refresh": "Refresh",
"repo_not_tested": "The repository has not been tested yet: use \"Test connection\".",
"requested_admin": "Administrator",
"requested_by": "Requested by",
"requested_by_short": "by %s",
"requested_schedule": "Schedule",
"rest_password": "Password",
"rest_url": "URL",
"rest_url_info": "e.g. https://backup.example.org:8000/mailcow, without user and password.",
"rest_user": "User",
"restore": "Restore",
"restore_added_many": "Restore job #%s queued for %s mailboxes",
"restore_added_one": "Restore job #%s queued for %s mailbox",
"restore_background": "The restore runs in the background. Its progress is shown in \"Previous restores\".",
"restore_card": "Restore from backup",
"restore_info": "Only what is missing is added. Nothing that exists is overwritten or deleted, except the items marked below.",
"restore_item": "Restore %s",
"restore_overwrite": "Restore and overwrite",
"restore_target_domain": "All mailboxes of the domain %s",
"restore_target_domain_short": "Domain %s",
"restore_target_mailbox": "Mailbox %s",
"restore_target_mailboxes": "%s mailboxes: %s",
"restore_target_mailboxes_of": "%s mailboxes of %s",
"restore_title": "Restore from backup",
"restore_what": "Selection",
"restores": "Previous restores",
"restores_info": "Restores of the last 180 days. \"Details\" shows the result per mailbox and item. Running restores can be cancelled until the mails are restored.",
"restores_none": "No restores yet",
"result": "Result",
"result_activated": "Activated script",
"result_details": "Details",
"result_empty": "no result",
"result_errors": "Errors: %s",
"result_failed": "with errors: %s",
"result_folder": "Folder",
"result_folders": "Folders",
"result_folders_created": "Folders created",
"result_folders_failed": "Folders with errors",
"result_mailboxes": "Mailboxes: %s",
"result_none": "no result yet",
"result_note_no_mails": "The snapshot has no mails of this mailbox.",
"result_note_no_sogo_data": "The snapshot has no SOGo data of this mailbox.",
"result_subscribed": "Subscriptions added",
"result_summary": "%s added, %s already present",
"result_unchanged": "Unchanged: %s",
"runner_missing": "The backup container (restic-mailcow) has not reported since %s. Backups and restores do not run until it is running again.",
"runner_never": "The backup container (restic-mailcow) has not reported yet. Backups and restores do not run until it is running.",
"s3_access_key": "Access key",
"s3_bucket": "Bucket",
"s3_endpoint": "Endpoint",
"s3_endpoint_info": "URL with https://, e.g. https://s3.eu-central-1.amazonaws.com or the URL of an S3-compatible storage.",
"s3_path": "Path in the bucket",
"s3_path_style": "Path-style access (needed by some S3-compatible storages, e.g. MinIO)",
"s3_region": "Region",
"s3_secret_key": "Secret key",
"save": "Save changes",
"schedule": "Schedule",
"schedule_add": "Add",
"schedule_at": "at",
"schedule_daily": "daily",
"schedule_days": "Weekdays",
"schedule_entry": "%s – %s",
"schedule_none": "No times set.",
"schedule_remove": "Remove",
"schedule_time": "Time",
"schedule_time_info": "Time zone: %s (TZ in mailcow.conf).",
"scope": "Scope",
"scope_all": "Everything",
"scope_all_in": "Mails, database, Redis, Rspamd, calendars and contacts, the whole mailcow directory",
"scope_config": "Configuration only",
"scope_config_in": "Database, Redis, mail crypt keys, mailcow.conf, data/conf",
"scope_config_out": "Mails, Rspamd, calendars and contacts",
"scope_data": "Data only",
"scope_data_in": "Mails, database, Redis, Rspamd, calendars and contacts, mailcow.conf",
"scope_data_out": "The rest of the mailcow directory, e.g. data/conf and data/web",
"scope_domain": "whole domain",
"scope_info": "Set with RESTIC_BACKUP_MODE in mailcow.conf (all, data or config), it cannot be changed in the UI. After a change: docker compose up -d.",
"scope_mailbox": "selected mailboxes",
"scope_unknown": "Not known yet: the backup container has not reported yet.",
"search_placeholder": "Search domain or mailbox (from 2 characters)",
"search_truncated": "More than 500 hits: refine the search.",
"seconds_ago": "%s s ago",
"secret_keep": "Stored. Leave empty to keep it.",
"secrets_error": "The stored credentials cannot be decrypted (e.g. after a restore on another server). Please enter the repository password and the credentials again.",
"select_whole_domain": "select the whole domain",
"selected_domain": "Whole domain %s",
"selected_mailbox_in": "%s mailbox in %s",
"selected_mailboxes_in": "%s mailboxes in %s",
"selection_none": "Nothing selected",
"selection_replaced": "A restore covers one domain: the selection in %s was replaced.",
"selection_reset": "The list was updated after a finished restore; the selection was cleared.",
"settings": "Settings",
"sftp_host": "Host",
"sftp_host_key": "Host key of the server",
"sftp_host_key_info": "Accepted at the first connection and checked from then on. It is forgotten when host or port change.",
"sftp_path": "Path",
"sftp_path_info": "Directory of the repository on the server, relative to the home directory of the user or absolute.",
"sftp_port": "Port",
"sftp_public_key": "Public key of mailcow",
"sftp_public_key_info": "mailcow logs in with this key. Add it to ~/.ssh/authorized_keys of the user on the SFTP server.",
"sftp_public_key_missing": "The backup container creates the key when it starts.",
"sftp_user": "User",
"show_more": "Show %s more (%s left)",
"show_more_domains": "Show %s more domains (%s left)",
"size": "Data",
"skipped": "Already present",
"snapshot": "Snapshot",
"snapshot_label": "Backup point (snapshot)",
"snapshot_mode_config": "without mails",
"snapshot_mode_data": "data only",
"snapshots": "Snapshots",
"snapshots_none": "No snapshots yet",
"started": "Started",
"state": "State",
"state_cancelled": "Cancelled",
"state_done": "Done",
"state_failed": "Failed",
"state_queued": "Queued",
"state_running": "Running",
"status_deleted": "deleted",
"status_last": "Last backup %s (%s)",
"status_never": "No backup yet",
"status_next": "next %s",
"status_restoring": "being restored",
"status_schedule_off": "schedule off",
"status_snapshot": "%s snapshot",
"status_snapshots": "%s snapshots",
"status_stale": "Backup overdue by the schedule (last success %s).",
"step_aliases": "restoring the aliases",
"step_backup": "uploading",
"step_check": "checking",
"step_connect": "connecting",
"step_domain": "creating the domain",
"step_dump": "database dump",
"step_export": "export of the domains",
"step_files": "restoring files",
"step_forget": "forgetting old snapshots",
"step_init": "creating the repository",
"step_mails": "waiting for the mail container",
"step_mails_done": "mails restored, finishing",
"step_mails_running": "restoring mails",
"step_prune": "pruning",
"step_settings": "restoring settings",
"step_snapshots": "reading the snapshots",
"step_sogo": "restoring calendars and contacts",
"step_stats": "repository size",
"summary_backup": "snapshot %s, %s added",
"summary_check": "part %s checked",
"summary_created": "repository created",
"summary_snapshots": "Snapshots: %s",
"tab": "Backup",
"target": "Backup target",
"target_count": "%s mailboxes in the snapshot, %s of them deleted",
"target_count_all": "%s mailboxes in the snapshot",
"target_count_one": "1 mailbox in the snapshot",
"target_count_one_deleted": "1 mailbox in the snapshot, deleted",
"target_local": "Local directory",
"target_rest": "restic REST server",
"target_s3": "S3 (Amazon S3 or S3-compatible)",
"target_sftp": "SFTP",
"test_connection": "Test connection",
"test_connection_info": "Checks the connection and creates the repository if it does not exist yet.",
"time": "Time",
"to_backup_settings": "Backup settings",
"type": "Type",
"type_backup": "Backup",
"type_check": "Check",
"type_init": "Connection test",
"type_prune": "Prune",
"type_restore": "Restore",
"warnings": "Warnings",
"weekday_0": "Sunday",
"weekday_1": "Monday",
"weekday_2": "Tuesday",
"weekday_3": "Wednesday",
"weekday_4": "Thursday",
"weekday_5": "Friday",
"weekday_6": "Saturday",
"weekday_never": "never (only manually)",
"will_be_created": "will be created"
},
"danger": {
"access_denied": "Access denied or invalid form data",
"alias_group_managed": "Alias %s belongs to a group and can only be changed there",
@@ -575,6 +915,32 @@
"archive_testmail_failed": "Test mail could not be queued: %s",
"archive_tls_invalid": "Invalid TLS mode",
"archive_username_invalid": "Invalid SMTP username",
"backup_action_invalid": "Invalid backup endpoint action",
"backup_component_denied": "You may not restore %s",
"backup_component_requires": "%s needs %s as well",
"backup_components_invalid": "Invalid or missing restore component: %s",
"backup_components_no_mails": "The snapshot holds no mails (RESTIC_BACKUP_MODE=config), cannot restore: %s",
"backup_domain_not_in_snapshot": "The snapshot does not contain the domain %s",
"backup_entries_exceeded": "Too many entries (at most %s)",
"backup_export_invalid": "The export in the snapshot is missing or invalid",
"backup_job_active": "A job of the type %s is already queued or running",
"backup_job_busy": "The job is being processed by another request, try again",
"backup_job_not_cancellable": "Job #%s is no longer queued and cannot be cancelled",
"backup_job_not_running": "The restore job is not running",
"backup_job_type_invalid": "Invalid job type %s",
"backup_mail_target_invalid": "Invalid target for the mails (folder or original)",
"backup_mailbox_not_found": "Mailbox %s does not exist",
"backup_mailboxes_empty": "No mailboxes to restore",
"backup_mailboxes_one_domain": "All mailboxes of a restore must belong to one domain",
"backup_not_configured": "Backup target and repository password are not configured completely",
"backup_password_not_confirmed": "Confirm first that the repository password is stored outside of mailcow",
"backup_password_too_short": "The repository password must have at least %s characters",
"backup_restore_active": "A restore of %s is already queued or running",
"backup_restore_domain_active": "A restore in %s is already queued or running, domain administrators can run one restore per domain at a time",
"backup_scope_invalid": "Invalid restore scope (mailbox or domain)",
"backup_secrets_key_missing": "The backup container has not provided its key yet: start it (restic-mailcow) and save again",
"backup_setting_invalid": "Invalid value for %s",
"backup_snapshot_not_found": "Snapshot %s not found",
"group_address_invalid": "Group address %s is invalid or not in the domain of the group",
"group_description_invalid": "The group description must not exceed 255 characters",
"group_grant_invalid": "Invalid share for mailbox %s",
@@ -1524,6 +1890,10 @@
"archive_settings_saved": "Archive settings saved",
"archive_test_ok": "Connection test passed: the archive server accepts the archive address",
"archive_testmail_queued": "Test mail to the archive queued (queue ID %s)",
"backup_job_added": "%s job #%s queued",
"backup_job_cancelled": "Job #%s cancelled",
"backup_restore_added": "Restore job #%s queued for %s mailbox(es)",
"backup_settings_saved": "Backup settings saved",
"bcc_deleted": "BCC map entries deleted: %s",
"bcc_edited": "BCC map entry %s edited",
"bcc_saved": "BCC map entry saved",
+6
View File
@@ -28,6 +28,7 @@
<li><button class="dropdown-item" data-bs-target="#tab-config-customize" aria-selected="false" aria-controls="tab-config-customize" role="tab" data-bs-toggle="tab">{{ lang.admin.customize }}</button></li>
</ul>
</li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-config-backup" aria-selected="false" aria-controls="tab-config-backup" role="tab" data-bs-toggle="tab">{{ lang.backup.tab }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-routing" aria-selected="false" aria-controls="tab-routing" role="tab" data-bs-toggle="tab">{{ lang.admin.routing }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-archive" aria-selected="false" aria-controls="tab-archive" role="tab" data-bs-toggle="tab">{{ lang.admin.archive }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-sys-mails" aria-selected="false" aria-controls="tab-sys-mails" role="tab" data-bs-toggle="tab">{{ lang.admin.sys_mails }}</button></li>
@@ -58,6 +59,7 @@
{% include 'admin/tab-config-rsettings.twig' %}
{% include 'admin/tab-config-customize.twig' %}
{% include 'admin/tab-config-password-settings.twig' %}
{% include 'admin/tab-config-backup.twig' %}
{% include 'admin/tab-sys-mails.twig' %}
{% include 'admin/tab-globalfilter-regex.twig' %}
</div>
@@ -66,9 +68,13 @@
</div>
{% include 'modals/admin.twig' %}
{% include 'modals/backup-job.twig' %}
<script type='text/javascript'>
var lang = {{ lang_admin|raw }};
lang.backup = {{ lang_backup|raw }};
var backup_timezone = '{{ backup_timezone|e('js') }}';
var backup_hostname = '{{ backup_hostname|e('js') }}';
var lang_datatables = {{ lang_datatables|raw }};
var admin_username = '{{ mailcow_cc_username }}';
var csrf_token = '{{ csrf_token }}';
@@ -0,0 +1,378 @@
{# Backup with restic: status, actions, settings, jobs and snapshots, filled by js/site/backup.js #}
{% set legend_style = 'font-family:var(--bs-body-font-family)' %}
<div class="tab-pane fade" id="tab-config-backup" role="tabpanel" aria-labelledby="tab-config-backup">
<div class="card mb-4">
<div class="card-header d-flex align-items-center fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-backup" data-bs-toggle="collapse" aria-controls="collapse-tab-config-backup">
{{ lang.backup.tab }}
</button>
<span class="d-none d-md-block">{{ lang.backup.tab }}</span>
<button type="button" class="btn btn-xs btn-secondary ms-auto backup-refresh" title="{{ lang.backup.refresh }}" aria-label="{{ lang.backup.refresh }}"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div id="collapse-tab-config-backup" class="card-body collapse" data-bs-parent="#admin-content">
<div class="row mb-4">
<div class="col-sm-9 offset-sm-3">
<p class="text-muted">{{ lang.backup.intro }} <a href="/admin/restore">{{ lang.backup.intro_restore_link }}</a></p>
<div id="backup_status">
<div class="backup-status-summary mb-2" aria-live="polite"><span class="text-muted">{{ lang.footer.loading }}</span></div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button type="button" class="btn btn-sm btn-xs-lg btn-success backup-job-request" data-type="backup" title="{{ lang.backup.backup_now_info }}"><i class="bi bi-cloud-arrow-up"></i> {{ lang.backup.backup_now }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary backup-job-request" data-type="init" title="{{ lang.backup.test_connection_info }}"><i class="bi bi-plug"></i> {{ lang.backup.test_connection }}</button>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary dropdown-toggle backup-maintenance" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-tools"></i> {{ lang.backup.maintenance }}</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item backup-job-request" href="#" data-type="check" title="{{ lang.backup.check_info }}"><i class="bi bi-shield-check"></i> {{ lang.backup.check_now }}</a></li>
<li><a class="dropdown-item backup-job-request" href="#" data-type="prune" title="{{ lang.backup.prune_info }}"><i class="bi bi-scissors"></i> {{ lang.backup.prune_now }}</a></li>
</ul>
</div>
</div>
<div id="backup_status_alerts"></div>
</div>
</div>
</div>
<form class="form-horizontal" id="backup_settings" role="form" autocapitalize="none" autocorrect="off" autocomplete="off">
<legend class="fs-5 fw-semibold mb-0" style="{{ legend_style }}">{{ lang.backup.settings }}</legend>
<hr />
{# RESTIC_BACKUP_MODE of mailcow.conf as the backup container reports it, filled by js/site/backup.js #}
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.scope_info }}"></i>
<span class="control-label">{{ lang.backup.scope }}</span>
</div>
<div class="col-sm-9 pt-sm-2 backup-scope">
<div><span class="fw-semibold backup-scope-title"></span> <small class="text-muted font-monospace ms-1 backup-scope-var"></small></div>
<div class="small mt-1 d-none backup-scope-in"><i class="bi bi-check-lg text-success me-1"></i><span></span></div>
<div class="small d-none backup-scope-out"><i class="bi bi-x-lg text-danger me-1"></i><span></span></div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_target">{{ lang.backup.target }}</label>
</div>
<div class="col-sm-9">
<select class="form-control" name="target" id="backup_target">
<option value="s3">{{ lang.backup.target_s3 }}</option>
<option value="sftp">{{ lang.backup.target_sftp }}</option>
<option value="rest">{{ lang.backup.target_rest }}</option>
<option value="local">{{ lang.backup.target_local }}</option>
</select>
</div>
</div>
<div class="backup-target" data-target="s3">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.s3_endpoint_info }}"></i>
<label class="control-label" for="backup_s3_endpoint">{{ lang.backup.s3_endpoint }}</label>
</div>
<div class="col-sm-9">
<input type="url" class="form-control" name="s3_endpoint" id="backup_s3_endpoint" maxlength="255" placeholder="https://s3.eu-central-1.amazonaws.com">
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_bucket">{{ lang.backup.s3_bucket }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="s3_bucket" id="backup_s3_bucket" maxlength="63">
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_path">{{ lang.backup.s3_path }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-7"><input type="text" class="form-control" name="s3_path" id="backup_s3_path" maxlength="255" placeholder="{{ lang.backup.optional }}"></div>
<div class="col-md-5">
<div class="input-group">
<label class="input-group-text" for="backup_s3_region">{{ lang.backup.s3_region }}</label>
<input type="text" class="form-control" name="s3_region" id="backup_s3_region" maxlength="64" placeholder="{{ lang.backup.optional }}">
</div>
</div>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_access_key">{{ lang.backup.s3_access_key }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="s3_access_key" id="backup_s3_access_key" maxlength="255">
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_s3_secret_key">{{ lang.backup.s3_secret_key }}</label>
</div>
<div class="col-sm-9">
<input type="password" class="form-control backup-secret" name="s3_secret_key" id="backup_s3_secret_key" maxlength="255" autocomplete="new-password" data-formcache-skip>
</div>
</div>
<div class="row mb-3">
<div class="offset-sm-3 col-sm-9">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="s3_path_style" value="1" id="backup_s3_path_style">
<label class="form-check-label" for="backup_s3_path_style">{{ lang.backup.s3_path_style }}</label>
</div>
</div>
</div>
</div>
<div class="backup-target" data-target="sftp">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_sftp_host">{{ lang.backup.sftp_host }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-8"><input type="text" class="form-control" name="sftp_host" id="backup_sftp_host" maxlength="253" placeholder="backup.example.org"></div>
<div class="col-md-4">
<div class="input-group">
<label class="input-group-text" for="backup_sftp_port">{{ lang.backup.sftp_port }}</label>
<input type="number" class="form-control" name="sftp_port" id="backup_sftp_port" min="1" max="65535">
</div>
</div>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_sftp_user">{{ lang.backup.sftp_user }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-5"><input type="text" class="form-control" name="sftp_user" id="backup_sftp_user" maxlength="64"></div>
<div class="col-md-7">
<div class="input-group">
<label class="input-group-text" for="backup_sftp_path">{{ lang.backup.sftp_path }}</label>
<input type="text" class="form-control" name="sftp_path" id="backup_sftp_path" maxlength="255" placeholder="mailcow" title="{{ lang.backup.sftp_path_info }}">
</div>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.sftp_public_key_info }}"></i>
<label class="control-label" for="backup_sftp_public_key">{{ lang.backup.sftp_public_key }}</label>
</div>
<div class="col-sm-9">
<div class="input-group">
<input type="text" class="form-control font-monospace" id="backup_sftp_public_key" readonly>
<button class="btn btn-secondary backup-copy" type="button" data-copy="#backup_sftp_public_key"><i class="bi bi-clipboard"></i> {{ lang.backup.copy }}</button>
</div>
<small class="text-muted d-none backup-key-missing">{{ lang.backup.sftp_public_key_missing }}</small>
<small class="text-muted d-none text-break backup-host-key" title="{{ lang.backup.sftp_host_key_info }}">{{ lang.backup.sftp_host_key }}: <code></code></small>
</div>
</div>
</div>
<div class="backup-target" data-target="rest">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.rest_url_info }}"></i>
<label class="control-label" for="backup_rest_url">{{ lang.backup.rest_url }}</label>
</div>
<div class="col-sm-9">
<input type="url" class="form-control" name="rest_url" id="backup_rest_url" maxlength="255" placeholder="https://backup.example.org:8000/mailcow">
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<label class="control-label" for="backup_rest_user">{{ lang.backup.rest_user }}</label>
</div>
<div class="col-sm-9">
<div class="row g-2">
<div class="col-md-5"><input type="text" class="form-control" name="rest_user" id="backup_rest_user" maxlength="64" placeholder="{{ lang.backup.optional }}"></div>
<div class="col-md-7">
<div class="input-group">
<label class="input-group-text" for="backup_rest_password">{{ lang.backup.rest_password }}</label>
<input type="password" class="form-control backup-secret" name="rest_password" id="backup_rest_password" maxlength="255" autocomplete="new-password" data-formcache-skip>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="backup-target" data-target="local">
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.local_path_info }}"></i>
<label class="control-label" for="backup_local_path">{{ lang.backup.local_path }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="local_path" id="backup_local_path" maxlength="255" placeholder="mailcow">
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.password_info }}"></i>
<label class="control-label" for="backup_password">{{ lang.backup.password }}</label>
</div>
<div class="col-sm-9">
<div class="input-group">
<input type="password" class="form-control backup-secret" name="password" id="backup_password" maxlength="255" autocomplete="new-password" data-formcache-skip>
<button class="btn btn-secondary backup-password-generate" type="button"><i class="bi bi-arrow-repeat"></i> {{ lang.backup.password_generate }}</button>
</div>
<small class="d-none backup-password-change"><i class="bi bi-exclamation-triangle-fill text-warning"></i> {{ lang.backup.password_change }}</small>
<div class="form-check mt-1 backup-password-confirm">
<input type="checkbox" class="form-check-input" name="password_confirmed" value="1" id="backup_password_confirmed">
<label class="form-check-label fw-semibold" for="backup_password_confirmed" title="{{ lang.backup.password_confirmed }}">{{ lang.backup.password_confirmed_short }}</label>
</div>
</div>
</div>
<div class="row mt-3 mb-4">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.schedule_time_info|format(backup_timezone) }}"></i>
<label class="control-label" for="backup_enabled">{{ lang.backup.schedule }}</label>
</div>
<div class="col-sm-9">
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" role="switch" name="enabled" value="1" id="backup_enabled">
<label class="form-check-label" for="backup_enabled">{{ lang.backup.enabled_label }}</label>
</div>
{# a new entry of the schedule (weekdays and time, no names); js/site/backup.js lists the entries below as schedule #}
<div class="d-flex flex-wrap align-items-center column-gap-4 row-gap-2 backup-schedule-new" data-setting="schedule">
<div class="btn-group backup-schedule-days" role="group" aria-label="{{ lang.backup.schedule_days }}">
{% for day in [1, 2, 3, 4, 5, 6, 0] %}
<input type="checkbox" class="btn-check" id="backup_schedule_day_{{ day }}" value="{{ day }}" autocomplete="off">
<label class="btn btn-outline-secondary btn-check-label" for="backup_schedule_day_{{ day }}" title="{{ lang.backup['weekday_' ~ day] }}">{{ lang.backup['weekday_' ~ day]|slice(0, 2) }}</label>
{% endfor %}
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<span>{{ lang.backup.schedule_at }}</span>
<input type="time" class="form-control w-auto backup-schedule-time" aria-label="{{ lang.backup.schedule_time }}">
<button type="button" class="btn btn-secondary backup-schedule-add"><i class="bi bi-plus-lg"></i> {{ lang.backup.schedule_add }}</button>
</div>
</div>
<ol class="mt-3 mb-0 ps-4 backup-schedule-list" aria-label="{{ lang.backup.schedule }}"></ol>
<div class="mt-3 text-muted d-none backup-schedule-none">{{ lang.backup.schedule_none }}</div>
</div>
</div>
<div class="row mb-2" role="group" aria-labelledby="backup_keep_label">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.keep_info }}"></i>
<div class="control-label" id="backup_keep_label">{{ lang.backup.keep }}</div>
</div>
<div class="col-sm-9">
<div class="d-flex flex-wrap align-items-center gap-2">
{% for keep in ['hourly', 'daily', 'weekly', 'monthly', 'yearly'] %}
<div class="input-group w-auto backup-keep-{{ keep }}">
<input type="number" class="form-control" style="max-width:5.5rem" name="keep_{{ keep }}" id="backup_keep_{{ keep }}" min="0" max="1000">
<label class="input-group-text" for="backup_keep_{{ keep }}">{{ lang.backup['keep_' ~ keep] }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.notify_email_info }}"></i>
<label class="control-label" for="backup_notify_email">{{ lang.backup.notify_email }}</label>
</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="notify_email" id="backup_notify_email" placeholder="admin@example.org, ops@example.org">
</div>
</div>
<legend class="fs-6 fw-semibold mb-0 mt-3" style="{{ legend_style }}">
<button type="button" class="btn btn-link p-0 text-reset text-decoration-none fw-semibold backup-collapse-toggle" data-bs-toggle="collapse" data-bs-target="#backup_advanced" aria-expanded="false" aria-controls="backup_advanced"><i class="bi bi-plus-square"></i> {{ lang.backup.advanced }}</button>
<small class="text-muted fw-normal">({{ lang.backup.advanced_info }})</small>
</legend>
<hr />
<div id="backup_advanced" class="collapse">
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.prune_weekday_info }}"></i>
<label class="control-label" for="backup_prune_weekday">{{ lang.backup.prune_weekday }}</label>
</div>
<div class="col-sm-9">
<select class="form-control" name="prune_weekday" id="backup_prune_weekday">
{% for day in [1, 2, 3, 4, 5, 6, 0] %}
<option value="{{ day }}">{{ lang.backup['weekday_' ~ day] }}</option>
{% endfor %}
<option value="">{{ lang.backup.weekday_never }}</option>
</select>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.max_unused_info }}"></i>
<label class="control-label" for="backup_max_unused">{{ lang.backup.max_unused }}</label>
</div>
<div class="col-sm-9">
<div class="input-group w-auto d-inline-flex">
<input type="number" class="form-control" style="max-width:6rem" name="max_unused" id="backup_max_unused" min="0" max="100">
<span class="input-group-text">%</span>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.check_subset_info }}"></i>
<label class="control-label" for="backup_check_subset">{{ lang.backup.check_subset }}</label>
</div>
<div class="col-sm-9">
<div class="input-group w-auto d-inline-flex">
<input type="number" class="form-control" style="max-width:6rem" name="check_subset" id="backup_check_subset" min="0" max="365">
<span class="input-group-text">{{ lang.backup.check_subset_unit }}</span>
</div>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="include_index" value="1" id="backup_include_index">
<label class="form-check-label" for="backup_include_index">{{ lang.backup.include_index }}</label>
</div>
<div class="form-check ms-4">
<input type="checkbox" class="form-check-input" name="include_fts" value="1" id="backup_include_fts">
<label class="form-check-label" for="backup_include_fts">{{ lang.backup.include_fts }}</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 d-flex align-items-start justify-content-sm-end pt-sm-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ lang.backup.limit_upload_info }}"></i>
<label class="control-label" for="backup_limit_upload">{{ lang.backup.limit_upload }}</label>
</div>
<div class="col-sm-9">
<div class="input-group w-auto d-inline-flex">
<input type="number" class="form-control" style="max-width:9rem" name="limit_upload" id="backup_limit_upload" min="0" max="100000000">
<span class="input-group-text">KiB/s</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="offset-sm-3 col-sm-9">
<button type="submit" class="btn btn-sm btn-xs-lg d-block d-sm-inline btn-success backup-settings-save"><i class="bi bi-check-lg"></i> {{ lang.backup.save }}</button>
</div>
</div>
</form>
<div class="backup-jobs-section d-none mt-4">
<legend class="fs-5 fw-semibold mb-0" style="{{ legend_style }}">{{ lang.backup.jobs }}</legend>
<hr />
<table id="backup_jobs_table" class="table table-striped dt-responsive w-100"></table>
</div>
<div class="backup-snapshots-section d-none mt-4">
<legend class="fs-5 fw-semibold mb-0" style="{{ legend_style }}">
<button type="button" class="btn btn-link p-0 text-reset text-decoration-none fw-semibold fs-5 backup-collapse-toggle" data-bs-toggle="collapse" data-bs-target="#backup_snapshots_collapse" aria-expanded="false" aria-controls="backup_snapshots_collapse"><i class="bi bi-plus-square"></i> {{ lang.backup.snapshots }} (<span class="backup-snapshot-count">0</span>)</button>
</legend>
<hr />
<div id="backup_snapshots_collapse" class="collapse">
<table id="backup_snapshots_table" class="table table-striped dt-responsive w-100"></table>
</div>
</div>
</div>
</div>
</div>
+2
View File
@@ -75,6 +75,7 @@
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-expanded="false">{{ lang.header.email }}</a>
<ul class="dropdown-menu">
<li><a href="/admin/mailbox" class="dropdown-item {% if is_uri('mailbox') %}active{% endif %}">{{ lang.header.mailcow_config }}</a></li>
<li><a href="/admin/restore" class="dropdown-item {% if is_uri('restore') %}active{% endif %}">{{ lang.backup.nav }}</a></li>
<li><a href="/quarantine" class="dropdown-item {% if is_uri('quarantine') %}active{% endif %}">{{ lang.header.quarantine }}</a></li>
<li><a href="/admin/queue" class="dropdown-item {% if is_uri('queue') %}active{% endif %}">{{ lang.queue.queue_manager }}</a></li>
<li><a href="#" class="dropdown-item" data-bs-toggle="modal" data-container="sogo-mailcow" data-bs-target="#RestartContainer">{{ lang.header.restart_sogo }}</a></li>
@@ -96,6 +97,7 @@
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-expanded="false">{{ lang.header.email }}</a>
<ul class="dropdown-menu">
<li><a href="/domainadmin/mailbox" class="dropdown-item {% if is_uri('mailbox') %}active{% endif %}">{{ lang.header.mailcow_config }}</a></li>
<li><a href="/domainadmin/restore" class="dropdown-item {% if is_uri('restore') %}active{% endif %}">{{ lang.backup.nav }}</a></li>
<li><a href="/quarantine" class="dropdown-item {% if is_uri('quarantine') %}active{% endif %}">{{ lang.header.quarantine }}</a></li>
</ul>
</li>
+19
View File
@@ -0,0 +1,19 @@
{# Details of a backup job: overview, restore result per mailbox and item, log; filled by js/site/backup.js #}
<div class="modal fade" id="backupJobModal" tabindex="-1" role="dialog" aria-labelledby="backupJobModalTitle" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="backupJobModalTitle"></h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.backup.close }}"></button>
</div>
<div class="modal-body">
<div class="backup-job-body"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-xs-lg btn-danger me-auto backup-job-cancel d-none"><i class="bi bi-x-circle"></i> {{ lang.backup.cancel_job }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary backup-job-refresh d-none"><i class="bi bi-arrow-clockwise"></i> {{ lang.backup.refresh }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary" data-bs-dismiss="modal">{{ lang.backup.close }}</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,112 @@
{# Restore modal of E-Mail > Restore: what is restored, the items, the mail target; js/site/backup.js opens and sends it.
Domain admins only see the items they have the ACL for (the API refuses the others as well). For deleted targets the
overwrite block is hidden and SOGo moves into "Calendars and contacts" (nothing to overwrite). #}
{% macro component(key, label, note, sub) %}
<div class="form-check{% if sub %} ms-4{% endif %}">
<input class="form-check-input backup-component" type="checkbox" value="{{ key }}" id="backup_restore_{{ key }}"{% if sub %} data-parent="{{ sub }}"{% endif %}>
<label class="form-check-label" for="backup_restore_{{ key }}">{{ label }}</label>
{% if note %}<div class="small text-muted">{{ note }}</div>{% endif %}
</div>
{% endmacro %}
{% import _self as restore %}
{% set is_admin = mailcow_cc_role == 'admin' %}
<div class="modal fade" id="backupRestoreModal" tabindex="-1" role="dialog" aria-labelledby="backupRestoreModalTitle" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<form class="modal-content" id="backup_restore_form" role="form" autocomplete="off">
<div class="modal-header">
<div class="text-break">
<h3 class="modal-title" id="backupRestoreModalTitle">{{ lang.backup.restore_title }}</h3>
<div class="small text-muted backup-restore-subtitle"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.backup.close }}"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-sm-3 col-lg-2 text-sm-end">{{ lang.backup.restore_what }}</div>
<div class="col-sm-9 col-lg-10">
<div class="backup-restore-summary text-break"></div>
<p class="text-muted small mb-1 mt-2">{{ lang.backup.restore_info }}</p>
<p class="text-muted small mb-1"><i class="bi bi-info-circle"></i> {{ lang.backup.note_inactive }}</p>
<div class="alert alert-info py-2 mb-0 d-none backup-create-note"><i class="bi bi-info-circle"></i> {{ lang.backup.create_note }}</div>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-3 col-lg-2 text-sm-end">{{ lang.backup.components }}</div>
<div class="col-sm-9 col-lg-10">
<p class="small mb-2 d-none backup-no-mail-note"><i class="bi bi-exclamation-triangle-fill text-warning"></i> {{ lang.backup.no_mail_data }}</p>
<div class="row">
<div class="col-lg-6">
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_mail }}</legend>
{{ restore.component('mails', lang.backup.component_mails) }}
<div class="ms-4 mb-1 backup-mail-target" role="radiogroup" aria-label="{{ lang.backup.mail_target }}">
<div class="form-check">
<input class="form-check-input" type="radio" name="mail_target" value="folder" id="backup_restore_target_folder" checked>
<label class="form-check-label" for="backup_restore_target_folder">{{ lang.backup.mail_target_folder|e|format('<span class="backup-folder-name"></span>')|raw }}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="mail_target" value="original" id="backup_restore_target_original">
<label class="form-check-label" for="backup_restore_target_original">{{ lang.backup.mail_target_original }}</label>
</div>
</div>
{{ restore.component('imap_acl', lang.backup.component_imap_acl) }}
</fieldset>
<fieldset class="mb-3 d-none backup-sogo-normal">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_calendar }}</legend>
</fieldset>
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_addresses }}</legend>
{{ restore.component('aliases', lang.backup.component_aliases) }}
{% if is_admin or acl.mailbox_groups == 1 %}{{ restore.component('groups', lang.backup.component_groups) }}{% endif %}
{{ restore.component('sender_acl', lang.backup.component_sender_acl) }}
{% if is_admin or acl.mail_policies == 1 %}{{ restore.component('mail_policies', lang.backup.component_mail_policies) }}{% endif %}
{% if is_admin or acl.retention == 1 %}{{ restore.component('retention_rules', lang.backup.component_retention_rules, lang.backup.note_retention_rules) }}{% endif %}
</fieldset>
</div>
<div class="col-lg-6">
{% if is_admin or acl.filters == 1 or acl.spam_policy == 1 or acl.bcc_maps == 1 %}
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_filters }}</legend>
{% if is_admin or acl.filters == 1 %}
{{ restore.component('filters', lang.backup.component_filters) }}
{{ restore.component('sieve_activate', lang.backup.component_sieve_activate, lang.backup.note_sieve_activate, 'filters') }}
{% endif %}
{% if is_admin or acl.spam_policy == 1 %}{{ restore.component('spam', lang.backup.component_spam) }}{% endif %}
{% if is_admin or acl.bcc_maps == 1 %}{{ restore.component('bcc', lang.backup.component_bcc) }}{% endif %}
</fieldset>
{% endif %}
<fieldset class="mb-3">
<legend class="fs-6 fw-bold mb-1">{{ lang.backup.group_access }}</legend>
{% if is_admin or acl.syncjobs == 1 %}{{ restore.component('syncjobs', lang.backup.component_syncjobs) }}{% endif %}
{% if is_admin or acl.app_passwds == 1 %}{{ restore.component('app_passwords', lang.backup.component_app_passwords, lang.backup.note_app_passwords) }}{% endif %}
{{ restore.component('tfa', lang.backup.component_tfa, lang.backup.note_tfa) }}
</fieldset>
<fieldset class="border border-warning rounded px-3 py-2 mb-3 backup-overwrite">
<legend class="fs-6 fw-bold mb-1 float-none w-auto px-1"><i class="bi bi-exclamation-triangle-fill text-warning"></i> {{ lang.backup.overwrite }}</legend>
<p class="small text-muted mb-1">{{ lang.backup.overwrite_info }}</p>
<div class="form-check backup-sogo-item">
<input class="form-check-input backup-component" type="checkbox" value="sogo" id="backup_restore_sogo">
<label class="form-check-label" for="backup_restore_sogo" data-overwrite="{{ lang.backup.component_sogo }}" data-normal="{{ lang.backup.component_sogo_new }}">{{ lang.backup.component_sogo }}</label>
<div class="small text-muted backup-sogo-note">
{{ lang.backup.note_sogo }}
<button type="button" class="btn btn-link btn-sm p-0 align-baseline" data-bs-toggle="collapse" data-bs-target="#backup_sogo_more" aria-expanded="false" aria-controls="backup_sogo_more">{{ lang.backup.more }}</button>
<div class="collapse" id="backup_sogo_more">{{ lang.backup.note_sogo_more }}</div>
</div>
</div>
{{ restore.component('settings', lang.backup.component_settings, lang.backup.note_settings) }}
{{ restore.component('password', lang.backup.component_password) }}
</fieldset>
</div>
</div>
</div>
</div>
<div class="text-danger small d-none backup-restore-error" role="alert"></div>
</div>
<div class="modal-footer">
<small class="text-muted me-auto">{{ lang.backup.restore_background }}</small>
<button type="button" class="btn btn-sm btn-xs-lg btn-secondary" data-bs-dismiss="modal">{{ lang.backup.cancel }}</button>
<button type="submit" class="btn btn-sm btn-xs-lg btn-success backup-restore-submit" data-normal="{{ lang.backup.restore }}" data-overwrite="{{ lang.backup.restore_overwrite }}"><i class="bi bi-clock-history"></i> <span>{{ lang.backup.restore }}</span></button>
</div>
</form>
</div>
</div>
+81
View File
@@ -0,0 +1,81 @@
{% extends 'base.twig' %}
{# E-Mail > Restore (admins, domain admins): snapshot, a tree of its domains and mailboxes (also deleted ones) with
a selection, the restore modal, the restores. Filled by js/site/backup.js. #}
{% block content %}
{% set is_admin = mailcow_cc_role == 'admin' %}
<div id="backup_restore_page">
<div class="card mb-4">
<div class="card-header d-flex align-items-center fs-5">
<span>{{ lang.backup.restore_card }}</span>
</div>
<div class="card-body">
<div class="backup-restore-hints"></div>
{# left-aligned with the search of the toolbar below #}
<div class="d-flex flex-wrap align-items-center gap-2 mb-3 px-2">
<label class="fw-semibold mb-0" for="backup_restore_snapshot">{{ lang.backup.snapshot_label }}</label>
<div class="flex-grow-1" style="min-width:16rem;max-width:28rem">
<select class="form-control" id="backup_restore_snapshot" data-live-search="true" data-show-subtext="true" data-width="100%"></select>
</div>
</div>
<div class="backup-tree-panel d-none">
<div class="backup-content-note"></div>
<div class="backup-restore-queued"></div>
<div class="position-sticky py-2 px-2 rounded backup-tree-toolbar" style="top:0;z-index:5;background:var(--bs-card-bg,var(--bs-body-bg))">
<div class="d-flex flex-wrap align-items-center gap-2">
<div class="flex-grow-1" style="min-width:12rem;max-width:28rem">
<input type="search" class="form-control form-control-sm backup-tree-search" placeholder="{{ lang.backup.search_placeholder }}" aria-label="{{ lang.backup.search_placeholder }}" autocomplete="off">
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input backup-tree-deleted" type="checkbox" role="switch" id="backup_tree_deleted">
<label class="form-check-label small" for="backup_tree_deleted">{{ lang.backup.only_deleted }}</label>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
<span class="small text-muted backup-selection-text" aria-live="polite"></span>
<button type="button" class="btn btn-sm btn-link px-1 backup-selection-clear d-none">{{ lang.backup.clear_selection }}</button>
<button type="button" class="btn btn-sm btn-xs-lg btn-success backup-selection-restore" disabled><i class="bi bi-clock-history"></i> {{ lang.backup.restore }}</button>
</div>
</div>
<div class="small mt-1 d-none backup-selection-hint" role="status"></div>
</div>
<table class="table table-sm table-hover align-middle mb-0 backup-tree" id="backup_tree">
<thead>
<tr>
<th>{{ lang.backup.name }}</th>
<th class="d-none d-md-table-cell">{{ lang.backup.state }}</th>
<th class="d-none d-sm-table-cell">{{ lang.backup.mailboxes }}</th>
<th class="text-end"><span class="visually-hidden">{{ lang.backup.restore }}</span></th>
</tr>
</thead>
{# not empty: mailcow removes empty tbody elements on load #}
<tbody><tr class="backup-tree-message"><td colspan="4" class="text-muted">{{ lang.footer.loading }}</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header d-flex align-items-center fs-5">
<span>{{ lang.backup.restores }}</span>
<button type="button" class="btn btn-xs btn-secondary ms-auto backup-table-refresh" data-table="backup_restores_table" title="{{ lang.backup.refresh }}" aria-label="{{ lang.backup.refresh }}"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div class="card-body">
<p class="text-muted small">{{ lang.backup.restores_info }}</p>
<table id="backup_restores_table" class="table table-striped dt-responsive w-100"></table>
</div>
</div>
</div>
{% include 'modals/backup-restore.twig' %}
{% include 'modals/backup-job.twig' %}
<script type='text/javascript'>
var lang = {backup: {{ lang_backup|raw }}};
var lang_datatables = {{ lang_datatables|raw }};
var csrf_token = '{{ csrf_token }}';
var pagination_size = Math.trunc('{{ pagination_size }}');
var role = '{{ role }}';
var backup_configured = {{ backup_configured ? 'true' : 'false' }};
var backup_timezone = '{{ backup_timezone|e('js') }}';
</script>
{% endblock %}