From 7970447feabbdd3a01973b42237dedb41e79aa97 Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Fri, 26 Jun 2026 12:04:41 +0200 Subject: [PATCH 1/3] feat: server-wide default quarantine notification settings + modern template Add an admin-configurable, server-wide default for the quarantine notification interval and category. Previously these two per-mailbox settings could only fall back to compile-time values in vars.inc.php and there was no way to set a default from the UI or apply one to existing mailboxes. - Store defaults in Redis (Q_DEF_NOTIFICATION, Q_DEF_CATEGORY) via the existing quarantine settings handler, alongside the other Q_* keys. - New mailboxes inherit the global default through a new get_quarantine_default() helper (Redis -> vars.inc.php fallback). Per-mailbox user overrides keep working unchanged. - New admin action "apply_existing" force-applies the default to all existing mailboxes (admin only, confirm dialog in the UI). - Add two dropdowns and an apply button to the admin quarantine settings tab plus the corresponding language strings. - Rewrite the notification email template (data/assets/templates/ quarantine.tpl) as a clean, mobile-first, responsive, dark-mode aware layout. All Jinja2 variables, HTML escaping and qhandler links are preserved; only installs with an empty custom template are affected. No DB schema change, no migration, no Docker image rebuild. --- data/assets/templates/quarantine.tpl | 232 +++++++++++++----- data/web/inc/functions.mailbox.inc.php | 33 ++- data/web/inc/functions.quarantine.inc.php | 63 ++++- data/web/lang/lang.en-gb.json | 6 + .../admin/tab-config-quarantine.twig | 28 +++ 5 files changed, 291 insertions(+), 71 deletions(-) diff --git a/data/assets/templates/quarantine.tpl b/data/assets/templates/quarantine.tpl index 8fa88c54b..eb517cc94 100644 --- a/data/assets/templates/quarantine.tpl +++ b/data/assets/templates/quarantine.tpl @@ -1,69 +1,173 @@ - - - + + + + + + + + + + Quarantine notification + - - -

Hi {{username}}!
- {% if counter == 1 %} - There is 1 new message waiting in quarantine:
- {% else %} - There are {{counter}} new messages waiting in quarantine:
- {% endif %} - - {% if quarantine_acl == 1 %}{% endif %} - {% for line in meta|reverse %} + + +
+ {% if counter == 1 %}1 new message is waiting in your quarantine.{% else %}{{ counter }} new messages are waiting in your quarantine.{% endif %} +  ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ +
+ +
SubjectSenderScoreActionArrived onActions
- - - - {% if line.action == "reject" %} - - {% else %} - - {% endif %} - - {% if quarantine_acl == 1 %} - {% if line.action == "reject" %} - - {% else %} - - {% endif %} - {% endif %} + - {% endfor %} - -

- + + diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index a9f17705d..6354b053e 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -1,4 +1,29 @@ compile-time default ($MAILBOX_DEFAULT_ATTRIBUTES in vars.inc.php). +// $key is either 'notification' or 'category'. +function get_quarantine_default($key) { + global $redis, $MAILBOX_DEFAULT_ATTRIBUTES; + $redis_keys = array( + 'notification' => 'Q_DEF_NOTIFICATION', + 'category' => 'Q_DEF_CATEGORY' + ); + $attr_keys = array( + 'notification' => 'quarantine_notification', + 'category' => 'quarantine_category' + ); + if (!isset($redis_keys[$key])) { + return null; + } + try { + $value = $redis->Get($redis_keys[$key]); + } + catch (RedisException $e) { + $value = false; + } + return ($value !== false && $value !== '') ? $value : $MAILBOX_DEFAULT_ATTRIBUTES[$attr_keys[$key]]; +} function mailbox($_action, $_type, $_data = null, $_extra = null) { global $pdo; global $redis; @@ -1109,8 +1134,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $eas_access = (isset($_data['eas_access'])) ? intval($_data['eas_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['eas_access']); $dav_access = (isset($_data['dav_access'])) ? intval($_data['dav_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['dav_access']); $relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0; - $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']); + $quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval(get_quarantine_default('notification')); + $quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval(get_quarantine_default('category')); // Validate quarantine_category if (!in_array($quarantine_category, array('add_header', 'reject', 'all'))) { $_SESSION['return'][] = array( @@ -1740,8 +1765,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $attr["quota"] = isset($_data['quota']) ? intval($_data['quota']) * 1048576 : 0; $attr['tags'] = (isset($_data['tags'])) ? $_data['tags'] : array(); $attr["tagged_mail_handler"] = (!empty($_data['tagged_mail_handler'])) ? $_data['tagged_mail_handler'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['tagged_mail_handler']); - $attr["quarantine_notification"] = (!empty($_data['quarantine_notification'])) ? $_data['quarantine_notification'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']); - $attr["quarantine_category"] = (!empty($_data['quarantine_category'])) ? $_data['quarantine_category'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']); + $attr["quarantine_notification"] = (!empty($_data['quarantine_notification'])) ? $_data['quarantine_notification'] : strval(get_quarantine_default('notification')); + $attr["quarantine_category"] = (!empty($_data['quarantine_category'])) ? $_data['quarantine_category'] : strval(get_quarantine_default('category')); // Validate quarantine_category if (!in_array($attr["quarantine_category"], array('add_header', 'reject', 'all'))) { $_SESSION['return'][] = array( diff --git a/data/web/inc/functions.quarantine.inc.php b/data/web/inc/functions.quarantine.inc.php index 5df86ef33..acbd493dc 100644 --- a/data/web/inc/functions.quarantine.inc.php +++ b/data/web/inc/functions.quarantine.inc.php @@ -6,6 +6,7 @@ function quarantine($_action, $_data = null) { global $pdo; global $redis; global $lang; + global $MAILBOX_DEFAULT_ATTRIBUTES; $_data_log = $_data; switch ($_action) { case 'quick_delete': @@ -22,7 +23,7 @@ function quarantine($_action, $_data = null) { return false; } $stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt` - WHERE `qhash` = :hash + WHERE `qhash` = :hash AND user_acl.quarantine = 1 AND rcpt IN (SELECT username FROM mailbox)'); $stmt->execute(array(':hash' => $hash)); @@ -65,7 +66,7 @@ function quarantine($_action, $_data = null) { return false; } $stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt` - WHERE `qhash` = :hash + WHERE `qhash` = :hash AND `user_acl`.`quarantine` = 1 AND `username` IN (SELECT `username` FROM `mailbox`)'); $stmt->execute(array(':hash' => $hash)); @@ -331,6 +332,9 @@ function quarantine($_action, $_data = null) { $max_age = 365; } $exclude_domains = (array)$_data['exclude_domains']; + // Server-wide default quarantine notification settings for new mailboxes + $def_notification = (in_array($_data['def_notification'], array('never', 'hourly', 'daily', 'weekly'))) ? $_data['def_notification'] : 'never'; + $def_category = (in_array($_data['def_category'], array('add_header', 'reject', 'all'))) ? $_data['def_category'] : 'reject'; try { $redis->Set('Q_RETENTION_SIZE', intval($retention_size)); $redis->Set('Q_MAX_SIZE', intval($max_size)); @@ -343,6 +347,8 @@ function quarantine($_action, $_data = null) { $redis->Set('Q_REDIRECT', $redirect); $redis->Set('Q_SUBJ', $subject); $redis->Set('Q_HTML', $html); + $redis->Set('Q_DEF_NOTIFICATION', $def_notification); + $redis->Set('Q_DEF_CATEGORY', $def_category); } catch (RedisException $e) { $_SESSION['return'][] = array( @@ -358,6 +364,55 @@ function quarantine($_action, $_data = null) { 'msg' => 'saved_settings' ); } + // Force the server-wide default onto all existing mailboxes (overwrites user overrides) + elseif ($_data['action'] == 'apply_existing') { + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + // Resolve the default to apply: prefer the values submitted with the form + // (the settings form is merged into this request), fall back to the stored default. + $def_notification = (in_array($_data['def_notification'], array('never', 'hourly', 'daily', 'weekly'))) ? $_data['def_notification'] : get_quarantine_default('notification'); + $def_category = (in_array($_data['def_category'], array('add_header', 'reject', 'all'))) ? $_data['def_category'] : get_quarantine_default('category'); + try { + // Keep the stored default in sync with what we are forcing onto mailboxes + $redis->Set('Q_DEF_NOTIFICATION', $def_notification); + $redis->Set('Q_DEF_CATEGORY', $def_category); + $stmt = $pdo->prepare("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, + '$.quarantine_notification', :quarantine_notification, + '$.quarantine_category', :quarantine_category)"); + $stmt->execute(array( + ':quarantine_notification' => $def_notification, + ':quarantine_category' => $def_category + )); + $affected = $stmt->rowCount(); + } + catch (RedisException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('redis_error', $e) + ); + return false; + } + catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('mysql_error', $e) + ); + return false; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('quarantine_defaults_applied', $affected) + ); + } // Release item elseif ($_data['action'] == 'release' || $_data['action'] == 'learnham') { if (!is_array($_data['id'])) { @@ -791,6 +846,8 @@ function quarantine($_action, $_data = null) { if (empty($settings['html_tmpl'])) { $settings['html_tmpl'] = htmlspecialchars(file_get_contents("/tpls/quarantine.tpl")); } + $settings['def_notification'] = $redis->Get('Q_DEF_NOTIFICATION') ?: $MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']; + $settings['def_category'] = $redis->Get('Q_DEF_CATEGORY') ?: $MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']; } catch (RedisException $e) { $_SESSION['return'][] = array( @@ -833,7 +890,7 @@ function quarantine($_action, $_data = null) { ))); return false; } - $stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash'); + $stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash'); $stmt->execute(array(':hash' => $hash)); return $stmt->fetch(PDO::FETCH_ASSOC); break; diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index d2412fbf7..b4be0b383 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -315,6 +315,11 @@ "private_key": "Private key", "quarantine": "Quarantine", "quarantine_bcc": "Send a copy of all notifications (BCC) to this recipient:
Leave empty to disable. Unsigned, unchecked mail. Should be delivered internally only.", + "quarantine_defaults_apply": "Apply default to all existing mailboxes", + "quarantine_defaults_apply_confirm": "This will overwrite the quarantine notification interval and category of ALL existing mailboxes with the default selected above. User overrides will be lost. Continue?", + "quarantine_defaults_apply_info": "Applies the default to all existing mailboxes and overwrites individual user settings. New mailboxes always inherit the default above.", + "quarantine_defaults_category": "Default notification category for new mailboxes", + "quarantine_defaults_notification": "Default notification interval for new mailboxes", "quarantine_exclude_domains": "Exclude domains and alias-domains", "quarantine_max_age": "Maximum age in days
Value must be equal to or greater than 1 day.", "quarantine_max_score": "Discard notification if spam score of a mail is higher than this value:
Defaults to 9999.0", @@ -1190,6 +1195,7 @@ "pushover_settings_edited": "Pushover settings successfully set, please verify credentials.", "qlearn_spam": "Message ID %s was learned as spam and deleted", "queue_command_success": "Queue command completed successfully", + "quarantine_defaults_applied": "Default quarantine notification settings applied to %s existing mailbox(es)", "recipient_map_entry_deleted": "Recipient map ID %s has been deleted", "recipient_map_entry_saved": "Recipient map entry \"%s\" has been saved", "recovery_email_sent": "Recovery email sent to %s", diff --git a/data/web/templates/admin/tab-config-quarantine.twig b/data/web/templates/admin/tab-config-quarantine.twig index be2d59a51..ce17d9aa8 100644 --- a/data/web/templates/admin/tab-config-quarantine.twig +++ b/data/web/templates/admin/tab-config-quarantine.twig @@ -62,6 +62,34 @@
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +

