diff --git a/data/conf/phpfpm/crons/identity-sync.php b/data/conf/phpfpm/crons/identity-sync.php index 11c55bff2..7483b3103 100644 --- a/data/conf/phpfpm/crons/identity-sync.php +++ b/data/conf/phpfpm/crons/identity-sync.php @@ -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'); diff --git a/data/web/admin/mailbox.php b/data/web/admin/mailbox.php index 05530e99e..4a1690bf4 100644 --- a/data/web/admin/mailbox.php +++ b/data/web/admin/mailbox.php @@ -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'; diff --git a/data/web/admin/system.php b/data/web/admin/system.php index 8353a8d2d..ed072ae12 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -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', diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index b08747722..a8f4e285a 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -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 `/` 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. diff --git a/data/web/domainadmin/mailbox.php b/data/web/domainadmin/mailbox.php index 4b99579a0..020f84384 100644 --- a/data/web/domainadmin/mailbox.php +++ b/data/web/domainadmin/mailbox.php @@ -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'; \ No newline at end of file diff --git a/data/web/edit.php b/data/web/edit.php index 51d88a12d..67b188319 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -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(), ]; } } diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 0c05293c4..c304e1b7c 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -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(); } } diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 121d43a0c..075700804 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -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"; diff --git a/data/web/inc/functions.quarantine.inc.php b/data/web/inc/functions.quarantine.inc.php index 55674e528..f45e057e5 100644 --- a/data/web/inc/functions.quarantine.inc.php +++ b/data/web/inc/functions.quarantine.inc.php @@ -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 diff --git a/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php b/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php index 420fc12df..201805a2c 100644 --- a/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php +++ b/data/web/inc/lib/mailcow/Archive/Controller/ArchiveApiController.php @@ -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 */ diff --git a/data/web/inc/lib/mailcow/Archive/Service/StatusService.php b/data/web/inc/lib/mailcow/Archive/Service/StatusService.php index eb97a26bc..2439db9a9 100644 --- a/data/web/inc/lib/mailcow/Archive/Service/StatusService.php +++ b/data/web/inc/lib/mailcow/Archive/Service/StatusService.php @@ -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(); } } diff --git a/data/web/js/site/archive.js b/data/web/js/site/archive.js new file mode 100644 index 000000000..3d830d0f1 --- /dev/null +++ b/data/web/js/site/archive.js @@ -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 '
' + html + '
'; + } + 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 '
  • ' + esc(r) + '
  • '; }); + html += notice('warning', text('archive_state_queued', [s.queued, time(s.oldest_queued)]) + + (reasons.length ? '' : '')); + } + 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); diff --git a/data/web/js/site/dashboard.js b/data/web/js/site/dashboard.js index 7c077e27b..f00b9ccd8 100644 --- a/data/web/js/site/dashboard.js +++ b/data/web/js/site/dashboard.js @@ -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 '' + escapeHtml(label) + ''; + } + }, + { + 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()); diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 32de2a8d1..596bacbfd 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -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?'1':(0==data?'0':2==data&&'—'); } }, + { + 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?'1':'0'; + } + }, { title: lang.action, data: 'action', diff --git a/data/web/json_api.php b/data/web/json_api.php index b059ae8be..8757f16c4 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -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; diff --git a/data/web/templates/admin.twig b/data/web/templates/admin.twig index 0c487c4ea..5b53656d3 100644 --- a/data/web/templates/admin.twig +++ b/data/web/templates/admin.twig @@ -29,6 +29,7 @@ +
  • +
  • {{ lang.debug.external_logs }}
  • {{ lang.debug.static_logs }}
  • @@ -547,6 +548,21 @@ +
    +
    {{ lang.admin.archive_log_info }}
    +
    +
    + {{ lang.admin.archive }} +
    + +
    +
    +
    +
    +
    +
    +
    + diff --git a/data/web/templates/edit/domain-templates.twig b/data/web/templates/edit/domain-templates.twig index d4612a198..0fe3c8276 100644 --- a/data/web/templates/edit/domain-templates.twig +++ b/data/web/templates/edit/domain-templates.twig @@ -8,6 +8,7 @@ + @@ -70,6 +71,14 @@ +
    +
    +
    + + {{ lang.edit.archive_domain_info }} +
    +
    +
    diff --git a/data/web/templates/edit/domain.twig b/data/web/templates/edit/domain.twig index 45ec654ce..d6da53108 100644 --- a/data/web/templates/edit/domain.twig +++ b/data/web/templates/edit/domain.twig @@ -25,6 +25,7 @@ +
    @@ -133,6 +134,14 @@
    +
    +
    +
    + + {{ lang.edit.archive_domain_info }} +
    +
    +

    diff --git a/data/web/templates/edit/mailbox-templates.twig b/data/web/templates/edit/mailbox-templates.twig index 8384fa053..f8efaff92 100644 --- a/data/web/templates/edit/mailbox-templates.twig +++ b/data/web/templates/edit/mailbox-templates.twig @@ -114,6 +114,17 @@
    +
    + +
    + + {{ lang.edit.archive_info }} +
    +
    diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index 9f56ac56c..0b6325172 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -331,6 +331,19 @@ {{ lang.edit.allow_from_smtp_info }}
    +
    + +
    + + {{ lang.edit.archive_info }} +
    +

    diff --git a/data/web/templates/mailbox.twig b/data/web/templates/mailbox.twig index 6a6cbe58d..d54cacad5 100644 --- a/data/web/templates/mailbox.twig +++ b/data/web/templates/mailbox.twig @@ -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 }}; diff --git a/data/web/templates/modals/mailbox.twig b/data/web/templates/modals/mailbox.twig index 0a886957c..a3e962d60 100644 --- a/data/web/templates/modals/mailbox.twig +++ b/data/web/templates/modals/mailbox.twig @@ -39,7 +39,7 @@
    -
    @@ -154,6 +154,17 @@
    +
    + +
    + + {{ lang.edit.archive_info }} +
    +
    @@ -360,6 +371,17 @@
    +
    + +
    + + {{ lang.edit.archive_info }} +
    +
    @@ -451,6 +473,7 @@
    {% endif %} +
    +
    +
    + + {{ lang.edit.archive_domain_info }} +
    +
    +
    @@ -664,6 +695,14 @@
    +
    +
    +
    + + {{ lang.edit.archive_domain_info }} +
    +
    +
    diff --git a/tests/php/Archive/Service/StatusServiceTest.php b/tests/php/Archive/Service/StatusServiceTest.php index 0753b8f10..f7458d6e7 100644 --- a/tests/php/Archive/Service/StatusServiceTest.php +++ b/tests/php/Archive/Service/StatusServiceTest.php @@ -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