[Web] Archive settings, domain and mailbox switches, archive log and API

This commit is contained in:
FreddleSpl0it committed 2026-11-07 00:45:11 +01:00
1 parent fb85a9eb5c
commit 1c24de19c3
25 files changed
+834 -12

No files matched your search

+1
View File
@@ -60,6 +60,7 @@ $_SESSION['acl']['sogo_access'] = "1";
$_SESSION['acl']['protocol_access'] = "1";
$_SESSION['acl']['mailbox_relayhost'] = "1";
$_SESSION['acl']['unlimited_quota'] = "1";
$_SESSION['acl']['archive'] = "1";
// One run at a time, released when the process ends
$lock = fopen('/tmp/identity-sync.lock', 'c');
+1
View File
@@ -46,6 +46,7 @@ $template_data = [
'lang_edit' => json_encode($lang['edit']),
'lang_datatables' => json_encode($lang['datatables']),
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
+2
View File
@@ -13,6 +13,7 @@ $js_minifier->add('/web/js/site/admin.js');
$js_minifier->add('/web/js/presets/rspamd.js');
$js_minifier->add('/web/js/site/pwgen.js');
$js_minifier->add('/web/js/site/identity.js');
$js_minifier->add('/web/js/site/archive.js');
// all domains
$domains = mailbox('get', 'domains');
@@ -116,6 +117,7 @@ $template_data = [
'ui_theme' => customize('get', 'ui_theme'),
'password_complexity' => password_complexity('get'),
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
'archive_settings' => \Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings(),
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
'cors_settings' => $cors_settings,
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
+277
View File
@@ -685,6 +685,11 @@ paths:
is domain global address list active or not, it enables
shared contacts accross domain in SOGo webmail
type: boolean
archive:
description: >-
archive mails of the mailboxes of this domain by default (mailboxes set to `inherit`),
see GET /api/v1/get/archive/mailboxes
type: boolean
mailboxes:
description: limit count of mailboxes associated with this domain
type: number
@@ -1254,6 +1259,12 @@ paths:
force_tfa:
description: force 2FA enrollment at login
type: boolean
archive:
description: >-
archiving of the mailbox: `inherit` follows the domain, `1` always, `0` never (admins, domain
admins with the archive permission; otherwise the template decides)
type: string
enum: [inherit, "1", "0"]
tls_enforce_in:
description: force inbound email tls encryption
type: boolean
@@ -3918,6 +3929,11 @@ paths:
is domain global address list active or not, it enables
shared contacts accross domain in SOGo webmail
type: boolean
archive:
description: >-
archive mails of the mailboxes of this domain by default (admins, domain admins with the
archive permission)
type: boolean
mailboxes:
description: limit count of mailboxes associated with this domain
type: number
@@ -4222,6 +4238,12 @@ paths:
force_tfa:
description: force 2FA enrollment at login
type: boolean
archive:
description: >-
archiving of the mailbox: `inherit` follows the domain, `1` always, `0` never (admins, domain
admins with the archive permission)
type: string
enum: [inherit, "1", "0"]
name:
description: Full name of the mailbox user
type: string
@@ -4546,6 +4568,104 @@ paths:
default: run
type: object
summary: Request identity sync run
/api/v1/edit/archive:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- archive
- edit
- settings
- enabled: "1"
address: journal@archive.example.org
signing_secret: "*"
msg: archive_settings_saved
type: success
description: OK
headers: {}
tags:
- Archive
description: >-
Archive target, admins with a read-write key only. mailcow sends a copy of every accepted mail of archived
mailboxes by SMTP to `address`, through the Postfix queue (envelope sender `archive-bounce@localhost`, notices
to it are dropped: no bounce or delay warning reaches a sender). Attributes missing in the request keep their
value, an empty `password` or `signing_secret` keeps the stored one. Switching on needs the address, and the
signing key for the profile archivecow. With `action: test` the stored target is tested (connection, TLS,
AUTH, whether the address is accepted; no mail is sent), with `action: testmail` a test copy is sent through
the mail queue.
operationId: Edit archive settings
requestBody:
content:
application/json:
schema:
example:
items:
- archive
attr:
enabled: "1"
address: journal@archive.example.org
host: archive.example.org
port: 25
tls: encrypt
profile: archivecow
signing_secret: secret-key
properties:
items:
type: array
default: ["archive"]
attr:
type: object
properties:
enabled:
description: copies of accepted mails go to the archive; copies already queued are delivered either way
type: boolean
address:
description: address the archive receives copies at
type: string
host:
description: SMTP server of the archive, empty for the MX of the address domain
type: string
port:
type: integer
default: 25
tls:
description: "`encrypt` (TLS required), `verify` (TLS required, certificate verified), `may`"
type: string
enum: [encrypt, verify, may]
username:
description: SMTP AUTH user at the archive, empty for none
type: string
password:
description: SMTP AUTH password, never returned
type: string
profile:
description: >-
headers in front of each copy: `archivecow` (X-Archive-Owner, -Rcpt, -From, -Queue-ID,
-Received, -Direction, -Source, -SHA256 and X-Archive-Signature), `envelope` (sender and
recipients under `header_from` / `header_rcpt`), `none`
type: string
enum: [archivecow, envelope, none]
signing_secret:
description: key of X-Archive-Signature (HMAC-SHA256), never returned
type: string
header_from:
type: string
default: X-Envelope-From
header_rcpt:
type: string
default: X-Envelope-To
action:
type: string
enum: [test, testmail]
type: object
summary: Edit archive settings
/api/v1/edit/mailq:
post:
responses:
@@ -5758,6 +5878,7 @@ paths:
description: Some description
domain_name: domain.tld
gal: "0"
archive: 1
max_new_mailbox_quota: 10737418240
max_num_aliases_for_domain: 400
max_num_mboxes_for_domain: 10
@@ -6312,7 +6433,9 @@ paths:
response:
value:
- active: "1"
archive: 1
attributes:
archive: inherit
force_pw_update: "0"
force_tfa: "0"
mailbox_format: "maildir:"
@@ -6507,6 +6630,154 @@ paths:
nothing was changed).
operationId: Get identity sync status
summary: Get identity sync status
/api/v1/get/archive/mailboxes:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
mailboxes:
- username: anna@example.org
domain: example.org
name: Anna Example
active: 1
source: domain
- username: board@example.net
domain: example.net
name: Board
active: 2
source: mailbox
description: OK
headers: {}
tags:
- Archive
description: >-
Mailboxes whose mails go to the archive now (admins, read-only keys included). A mailbox is archived when
archiving is switched on (`enabled`) and its setting is `1`, or `inherit` while its domain archives by default
(`source` says which). Only mailboxes that receive mail are listed: `active` 1, or 2 (no login, mail is
delivered). `username` is the value of the X-Archive-Owner header. Mailboxes that drop out of the list
(archiving switched off, deleted, renamed) keep their mails in the archive.
operationId: Get archived mailboxes
summary: Get archived mailboxes
/api/v1/get/archive/status:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
configured: 1
queued: 2
queued_bytes: 182044
oldest_queued: 1762425600
delay_reasons:
- "connect to archive.example.org[192.0.2.10]:25: Connection refused"
last_delivery: 1762425580
dropped: 3
dropped_24h: 1
last_dropped:
time: 1762425500
reason: "owner lookup failed (nil, 502)"
description: OK
headers: {}
tags:
- Archive
description: >-
Summary of the archive state (admins, read-only keys included): copies waiting in the Postfix queue with the
reasons Postfix gives, the time of the last delivery to the archive, and mails that were not archived because
their copy could not be handed to Postfix (`dropped` since ever, `dropped_24h` and `last_dropped` for the last
24 hours).
operationId: Get archive status
summary: Get archive status
"/api/v1/get/logs/archive/{count}":
get:
parameters:
- description: Number of logs to return
in: path
name: count
required: true
schema:
type: number
- description: e.g. api-key-string
example: api-key-string
in: header
name: X-API-Key
required: false
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- time: 1762425580
status: sent
qid: 4XyZ1abc
message_id: "c3d1e0f2@example.org"
text: "250 2.0.0 Ok: queued as 9F2C4"
- time: 1762425500
status: dropped
qid: 3AbC2def
message_id: "a1b2c3@example.org"
text: "owner lookup failed (nil, 502)"
description: OK
headers: {}
tags:
- Logs
description: >-
Archive log, newest first (admins, read-only keys included): deliveries of copies from the Postfix log
(`status` sent, deferred, softbounce when the archive refused the copy and Postfix keeps it, or bounced),
copies discarded after the queue lifetime (expired) and mails that were not archived (dropped, `text` is the
reason). `message_id` is the Message-ID of the mail, `qid` the queue ID of the copy (of the mail for dropped).
Tip: You can limit how many logs you want to get by using `/<count>` at
the end of the api url.
operationId: Get archive log
summary: Get archive log
/api/v1/get/archive/settings:
get:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
enabled: 1
address: journal@archive.example.org
host: archive.example.org
port: 25
tls: encrypt
username: ""
has_password: 0
profile: archivecow
has_signing_secret: 1
header_from: X-Envelope-From
header_rcpt: X-Envelope-To
description: OK
headers: {}
tags:
- Archive
description: Archive target (admins, read-only keys included); password and signing key are never returned.
operationId: Get archive settings
summary: Get archive settings
"/api/v1/get/identity-link/{username}":
get:
parameters:
@@ -7597,7 +7868,9 @@ paths:
response:
value:
- active: "1"
archive: 1
attributes:
archive: inherit
force_pw_update: "0"
force_tfa: "0"
mailbox_format: "maildir:"
@@ -8025,3 +8298,7 @@ tags:
description: Manage Cross-Origin Resource Sharing (CORS) settings
- name: Identity Provider
description: Manage external Identity Provider settings
- name: Archive
description: >-
Archiving (SMTP journaling): a copy of every accepted incoming, outgoing and internal mail of archived
mailboxes goes by SMTP to the archive, through the Postfix queue.
+1
View File
@@ -45,6 +45,7 @@ $template_data = [
'lang_edit' => json_encode($lang['edit']),
'lang_datatables' => json_encode($lang['datatables']),
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
+5 -1
View File
@@ -65,7 +65,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
'mta_sts' => $mta_sts,
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"])
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"]),
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled()
];
}
}
@@ -75,6 +76,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
$template_data = [
'template' => $domain_template,
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
$template = 'edit/domain-templates.twig';
$result = true;
@@ -85,6 +87,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
$template_data = [
'template' => $mailbox_template,
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
$template = 'edit/mailbox-templates.twig';
$result = true;
@@ -162,6 +165,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
'user_acls' => acl('get', 'user', $mailbox),
'mailbox_details' => $result,
'iam_settings' => $iam_settings,
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
}
}
+5
View File
@@ -539,6 +539,7 @@ function sys_mail($_data) {
$mail->Body = $mass_text;
}
$mail->XMailer = 'MooMassMail';
$archive = \Mailcow\Archive\ArchiveModule::fromGlobals();
foreach ($rcpts as $rcpt) {
$mail->AddAddress($rcpt);
if (!$mail->send()) {
@@ -548,6 +549,10 @@ function sys_mail($_data) {
'msg' => 'Mailer error (RCPT "' . htmlspecialchars($rcpt) . '"): ' . str_replace('https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting', '', $mail->ErrorInfo)
);
}
else {
// Delivered by LMTP without rspamd, so the archive gets it here
$archive->journal->submitDelivered($mail->getSentMIMEMessage(), $mass_from, array($rcpt), 'massmail');
}
$mail->ClearAllRecipients();
}
}
+40 -4
View File
@@ -366,6 +366,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only'];
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal'];
$archive = (intval((isset($_data['archive'])) ? $_data['archive'] : ($DOMAIN_DEFAULT_ATTRIBUTES['archive'] ?? 0)) == 1) ? 1 : 0;
if ($relay_all_recipients == 1) {
$backupmx = '1';
}
@@ -421,8 +422,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => '%@' . $domain
));
// save domain
$stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`)
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients)");
$stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`, `archive`)
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients, :archive)");
$stmt->execute(array(
':domain' => $domain,
':description' => $description,
@@ -435,7 +436,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':gal' => $gal,
':active' => $active,
':relay_unknown_only' => $relay_unknown_only,
':relay_all_recipients' => $relay_all_recipients
':relay_all_recipients' => $relay_all_recipients,
':archive' => $archive
));
// save tags
foreach($tags as $index => $tag){
@@ -940,6 +942,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0;
// Shared mailbox: no login of its own, members open it through group shares
$shared = (isset($_data['shared'])) ? intval($_data['shared']) : 0;
// Archiving comes from the template unless the caller may set it
$archive = \Mailcow\Archive\Model\Mailbox::setting((isset($_data['archive']) && hasACLAccess("archive")) ? $_data['archive'] : ($MAILBOX_DEFAULT_ATTRIBUTES['archive'] ?? 'inherit'));
$quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
$quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
// Validate quarantine_category
@@ -974,6 +978,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
'dav_access' => strval($dav_access),
'relayhost' => strval($relayhost),
'shared' => strval($shared),
'archive' => $archive,
'passwd_update' => time(),
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
'quarantine_notification' => strval($quarantine_notification),
@@ -1525,6 +1530,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
$attr['archive'] = (isset($_data['archive']) && intval($_data['archive']) == 1) ? 1 : 0;
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
@@ -1600,6 +1606,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_tfa']);
$attr["archive"] = \Mailcow\Archive\Model\Mailbox::setting($_data['archive'] ?? $MAILBOX_DEFAULT_ATTRIBUTES['archive']);
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sogo_access']);
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : 1;
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
@@ -2488,6 +2495,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
$description = (!empty($_data['description']) && isset($_SESSION['acl']['domain_desc']) && $_SESSION['acl']['domain_desc'] == "1") ? $_data['description'] : $is_now['description'];
(int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['domain_relayhost']) && $_SESSION['acl']['domain_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['relayhost']);
$archive = (isset($_data['archive']) && hasACLAccess("archive")) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['archive']);
$tags = (is_array($_data['tags']) ? $_data['tags'] : array());
}
else {
@@ -2501,11 +2509,13 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt = $pdo->prepare("UPDATE `domain` SET
`description` = :description,
`gal` = :gal
`gal` = :gal,
`archive` = :archive
WHERE `domain` = :domain");
$stmt->execute(array(
':description' => $description,
':gal' => $gal,
':archive' => $archive,
':domain' => $domain
));
// save tags
@@ -2538,6 +2548,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
$archive = (isset($_data['archive'])) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['archive']);
$relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $is_now['relay_all_recipients'];
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $is_now['relay_unknown_only'];
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : $is_now['relayhost'];
@@ -2652,6 +2663,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`relay_unknown_only` = :relay_unknown_only,
`backupmx` = :backupmx,
`gal` = :gal,
`archive` = :archive,
`active` = :active,
`quota` = :quota,
`defquota` = :defquota,
@@ -2666,6 +2678,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':relay_unknown_only' => $relay_unknown_only,
':backupmx' => $backupmx,
':gal' => $gal,
':archive' => $archive,
':active' => $active,
':quota' => $quota,
':defquota' => $defquota,
@@ -2753,6 +2766,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
$attr['archive'] = (isset($_data['archive'])) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['attributes']['archive'] ?? 0);
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
@@ -2814,6 +2828,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
(int)$eas_access = (isset($_data['eas_access']) && hasACLAccess("protocol_access")) ? intval($_data['eas_access']) : intval($is_now['attributes']['eas_access']);
(int)$dav_access = (isset($_data['dav_access']) && hasACLAccess("protocol_access")) ? intval($_data['dav_access']) : intval($is_now['attributes']['dav_access']);
(int)$relayhost = (isset($_data['relayhost']) && hasACLAccess("mailbox_relayhost")) ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']);
$archive = \Mailcow\Archive\Model\Mailbox::setting((isset($_data['archive']) && hasACLAccess("archive")) ? $_data['archive'] : ($is_now['attributes']['archive'] ?? null));
(int)$shared = (isset($_data['shared'])) ? intval($_data['shared']) : intval($is_now['attributes']['shared'] ?? 0);
(int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576);
$name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
@@ -3114,6 +3129,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access),
`attributes` = JSON_SET(`attributes`, '$.dav_access', :dav_access),
`attributes` = JSON_SET(`attributes`, '$.archive', :archive),
`attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email),
`attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
WHERE `username` = :username");
@@ -3131,6 +3147,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':smtp_access' => $smtp_access,
':eas_access' => $eas_access,
':dav_access' => $dav_access,
':archive' => $archive,
':recovery_email' => $pw_recovery_email,
':relayhost' => $relayhost,
':shared' => strval($shared),
@@ -3534,6 +3551,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : $is_now['rl_value'];
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : $is_now['force_pw_update'];
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : $is_now['force_tfa'];
$attr["archive"] = \Mailcow\Archive\Model\Mailbox::setting($_data['archive'] ?? ($is_now['attributes']['archive'] ?? null));
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : $is_now['sogo_access'];
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : $is_now['active'];
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : $is_now['tls_enforce_in'];
@@ -4682,6 +4700,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`relay_unknown_only`,
`backupmx`,
`gal`,
`archive`,
`active`
FROM `domain` WHERE `domain`= :domain");
$stmt->execute(array(
@@ -4742,6 +4761,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$domaindata['backupmx_int'] = $row['backupmx'];
$domaindata['gal'] = $row['gal'];
$domaindata['gal_int'] = $row['gal'];
$domaindata['archive'] = $row['archive'];
$domaindata['rl'] = $rl;
$domaindata['active'] = $row['active'];
$domaindata['active_int'] = $row['active'];
@@ -4829,6 +4849,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
if (preg_match('/y|yes/i', getenv('MASTER'))) {
$stmt = $pdo->prepare("SELECT
`domain`.`backupmx`,
`domain`.`active` AS `domain_active`,
`domain`.`archive` AS `domain_archive`,
`mailbox`.`username`,
`mailbox`.`name`,
`mailbox`.`active`,
@@ -4851,6 +4873,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
else {
$stmt = $pdo->prepare("SELECT
`domain`.`backupmx`,
`domain`.`active` AS `domain_active`,
`domain`.`archive` AS `domain_archive`,
`mailbox`.`username`,
`mailbox`.`name`,
`mailbox`.`active`,
@@ -4890,6 +4914,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$mailboxdata['created'] = $row['created'];
$mailboxdata['modified'] = $row['modified'];
$mailboxdata['authsource'] = ($row['authsource']) ? $row['authsource'] : 'mailcow';
// Mails of the mailbox go to the archive: global switch, mailbox setting ("inherit" follows the domain), receives mail
static $archive_enabled = null;
if ($archive_enabled === null) {
try {
$archive_enabled = \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled();
}
catch (PDOException $e) {
$archive_enabled = false;
}
}
$mailboxdata['archive'] = ($archive_enabled && $row['active'] != 0 && $row['domain_active'] == 1 && $row['backupmx'] == 0
&& \Mailcow\Archive\Model\Mailbox::effective($mailboxdata['attributes']['archive'] ?? null, $row['domain_archive'])) ? 1 : 0;
if ($mailboxdata['percent_in_use'] === '- ') {
$mailboxdata['percent_class'] = "info";
+13
View File
@@ -154,6 +154,7 @@ function quarantine($_action, $_data = null) {
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
$mail->Body = sprintf($lang['quarantine']['release_body']);
$mail->send();
$released_msg = $mail->getSentMIMEMessage();
unlink($msg_tmpf);
}
catch (phpmailerException $e) {
@@ -170,6 +171,7 @@ function quarantine($_action, $_data = null) {
}
elseif ($release_format == 'raw') {
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
$released_msg = $detail_row['msg'];
// dot-stuffing per RFC5321 4.5.2: escape leading dots
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
$postfix_talk = array(
@@ -213,6 +215,10 @@ function quarantine($_action, $_data = null) {
}
fclose($smtp_connection);
}
// Port 590 bypasses rspamd, so the archive gets released mails here
if (isset($released_msg)) {
\Mailcow\Archive\ArchiveModule::fromGlobals()->journal->submitDelivered($released_msg, $sender, array($detail_row['rcpt']), 'quarantine');
}
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE id = :id");
$stmt->execute(array(
':id' => $row['id']
@@ -453,6 +459,7 @@ function quarantine($_action, $_data = null) {
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
$mail->Body = sprintf($lang['quarantine']['release_body']);
$mail->send();
$released_msg = $mail->getSentMIMEMessage();
unlink($msg_tmpf);
}
catch (phpmailerException $e) {
@@ -467,6 +474,7 @@ function quarantine($_action, $_data = null) {
}
elseif ($release_format == 'raw') {
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
$released_msg = $row['msg'];
// dot-stuffing per RFC5321 4.5.2: escape leading dots
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
$postfix_talk = array(
@@ -506,6 +514,11 @@ function quarantine($_action, $_data = null) {
}
fclose($smtp_connection);
}
// Port 590 bypasses rspamd, so the archive gets released mails here
if (isset($released_msg)) {
\Mailcow\Archive\ArchiveModule::fromGlobals()->journal->submitDelivered($released_msg, $sender, array($row['rcpt']), 'quarantine');
unset($released_msg);
}
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE `id` = :id");
$stmt->execute(array(
':id' => $id
@@ -37,10 +37,10 @@ class ArchiveApiController
return $this->read(fn(): array => $this->status->status());
}
/** GET archive/log */
public function getLog(): array
/** GET logs/archive[/count] */
public function getLog(int $count = 0): array
{
return $this->read(fn(): array => $this->status->log());
return $this->read(fn(): array => $this->status->log($count));
}
/** GET archive/mailboxes */
@@ -72,13 +72,14 @@ class StatusService
}
/**
* GET archive/log: deliveries, expired copies and mails not archived, newest first (admins, read-only keys included).
* GET logs/archive: deliveries, expired copies and mails not archived, newest first (admins, read-only keys included);
* $count limits the entries, 0 keeps the default.
*
* @throws AccessDenied
*/
public function log(): array
public function log(int $count = 0): array
{
$this->context->requireAdmin(false);
return $this->state->events();
return ($count > 0) ? $this->state->events($count) : $this->state->events();
}
}
+92
View File
@@ -0,0 +1,92 @@
// Archive tab of the admin system page: header profile fields and, while archiving is enabled, the state of the archive
// under the switch (one line per problem; the details are in the archive log of the dashboard and in the queue manager).
var mailcowArchive = (function($) {
'use strict';
function L(key) {
return (lang[key] !== undefined) ? lang[key] : key;
}
function esc(s) {
return escapeHtml(s === null || s === undefined ? '' : String(s));
}
function time(ts) {
return ts ? new Date(ts * 1000).toLocaleString(LOCALE, DATETIME_FORMAT) : '-';
}
// lang string with %s placeholders, values escaped
function text(key, values) {
var i = 0;
return esc(L(key)).replace(/%s/g, function() { return esc(values[i++]); });
}
function notice(cls, html) {
return '<div class="alert alert-' + cls + ' py-2 mb-2">' + html + '</div>';
}
var loaded = 0;
function renderStatus(s) {
var html = '';
if (s.enabled && !s.configured) {
html += notice('warning', esc(L('archive_state_unconfigured')));
}
if (s.dropped_24h) {
html += notice('danger', text('archive_state_dropped', [s.dropped_24h, time(s.last_dropped.time), s.last_dropped.reason]));
}
if (s.queued) {
var reasons = $.map(s.delay_reasons || [], function(r) { return '<li class="text-break">' + esc(r) + '</li>'; });
html += notice('warning', text('archive_state_queued', [s.queued, time(s.oldest_queued)]) +
(reasons.length ? '<ul class="mb-0 mt-1 small">' + reasons.join('') + '</ul>' : ''));
}
if (!s.enabled) {
html += notice('secondary', esc(L('archive_state_off')));
} else if (!html) {
html += notice('success', s.last_delivery ? text('archive_state_ok', [time(s.last_delivery)]) : esc(L('archive_state_ok_none')));
}
$('#archive_state').html(html);
}
function load() {
loaded = Date.now();
$.get('/api/v1/get/archive/status', function(s) { renderStatus(s || {}); }, 'json').fail(function() {
$('#archive_state').html(notice('danger', esc(lang_danger.generic_server_error)));
});
}
// fields of the selected header profile only; hidden fields are not posted, so their stored values are kept
function showProfile() {
var profile = $('#archive_profile').val();
$('.archive-profile').each(function() {
var on = $(this).hasClass('archive-profile-' + profile);
$(this).toggleClass('d-none', !on).find(':input').prop('disabled', !on);
});
$('.archive-profile-info').each(function() {
$(this).toggleClass('d-none', !$(this).hasClass('archive-profile-info-' + profile));
});
}
$(document).ready(function() {
$('#archive_profile').on('change', showProfile);
// the tests use the saved settings: not with unsaved changes in the form
$('form[data-id="archive"]').on('input change', ':input', function() {
$('.archive-test').prop('disabled', true);
$('#archive_test_info').text(L('archive_save_first')).removeClass('text-muted').addClass('text-warning');
});
// the state line is only shown while archiving is enabled
var tab = document.getElementById('collapse-tab-archive');
if (!tab || !document.getElementById('archive_state')) return;
// the tab body is at the top: visible as soon as the tab (or on mobile its card) is opened; load again when shown later
new IntersectionObserver(function(entries) {
if (entries.some(function(e) { return e.intersectionRatio > 0; }) && Date.now() - loaded > 10000) load();
}).observe(tab);
$('#archive_refresh').on('click', function(e) {
e.preventDefault();
load();
});
// open the dashboard with the archive log (the dashboard restores the last tab)
$('#archive_log_link').on('click', function() {
try {
localStorage.setItem('lastTag', 'tab-archive-logs');
} catch (e) {}
});
});
return {load: load};
})(jQuery);
+76
View File
@@ -875,6 +875,81 @@ jQuery(function($){
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
});
}
// archive copies from the Postfix log and mails not archived; the Message-ID finds a mail
function draw_archive_logs() {
// just recalc width if instance already exists
if ($.fn.DataTable.isDataTable('#archive_log') ) {
$('#archive_log').DataTable().columns.adjust().responsive.recalc();
return;
}
// softbounce: the archive refused the copy, Postfix keeps it and tries again
var status_class = {sent: 'bg-success', deferred: 'bg-warning text-dark', softbounce: 'bg-danger', bounced: 'bg-danger', expired: 'bg-danger', dropped: 'bg-danger'};
$('#archive_log').DataTable({
responsive: true,
processing: true,
serverSide: false,
stateSave: true,
pageLength: log_pagination_size,
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" +
"tr" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
language: lang_datatables,
order: [[0, 'desc']],
ajax: {
type: "GET",
url: "/api/v1/get/logs/archive",
dataSrc: function(data){
return Array.isArray(data) ? data : [];
}
},
columns: [
{
title: lang.time,
data: 'time',
defaultContent: '',
createdCell: function(td, cellData) {
createSortableDate(td, cellData)
}
},
{
title: lang.archive_delivery,
data: 'status',
defaultContent: '',
render: function(data) {
var label = lang['archive_delivery_' + data] || data;
return '<span class="badge ' + (status_class[data] || 'bg-secondary') + '">' + escapeHtml(label) + '</span>';
}
},
{
title: lang.archive_message_id,
data: 'message_id',
defaultContent: '',
className: 'text-break',
render: function(data) {
return escapeHtml(data);
}
},
{
title: lang.archive_queue_id,
data: 'qid',
defaultContent: '',
render: function(data) {
return escapeHtml(data);
}
},
{
title: lang.archive_details,
data: 'text',
defaultContent: '',
className: 'dtr-col-md text-break',
render: function(data) {
return escapeHtml(data);
}
}
]
});
}
function rspamd_pie_graph() {
$.ajax({
url: '/api/v1/get/rspamd/actions',
@@ -1295,6 +1370,7 @@ jQuery(function($){
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
onVisible("[id^=cron_log]", () => draw_cron_logs());
onVisible("[id^=archive_log]", () => draw_archive_logs());
onVisible("[id^=rspamd_history]", () => draw_rspamd_history());
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
+14
View File
@@ -232,6 +232,7 @@ $(document).ready(function() {
} else {
$('#addDomain_gal').prop('checked', false);
}
$('#addDomain_archive').prop('checked', template.archive == 1);
if (template.active == 1){
$('#addDomain_active').prop('checked', true);
@@ -433,6 +434,7 @@ $(document).ready(function() {
} else {
$('#force_tfa').prop('checked', false);
}
$('#addMailbox_archive').selectpicker('val', ['0', '1'].includes(String(template.archive)) ? String(template.archive) : 'inherit');
if (template.sogo_access == 1){
$('#sogo_access').prop('checked', true);
} else {
@@ -1187,6 +1189,18 @@ jQuery(function($){
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':(0==data?'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>':2==data&&'&#8212;');
}
},
{
title: lang.archive,
data: 'archive',
searchable: false,
defaultContent: '',
responsivePriority: 5,
// not shown while archiving is switched off
className: archive_enabled ? '' : 'never',
render: function (data, type) {
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
}
},
{
title: lang.action,
data: 'action',
+30
View File
@@ -858,6 +858,26 @@ if (isset($_GET['query'])) {
}
break;
case "archive":
switch ($object) {
case "settings":
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings());
break;
case "status":
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getStatus());
break;
case "mailboxes":
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getMailboxes());
break;
default:
process_get_return(null);
break;
}
break;
case "logs":
switch ($object) {
case "dovecot":
@@ -904,6 +924,11 @@ if (isset($_GET['query'])) {
}
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
break;
case "archive":
// deliveries of archive copies and mails not archived, newest first
$logs = \Mailcow\Archive\ArchiveModule::fromGlobals()->api->getLog(isset($extra) ? intval(preg_replace('/[^\d]/', '', $extra)) : 0);
echo (!empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
break;
case "postfix":
// 0 is first record, so empty is fine
if (isset($extra)) {
@@ -1682,6 +1707,8 @@ if (isset($_GET['query'])) {
['db' => 'messages', 'dt' => 20, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],
['db' => 'tags', 'dt' => 23, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],
['db' => 'active', 'dt' => 24],
// archived by the mailbox setting or ('inherit') the domain default
['db' => 'archive', 'dt' => 25, 'dummy' => true, 'order_subquery' => "SELECT IF(JSON_VALUE(`m`.`attributes`, '$.archive') = '1' OR (COALESCE(JSON_VALUE(`m`.`attributes`, '$.archive'), '') NOT IN ('0', '1') AND `domain`.`archive` = '1'), 1, 0) FROM `domain` WHERE `domain`.`domain` = `m`.`domain`"],
];
if($_SESSION['mailcow_cc_role'] === 'admin') {
@@ -1953,6 +1980,9 @@ if (isset($_GET['query'])) {
case "mailbox-group":
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
break;
case "archive":
process_edit_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->edit($attr));
break;
case "recipient_map":
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
break;
+2
View File
@@ -29,6 +29,7 @@
</ul>
</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>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">{{ lang.admin.rspamd_global_filters }}</a>
@@ -47,6 +48,7 @@
{% include 'admin/tab-config-oauth2.twig' %}
{% include 'admin/tab-config-rspamd.twig' %}
{% include 'admin/tab-routing.twig' %}
{% include 'admin/tab-archive.twig' %}
{% include 'admin/tab-config-dkim.twig' %}
{% include 'admin/tab-config-fwdhosts.twig' %}
{% include 'admin/tab-config-f2b.twig' %}
+168
View File
@@ -0,0 +1,168 @@
<div class="tab-pane fade" id="tab-archive" role="tabpanel" aria-labelledby="tab-archive">
<div class="card mb-4">
<div class="card-header d-flex fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-archive" data-bs-toggle="collapse" aria-controls="collapse-tab-archive">
{{ lang.admin.archive }}
</button>
<span class="d-none d-md-block">{{ lang.admin.archive }}</span>
</div>
<div id="collapse-tab-archive" class="card-body collapse" data-bs-parent="#admin-content">
<div class="row">
<p class="offset-md-3 col-12 col-md-9 mb-4">{{ lang.admin.archive_info }}</p>
</div>
<form class="form-horizontal" role="form" data-id="archive" method="post" autocomplete="off">
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_enabled">{{ lang.admin.archive_enabled }}:</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<input type="hidden" name="enabled" value="0">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="archive_enabled" name="enabled" value="1"{% if archive_settings.enabled == 1 %} checked{% endif %}>
</div>
</div>
<div class="offset-md-3 col-12 col-md-9">
<small class="text-muted">{{ lang.admin.archive_enabled_info }}</small>
</div>
</div>
{% if archive_settings.enabled == 1 %}
{# Filled by js/site/archive.js: one line per problem; details in the archive log and the queue manager #}
<div class="row mb-2" id="archive_status">
<div class="offset-md-3 col-12 col-md-9 col-lg-7">
<div id="archive_state"><p class="text-muted mb-2">{{ lang.datatables.loadingRecords }}</p></div>
<small class="d-block">
<a href="/admin/dashboard" id="archive_log_link"><i class="bi bi-journal-text"></i> {{ lang.admin.archive_log }}</a>
<a href="/admin/queue" class="ms-3"><i class="bi bi-inboxes"></i> {{ lang.queue.queue_manager }}</a>
<a href="#" class="ms-3" id="archive_refresh"><i class="bi bi-arrow-clockwise"></i> {{ lang.admin.refresh }}</a>
</small>
</div>
</div>
{% endif %}
<legend class="fs-5 mt-4"><i class="bi bi-plug"></i> {{ lang.admin.archive_section_connection }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_address">{{ lang.admin.archive_address }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<input type="email" class="form-control" id="archive_address" name="address" placeholder="journal@archive.example.com" value="{{ archive_settings.address }}">
</div>
<div class="offset-md-3 col-12 col-md-9">
<small class="text-muted">{{ lang.admin.archive_address_info }}</small>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_host">{{ lang.admin.archive_host }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<div class="input-group">
<input type="text" class="form-control" id="archive_host" name="host" placeholder="archive.example.com" value="{{ archive_settings.host }}">
<span class="input-group-text">{{ lang.admin.archive_port }}</span>
<input type="number" class="form-control" id="archive_port" name="port" min="1" max="65535" style="max-width: 7rem" aria-label="{{ lang.admin.archive_port }}" value="{{ archive_settings.port }}">
</div>
</div>
<div class="offset-md-3 col-12 col-md-9">
<small class="text-muted">{{ lang.admin.archive_host_info }}</small>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_tls">{{ lang.admin.archive_tls }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<select class="full-width-select form-control" id="archive_tls" name="tls">
{% for mode in ['encrypt', 'verify', 'may'] %}
<option value="{{ mode }}"{% if archive_settings.tls == mode %} selected{% endif %}>{{ attribute(lang.admin, 'archive_tls_' ~ mode) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_username">{{ lang.admin.archive_username }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<input type="text" class="form-control" id="archive_username" name="username" autocomplete="off" value="{{ archive_settings.username }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_password">{{ lang.admin.archive_password }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<input type="password" class="form-control" id="archive_password" name="password" autocomplete="new-password"
placeholder="{% if archive_settings.has_password == 1 %}{{ lang.edit.unchanged_if_empty }}{% endif %}">
{% if archive_settings.has_password == 1 %}
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" id="archive_password_remove" name="password_remove" value="1">
<label class="form-check-label" for="archive_password_remove">{{ lang.admin.archive_password_remove }}</label>
</div>
{% endif %}
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-file-earmark-text"></i> {{ lang.admin.archive_section_headers }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_profile">{{ lang.admin.archive_profile }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<select class="full-width-select form-control" id="archive_profile" name="profile">
{% for profile in ['archivecow', 'envelope', 'none'] %}
<option value="{{ profile }}"{% if archive_settings.profile == profile %} selected{% endif %}>{{ attribute(lang.admin, 'archive_profile_' ~ profile) }}</option>
{% endfor %}
</select>
</div>
<div class="offset-md-3 col-12 col-md-9">
{% for profile in ['archivecow', 'envelope', 'none'] %}
<small class="text-muted archive-profile-info archive-profile-info-{{ profile }}{% if archive_settings.profile != profile %} d-none{% endif %}">{{ attribute(lang.admin, 'archive_profile_' ~ profile ~ '_info') }}</small>
{% endfor %}
</div>
</div>
{# Fields of the other profiles are hidden and disabled, so their stored values are kept #}
{% set archivecow = archive_settings.profile == 'archivecow' %}
{% set envelope = archive_settings.profile == 'envelope' %}
<div class="row mb-2 archive-profile archive-profile-archivecow{% if not archivecow %} d-none{% endif %}">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_signing_secret">{{ lang.admin.archive_signing_secret }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<input type="password" class="form-control" id="archive_signing_secret" name="signing_secret" autocomplete="new-password"
placeholder="{% if archive_settings.has_signing_secret == 1 %}{{ lang.edit.unchanged_if_empty }}{% endif %}"{% if not archivecow %} disabled{% endif %}>
</div>
<div class="offset-md-3 col-12 col-md-9">
<small class="text-muted">{{ lang.admin.archive_signing_secret_info }}</small>
</div>
</div>
<div class="row mb-2 archive-profile archive-profile-envelope{% if not envelope %} d-none{% endif %}">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_header_from">{{ lang.admin.archive_header_from }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<input type="text" class="form-control" id="archive_header_from" name="header_from" value="{{ archive_settings.header_from }}"{% if not envelope %} disabled{% endif %}>
</div>
</div>
<div class="row mb-2 archive-profile archive-profile-envelope{% if not envelope %} d-none{% endif %}">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="archive_header_rcpt">{{ lang.admin.archive_header_rcpt }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-5">
<input type="text" class="form-control" id="archive_header_rcpt" name="header_rcpt" value="{{ archive_settings.header_rcpt }}"{% if not envelope %} disabled{% endif %}>
</div>
</div>
<hr class="mt-4">
<div class="row mb-2">
<div class="offset-md-3 col-12 col-md-9">
{% set archive_ready = archive_settings.address and (archive_settings.profile != 'archivecow' or archive_settings.has_signing_secret == 1) %}
<button type="button" class="btn btn-sm d-block d-sm-inline btn-success mb-1" data-action="edit_selected" data-item="archive" data-id="archive" data-api-url='edit/archive' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button type="button" class="btn btn-sm d-block d-sm-inline btn-secondary mb-1 archive-test" data-action="edit_selected" data-item="archive" data-id="archive_test" data-api-url='edit/archive' data-api-attr='{"action":"test"}'{% if not archive_ready %} disabled{% endif %}><i class="bi bi-plug"></i> {{ lang.admin.archive_test }}</button>
<button type="button" class="btn btn-sm d-block d-sm-inline btn-secondary mb-1 archive-test" data-action="edit_selected" data-item="archive" data-id="archive_test" data-api-url='edit/archive' data-api-attr='{"action":"testmail"}'{% if not archive_ready %} disabled{% endif %}><i class="bi bi-envelope"></i> {{ lang.admin.archive_testmail }}</button>
<small class="text-muted d-block" id="archive_test_info">{{ lang.admin.archive_test_info }}</small>
</div>
</div>
</form>
</div>
</div>
</div>
+16
View File
@@ -17,6 +17,7 @@
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-logs" aria-selected="false" aria-controls="tab-api-logs" role="tab" data-bs-toggle="tab">API</button></li>
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-rl" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Ratelimits</button></li>
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-cron-logs" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Crontasks</button></li>
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-archive-logs" aria-selected="false" aria-controls="tab-archive-logs" role="tab" data-bs-toggle="tab">{{ lang.admin.archive }}</button></li>
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.external_logs }}</span></li>
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-rspamd-history" aria-selected="false" aria-controls="tab-rspamd-history" role="tab" data-bs-toggle="tab">Rspamd</button></li>
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.static_logs }}</span></li>
@@ -547,6 +548,21 @@
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tab-archive-logs">
<div class="debug-log-info">{{ lang.admin.archive_log_info }}</div>
<div class="card">
<div class="card-header d-flex align-items-center fs-5">
<span class="mt-2 ms-2">{{ lang.admin.archive }}</span>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-secondary refresh_table" data-table="archive_log">{{ lang.admin.refresh }}</button>
</div>
</div>
<div class="card-body">
<table id="archive_log" class="table table-striped dt-responsive w-100"></table>
</div>
</div>
</div>
</div> <!-- /tab-content -->
</div> <!-- /col-md-12 -->
</div> <!-- /row -->
@@ -8,6 +8,7 @@
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="backupmx">
<input type="hidden" value="0" name="gal">
<input type="hidden" value="0" name="archive">
<input type="hidden" value="0" name="relay_all_recipients">
<input type="hidden" value="0" name="relay_unknown_only">
@@ -70,6 +71,14 @@
</div>
</div>
</div>
<div class="row{% if not archive_enabled %} d-none{% endif %}">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="archive"{% if template.attributes.archive == '1' %} checked{% endif %}> {{ lang.edit.archive_domain }}</label>
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
</div>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
+9
View File
@@ -25,6 +25,7 @@
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="backupmx">
<input type="hidden" value="0" name="gal">
<input type="hidden" value="0" name="archive">
<input type="hidden" value="0" name="relay_all_recipients">
<input type="hidden" value="0" name="relay_unknown_only">
<div class="row mb-4">
@@ -133,6 +134,14 @@
</div>
</div>
</div>
<div class="row{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="archive"{% if result.archive == '1' %} checked{% endif %}{% if acl.archive != 1 %} disabled{% endif %}> {{ lang.edit.archive_domain }}</label>
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
</div>
</div>
</div>
<hr>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
@@ -114,6 +114,17 @@
</select>
</div>
</div>
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}">
<label class="control-label col-sm-2" for="archive">{{ lang.edit.archive }}</label>
<div class="col-sm-10">
<select class="form-control" name="archive" id="archive">
<option value="inherit"{% if template.attributes.archive not in ['0', '1'] %} selected{% endif %}>{{ lang.edit.archive_inherit }}</option>
<option value="1"{% if template.attributes.archive == '1' %} selected{% endif %}>{{ lang.edit.archive_on }}</option>
<option value="0"{% if template.attributes.archive == '0' %} selected{% endif %}>{{ lang.edit.archive_off }}</option>
</select>
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2">ACL</label>
<div class="col-sm-10">
+13
View File
@@ -331,6 +331,19 @@
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
</div>
</div>
<div class="row mt-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
<label class="control-label col-sm-2" for="archive">{{ lang.edit.archive }}
<br><span class="badge {% if result.archive == 1 %}bg-success{% else %}bg-secondary{% endif %}">{% if result.archive == 1 %}{{ lang.edit.archive_active }}{% else %}{{ lang.edit.archive_inactive }}{% endif %}</span>
</label>
<div class="col-sm-10">
<select class="form-control" name="archive" id="archive"{% if acl.archive != 1 %} disabled{% endif %}>
<option value="inherit"{% if result.attributes.archive not in ['0', '1'] %} selected{% endif %}>{{ lang.edit.archive_inherit }}</option>
<option value="1"{% if result.attributes.archive == '1' %} selected{% endif %}>{{ lang.edit.archive_on }}</option>
<option value="0"{% if result.attributes.archive == '0' %} selected{% endif %}>{{ lang.edit.archive_off }}</option>
</select>
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
</div>
</div>
<hr>
<div class="row">
<div class="offset-sm-2 col-sm-10">
+1
View File
@@ -81,6 +81,7 @@
var csrf_token = '{{ csrf_token }}';
var pagination_size = Math.trunc('{{ pagination_size }}');
var role = '{{ role }}';
var archive_enabled = {{ archive_enabled ? 1 : 0 }};
var is_dual = {{ is_dual }};
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
</script>
+40 -1
View File
@@ -39,7 +39,7 @@
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
<div class="col-sm-10">
<select data-live-search="true" id="mailbox_templates" class="form-control" title="{{ lang.mailbox.template }}">
<select data-live-search="true" id="mailbox_templates" name="template" class="form-control" title="{{ lang.mailbox.template }}">
</select>
</div>
</div>
@@ -154,6 +154,17 @@
</select>
</div>
</div>
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
<label class="control-label col-sm-2 text-sm-end" for="addMailbox_archive">{{ lang.edit.archive }}</label>
<div class="col-sm-10">
<select class="form-control" name="archive" id="addMailbox_archive"{% if acl.archive != 1 %} disabled{% endif %}>
<option value="inherit" selected>{{ lang.edit.archive_inherit }}</option>
<option value="1">{{ lang.edit.archive_on }}</option>
<option value="0">{{ lang.edit.archive_off }}</option>
</select>
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
<div class="col-sm-10">
@@ -360,6 +371,17 @@
</select>
</div>
</div>
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}">
<label class="control-label col-sm-2 text-sm-end" for="addMailboxTemplate_archive">{{ lang.edit.archive }}</label>
<div class="col-sm-10">
<select class="form-control" name="archive" id="addMailboxTemplate_archive">
<option value="inherit" selected>{{ lang.edit.archive_inherit }}</option>
<option value="1">{{ lang.edit.archive_on }}</option>
<option value="0">{{ lang.edit.archive_off }}</option>
</select>
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
<div class="col-sm-10">
@@ -451,6 +473,7 @@
<div class="modal-body">
<form class="form-horizontal" data-cached-form="true" data-id="add_domain" role="form">
<input type="hidden" value="0" name="gal">
<input type="hidden" value="0" name="archive">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="backupmx">
<input type="hidden" value="0" name="relay_all_recipients">
@@ -525,6 +548,14 @@
</div>
</div>
{% endif %}
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="archive" id="addDomain_archive"> {{ lang.edit.archive_domain }}</label>
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
@@ -664,6 +695,14 @@
</div>
</div>
</div>
<div class="row{% if not archive_enabled %} d-none{% endif %}">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="archive"> {{ lang.edit.archive_domain }}</label>
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
</div>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
@@ -99,6 +99,7 @@ class StatusServiceTest extends TestCase
['time' => 1700000280, 'status' => 'softbounce', 'qid' => 'A2', 'message_id' => 'm2@firma.de',
'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'],
], array_slice($log, 1));
$this->assertCount(2, $this->service()->log(2));
}
public function testOnlyAdmins(): void