From 9f934af3f2a039925d7e10bb5b38d9ffe0c2855a Mon Sep 17 00:00:00 2001 From: Joris Le Blansch Date: Sun, 27 Sep 2026 21:20:23 +0200 Subject: [PATCH 01/33] [Rspamd] write full DBL rule when SPAMHAUS_DQS_KEY is set The generated "DBL" block only set rbl and disable_monitoring, assuming it would merge into the stock rule. It is included with override=true, so it replaced the stock rule instead: DBL lost all its checks (urls, emails, dkim, helo, rdns, replyto), no_ip, selectors and returncodes, and rspamd fell back to querying the client IP against DBL. Write the full stock rule pointed at the DQS zone, like the ZRD block. Fixes #7485 Co-Authored-By: Claude Opus 5.5 --- data/Dockerfiles/rspamd/docker-entrypoint.sh | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/data/Dockerfiles/rspamd/docker-entrypoint.sh b/data/Dockerfiles/rspamd/docker-entrypoint.sh index a764725cb..0198a3969 100755 --- a/data/Dockerfiles/rspamd/docker-entrypoint.sh +++ b/data/Dockerfiles/rspamd/docker-entrypoint.sh @@ -277,9 +277,34 @@ if [[ ! -z ${SPAMHAUS_DQS_KEY} ]]; then } } "DBL" { - # override the defaults for DBL defined in modules.d/rbl.conf + # full copy of the DBL rule from modules.d/rbl.conf, pointed at DQS: + # the override include replaces the stock rule instead of merging into it rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net"; disable_monitoring = true; + checks = ["emails", "dkim", "helo", "rdns", "replyto", "urls", "content_urls"]; + no_ip = true; + ignore_defaults = true; + exclude_users = false; + emails_domainonly = true; + selector = { + from_smtp = "from('smtp'):domain"; + from_mime = "from('mime'):domain"; + mid = "header(Message-Id).regexp('@([^\.]+\.[^>]+)').last"; + } + returncodes = { + DBL_SPAM = "127.0.1.2"; + DBL_PHISH = "127.0.1.4"; + DBL_MALWARE = "127.0.1.5"; + DBL_BOTNET = "127.0.1.6"; + DBL_ABUSE = "127.0.1.102"; + DBL_ABUSE_REDIR = "127.0.1.103"; + DBL_ABUSE_PHISH = "127.0.1.104"; + DBL_ABUSE_MALWARE = "127.0.1.105"; + DBL_ABUSE_BOTNET = "127.0.1.106"; + DBL_PROHIBIT = "127.0.1.255"; + DBL_BLOCKED_OPENRESOLVER = "127.255.255.254"; + DBL_BLOCKED = "127.255.255.255"; + } } "ZRD" { ignore_defaults = true; From 7b658fa65c0125a23d401d03557fdf11a5b0278c Mon Sep 17 00:00:00 2001 From: Philipp Harms Date: Fri, 2 Oct 2026 18:45:20 +0200 Subject: [PATCH 02/33] [Update] Fix update.sh deleting mailcow's own MASQUERADE rule The "Checking for conflicting bridges" step extracts the bridge name with `cut -d':' -f2`, which yields " br-mailcow" (leading space). Since the variable got quoted in `grep -v "${MAILCOW_BRIDGE}"`, the leading space is significant and never matches "!br-mailcow" in the `iptables -L` output, so the rule `-s .0/24 ! -o br-mailcow -j MASQUERADE` itself gets deleted. The mailcow network loses outbound NAT, unbound turns unhealthy and dependent containers stay in "Created" until Docker is restarted. Strip whitespace/quotes from the bridge name, match the interface as a whole token, and skip the cleanup if the bridge name could not be determined. --- update.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/update.sh b/update.sh index 3e53d2134..02d204db7 100755 --- a/update.sh +++ b/update.sh @@ -374,10 +374,12 @@ if ! $COMPOSE_COMMAND config -q; then fi echo -e "\e[32mChecking for conflicting bridges...\e[0m" -MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2) -while read NAT_ID; do - iptables -t nat -D POSTROUTING "$NAT_ID" -done < <(iptables -L -vn -t nat --line-numbers | grep "$IPV4_NETWORK" | grep -E 'MASQUERADE.*all' | grep -v "${MAILCOW_BRIDGE}" | cut -d' ' -f1) +MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2 | tr -d "[:space:]\"'") +if [[ -n "${MAILCOW_BRIDGE}" ]]; then + while read NAT_ID; do + iptables -t nat -D POSTROUTING "$NAT_ID" + done < <(iptables -L -vn -t nat --line-numbers | grep "$IPV4_NETWORK" | grep -E 'MASQUERADE.*all' | grep -vE "[[:space:]]!?${MAILCOW_BRIDGE}[[:space:]]" | cut -d' ' -f1) +fi DIFF_DIRECTORY=update_diffs DIFF_FILE=${DIFF_DIRECTORY}/diff_before_update_$(date +"%Y-%m-%d-%H-%M-%S") From d93f32072ed4322974702cb2fbd250932264a1a9 Mon Sep 17 00:00:00 2001 From: Bilal Zahid Date: Tue, 13 Oct 2026 19:25:17 +0500 Subject: [PATCH 03/33] fix(web): post WebAuthn TFA enrollment through the JSON API The forced TFA enrollment form submitted the raw credential JSON in the 'token' field, which set_tfa()'s webauthn case never reads. It expects the parsed \['registration'] object that only the JSON API builds, so the form POST inserted a row with an empty keyHandle and a NULL publicKey while still reporting success. Every WebAuthn 2FA login then failed, because webauthn-tfa-get-args sent an empty credential id in allowCredentials. - base.twig: POST the attestation to /api/v1/add/webauthn-tfa-registration like the regular WebAuthn modal does - functions.inc.php: refuse incomplete WebAuthn registrations - lang.en-gb.json: message for the new guard --- data/web/inc/functions.inc.php | 12 ++++++++++++ data/web/lang/lang.en-gb.json | 1 + data/web/templates/base.twig | 28 +++++++++++++++++----------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 09e8ec193..ec64e60b8 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1389,6 +1389,18 @@ function set_tfa($_data) { case "webauthn": $key_id = (!isset($_data["key_id"])) ? 'unidentified' : $_data["key_id"]; + // Refuse incomplete WebAuthn registrations. Without this guard a request + // that does not contain the parsed credential data (e.g. a plain form + // POST) would silently insert an empty keyHandle while reporting success. + if (empty($_data['registration']) || empty($_data['registration']->credentialId)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_data_log), + 'msg' => 'webauthn_registration_failed' + ); + return false; + } + $stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `keyHandle`, `publicKey`, `certificate`, `counter`, `active`) VALUES (?, ?, 'webauthn', ?, ?, ?, ?, '1')"); $stmt->execute(array( diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index c958e68eb..6ce823841 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -1249,6 +1249,7 @@ "u2f_deprecated": "It seems that your Key was registered using the deprecated U2F method. We will deactivate Two-Factor-Authenticaiton for you and delete your Key.", "u2f_deprecated_important": "Please register your Key in the admin panel with the new WebAuthn method.", "webauthn": "WebAuthn authentication", + "webauthn_registration_failed": "WebAuthn registration failed. The credential could not be verified, please try again.", "waiting_usb_auth": "Waiting for USB device...

Please tap the button on your USB device now.", "waiting_usb_register": "Waiting for USB device...