{{ lang.admin.quarantine_defaults_apply_info|raw }}

+
+
+
From aeec34bcfbcbfd9050e3764986071b9bb2e9aaae Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Mon, 29 Jun 2026 10:26:27 +0200 Subject: [PATCH 2/3] style: neutralize quarantine notification template colors Make the default notification email look more professional and less playful by removing the bright-green chrome: - Header: solid green bar -> white header with dark slate title and a subtle bottom divider, merged flush with the body as one card. - Primary action button (release / send copy): green -> dark slate, with a lighter-slate dark-mode override. Semantic status badges (rejected = red, junk = amber) and all Jinja2 variables, escaping, qhandler links, dark-mode and mobile rules are unchanged. --- data/assets/templates/quarantine.tpl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/data/assets/templates/quarantine.tpl b/data/assets/templates/quarantine.tpl index eb517cc94..8442809fe 100644 --- a/data/assets/templates/quarantine.tpl +++ b/data/assets/templates/quarantine.tpl @@ -42,6 +42,7 @@ .border-card { border-color: #343842 !important; } .divider { border-color: #343842 !important; } .btn-secondary a { color: #e7e9ee !important; border-color: #4a4f5a !important; } + .btn-primary-cell { background-color: #3a4350 !important; } } @@ -58,11 +59,11 @@ - + - @@ -125,7 +126,7 @@ -
- + + ✉  Quarantine notification - From 8dc5d159453057d0b25b84390e447bee4399d95c Mon Sep 17 00:00:00 2001 From: Kilian Ries Date: Mon, 29 Jun 2026 13:02:38 +0200 Subject: [PATCH 3/3] style: make delete button match the release button style Replace the outlined delete button with a clean filled light-grey button (dark slate text) so it visually matches the filled "Release to inbox" button while staying clearly secondary. Dark-mode override added. --- data/assets/templates/quarantine.tpl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/data/assets/templates/quarantine.tpl b/data/assets/templates/quarantine.tpl index 8442809fe..d6ff0c58c 100644 --- a/data/assets/templates/quarantine.tpl +++ b/data/assets/templates/quarantine.tpl @@ -41,8 +41,9 @@ .text-muted { color: #a4a9b4 !important; } .border-card { border-color: #343842 !important; } .divider { border-color: #343842 !important; } - .btn-secondary a { color: #e7e9ee !important; border-color: #4a4f5a !important; } .btn-primary-cell { background-color: #3a4350 !important; } + .btn-secondary-cell { background-color: #2c313a !important; } + .btn-secondary-cell a { color: #e7e9ee !important; } } @@ -132,11 +133,11 @@
+ {% if line.action == "reject" %}Release to inbox{% else %}Send copy to inbox{% endif %}
+ -
- Delete + + Delete