Please enter your password above and confirm your registration by tapping the button on your USB device.", "yubi_otp": "Yubico OTP authentication" diff --git a/data/web/templates/base.twig b/data/web/templates/base.twig index e0f3b5b35..3c01461f3 100644 --- a/data/web/templates/base.twig +++ b/data/web/templates/base.twig @@ -415,18 +415,24 @@ function recursiveBase64StrToArrayBuffer(obj) { return navigator.credentials.create(json); }).then(function(cred) { return { - id: cred.id, - rawId: arrayBufferToBase64(cred.rawId), - response: { - attestationObject: arrayBufferToBase64(cred.response.attestationObject), - clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON) - }, - type: cred.type + clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON), + attestationObject: arrayBufferToBase64(cred.response.attestationObject), + key_id: keyId, + tfa_method: 'webauthn', + confirm_password: '' }; - }).then(function(credData) { - $('#setup_webauthn_register_data').val(JSON.stringify(credData)); - $('#setup_webauthn_reg_form input[name=set_tfa]').val('1'); - $('#setup_webauthn_reg_form').submit(); + }).then(JSON.stringify).then(function(credentialData) { + // send the attestation to the JSON API, like the regular WebAuthn modal does, + // so that the credential is parsed and stored correctly + return window.fetch('/api/v1/add/webauthn-tfa-registration', {method: 'POST', body: credentialData, cache: 'no-cache'}); + }).then(function(response) { + return response.json(); + }).then(function(json) { + if (json.success) { + window.location = window.location.href.split('#')[0]; + } else { + throw new Error(json.msg); + } }).catch(function(err) { $('#setup_webauthn_return_code').show().text(err.message || 'Registration failed'); }); From 8db3cae826a00f26d0f5e5dfa10a284e6bc4c0dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20M=C3=BCller?= <1113810+ThxGiving@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:48:30 +0200 Subject: [PATCH 04/33] [Nginx] Send Basic auth challenge for unauthenticated ActiveSync requests Outlook for iOS/Android connects through Microsoft's sync service, which sends each EAS command without credentials first and only retries with Basic auth after a 401 + WWW-Authenticate challenge. SOGo answers these unauthenticated POSTs (Provision, FolderSync, Sync, ...) with 403, so the Outlook apps never get past Provision and show an empty mailbox (#5846). Answer requests without an Authorization header with a Basic challenge in nginx before they reach SOGo. Clients that send credentials up front are not affected. --- data/conf/nginx/templates/sites-default.conf.j2 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 842c50a54..13f6fda10 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -184,6 +184,12 @@ location /sogo-auth-verify { } location ^~ /Microsoft-Server-ActiveSync { + # Outlook for iOS/Android (Microsoft's sync service) sends EAS commands without credentials + # and only authenticates after a 401 Basic challenge. SOGo answers those with 403, so challenge here. + if ($http_authorization = "") { + add_header WWW-Authenticate 'Basic realm="SOGo"' always; + return 401; + } auth_request /sogo-auth-verify; auth_request_set $user $upstream_http_x_user; auth_request_set $auth $upstream_http_x_auth; From 81d6b1f5478c1bdbed69c49c80b50ac77d177355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20St=C3=BCckler?= Date: Thu, 22 Oct 2026 12:24:04 +0200 Subject: [PATCH 05/33] [Web] Clearer captions for domain table columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption the "RL" column "Rate limit" using the existing translated edit.ratelimit string, and shorten "Max. size of a mailbox" to "Max. mailbox size" (en-gb only; the key is only used for this column). Signed-off-by: Moritz Stückler --- data/web/js/site/mailbox.js | 2 +- data/web/lang/lang.en-gb.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 5ad5cceab..a89563ed3 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -610,7 +610,7 @@ jQuery(function($){ defaultContent: '' }, { - title: 'RL', + title: lang_edit.ratelimit, data: 'rl', searchable: false, orderable: false, diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 1f1c1f273..824c66ca8 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -959,7 +959,7 @@ "mailbox_defaults_info": "Define default settings for new mailboxes.", "mailbox_defquota": "Default mailbox size", "mailbox_templates": "Mailbox Templates", - "mailbox_quota": "Max. size of a mailbox", + "mailbox_quota": "Max. mailbox size", "mailboxes": "Mailboxes", "max_aliases": "Max. aliases", "max_mailboxes": "Max. possible mailboxes", From 8d4895a54d9025a769427e167602ec768d85d101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20St=C3=BCckler?= Date: Thu, 22 Oct 2026 12:36:51 +0200 Subject: [PATCH 06/33] [Web] Align the table expand control and row checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Center the +/- glyph in the expand control: its line-height (1.25em) was larger than the 1em box, pushing the glyph down. Derive the box's margin-top from its size so it is centered in the row (desktop and the larger mobile variant). - Hide sort arrows on non-orderable headers. The default initial order targets column 0, the expand column, which got a sorting_asc arrow and arrow padding although it cannot be sorted. - Center row checkboxes level with the expand control by using Bootstrap's 0.25em margin-top instead of a fixed 7px. Signed-off-by: Moritz Stückler --- data/web/css/build/013-datatables.css | 17 +++++++++++++++-- data/web/css/build/015-responsive.css | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/data/web/css/build/013-datatables.css b/data/web/css/build/013-datatables.css index 1bc18119e..a0fa34cce 100644 --- a/data/web/css/build/013-datatables.css +++ b/data/web/css/build/013-datatables.css @@ -28,7 +28,8 @@ table.dataTable td.dt-control:before { color: var(--mc-dtr-color); height: 1em; width: 1em; - line-height: 1.25em; + line-height: 1em; + margin-top: calc(-0.5em - 1.5px); border-radius: 0px; box-shadow: none; font-size: 14px; @@ -39,11 +40,23 @@ table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before, table.dataTable td.dt-control:before { background-color: var(--mc-dtr-active-bg) !important; } +/* The default initial order targets column 0, which is usually the non-orderable expand column */ +table.dataTable thead > tr > th.sorting_disabled.sorting_asc, +table.dataTable thead > tr > th.sorting_disabled.sorting_desc { + padding-right: 0.5rem; +} +table.dataTable thead > tr > th.sorting_disabled.sorting_asc:before, +table.dataTable thead > tr > th.sorting_disabled.sorting_asc:after, +table.dataTable thead > tr > th.sorting_disabled.sorting_desc:before, +table.dataTable thead > tr > th.sorting_disabled.sorting_desc:after { + display: none; +} table.dataTable.table-striped>tbody>tr>td { vertical-align: middle; } table.dataTable.table-striped>tbody>tr>td>input[type="checkbox"] { - margin-top: 7px; + /* centers the 1em checkbox in the 1.5em line, level with the expand control */ + margin-top: 0.25em; } td.dtr-col-lg { min-width: 350px; diff --git a/data/web/css/build/015-responsive.css b/data/web/css/build/015-responsive.css index 82be71069..d3e0263cd 100644 --- a/data/web/css/build/015-responsive.css +++ b/data/web/css/build/015-responsive.css @@ -191,7 +191,7 @@ height: 2rem; width: 2rem; line-height: 2rem; - margin-top: -15px; + margin-top: calc(-1rem - 1.5px); } li .dtr-data { From f662af4c5002fd3c8bb87b74b474d2bcbd44a215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20St=C3=BCckler?= Date: Thu, 22 Oct 2026 13:06:00 +0200 Subject: [PATCH 07/33] [Web] Even out badge and tag sizes in tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use the normal cursor on non-orderable headers. The expand column header kept a pointer cursor from sorting_asc although clicking it does nothing. - Let icons in badges inherit the badge font size, like .btn .bi, and keep their glyph from adding line height. The global 12pt icon size made e.g. the "SSO @" badge in Last mail login taller than the IMAP and SMTP badges next to it. Tag badges keep their 12px icon. - Drop the vertical margin of tag badges inside tables. It is meant for the tag editor in edit forms and made the Tags entry of the expanded row taller than the others. - Center tag badge captions: the 12px tag icon stretched the 10.5px badge line and pushed the caption below center. Signed-off-by: Moritz Stückler --- data/web/css/build/013-datatables.css | 6 ++++++ data/web/css/build/014-mailcow.css | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/data/web/css/build/013-datatables.css b/data/web/css/build/013-datatables.css index a0fa34cce..ded00bc7b 100644 --- a/data/web/css/build/013-datatables.css +++ b/data/web/css/build/013-datatables.css @@ -44,6 +44,7 @@ table.dataTable td.dt-control:before { table.dataTable thead > tr > th.sorting_disabled.sorting_asc, table.dataTable thead > tr > th.sorting_disabled.sorting_desc { padding-right: 0.5rem; + cursor: auto; } table.dataTable thead > tr > th.sorting_disabled.sorting_asc:before, table.dataTable thead > tr > th.sorting_disabled.sorting_asc:after, @@ -58,6 +59,11 @@ table.dataTable.table-striped>tbody>tr>td>input[type="checkbox"] { /* centers the 1em checkbox in the 1.5em line, level with the expand control */ margin-top: 0.25em; } +/* the vertical margin is meant for the tag editor in edit forms, in tables it only adds height */ +table.dataTable .tag-badge { + margin-top: 0; + margin-bottom: 0; +} td.dtr-col-lg { min-width: 350px; word-break: break-word; diff --git a/data/web/css/build/014-mailcow.css b/data/web/css/build/014-mailcow.css index 52a34f8b5..99925ca28 100644 --- a/data/web/css/build/014-mailcow.css +++ b/data/web/css/build/014-mailcow.css @@ -139,6 +139,13 @@ body { display: inline-block; font-size: inherit; } +.badge .bi { + font-size: inherit; +} +/* keeps icons from making a badge taller than one with text only */ +.badge .bi::before { + line-height: 0; +} .btn-group-xs > .btn, .btn-xs { padding: .25rem .4rem; font-size: .875rem; @@ -343,6 +350,7 @@ code { } .tag-badge .bi { font-size: 12px; + line-height: 0; } .tag-badge.btn-badge:hover { filter: brightness(0.9); From 498434ee3dd3f9581da8b712e1f9539b6c1953b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20St=C3=BCckler?= Date: Thu, 22 Oct 2026 13:10:49 +0200 Subject: [PATCH 08/33] [Web] Even out spacing of the expanded table row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataTables removes the top padding of the first entry in the expanded row, but mailcow's 0.5em padding left 7px under the last entry, so the list had 7px of space above and 15px below and the last entry (often Tags) looked taller than the others. Drop the last entry's bottom padding as well. Signed-off-by: Moritz Stückler --- data/web/css/build/013-datatables.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/data/web/css/build/013-datatables.css b/data/web/css/build/013-datatables.css index ded00bc7b..21d5c9a54 100644 --- a/data/web/css/build/013-datatables.css +++ b/data/web/css/build/013-datatables.css @@ -15,6 +15,10 @@ table.dataTable>tbody>tr.child ul.dtr-details>li { border-bottom: 1px solid rgba(0, 0, 0, 0.129); padding: 0.5em 0; } +/* like the first entry has no top padding, the last one needs no bottom padding */ +table.dataTable>tbody>tr.child ul.dtr-details>li:last-child { + padding-bottom: 0; +} table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover, table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover { background-color: var(--mc-dtr-hover-bg); From 2f76230daf22e4f1f60c7831bd1722afd56c1f79 Mon Sep 17 00:00:00 2001 From: Jorge Lobo <12179834+jorgeavlobo@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:52:03 +0100 Subject: [PATCH 09/33] [Web] Keycloak: skip introspection of expired admin token --- data/web/inc/functions.inc.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 09e8ec193..556e7ffdf 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -3104,7 +3104,17 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { case "get-keycloak-admin-token": // get access_token for service account of mailcow client if ($iam_settings['authsource'] !== 'keycloak') return false; - if (isset($iam_settings['access_token'])) { + // only introspect a cached access_token that is not (about to be) expired, + // introspecting an expired token makes Keycloak log INTROSPECT_TOKEN_ERROR + $token_not_expired = false; + if (!empty($iam_settings['access_token'])) { + $token_parts = explode('.', $iam_settings['access_token']); + if (count($token_parts) === 3) { + $token_payload = json_decode(base64_decode(strtr($token_parts[1], '-_', '+/')), true); + $token_not_expired = is_array($token_payload) && isset($token_payload['exp']) && intval($token_payload['exp']) > time() + 30; + } + } + if ($token_not_expired) { // check if access_token is valid $url = "{$iam_settings['server_url']}/realms/{$iam_settings['realm']}/protocol/openid-connect/token/introspect"; $req = http_build_query(array( @@ -3155,6 +3165,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { ':key' => 'access_token', ':value' => $res['access_token'] )); + // keep the cached token in sync, so subsequent calls in the same run reuse it + $iam_settings['access_token'] = $res['access_token']; return $res['access_token']; break; } From 5705a4872439d6607decb6cbf0c88c838e7e2b83 Mon Sep 17 00:00:00 2001 From: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:05:10 +0200 Subject: [PATCH 10/33] [Web] Fix ignore_ssl_error key in identity provider settings The edit action stores the setting as ignore_ssl_error, but the get action cast and defaulted ignore_ssl_errors. The stored value was returned as a string and LDAP setups got an extra ignore_ssl_errors field that was always false. Co-authored-by: diko <108140792+admins-little-helper@users.noreply.github.com> --- data/web/inc/functions.inc.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 09e8ec193..de17f7a0b 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2499,7 +2499,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { case "use_ssl": case "use_tls": case "login_provisioning": - case "ignore_ssl_errors": + case "ignore_ssl_error": $settings[$row["key"]] = boolval($row["value"]); break; default: @@ -2523,7 +2523,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { if ($settings["authsource"] == "ldap"){ $settings['use_ssl'] = !isset($settings['use_ssl']) ? false : $settings['use_ssl']; $settings['use_tls'] = !isset($settings['use_tls']) ? false : $settings['use_tls']; - $settings['ignore_ssl_errors'] = !isset($settings['ignore_ssl_errors']) ? false : $settings['ignore_ssl_errors']; + $settings['ignore_ssl_error'] = !isset($settings['ignore_ssl_error']) ? false : $settings['ignore_ssl_error']; } return $settings; break; From 24b91f60891f1372e2c7ab48b512d9c76f87b986 Mon Sep 17 00:00:00 2001 From: Peter Schwips <121677584+peterschwps@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:54:21 +0100 Subject: [PATCH 11/33] [Dovecot] Mask Redis password in trim_logs output --- data/Dockerfiles/dovecot/trim_logs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/dovecot/trim_logs.sh b/data/Dockerfiles/dovecot/trim_logs.sh index 3b5e05278..748c80a2a 100755 --- a/data/Dockerfiles/dovecot/trim_logs.sh +++ b/data/Dockerfiles/dovecot/trim_logs.sh @@ -4,7 +4,7 @@ catch_non_zero() { ${CMD} > /dev/null EC=$? if [ ${EC} -ne 0 ]; then - echo "Command ${CMD} failed to execute, exit code was ${EC}" + echo "Command ${CMD//${REDISPASS}/***} failed to execute, exit code was ${EC}" fi } source /source_env.sh From 0a0686ef1940f9723de3a04ce88fe437917ff982 Mon Sep 17 00:00:00 2001 From: woodfairy <66573402+woodfairy@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:46:44 +0100 Subject: [PATCH 12/33] [Nginx][Clamd][Postfix] Rebuild images for brotli CVE-2026-111497 The current nginx, clamd and postfix images ship a brotli decoder in the affected range 1.0.3 - 1.2.0 (CVE-2026-111497, CVSS 9.8, listed in CISA KEV): - nginx:1.30.6-2 brotli-libs 1.2.0-r1 (fixed in Alpine 3.24: 1.2.1-r0) - clamd:1.4.6-1 brotli-libs 1.2.0-r1 (fixed in Alpine 3.24: 1.2.1-r0) - postfix:3.10.12-1 libbrotli1 1.1.0-2+b7 (fixed in trixie-security: 1.1.0-2+deb13u1, DSA-6582-1) All other mailcow images already contain a fixed version. clamd and postfix only need a rebuild, which pulls the fixed packages. The postfix rebuild also picks up Debian's postfix 3.10.13, hence the new tag postfix:3.10.13-1. The nginx image never upgraded the packages of its base image, so it only got fixes when nginx:-alpine was republished. It now runs apk upgrade like clamd and acme do. --- data/Dockerfiles/nginx/Dockerfile | 3 ++- docker-compose.yml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/data/Dockerfiles/nginx/Dockerfile b/data/Dockerfiles/nginx/Dockerfile index c4230482a..8d1b004a9 100644 --- a/data/Dockerfiles/nginx/Dockerfile +++ b/data/Dockerfiles/nginx/Dockerfile @@ -3,7 +3,8 @@ LABEL maintainer "The Infrastructure Company GmbH " ENV PIP_BREAK_SYSTEM_PACKAGES=1 -RUN apk add --no-cache nginx \ +RUN apk upgrade --no-cache && \ + apk add --no-cache nginx \ python3 \ py3-pip && \ pip install --upgrade pip && \ diff --git a/docker-compose.yml b/docker-compose.yml index c6e07d7c5..c7cd91a4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,7 +68,7 @@ services: - redis clamd-mailcow: - image: ghcr.io/mailcow/clamd:1.4.6-1 + image: ghcr.io/mailcow/clamd:1.4.6-2 restart: always depends_on: unbound-mailcow: @@ -348,7 +348,7 @@ services: - dovecot postfix-mailcow: - image: ghcr.io/mailcow/postfix:3.10.12-1 + image: ghcr.io/mailcow/postfix:3.10.13-1 depends_on: mysql-mailcow: condition: service_started @@ -428,7 +428,7 @@ services: - php-fpm-mailcow - sogo-mailcow - rspamd-mailcow - image: ghcr.io/mailcow/nginx:1.30.6-2 + image: ghcr.io/mailcow/nginx:1.30.6-3 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: From 5f13bb20467df80523ff91b99df8f027059ebf13 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:41:17 +0100 Subject: [PATCH 13/33] [Dockerapi] Group IMAP grants: set ACLs only on existing folders --- data/Dockerfiles/dockerapi/modules/DockerApi.py | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/Dockerfiles/dockerapi/modules/DockerApi.py b/data/Dockerfiles/dockerapi/modules/DockerApi.py index 3de02ac3f..362581631 100644 --- a/data/Dockerfiles/dockerapi/modules/DockerApi.py +++ b/data/Dockerfiles/dockerapi/modules/DockerApi.py @@ -586,8 +586,8 @@ class DockerApi: if not user or id in ('user=', 'group='): return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json") action = 'doveadm acl set -u "$ACL_USER" -- "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" -- "$folder" "$ACL_ID"' - script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do ' - 'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"') + script = ('set -o pipefail; doveadm -f tab mailbox status -u "$ACL_USER" guid "*" | tail -n +2 | cut -f1 | while IFS= read -r folder; do ' + 'case "$folder" in Shared|Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"') acl_return = container.exec_run(["/bin/bash", "-c", script], environment={ 'ACL_USER': user, 'ACL_ID': id, 'ACL_RIGHTS': ' '.join(rights) }) return self.exec_run_handler('generic', acl_return) diff --git a/docker-compose.yml b/docker-compose.yml index c6e07d7c5..ecd460d46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -612,7 +612,7 @@ services: - watchdog dockerapi-mailcow: - image: ghcr.io/mailcow/dockerapi:2.15 + image: ghcr.io/mailcow/dockerapi:2.16 security_opt: - label=disable restart: always From bf744413bd12bdfc15c95475e42deae8496e596e Mon Sep 17 00:00:00 2001 From: milkmaker Date: Mon, 9 Nov 2026 21:41:36 +0100 Subject: [PATCH 14/33] Translations update from Weblate (#7623) * [Web] Language file updated by 'Cleanup translation files' addon Co-authored-by: milkmaker * [Web] Language file updated by 'Cleanup translation files' addon Co-authored-by: milkmaker --- data/web/lang/lang.es-es.json | 5 ----- data/web/lang/lang.it-it.json | 1 - 2 files changed, 6 deletions(-) diff --git a/data/web/lang/lang.es-es.json b/data/web/lang/lang.es-es.json index cf68eace9..bc4fed946 100644 --- a/data/web/lang/lang.es-es.json +++ b/data/web/lang/lang.es-es.json @@ -1074,7 +1074,6 @@ "verified_webauthn_login": "Inicio de sesión WebAuthn verificado", "verified_yotp_login": "Inicio de sesión Yubico OTP verificado", "admin_added": "Se agregó el administrador %s", - "admin_api_modified": "Se guardaron los cambios en la API", "admin_removed": "Se eliminó el administrador %s", "app_links": "Se guardaron los cambios en los enlaces de aplicaciones", "app_passwd_added": "Se agregó una nueva contraseña de aplicación", @@ -1333,10 +1332,6 @@ "sogo_profile_reset_help": "Esto destruirá el perfil de SOGo del usuario y eliminará de forma irrecuperable todos sus contactos y datos de calendario.", "spam_score_reset": "Restablecer al valor predeterminado del servidor", "spamfilter_table_domain_policy": "n/d (política del dominio)", - "syncjob_check_log": "Revisar registro", - "syncjob_EX_OK": "Correcto", - "syncjob_EXIT_OVERQUOTA": "El buzón de destino superó su cuota", - "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Usuario o contraseña incorrectos", "tfa_info": "La autenticación de dos factores ayuda a proteger su cuenta. Si la activa, necesitará contraseñas de aplicación para iniciar sesión en aplicaciones o servicios que no admiten la autenticación de dos factores (p. ej., clientes de correo)." }, "warning": { diff --git a/data/web/lang/lang.it-it.json b/data/web/lang/lang.it-it.json index 0c9288555..3cbb4a269 100644 --- a/data/web/lang/lang.it-it.json +++ b/data/web/lang/lang.it-it.json @@ -95,7 +95,6 @@ "bcc_dest_format": "Il destinatario in copia nascosta deve essere un singolo indirizzo email.
Se si vuole spedire una copia del messaggio a più destinatari, bisogna creare un alias ed utilizzarlo per questa opzione.", "app_passwd_protocols": "Protocolli consentiti per la password dell'app", "tags": "Tag", - "dry": "Simula sincronizzazione", "internal": "Interno", "internal_info": "Gli alias interni sono accessibili solo dal proprio dominio o dai domini alias.", "sender_allowed": "Consenti l'invio con questo alias" From e0640949a2911f475d36dec1d2a3b49ac4aa44c3 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:39:42 +0100 Subject: [PATCH 15/33] [Web][Dovecot] Show the last SOGo login in the mailbox list and API --- data/conf/dovecot/auth/mailcowauth.php | 4 ++-- data/web/inc/functions.mailbox.inc.php | 7 +++++++ data/web/js/site/mailbox.js | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/data/conf/dovecot/auth/mailcowauth.php b/data/conf/dovecot/auth/mailcowauth.php index 9a4637506..7d4424dc5 100644 --- a/data/conf/dovecot/auth/mailcowauth.php +++ b/data/conf/dovecot/auth/mailcowauth.php @@ -116,8 +116,8 @@ if ($result === false){ exit; } if ($result) { - error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip); - set_sasl_log($post['username'], $client_ip, $post['service']); + error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip); + set_sasl_log($post['username'], $client_ip, $service); } } diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 121d43a0c..c6e4f6f80 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -4923,6 +4923,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { else if ($SaslLogs['service'] == 'SSO') { $last_sso_login = strtotime($SaslLogs['datetime']); } + else if ($SaslLogs['service'] == 'SOGO') { + $last_sogo_login = strtotime($SaslLogs['datetime']); + } } if (!isset($last_imap_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) { $last_imap_login = 0; @@ -4936,10 +4939,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { if (!isset($last_sso_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) { $last_sso_login = 0; } + if (!isset($last_sogo_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) { + $last_sogo_login = 0; + } $mailboxdata['last_imap_login'] = $last_imap_login; $mailboxdata['last_smtp_login'] = $last_smtp_login; $mailboxdata['last_pop3_login'] = $last_pop3_login; $mailboxdata['last_sso_login'] = $last_sso_login; + $mailboxdata['last_sogo_login'] = $last_sogo_login; if (!isset($_extra) || $_extra != 'reduced') { $rl = ratelimit('get', 'mailbox', $_data); diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 32de2a8d1..2f3088839 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -932,6 +932,7 @@ jQuery(function($){ item.last_mail_login = (item.attributes.imap_access == 1 ? '
IMAP @ ' + unix_time_format(Number(item.last_imap_login)) + '

' : '') + (item.attributes.pop3_access == 1 ? '
POP3 @ ' + unix_time_format(Number(item.last_pop3_login)) + '

' : '') + (item.attributes.smtp_access == 1 ? '
SMTP @ ' + unix_time_format(Number(item.last_smtp_login)) + '

' : '') + + '
SOGO @ ' + unix_time_format(Number(item.last_sogo_login)) + '

' + '
SSO @ ' + unix_time_format(Number(item.last_sso_login)) + '
'; /* if (!item.rl) { From 497872080b4deaffc67fd92cdc9e6a385e636805 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:46:15 +0100 Subject: [PATCH 16/33] [Web] Update "Last modified" when tags of a domain or mailbox change --- data/web/inc/functions.mailbox.inc.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 121d43a0c..33fc40369 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -2524,6 +2524,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':tag_name' => $tag, )); + $stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); } $_SESSION['return'][] = array( @@ -2692,6 +2696,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':tag_name' => $tag, )); + $stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); } $_SESSION['return'][] = array( @@ -3170,6 +3178,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':username' => $username, ':tag_name' => $tag, )); + $stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username"); + $stmt->execute(array( + ':username' => $username, + )); } catch (Exception $e) { } } @@ -5916,6 +5928,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':tag_name' => $tag, )); + if ($stmt->rowCount() > 0) { + $stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); + } } } @@ -5968,6 +5986,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':username' => $username, ':tag_name' => $tag, )); + if ($stmt->rowCount() > 0) { + $stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username"); + $stmt->execute(array( + ':username' => $username, + )); + } } } From 2b4ffe880825f826cbb626120948f3d68454cd38 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:53:07 +0100 Subject: [PATCH 17/33] [Rspamd] Sub-addressing: read the tag from the original SMTP recipient --- data/conf/rspamd/lua/rspamd.local.lua | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index 5642fba2c..ca269398d 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -390,9 +390,8 @@ rspamd_config:register_symbol({ local rspamd_logger = require "rspamd_logger" local redis_params = rspamd_parse_redis_server('taghandler') local rspamd_http = require "rspamd_http" - local rcpts = task:get_recipients('smtp') + local rcpts = task:get_recipients({'smtp', 'orig'}) local lua_util = require "lua_util" - local tagged_rcpt = task:get_symbol("TAGGED_RCPT") local function remove_moo_tag() local moo_tag_header = task:get_header('X-Moo-Tag', false) @@ -417,8 +416,9 @@ rspamd_config:register_symbol({ -- Check if recipient has a tag (contains '+') local tag = nil - if tagged_rcpt ~= nil then - tag = tagged_rcpt + local base_user, tag_part = rcpt_user:match('^([^%+]+)%+(.+)$') + if base_user and tag_part then + tag = tag_part rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag) end @@ -498,8 +498,7 @@ rspamd_config:register_symbol({ else rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail") local sbj = task:get_header('Subject') or '' - local tag_value = tag[1] and tag[1].options and tag[1].options[1] or '' - new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag_value .. '] ' .. sbj)) .. '?=' + new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' task:set_milter_reply({ remove_headers = { ['Subject'] = 1, From 3fd1d017cf5f0eff918c0bb64966bab2ba772d60 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:00:38 +0100 Subject: [PATCH 18/33] [Rspamd] Raise max_message above the Postfix message size limit --- data/conf/rspamd/local.d/options.inc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/conf/rspamd/local.d/options.inc b/data/conf/rspamd/local.d/options.inc index f83ddf0fc..338749351 100644 --- a/data/conf/rspamd/local.d/options.inc +++ b/data/conf/rspamd/local.d/options.inc @@ -9,3 +9,5 @@ disable_monitoring = true; # instead of silently accepting the message without further processing. soft_reject_on_timeout = true; local_addrs = /etc/rspamd/custom/mailcow_networks.map; +# Must not be lower than message_size_limit in postfix main.cf +max_message = 134217728; From ce4150f921368c8bd5f0057f59abc1afa914469d Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:04:33 +0100 Subject: [PATCH 19/33] [Postfix-TLSPol] Update to 1.14.5 --- data/Dockerfiles/postfix-tlspol/Dockerfile | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/Dockerfiles/postfix-tlspol/Dockerfile b/data/Dockerfiles/postfix-tlspol/Dockerfile index 51e8f775d..0f792a074 100644 --- a/data/Dockerfiles/postfix-tlspol/Dockerfile +++ b/data/Dockerfiles/postfix-tlspol/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /src ENV CGO_ENABLED=0 \ GO111MODULE=on \ NOOPT=1 \ - VERSION=1.14.0 + VERSION=1.14.5 RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \ cd /src/postfix-tlspol && \ diff --git a/docker-compose.yml b/docker-compose.yml index ecd460d46..c7fc240c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -391,7 +391,7 @@ services: - postfix postfix-tlspol-mailcow: - image: ghcr.io/mailcow/postfix-tlspol:1.14.0 + image: ghcr.io/mailcow/postfix-tlspol:1.14.5 depends_on: unbound-mailcow: condition: service_healthy From 1a42c19050ac2bd288aacf442792de41c53093dd Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:08:14 +0100 Subject: [PATCH 20/33] [Nginx] Accept a trailing slash in /edit and /qhandler links --- data/conf/nginx/templates/sites-default.conf.j2 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 50a24f247..c39b7db65 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -94,11 +94,11 @@ location / { } location /qhandler { - rewrite ^/qhandler/(.*)/(.*) /qhandler.php?action=$1&hash=$2; + rewrite ^/qhandler/([^/]+)/(.+?)/?$ /qhandler.php?action=$1&hash=$2; } location /edit { - rewrite ^/edit/(.*)/(.*) /edit.php?$1=$2; + rewrite ^/edit/([^/]+)/(.+?)/?$ /edit.php?$1=$2; } location ~ ^/api/v1/(.*)$ { From cfad223824673a2326130a7a1065a5e1a637dbd7 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:13:26 +0100 Subject: [PATCH 21/33] [Web] force_tfa: read the flag with JSON_VALUE --- data/web/inc/functions.inc.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 0c05293c4..d548e6764 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1416,11 +1416,11 @@ function set_tfa($_data) { // Block TFA removal if force_tfa policy is active $is_forced_tfa = false; if ($_SESSION['mailcow_cc_role'] === 'user') { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } else { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } @@ -1687,11 +1687,11 @@ function unset_tfa_key($_data) { // Block key removal if force_tfa policy is active $is_forced_tfa = false; if ($_SESSION['mailcow_cc_role'] === 'user') { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } else { - $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); + $stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?"); $stmt_check->execute(array($username)); $is_forced_tfa = ($stmt_check->fetchColumn() == '1'); } From e27cb833262315db9c1864e767b7f23dd504da24 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:17:55 +0100 Subject: [PATCH 22/33] [Web] Autodiscover: no ActiveSync when autodiscoverType is imap --- data/web/autodiscover-json.php | 2 +- data/web/autodiscover.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/web/autodiscover-json.php b/data/web/autodiscover-json.php index 2fa96990e..661699293 100644 --- a/data/web/autodiscover-json.php +++ b/data/web/autodiscover-json.php @@ -8,7 +8,7 @@ if(file_exists('inc/vars.local.inc.php')) { $autodiscover_config = array_merge($default_autodiscover_config, $autodiscover_config); header('Content-type: application/json'); -if (strtolower($_GET['Protocol']) == 'activesync' && getenv('SKIP_SOGO') != "y") { +if (strtolower($_GET['Protocol']) == 'activesync' && $autodiscover_config['autodiscoverType'] == 'activesync' && getenv('SKIP_SOGO') != "y") { echo '{"Protocol":"ActiveSync","Url":"' . $autodiscover_config['activesync']['url'] . '"}'; } elseif (strtolower($_GET['Protocol']) == 'autodiscoverv1') { diff --git a/data/web/autodiscover.php b/data/web/autodiscover.php index e2d70bbae..8a9349934 100644 --- a/data/web/autodiscover.php +++ b/data/web/autodiscover.php @@ -31,7 +31,7 @@ error_reporting(0); $data = trim(file_get_contents("php://input")); -if (strpos($data, 'autodiscover/outlook/responseschema') !== false) { +if (strpos($data, 'autodiscover/outlook/responseschema') !== false && $autodiscover_config['autodiscoverType'] == 'activesync') { $autodiscover_config['autodiscoverType'] = 'imap'; if ($autodiscover_config['useEASforOutlook'] == 'yes' && // Office for macOS does not support EAS From 38b031e10c327b250d4b6a453c8e41ae998ff3d3 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:21:14 +0100 Subject: [PATCH 23/33] [Rspamd] Quarantine: store mail when Q_EXCLUDE_DOMAINS is not set --- data/conf/rspamd/meta_exporter/pipe.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/conf/rspamd/meta_exporter/pipe.php b/data/conf/rspamd/meta_exporter/pipe.php index c0e056c6a..439238cc9 100644 --- a/data/conf/rspamd/meta_exporter/pipe.php +++ b/data/conf/rspamd/meta_exporter/pipe.php @@ -109,7 +109,7 @@ foreach ($rcpts as $rcpt) { } // Skip if domain is excluded - if (in_array($parsed_rcpt['domain'], $exclude_domains)) { + if (is_array($exclude_domains) && in_array($parsed_rcpt['domain'], $exclude_domains)) { error_log(sprintf("QUARANTINE: Skipped domain %s", $parsed_rcpt['domain']) . PHP_EOL); continue; } From 45d31b327940031169d445e84a5c524e23d175c8 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:24:39 +0100 Subject: [PATCH 24/33] [Web] Forwarding hosts: store IPv6 addresses in canonical form --- data/web/inc/functions.fwdhost.inc.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/data/web/inc/functions.fwdhost.inc.php b/data/web/inc/functions.fwdhost.inc.php index 52c751591..ff77fcc61 100644 --- a/data/web/inc/functions.fwdhost.inc.php +++ b/data/web/inc/functions.fwdhost.inc.php @@ -35,6 +35,13 @@ function fwdhost($_action, $_data = null) { ); return false; } + $hosts = array_unique(array_map(function ($host) { + $net = explode('/', $host, 2); + if (filter_var($net[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $net[0] = inet_ntop(inet_pton($net[0])); + } + return implode('/', $net); + }, $hosts)); foreach ($hosts as $host) { try { $redis->hSet('WHITELISTED_FWD_HOST', $host, $source); From a4326e6642bdcbd4151ef7537e98280db4c4f03d Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:28:53 +0100 Subject: [PATCH 25/33] [Web] Keycloak Mailpassword Flow: accept Base64-encoded password hashes --- data/web/inc/functions.auth.inc.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index d5ace0d5a..832b88359 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -601,6 +601,13 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ // validate mailcow_password $mailcow_password = $user_res['attributes']['mailcow_password'][0]; + // Keycloak returns binary LDAP attributes like userPassword Base64 encoded + if (substr($mailcow_password, 0, 1) != '{') { + $decoded_password = base64_decode($mailcow_password, true); + if ($decoded_password !== false && substr($decoded_password, 0, 1) == '{') { + $mailcow_password = $decoded_password; + } + } if (!verify_hash($mailcow_password, $pass)) { return false; } From 0bcde135006b18d108c15b928cd5d463fbced660 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:34:57 +0100 Subject: [PATCH 26/33] [Rspamd] Domain footer: add the Content-Transfer-Encoding header to the delivered mail --- data/conf/rspamd/lua/rspamd.local.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index 5642fba2c..79867820f 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -907,6 +907,9 @@ rspamd_config:register_symbol({ if not seen_cte and rewrite.need_rewrite_ct then out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte) + task:set_milter_reply({ + add_headers = {['Content-Transfer-Encoding'] = new_cte} + }) end -- End of headers From 4878840bdd8fd827912ce46b1b89cf403a0c4693 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:56:53 +0100 Subject: [PATCH 27/33] [Rspamd] Forwarding hosts: check them as prefilter and scan the mail completely --- data/conf/rspamd/local.d/multimap.conf | 6 --- data/conf/rspamd/lua/rspamd.local.lua | 55 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/data/conf/rspamd/local.d/multimap.conf b/data/conf/rspamd/local.d/multimap.conf index 888bf3630..fe53065ff 100644 --- a/data/conf/rspamd/local.d/multimap.conf +++ b/data/conf/rspamd/local.d/multimap.conf @@ -5,12 +5,6 @@ RCPT_MAILCOW_DOMAIN { symbols_set = ["RCPT_MAILCOW_DOMAIN"]; } -WHITELISTED_FWD_HOST { - type = "ip"; - map = "redis://WHITELISTED_FWD_HOST"; - symbols_set = ["WHITELISTED_FWD_HOST"]; -} - BULK_HEADER { type = "content"; map = "${LOCAL_CONFDIR}/custom/bulk_header.map"; diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index 5642fba2c..19bf07fb3 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -362,6 +362,61 @@ rspamd_config:register_symbol({ priority = 19 }) +rspamd_config:register_symbol({ + name = 'WHITELISTED_FWD_HOST', + type = 'prefilter', + score = 0.0, + callback = function(task) + local rspamd_logger = require "rspamd_logger" + local redis_params = rspamd_parse_redis_server('fwd_host') + local ip = task:get_from_ip() + + if ip == nil or not ip:is_valid() then + return false + end + + local from_ip_string = tostring(ip) + local ip_check_table = {'WHITELISTED_FWD_HOST', from_ip_string} + + local maxbits = 128 + local minbits = 7 + if ip:get_version() == 4 then + maxbits = 32 + minbits = 8 + end + for i=maxbits,minbits,-1 do + table.insert(ip_check_table, ip:apply_mask(i):to_string() .. "/" .. i) + end + + local function fwd_host_cb(err, data) + if err or type(data) ~= 'table' then + rspamd_logger.infox(rspamd_config, "fwd_host query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err) + return + end + for _,v in pairs(data) do + if type(v) == 'string' then + task:insert_result('WHITELISTED_FWD_HOST', 1.0, from_ip_string) + task:set_flag('pass_all') + return + end + end + end + + local redis_ret = rspamd_redis_make_request(task, + redis_params, -- connect params + 'WHITELISTED_FWD_HOST', -- hash key + false, -- is write + fwd_host_cb, --callback + 'HMGET', -- command + ip_check_table -- arguments + ) + if not redis_ret then + rspamd_logger.infox(rspamd_config, "cannot check fwd_host redis map") + end + end, + priority = 18 +}) + rspamd_config:register_symbol({ name = 'TLS_HEADER', type = 'postfilter', From 4ab954dcd17dfe5b2dc1d2cf413c8f20e92a7bdd Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:01:22 +0100 Subject: [PATCH 28/33] [Web] Domain admins: show sender ACL and alias entries of their own domains only --- data/web/inc/functions.mailbox.inc.php | 14 +++++++++++++- data/web/json_api.php | 8 ++++++-- data/web/templates/edit/mailbox.twig | 8 +++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 121d43a0c..baa92faa3 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -3988,6 +3988,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array(':goto' => '(^|,)'.preg_quote($_data, '/').'($|,)')); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($row = array_shift($rows)) { + if (!hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['address'])) { + continue; + } // Keep old array for backward compatibility $data['fixed_sender_aliases'][] = $row['address']; // Split into allowed/blocked for proper display @@ -4977,7 +4980,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array(':username' => $_data)); $sender_acl_rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($sender_acl_rows as $sender_acl_row) { - $mailboxdata['sender_acl'][] = $sender_acl_row['send_as']; + $send_as = $sender_acl_row['send_as']; + if ($_SESSION['mailcow_cc_role'] == 'domainadmin' && $send_as != '*') { + $in_scope = (substr($send_as, 0, 1) == '@') + ? hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr($send_as, 1)) + : hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $send_as); + if (!$in_scope) { + continue; + } + } + $mailboxdata['sender_acl'][] = $send_as; } } $stmt = $pdo->prepare("SELECT `tag_name` diff --git a/data/web/json_api.php b/data/web/json_api.php index b059ae8be..ec1a67f50 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -1087,10 +1087,14 @@ if (isset($_GET['query'])) { $data['mailboxes'][$mailbox][] = $mailbox; $user_alias_details = user_get_alias_details($mailbox); foreach ($user_alias_details['direct_aliases'] as $k => $v) { - $data['mailboxes'][$mailbox][] = $k; + if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) { + $data['mailboxes'][$mailbox][] = $k; + } } foreach ($user_alias_details['shared_aliases'] as $k => $v) { - $data['mailboxes'][$mailbox][] = $k; + if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) { + $data['mailboxes'][$mailbox][] = $k; + } } } } diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index 9f56ac56c..35d9866dc 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -97,9 +97,11 @@
'; - item.attributes.def_quota_for_mbox = humanFileSize(item.attributes.def_quota_for_mbox); - item.attributes.max_quota_for_mbox = humanFileSize(item.attributes.max_quota_for_mbox); - item.attributes.max_quota_for_domain = humanFileSize(item.attributes.max_quota_for_domain); - item.template = escapeHtml(item.template); if (item.attributes.rl_frame === "s"){ item.attributes.rl_frame = lang_rl.second; @@ -801,16 +797,25 @@ jQuery(function($){ title: lang.mailbox_defquota, data: 'attributes.def_quota_for_mbox', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.max_quota, data: 'attributes.max_quota_for_mbox', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.domain_quota_total, data: 'attributes.max_quota_for_domain', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.gal, @@ -1244,8 +1249,6 @@ jQuery(function($){ item.attributes.rl_value = (!item.attributes.rl_value) ? "∞" : escapeHtml(item.attributes.rl_value); item.attributes.ratelimit = item.attributes.rl_value + " " + item.attributes.rl_frame; - item.attributes.quota = humanFileSize(item.attributes.quota); - item.attributes.tls_enforce_in = '' + (item.attributes.tls_enforce_in == 1 ? '1' : '0') + ''; item.attributes.tls_enforce_out = '' + (item.attributes.tls_enforce_out == 1 ? '1' : '0') + ''; item.attributes.pop3_access = '' + (item.attributes.pop3_access == 1 ? '1' : '0') + ''; @@ -1332,6 +1335,9 @@ jQuery(function($){ title: lang.domain_quota, data: 'attributes.quota', defaultContent: '', + render: function (data, type) { + return type === 'sort' || type === 'type' ? data : humanFileSize(data); + } }, { title: lang.tls_enforce_in, From 6fd271ee6434571cae2a15a612638ba91c35ce88 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:27:52 +0100 Subject: [PATCH 30/33] [Rspamd] Remove MX check overrides that rspamd ignores --- data/conf/rspamd/local.d/composites.conf | 4 ---- data/conf/rspamd/local.d/groups.conf | 18 ------------------ data/conf/rspamd/local.d/mx_check.conf | 1 - 3 files changed, 23 deletions(-) diff --git a/data/conf/rspamd/local.d/composites.conf b/data/conf/rspamd/local.d/composites.conf index 4ce042aab..cad77f251 100644 --- a/data/conf/rspamd/local.d/composites.conf +++ b/data/conf/rspamd/local.d/composites.conf @@ -1,7 +1,3 @@ -MX_IMPLICIT { - expression = "MX_GOOD & MX_MISSING"; - score = -0.01; -} VIRUS_FOUND { expression = "CLAM_VIRUS & !MAILCOW_WHITE"; score = 2000.0; diff --git a/data/conf/rspamd/local.d/groups.conf b/data/conf/rspamd/local.d/groups.conf index f77d8a463..bc4eb0bae 100644 --- a/data/conf/rspamd/local.d/groups.conf +++ b/data/conf/rspamd/local.d/groups.conf @@ -23,24 +23,6 @@ symbols { } } -group "MX" { - "MX_INVALID" { - score = 0.5; - description = "No connectable MX"; - one_shot = true; - } - "MX_MISSING" { - score = 2.0; - description = "No MX record"; - one_shot = true; - } - "MX_GOOD" { - score = -0.01; - description = "MX was ok"; - one_shot = true; - } -} - group "reputation" { symbols = { "IP_REPUTATION_HAM" { diff --git a/data/conf/rspamd/local.d/mx_check.conf b/data/conf/rspamd/local.d/mx_check.conf index 22fcedf19..d421a9803 100644 --- a/data/conf/rspamd/local.d/mx_check.conf +++ b/data/conf/rspamd/local.d/mx_check.conf @@ -1,6 +1,5 @@ timeout = 8.0; symbol_bad_mx = "MX_INVALID"; -symbol_no_mx = "MX_MISSING"; symbol_good_mx = "MX_GOOD"; expire = 86400; key_prefix = "rmx"; From 04c9413ed91264bc8eb8b69782d0415a1fad6c7a Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:50:48 +0100 Subject: [PATCH 31/33] [Rspamd] Remove the URLhaus and MalwareBazaar maps retired by abuse.ch --- data/conf/rspamd/local.d/multimap.conf | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/data/conf/rspamd/local.d/multimap.conf b/data/conf/rspamd/local.d/multimap.conf index fe53065ff..210d78f94 100644 --- a/data/conf/rspamd/local.d/multimap.conf +++ b/data/conf/rspamd/local.d/multimap.conf @@ -145,20 +145,6 @@ BAD_LANG { score = 5.0; } -BAZAAR_ABUSE_CH { - type = "selector"; - selector = "attachments(hex,md5)"; - map = "https://bazaar.abuse.ch/export/txt/md5/recent/"; - score = 10.0; -} - -URLHAUS_ABUSE_CH { - type = "selector"; - selector = "urls"; - map = "https://urlhaus.abuse.ch/downloads/text_online/"; - score = 10.0; -} - SMTP_LIMITED_ACCESS { type = "user"; map = "redis://SMTP_LIMITED_ACCESS"; From dc880d861c78a14a4304af5a94844b66799fbdaf Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:04:50 +0100 Subject: [PATCH 32/33] [Update] Keep ENABLE_IPV6 as set, do not disable IPv6 on hosts with a public IPv6 address --- _modules/scripts/ipv6_controller.sh | 67 ++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/_modules/scripts/ipv6_controller.sh b/_modules/scripts/ipv6_controller.sh index a025ee178..689c601b2 100644 --- a/_modules/scripts/ipv6_controller.sh +++ b/_modules/scripts/ipv6_controller.sh @@ -3,6 +3,11 @@ # THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY! # DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!! +# Global IPv6 address that is no unique local address (fc00::/7, e.g. Docker bridges) +_has_public_ipv6() { + ip -6 -o addr show scope global 2>/dev/null | awk '{print $4}' | grep -qviE '^f[cd]' +} + # 1) Check if the host supports IPv6 get_ipv6_support() { # ---- helper: probe external IPv6 connectivity without DNS ---- @@ -37,6 +42,9 @@ get_ipv6_support() { if _probe_ipv6_connectivity; then DETECTED_IPV6=true echo -e "IPv6 detected on host – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}" + elif _has_public_ipv6; then + DETECTED_IPV6=true + echo -e "${YELLOW}External IPv6 connectivity test failed, but the host has a public IPv6 address – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}" else DETECTED_IPV6=false echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}" @@ -192,9 +200,31 @@ EOF fi } +_set_enable_ipv6() { + if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then + if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then + sed -i "s/^ENABLE_IPV6=.*/ENABLE_IPV6=$1/" "$MAILCOW_CONF" + else + echo "ENABLE_IPV6=$1" >> "$MAILCOW_CONF" + fi + else + export IPV6_BOOL=$1 + fi +} + +_warn_ipv6_disabled() { + _has_public_ipv6 || return 0 + ip -6 route show default 2>/dev/null | grep -qE '^default' || return 0 + grep -Eq '"userland-proxy"[[:space:]]*:[[:space:]]*false' /etc/docker/daemon.json 2>/dev/null && return 0 + echo -e "${LIGHT_RED}WARNING: ENABLE_IPV6=false, but this host has a public IPv6 address.${NC}" + echo -e "${YELLOW}Docker forwards IPv6 connections to mailcow with an internal source address, which Postfix trusts. This can make your server an open relay over IPv6.${NC}" + echo -e "${YELLOW}Set ENABLE_IPV6=true in mailcow.conf, or set \"userland-proxy\": false in /etc/docker/daemon.json and restart Docker.${NC}" + echo -e "${YELLOW}See https://docs.mailcow.email/post_installation/firststeps-disable_ipv6/${NC}" + sleep 5 +} + # 3) Main wrapper for generate_config.sh and update.sh configure_ipv6() { - # detect manual override if mailcow.conf is present if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2) elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then @@ -203,19 +233,23 @@ configure_ipv6() { MANUAL_SETTING="" fi + if [[ "${MANUAL_SETTING,,}" == "false" ]]; then + _set_enable_ipv6 false + echo "ENABLE_IPV6=false is set, skipping Docker IPv6 configuration." + _warn_ipv6_disabled + echo "IPv6 configuration complete: ENABLE_IPV6=false" + return + fi + get_ipv6_support - # if user manually set it, check for mismatch + if [[ "$DETECTED_IPV6" != "true" && "${MANUAL_SETTING,,}" == "true" ]] && _has_public_ipv6; then + echo -e "${YELLOW}Keeping ENABLE_IPV6=true, the host has a public IPv6 address.${NC}" + DETECTED_IPV6=true + fi + if [[ "$DETECTED_IPV6" != "true" ]]; then - if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then - if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then - sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF" - else - echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF" - fi - else - export IPV6_BOOL=false - fi + _set_enable_ipv6 false echo "Skipping Docker IPv6 configuration because host does not support IPv6." echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6." echo "IPv6 configuration complete: ENABLE_IPV6=false" @@ -224,16 +258,7 @@ configure_ipv6() { fi docker_daemon_edit - - if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then - if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then - sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF" - else - echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF" - fi - else - export IPV6_BOOL=true - fi + _set_enable_ipv6 true echo "IPv6 configuration complete: ENABLE_IPV6=true" } \ No newline at end of file From 18bc6dc98d390dcec814dbdf762459d9376c8abb Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:50:48 +0100 Subject: [PATCH 33/33] [Update] Restart update.sh automatically after it updated itself --- update.sh | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/update.sh b/update.sh index 02d204db7..7fb7d5b8b 100755 --- a/update.sh +++ b/update.sh @@ -18,6 +18,18 @@ if [ ! -f "${PWD}/mailcow.conf" ]; then fi fi BRANCH="$(cd "${SCRIPT_DIR}" && git rev-parse --abbrev-ref HEAD)" +UPDATE_ARGS=("$@") +SCRIPT_HASH="$(sha256sum "${SCRIPT_DIR}/update.sh" | awk '{print $1}')" + +restart_update_script() { + if [[ "${MAILCOW_UPDATE_RESTARTS:-0}" -ge 3 ]]; then + echo -e "\e[31mupdate.sh keeps changing, please run it again.\e[0m" + exit 2 + fi + export MAILCOW_UPDATE_RESTARTS=$(( ${MAILCOW_UPDATE_RESTARTS:-0} + 1 )) + echo -e "\e[33m$1, restarting the update script...\e[0m" + exec "${SCRIPT_DIR}/update.sh" "${UPDATE_ARGS[@]}" +} # Check for --dev flag early to skip _modules update for arg in "$@"; do @@ -38,9 +50,9 @@ if [ ! "$DEV" ]; then MODULES_HASH_BEFORE="EMPTY" fi - echo -e "\e[33mFetching latest _modules from origin/${BRANCH}…\e[0m" + echo -e "\e[33mFetching latest _modules and update.sh from origin/${BRANCH}…\e[0m" git fetch origin "${BRANCH}" - git checkout "origin/${BRANCH}" -- _modules + git checkout "origin/${BRANCH}" -- _modules update.sh if [[ ! -d "${MODULE_DIR}" || -z "$(ls -A "${MODULE_DIR}")" ]]; then echo -e "\e[31mError: _modules is still missing or empty after fetch!\e[0m" @@ -50,10 +62,10 @@ if [ ! "$DEV" ]; then # Calculate hash after fetch MODULES_HASH_AFTER=$(find "${MODULE_DIR}" -type f -exec sha256sum {} \; 2>/dev/null | sort | sha256sum | awk '{print $1}') - # Check if modules changed - if [[ "${MODULES_HASH_BEFORE}" != "${MODULES_HASH_AFTER}" ]]; then - echo -e "\e[33m_modules have been updated. Please restart the update script.\e[0m" - exit 2 + # Check if modules or the script changed + if [[ "${MODULES_HASH_BEFORE}" != "${MODULES_HASH_AFTER}" || "$(sha256sum "${SCRIPT_DIR}/update.sh" | awk '{print $1}')" != "${SCRIPT_HASH}" ]]; then + chmod +x "${SCRIPT_DIR}/update.sh" + restart_update_script "update.sh or _modules have been updated" fi else echo -e "\e[33mDeveloper mode: Skipping _modules update from git\e[0m" @@ -344,15 +356,12 @@ elif [ "$NEW_BRANCH" == "legacy" ] && [ "$CURRENT_BRANCH" != "legacy" ]; then fi if [ ! "$DEV" ]; then - EXIT_COUNT=0 echo -e "\e[32mChecking for newer update script...\e[0m" - SHA1_1="$(sha1sum update.sh)" git fetch origin git checkout "origin/${BRANCH}" -- update.sh - SHA1_2="$(sha1sum update.sh)" - if [[ "${SHA1_1}" != "${SHA1_2}" ]]; then + if [[ "$(sha256sum update.sh | awk '{print $1}')" != "${SCRIPT_HASH}" ]]; then chmod +x update.sh - EXIT_COUNT+=1 + restart_update_script "update.sh has been updated" fi fi