From 95e06087494cb28e4a40c68147bcf148b825d231 Mon Sep 17 00:00:00 2001 From: Markku Post Date: Thu, 23 Oct 2025 00:27:13 +0300 Subject: [PATCH 01/26] [Web] Disable login on autodiscover/autoconfig domains Autodiscover and autoconfig domains (autodiscover.*, autoconfig.*) are intended solely for client autoconfiguration endpoints and should not display the mailcow login page. This change check the hostname and disables unauthenticated users from seeing the login page on those domains; HTTP 404 response is returned when necessary. --- data/web/index.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/data/web/index.php b/data/web/index.php index d4fa46e74..a1ff9310f 100644 --- a/data/web/index.php +++ b/data/web/index.php @@ -27,6 +27,12 @@ elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == ' exit(); } +$host = strtolower($_SERVER['HTTP_HOST'] ?? ''); +if (str_starts_with($host, 'autodiscover.') || str_starts_with($host, 'autoconfig.')) { + http_response_code(404); + exit(); +} + require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['index_query_string'] = $_SERVER['QUERY_STRING']; From 5d95c48e0d77de2de434b81dcb7595626088fa3e Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Wed, 10 Dec 2025 08:43:04 +0100 Subject: [PATCH 02/26] backup: add image prefetch function to verify latest image is used --- helper-scripts/backup_and_restore.sh | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/helper-scripts/backup_and_restore.sh b/helper-scripts/backup_and_restore.sh index 7b75272cf..2e65f1343 100755 --- a/helper-scripts/backup_and_restore.sh +++ b/helper-scripts/backup_and_restore.sh @@ -91,6 +91,44 @@ if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then exit 1 fi +# Add image prefetch function +function prefetch_image() { + echo "Checking Docker image: ${DEBIAN_DOCKER_IMAGE}" + + # Get local image digest if it exists + local local_digest=$(docker image inspect ${DEBIAN_DOCKER_IMAGE} --format='{{index .RepoDigests 0}}' 2>/dev/null | cut -d'@' -f2) + + # Get remote image digest without pulling + local remote_digest=$(docker manifest inspect ${DEBIAN_DOCKER_IMAGE} 2>/dev/null | grep -oP '"digest":\s*"\K[^"]+' | head -1) + + if [[ -z "${remote_digest}" ]]; then + echo "Warning: Unable to check remote image" + if [[ -n "${local_digest}" ]]; then + echo "Using cached version" + echo + return 0 + else + echo "Error: Image ${DEBIAN_DOCKER_IMAGE} not found locally or remotely" + exit 1 + fi + fi + + if [[ "${local_digest}" != "${remote_digest}" ]]; then + echo "Image update available, pulling ${DEBIAN_DOCKER_IMAGE}" + if docker pull ${DEBIAN_DOCKER_IMAGE} 2>/dev/null; then + echo "Successfully pulled ${DEBIAN_DOCKER_IMAGE}" + else + echo "Error: Failed to pull ${DEBIAN_DOCKER_IMAGE}" + exit 1 + fi + else + echo "Image is up to date (${remote_digest:0:12}...)" + fi + echo +} + +# Prefetch the image early in the script +prefetch_image function backup() { DATE=$(date +"%Y-%m-%d-%H-%M-%S") From 1ab6af21e38f95d631d39f3580bc25de92ef60f4 Mon Sep 17 00:00:00 2001 From: Ashitaka <65665184+Ashitaka57@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:41:06 +0100 Subject: [PATCH 03/26] Merge pull request #6905 from Ashitaka57/6646-pbkdf2-sha512-verify-hash Support for PBKDF2-SHA512 hash algorithm in verify_hash() (FreeIPA compatibility) (issue 6646) --- data/web/inc/functions.inc.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 81b3f7e08..1947ec465 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -814,6 +814,32 @@ function verify_hash($hash, $password) { $hash = $components[4]; return hash_equals(hash_pbkdf2('sha1', $password, $salt, $rounds), $hash); + case "PBKDF2-SHA512": + // Handle FreeIPA-style hash: {PBKDF2-SHA512}10000$$ + $components = explode('$', $hash); + if (count($components) !== 3) return false; + + // 1st part: iteration count (integer) + $iterations = intval($components[0]); + if ($iterations <= 0) return false; + + // 2nd part: salt (base64-encoded) + $salt = $components[1]; + // 3rd part: hash (base64-encoded) + $stored_hash_b64 = $components[2]; + + // Decode salt and hash from base64 + $salt_bin = base64_decode($salt, true); + $hash_bin = base64_decode($stored_hash_b64, true); + if ($salt_bin === false || $hash_bin === false) return false; + // Get length of hash in bytes + $hash_len = strlen($hash_bin); + if ($hash_len === 0) return false; + + // Calculate PBKDF2-SHA512 hash for provided password + $test_hash = hash_pbkdf2('sha512', $password, $salt_bin, $iterations, $hash_len, true); + return hash_equals($hash_bin, $test_hash); + case "PLAIN-MD4": return hash_equals(hash('md4', $password), $hash); From 39f29e6c30c55e306ca1ced585835dcdb783f834 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:41:38 +0000 Subject: [PATCH 04/26] chore(deps): update dependency imagick/imagick to v3.8.1 Signed-off-by: milkmaker --- data/Dockerfiles/phpfpm/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/phpfpm/Dockerfile b/data/Dockerfiles/phpfpm/Dockerfile index b8a7a432a..16c4b6021 100644 --- a/data/Dockerfiles/phpfpm/Dockerfile +++ b/data/Dockerfiles/phpfpm/Dockerfile @@ -5,7 +5,7 @@ LABEL maintainer = "The Infrastructure Company GmbH " # renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?.*)$ ARG APCU_PECL_VERSION=5.1.27 # renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?.*)$ -ARG IMAGICK_PECL_VERSION=3.8.0 +ARG IMAGICK_PECL_VERSION=3.8.1 # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?.*)$ ARG MAILPARSE_PECL_VERSION=3.1.9 # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?.*)$ From 1bd795a9c6f907a53338d7de969882b18a4dfc0d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:41:42 +0000 Subject: [PATCH 05/26] chore(deps): update dependency krakjoe/apcu to v5.1.28 Signed-off-by: milkmaker --- data/Dockerfiles/phpfpm/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/phpfpm/Dockerfile b/data/Dockerfiles/phpfpm/Dockerfile index b8a7a432a..05bdf129b 100644 --- a/data/Dockerfiles/phpfpm/Dockerfile +++ b/data/Dockerfiles/phpfpm/Dockerfile @@ -3,7 +3,7 @@ FROM php:8.2-fpm-alpine3.21 LABEL maintainer = "The Infrastructure Company GmbH " # renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?.*)$ -ARG APCU_PECL_VERSION=5.1.27 +ARG APCU_PECL_VERSION=5.1.28 # renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?.*)$ ARG IMAGICK_PECL_VERSION=3.8.0 # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?.*)$ From 4cdb97c6998226a9e6daad01f151fe8c757bf028 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:41:50 +0000 Subject: [PATCH 06/26] chore(deps): update dependency php-memcached-dev/php-memcached to v3.4.0 Signed-off-by: milkmaker --- data/Dockerfiles/phpfpm/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/phpfpm/Dockerfile b/data/Dockerfiles/phpfpm/Dockerfile index b8a7a432a..7f1032f7f 100644 --- a/data/Dockerfiles/phpfpm/Dockerfile +++ b/data/Dockerfiles/phpfpm/Dockerfile @@ -9,7 +9,7 @@ ARG IMAGICK_PECL_VERSION=3.8.0 # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?.*)$ ARG MAILPARSE_PECL_VERSION=3.1.9 # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?.*)$ -ARG MEMCACHED_PECL_VERSION=3.3.0 +ARG MEMCACHED_PECL_VERSION=3.4.0 # renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?.*)$ ARG REDIS_PECL_VERSION=6.2.0 # renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?.*)$ From 01cf72cdefdd6ab8eba462875f87ce26c2189ebd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:41:54 +0000 Subject: [PATCH 07/26] chore(deps): update dependency phpredis/phpredis to v6.3.0 Signed-off-by: milkmaker --- data/Dockerfiles/phpfpm/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/phpfpm/Dockerfile b/data/Dockerfiles/phpfpm/Dockerfile index b8a7a432a..7ff78c916 100644 --- a/data/Dockerfiles/phpfpm/Dockerfile +++ b/data/Dockerfiles/phpfpm/Dockerfile @@ -11,7 +11,7 @@ ARG MAILPARSE_PECL_VERSION=3.1.9 # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?.*)$ ARG MEMCACHED_PECL_VERSION=3.3.0 # renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?.*)$ -ARG REDIS_PECL_VERSION=6.2.0 +ARG REDIS_PECL_VERSION=6.3.0 # renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?.*)$ ARG COMPOSER_VERSION=2.8.6 From 689336b3e178c035776991eafdc9496bd1a5cf63 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:41:59 +0000 Subject: [PATCH 08/26] chore(deps): update dependency tianon/gosu to v1.19 Signed-off-by: milkmaker --- data/Dockerfiles/dovecot/Dockerfile | 2 +- data/Dockerfiles/sogo/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/Dockerfiles/dovecot/Dockerfile b/data/Dockerfiles/dovecot/Dockerfile index 10e141ab8..f1152c8a1 100644 --- a/data/Dockerfiles/dovecot/Dockerfile +++ b/data/Dockerfiles/dovecot/Dockerfile @@ -3,7 +3,7 @@ FROM alpine:3.21 LABEL maintainer="The Infrastructure Company GmbH " # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?.*)$ -ARG GOSU_VERSION=1.17 +ARG GOSU_VERSION=1.19 ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 diff --git a/data/Dockerfiles/sogo/Dockerfile b/data/Dockerfiles/sogo/Dockerfile index f2981ad04..ed7e07ed6 100644 --- a/data/Dockerfiles/sogo/Dockerfile +++ b/data/Dockerfiles/sogo/Dockerfile @@ -6,7 +6,7 @@ ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_VERSION=bookworm ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/ # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?.*)$ -ARG GOSU_VERSION=1.17 +ARG GOSU_VERSION=1.19 ENV LC_ALL=C # Prerequisites From 910ce573d696370d4d746d57fea203c65def28fc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:48:02 +0100 Subject: [PATCH 09/26] chore(deps): update peter-evans/create-pull-request action to v8 (#6953) --- .github/workflows/update_postscreen_access_list.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update_postscreen_access_list.yml b/.github/workflows/update_postscreen_access_list.yml index 80b218c1b..066616aee 100644 --- a/.github/workflows/update_postscreen_access_list.yml +++ b/.github/workflows/update_postscreen_access_list.yml @@ -22,7 +22,7 @@ jobs: bash helper-scripts/update_postscreen_whitelist.sh - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }} commit-message: update postscreen_access.cidr From 67e7acd6bd444c5721bb82dceafa9d0d1b35d4e2 Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Thu, 11 Dec 2025 09:45:56 +0100 Subject: [PATCH 10/26] rspamd: upgrade to 3.14.1, trixie rebuild + bcc forwarded hosts fix (#6958) * rspamd: fix bcc + subadress handling when using forward hosts * rspamd: build against trixie + use version 3.14.1 --- data/Dockerfiles/rspamd/Dockerfile | 6 +- data/conf/rspamd/lua/rspamd.local.lua | 433 ++++++++++++++++++++------ docker-compose.yml | 2 +- 3 files changed, 339 insertions(+), 102 deletions(-) diff --git a/data/Dockerfiles/rspamd/Dockerfile b/data/Dockerfiles/rspamd/Dockerfile index 061f3d1cb..2764aeeb8 100644 --- a/data/Dockerfiles/rspamd/Dockerfile +++ b/data/Dockerfiles/rspamd/Dockerfile @@ -1,9 +1,9 @@ -FROM debian:bookworm-slim +FROM debian:trixie-slim LABEL maintainer="The Infrastructure Company GmbH " ARG DEBIAN_FRONTEND=noninteractive -ARG RSPAMD_VER=rspamd_3.13.2-1~8bf602278 -ARG CODENAME=bookworm +ARG RSPAMD_VER=rspamd_3.14.1-1~46a758617 +ARG CODENAME=trixie ENV LC_ALL=C RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/data/conf/rspamd/lua/rspamd.local.lua b/data/conf/rspamd/lua/rspamd.local.lua index 9d0a58bcf..503b41e6d 100644 --- a/data/conf/rspamd/lua/rspamd.local.lua +++ b/data/conf/rspamd/lua/rspamd.local.lua @@ -146,8 +146,171 @@ rspamd_config:register_symbol({ return false end + -- Helper function to parse IPv6 into 8 segments + local function ipv6_to_segments(ip_str) + -- Remove zone identifier if present (e.g., %eth0) + ip_str = ip_str:gsub("%%.*$", "") + + local segments = {} + + -- Handle :: compression + if ip_str:find('::') then + local before, after = ip_str:match('^(.*)::(.*)$') + before = before or '' + after = after or '' + + local before_parts = {} + local after_parts = {} + + if before ~= '' then + for seg in before:gmatch('[^:]+') do + table.insert(before_parts, tonumber(seg, 16) or 0) + end + end + + if after ~= '' then + for seg in after:gmatch('[^:]+') do + table.insert(after_parts, tonumber(seg, 16) or 0) + end + end + + -- Add before segments + for _, seg in ipairs(before_parts) do + table.insert(segments, seg) + end + + -- Add compressed zeros + local zeros_needed = 8 - #before_parts - #after_parts + for i = 1, zeros_needed do + table.insert(segments, 0) + end + + -- Add after segments + for _, seg in ipairs(after_parts) do + table.insert(segments, seg) + end + else + -- No compression + for seg in ip_str:gmatch('[^:]+') do + table.insert(segments, tonumber(seg, 16) or 0) + end + end + + -- Ensure we have exactly 8 segments + while #segments < 8 do + table.insert(segments, 0) + end + + return segments + end + + -- Generate all common IPv6 notations + local function get_ipv6_variants(ip_str) + local variants = {} + local seen = {} + + local function add_variant(v) + if v and not seen[v] then + table.insert(variants, v) + seen[v] = true + end + end + + -- For IPv4, just return the original + if not ip_str:find(':') then + add_variant(ip_str) + return variants + end + + local segments = ipv6_to_segments(ip_str) + + -- 1. Fully expanded form (all zeros shown as 0000) + local expanded_parts = {} + for _, seg in ipairs(segments) do + table.insert(expanded_parts, string.format('%04x', seg)) + end + add_variant(table.concat(expanded_parts, ':')) + + -- 2. Standard form (no leading zeros, but all segments present) + local standard_parts = {} + for _, seg in ipairs(segments) do + table.insert(standard_parts, string.format('%x', seg)) + end + add_variant(table.concat(standard_parts, ':')) + + -- 3. Find all possible :: compressions + -- RFC 5952: compress the longest run of consecutive zeros + -- But we need to check all possibilities since Redis might have any form + + -- Find all zero runs + local zero_runs = {} + local in_run = false + local run_start = 0 + local run_length = 0 + + for i = 1, 8 do + if segments[i] == 0 then + if not in_run then + in_run = true + run_start = i + run_length = 1 + else + run_length = run_length + 1 + end + else + if in_run then + if run_length >= 1 then -- Allow single zero compression too + table.insert(zero_runs, {start = run_start, length = run_length}) + end + in_run = false + end + end + end + + -- Don't forget the last run + if in_run and run_length >= 1 then + table.insert(zero_runs, {start = run_start, length = run_length}) + end + + -- Generate variant for each zero run compression + for _, run in ipairs(zero_runs) do + local parts = {} + + -- Before compression + for i = 1, run.start - 1 do + table.insert(parts, string.format('%x', segments[i])) + end + + -- The compression + if run.start == 1 then + table.insert(parts, '') + table.insert(parts, '') + elseif run.start + run.length - 1 == 8 then + table.insert(parts, '') + table.insert(parts, '') + else + table.insert(parts, '') + end + + -- After compression + for i = run.start + run.length, 8 do + table.insert(parts, string.format('%x', segments[i])) + end + + local compressed = table.concat(parts, ':'):gsub('::+', '::') + add_variant(compressed) + end + + return variants + end + local from_ip_string = tostring(ip) - ip_check_table = {from_ip_string} + local ip_check_table = {} + + -- Add all variants of the exact IP + for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do + table.insert(ip_check_table, variant) + end local maxbits = 128 local minbits = 32 @@ -155,10 +318,18 @@ rspamd_config:register_symbol({ maxbits = 32 minbits = 8 end + + -- Add all CIDR notations with variants for i=maxbits,minbits,-1 do - local nip = ip:apply_mask(i):to_string() .. "/" .. i - table.insert(ip_check_table, nip) + local masked_ip = ip:apply_mask(i) + local cidr_base = masked_ip:to_string() + + for _, variant in ipairs(get_ipv6_variants(cidr_base)) do + local cidr = variant .. "/" .. i + table.insert(ip_check_table, cidr) + end end + local function keep_spam_cb(err, data) if err then rspamd_logger.infox(rspamd_config, "keep_spam query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err) @@ -166,12 +337,15 @@ rspamd_config:register_symbol({ else for k,v in pairs(data) do if (v and v ~= userdata and v == '1') then - rspamd_logger.infox(rspamd_config, "found ip in keep_spam map, setting pre-result") + rspamd_logger.infox(rspamd_config, "found ip %s (checked as: %s) in keep_spam map, setting pre-result accept", from_ip_string, ip_check_table[k]) task:set_pre_result('accept', 'ip matched with forward hosts', 'keep_spam') + task:set_flag('no_stat') + return end end end end + table.insert(ip_check_table, 1, 'KEEP_SPAM') local redis_ret_user = rspamd_redis_make_request(task, redis_params, -- connect params @@ -210,6 +384,7 @@ rspamd_config:register_symbol({ rspamd_config:register_symbol({ name = 'TAG_MOO', type = 'postfilter', + flags = 'ignore_passthrough', callback = function(task) local util = require("rspamd_util") local rspamd_logger = require "rspamd_logger" @@ -218,9 +393,6 @@ rspamd_config:register_symbol({ local rcpts = task:get_recipients('smtp') local lua_util = require "lua_util" - local tagged_rcpt = task:get_symbol("TAGGED_RCPT") - local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN") - local function remove_moo_tag() local moo_tag_header = task:get_header('X-Moo-Tag', false) if moo_tag_header then @@ -231,101 +403,149 @@ rspamd_config:register_symbol({ return true end - if tagged_rcpt and tagged_rcpt[1].options and mailcow_domain then - local tag = tagged_rcpt[1].options[1] - rspamd_logger.infox("found tag: %s", tag) - local action = task:get_metric_action('default') - rspamd_logger.infox("metric action now: %s", action) + -- Check if we have exactly one recipient + if not (rcpts and #rcpts == 1) then + rspamd_logger.infox("TAG_MOO: not exactly one rcpt (%s), removing moo tag", rcpts and #rcpts or 0) + remove_moo_tag() + return + end - if action ~= 'no action' and action ~= 'greylist' then - rspamd_logger.infox("skipping tag handler for action: %s", action) - remove_moo_tag() - return true + local rcpt_addr = rcpts[1]['addr'] + local rcpt_user = rcpts[1]['user'] + local rcpt_domain = rcpts[1]['domain'] + + -- Check if recipient has a tag (contains '+') + local tag = nil + if rcpt_user:find('%+') then + 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 + end - local function http_callback(err_message, code, body, headers) - if body ~= nil and body ~= "" then - rspamd_logger.infox(rspamd_config, "expanding rcpt to \"%s\"", body) + if not tag then + rspamd_logger.infox("TAG_MOO: no tag found in recipient %s, removing moo tag", rcpt_addr) + remove_moo_tag() + return + end - local function tag_callback_subject(err, data) - if err or type(data) ~= 'string' then - rspamd_logger.infox(rspamd_config, "subject tag handler rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying subfolder tag handler...", body, data, err) + -- Optional: Check if domain is a mailcow domain + -- When KEEP_SPAM is active, RCPT_MAILCOW_DOMAIN might not be set + -- If the mail is being delivered, we can assume it's valid + local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN") + if not mailcow_domain then + rspamd_logger.infox("TAG_MOO: RCPT_MAILCOW_DOMAIN not set (possibly due to pre-result), proceeding anyway for domain %s", rcpt_domain) + end - local function tag_callback_subfolder(err, data) - if err or type(data) ~= 'string' then - rspamd_logger.infox(rspamd_config, "subfolder tag handler for rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\")", body, data, err) - remove_moo_tag() - else - rspamd_logger.infox("Add X-Moo-Tag header") - task:set_milter_reply({ - add_headers = {['X-Moo-Tag'] = 'YES'} - }) - end - end + local action = task:get_metric_action('default') + rspamd_logger.infox("TAG_MOO: metric action: %s", action) - local redis_ret_subfolder = rspamd_redis_make_request(task, - redis_params, -- connect params - body, -- hash key - false, -- is write - tag_callback_subfolder, --callback - 'HGET', -- command - {'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments - ) - if not redis_ret_subfolder then - rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt") + -- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER) + local allow_processing = false + + if task.has_pre_result then + local has_pre, pre_action = task:has_pre_result() + if has_pre then + rspamd_logger.infox("TAG_MOO: pre-result detected: %s", tostring(pre_action)) + if pre_action == 'accept' then + allow_processing = true + rspamd_logger.infox("TAG_MOO: pre-result is accept, will process") + end + end + end + + -- Allow processing for mild actions or when we have pre-result accept + if not allow_processing and action ~= 'no action' and action ~= 'greylist' then + rspamd_logger.infox("TAG_MOO: skipping tag handler for action: %s", action) + remove_moo_tag() + return true + end + + rspamd_logger.infox("TAG_MOO: processing allowed") + + local function http_callback(err_message, code, body, headers) + if body ~= nil and body ~= "" then + rspamd_logger.infox(rspamd_config, "TAG_MOO: expanding rcpt to \"%s\"", body) + + local function tag_callback_subject(err, data) + if err or type(data) ~= 'string' or data == '' then + rspamd_logger.infox(rspamd_config, "TAG_MOO: subject tag handler rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying subfolder tag handler...", body, data, err) + + local function tag_callback_subfolder(err, data) + if err or type(data) ~= 'string' or data == '' then + rspamd_logger.infox(rspamd_config, "TAG_MOO: subfolder tag handler for rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\")", body, data, err) remove_moo_tag() + else + rspamd_logger.infox("TAG_MOO: User wants subfolder tag, adding X-Moo-Tag header") + task:set_milter_reply({ + add_headers = {['X-Moo-Tag'] = 'YES'} + }) end - - else - rspamd_logger.infox("user wants subject modified for tagged mail") - local sbj = task:get_header('Subject') - new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' - task:set_milter_reply({ - remove_headers = { - ['Subject'] = 1, - ['X-Moo-Tag'] = 0 - }, - add_headers = {['Subject'] = new_sbj} - }) end - end - local redis_ret_subject = rspamd_redis_make_request(task, - redis_params, -- connect params - body, -- hash key - false, -- is write - tag_callback_subject, --callback - 'HGET', -- command - {'RCPT_WANTS_SUBJECT_TAG', body} -- arguments - ) - if not redis_ret_subject then - rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt") - remove_moo_tag() - end - - end - end - - if rcpts and #rcpts == 1 then - for _,rcpt in ipairs(rcpts) do - local rcpt_split = rspamd_str_split(rcpt['addr'], '@') - if #rcpt_split == 2 then - if rcpt_split[1] == 'postmaster' then - rspamd_logger.infox(rspamd_config, "not expanding postmaster alias") + local redis_ret_subfolder = rspamd_redis_make_request(task, + redis_params, -- connect params + body, -- hash key + false, -- is write + tag_callback_subfolder, --callback + 'HGET', -- command + {'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments + ) + if not redis_ret_subfolder then + rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt") remove_moo_tag() - else - rspamd_http.request({ - task=task, - url='http://nginx:8081/aliasexp.php', - body='', - callback=http_callback, - headers={Rcpt=rcpt['addr']}, - }) end + + else + rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail") + local sbj = task:get_header('Subject') or '' + new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' + task:set_milter_reply({ + remove_headers = { + ['Subject'] = 1, + ['X-Moo-Tag'] = 0 + }, + add_headers = {['Subject'] = new_sbj} + }) end end + + local redis_ret_subject = rspamd_redis_make_request(task, + redis_params, -- connect params + body, -- hash key + false, -- is write + tag_callback_subject, --callback + 'HGET', -- command + {'RCPT_WANTS_SUBJECT_TAG', body} -- arguments + ) + if not redis_ret_subject then + rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt") + remove_moo_tag() + end + else + rspamd_logger.infox("TAG_MOO: alias expansion returned empty body") + remove_moo_tag() + end + end + + local rcpt_split = rspamd_str_split(rcpt_addr, '@') + if #rcpt_split == 2 then + if rcpt_split[1]:match('^postmaster') then + rspamd_logger.infox(rspamd_config, "TAG_MOO: not expanding postmaster alias") + remove_moo_tag() + else + rspamd_logger.infox("TAG_MOO: requesting alias expansion for %s", rcpt_addr) + rspamd_http.request({ + task=task, + url='http://nginx:8081/aliasexp.php', + body='', + callback=http_callback, + headers={Rcpt=rcpt_addr}, + }) end else + rspamd_logger.infox("TAG_MOO: invalid rcpt format") remove_moo_tag() end end, @@ -335,6 +555,7 @@ rspamd_config:register_symbol({ rspamd_config:register_symbol({ name = 'BCC', type = 'postfilter', + flags = 'ignore_passthrough', callback = function(task) local util = require("rspamd_util") local rspamd_http = require "rspamd_http" @@ -363,11 +584,13 @@ rspamd_config:register_symbol({ local email_content = tostring(task:get_content()) email_content = string.gsub(email_content, "\r\n%.", "\r\n..") -- send mail + local from_smtp = task:get_from('smtp') + local from_addr = (from_smtp and from_smtp[1] and from_smtp[1].addr) or 'mailer-daemon@localhost' lua_smtp.sendmail({ task = task, host = os.getenv("IPV4_NETWORK") .. '.253', port = 591, - from = task:get_from(stp)[1].addr, + from = from_addr, recipients = bcc_dest, helo = 'bcc', timeout = 20, @@ -397,27 +620,41 @@ rspamd_config:register_symbol({ end local action = task:get_metric_action('default') - rspamd_logger.infox("metric action now: %s", action) + rspamd_logger.infox("BCC: metric action: %s", action) + + -- Check for pre-result accept (e.g., from KEEP_SPAM) + local allow_bcc = false + if task.has_pre_result then + local has_pre, pre_action = task:has_pre_result() + if has_pre and pre_action == 'accept' then + allow_bcc = true + rspamd_logger.infox("BCC: pre-result accept detected, will send BCC") + end + end + + -- Allow BCC for mild actions or when we have pre-result accept + if not allow_bcc and action ~= 'no action' and action ~= 'add header' and action ~= 'rewrite subject' then + rspamd_logger.infox("BCC: skipping for action: %s", action) + return + end local function rcpt_callback(err_message, code, body, headers) if err_message == nil and code == 201 and body ~= nil then - if action == 'no action' or action == 'add header' or action == 'rewrite subject' then - send_mail(task, body) - end + rspamd_logger.infox("BCC: sending BCC to %s for rcpt match", body) + send_mail(task, body) end end local function from_callback(err_message, code, body, headers) if err_message == nil and code == 201 and body ~= nil then - if action == 'no action' or action == 'add header' or action == 'rewrite subject' then - send_mail(task, body) - end + rspamd_logger.infox("BCC: sending BCC to %s for from match", body) + send_mail(task, body) end end if rcpt_table then for _,e in ipairs(rcpt_table) do - rspamd_logger.infox(rspamd_config, "checking bcc for rcpt address %s", e) + rspamd_logger.infox(rspamd_config, "BCC: checking bcc for rcpt address %s", e) rspamd_http.request({ task=task, url='http://nginx:8081/bcc.php', @@ -430,7 +667,7 @@ rspamd_config:register_symbol({ if from_table then for _,e in ipairs(from_table) do - rspamd_logger.infox(rspamd_config, "checking bcc for from address %s", e) + rspamd_logger.infox(rspamd_config, "BCC: checking bcc for from address %s", e) rspamd_http.request({ task=task, url='http://nginx:8081/bcc.php', @@ -441,7 +678,7 @@ rspamd_config:register_symbol({ end end - return true + -- Don't return true to avoid symbol being logged end, priority = 20 }) @@ -708,4 +945,4 @@ rspamd_config:register_symbol({ return true end, priority = 1 -}) +}) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 75d00af34..cc8c9b98c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: - clamd rspamd-mailcow: - image: ghcr.io/mailcow/rspamd:2.4 + image: ghcr.io/mailcow/rspamd:3.14.1 stop_grace_period: 30s depends_on: - dovecot-mailcow From 1bac6f1ee726f1d7b8adddbd16719b373326ac6d Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Thu, 11 Dec 2025 13:29:11 +0100 Subject: [PATCH 11/26] ofelia: revert fixed cron syntax for sa-rules download --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index cc8c9b98c..b460086d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -321,7 +321,7 @@ services: ofelia.job-exec.dovecot_clean_q_aged.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/clean_q_aged.sh || exit 0\"" ofelia.job-exec.dovecot_maildir_gc.schedule: "0 */30 * * * *" ofelia.job-exec.dovecot_maildir_gc.command: "/bin/bash -c \"source /source_env.sh ; /usr/local/bin/gosu vmail /usr/local/bin/maildir_gc.sh\"" - ofelia.job-exec.dovecot_sarules.schedule: "0 0 0 * * *" + ofelia.job-exec.dovecot_sarules.schedule: "@every 24h" ofelia.job-exec.dovecot_sarules.command: "/bin/bash -c \"/usr/local/bin/sa-rules.sh\"" ofelia.job-exec.dovecot_fts.schedule: "0 0 0 * * *" ofelia.job-exec.dovecot_fts.command: "/bin/bash -c \"/usr/local/bin/gosu vmail /usr/local/bin/optimize-fts.sh\"" From 3ebf2c2d2db44195ed6b3c4d4e5cd08446fba9eb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:34:20 +0100 Subject: [PATCH 12/26] Prevent duplicate/plaintext login announcement rendering (#6963) * Initial plan * Fix duplicate login announcement display Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> --- data/web/templates/base.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/web/templates/base.twig b/data/web/templates/base.twig index a8d0f6f39..98fdd86e4 100644 --- a/data/web/templates/base.twig +++ b/data/web/templates/base.twig @@ -144,7 +144,7 @@
-{% if ui_texts.ui_announcement_text and ui_texts.ui_announcement_active and not is_root_uri %} +{% if ui_texts.ui_announcement_text and ui_texts.ui_announcement_active and not is_root_uri and mailcow_cc_username %}
{{ ui_texts.ui_announcement_text }}
From b6f57dfb78727b14a6f81cd6f34668c8da01ba0f Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Fri, 12 Dec 2025 14:06:49 +0100 Subject: [PATCH 13/26] rspamd: update to 3.14.2 --- data/Dockerfiles/rspamd/Dockerfile | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/Dockerfiles/rspamd/Dockerfile b/data/Dockerfiles/rspamd/Dockerfile index 2764aeeb8..55bcc9572 100644 --- a/data/Dockerfiles/rspamd/Dockerfile +++ b/data/Dockerfiles/rspamd/Dockerfile @@ -2,7 +2,7 @@ FROM debian:trixie-slim LABEL maintainer="The Infrastructure Company GmbH " ARG DEBIAN_FRONTEND=noninteractive -ARG RSPAMD_VER=rspamd_3.14.1-1~46a758617 +ARG RSPAMD_VER=rspamd_3.14.2-82~90302bc ARG CODENAME=trixie ENV LC_ALL=C diff --git a/docker-compose.yml b/docker-compose.yml index b460086d7..fccd9ee4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: - clamd rspamd-mailcow: - image: ghcr.io/mailcow/rspamd:3.14.1 + image: ghcr.io/mailcow/rspamd:3.14.2 stop_grace_period: 30s depends_on: - dovecot-mailcow From 12e02e67ffa90528874c0681e2efe612798a6728 Mon Sep 17 00:00:00 2001 From: milkmaker Date: Fri, 12 Dec 2025 15:21:04 +0100 Subject: [PATCH 14/26] Translations update from Weblate (#6965) * [Web] Updated lang.fr-fr.json Co-authored-by: Keo * [Web] Updated lang.pt-pt.json Co-authored-by: Germano Pires Ferreira Co-authored-by: milkmaker * [Web] Updated lang.pl-pl.json Co-authored-by: Monika Bark --------- Co-authored-by: Keo Co-authored-by: Germano Pires Ferreira Co-authored-by: Monika Bark --- data/web/lang/lang.fr-fr.json | 2 +- data/web/lang/lang.pl-pl.json | 10 +++++++--- data/web/lang/lang.pt-pt.json | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/data/web/lang/lang.fr-fr.json b/data/web/lang/lang.fr-fr.json index af0df47c9..43c9c065a 100644 --- a/data/web/lang/lang.fr-fr.json +++ b/data/web/lang/lang.fr-fr.json @@ -1266,7 +1266,7 @@ "no_last_login": "Aucune dernière information de connexion à l'interface", "no_record": "Pas d'enregistrement", "password": "Mot de passe", - "password_now": "Mot de passe courant (confirmer les changements)", + "password_now": "Mot de passe actuel (confirmer les changements)", "password_repeat": "Mot de passe (répéter)", "pushover_evaluate_x_prio": "Acheminement du courrier hautement prioritaire [X-Priority: 1]", "pushover_info": "Les paramètres de notification push s’appliqueront à tout le courrier propre (non spam) livré à %s y compris les alias (partagés, non partagés, étiquetés).", diff --git a/data/web/lang/lang.pl-pl.json b/data/web/lang/lang.pl-pl.json index 41cf10283..d78d46f7a 100644 --- a/data/web/lang/lang.pl-pl.json +++ b/data/web/lang/lang.pl-pl.json @@ -240,7 +240,7 @@ "generate": "Generuj", "guid": "GUID - unikalny identyfikator instancji", "guid_and_license": "GUID & licencja", - "hash_remove_info": "Usunięcie hasha z limitem współczynnika (jeśli nadal istnieje) spowoduje całkowite zresetowanie jego licznika.
\n\n\n\n Każdy hash jest oznaczony indywidualnym kolorem.", + "hash_remove_info": "Usunięcie hasha z limitem współczynnika (jeśli nadal istnieje) spowoduje całkowite zresetowanie jego licznika.
Każdy hash jest oznaczony indywidualnym kolorem.", "help_text": "Zastąp tekst pomocy poniżej maski logowania (dozwolone HTML)", "html": "HTML", "iam": "Dostawca tożsamości", @@ -683,7 +683,11 @@ "mailbox_rename_agree": "Stworzyłem kopię zapasową.", "mailbox_rename_warning": "WAŻNE! Utwórz kopię zapasową przed zmianą nazwy skrzynki pocztowej.", "mailbox_rename_alias": "Tworzenie aliasów automatycznie", - "mailbox_rename_title": "Nowa nazwa lokalnej skrzynki pocztowej" + "mailbox_rename_title": "Nowa nazwa lokalnej skrzynki pocztowej", + "mbox_rl_info": "Ten limit szybkości dotyczy nazwy logowania SASL i odpowiada dowolnemu adresowi „from” używanemu przez zalogowanego użytkownika. Limit szybkości dla skrzynki pocztowej nadpisuje limit szybkości dla całej domeny.", + "nexthop": "Następny hop", + "private_comment": "Prywatny komentarz", + "public_comment": "Komentarz publiczny" }, "footer": { "cancel": "Anuluj", @@ -1075,7 +1079,7 @@ "spamfilter_table_remove": "Usuń", "spamfilter_table_rule": "Zasada", "spamfilter_wl": "Biała lista", - "spamfilter_wl_desc": "Adresy e-mail znajdujące się na liście dozwolonych (allowlist) są zaprogramowane tak, aby nigdy nie były klasyfikowane jako spam.\nMożna używać symboli wieloznacznych (wildcardów).\nFiltr jest stosowany wyłącznie do bezpośrednich aliasów (aliasów wskazujących na jedną skrzynkę pocztową), z wyłączeniem aliasów typu „catch-all” oraz samej skrzynki pocztowej", + "spamfilter_wl_desc": "Adresy e-mail znajdujące się na liście dozwolonych (allowlist) są zaprogramowane tak, aby nigdy nie były klasyfikowane jako spam. Można używać symboli wieloznacznych (wildcardów).Filtr jest stosowany wyłącznie do bezpośrednich aliasów (aliasów wskazujących na jedną skrzynkę pocztową), z wyłączeniem aliasów typu „catch-all” oraz samej skrzynki pocztowej", "spamfilter_yellow": "Żółty: ta wiadomość może być spamem, zostanie oznaczona jako spam i przeniesiona do folderu spam", "sync_jobs": "Zadania synchronizacji", "tag_handling": "Ustaw obsługę znaczników pocztowych", diff --git a/data/web/lang/lang.pt-pt.json b/data/web/lang/lang.pt-pt.json index e86391fa7..64f85dd35 100644 --- a/data/web/lang/lang.pt-pt.json +++ b/data/web/lang/lang.pt-pt.json @@ -340,7 +340,8 @@ "tls_policy": "Política de TLS", "quarantine_attachments": "Anexos de quarentena", "filters": "Filtros", - "smtp_ip_access": "Mudar anfitriões permitidos para SMTP" + "smtp_ip_access": "Mudar anfitriões permitidos para SMTP", + "app_passwds": "Gerenciar senhas de aplicativos" }, "warning": { "no_active_admin": "Não é possível desactivar o último administrador activo" From 1fe4cd03e9c657d2d5f80c30d7d364969b9cba8c Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Fri, 12 Dec 2025 16:01:18 +0100 Subject: [PATCH 15/26] ui: fix global filters ui tickbox reappearing (#6966) --- data/web/js/site/admin.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/data/web/js/site/admin.js b/data/web/js/site/admin.js index 009a27fbb..91bb0e4cf 100644 --- a/data/web/js/site/admin.js +++ b/data/web/js/site/admin.js @@ -54,7 +54,16 @@ jQuery(function($){ $.get("/inc/ajax/show_rspamd_global_filters.php"); $("#confirm_show_rspamd_global_filters").hide(); $("#rspamd_global_filters").removeClass("d-none"); + localStorage.setItem('rspamd_global_filters_confirmed', 'true'); }); + + $(document).ready(function() { + if (localStorage.getItem('rspamd_global_filters_confirmed') === 'true') { + $("#confirm_show_rspamd_global_filters").hide(); + $("#rspamd_global_filters").removeClass("d-none"); + } + }); + $("#super_delete").click(function() { return confirm(lang.queue_ays); }); $(".refresh_table").on('click', function(e) { From 038b2efb759e6d011b35beee53e3e534d266341f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:29:21 +0100 Subject: [PATCH 16/26] Add MTA-STS support for alias domains (#6972) * Initial plan * Add MTA-STS support for alias domains Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> * Improve domain normalization and code style in mta-sts.php Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> * Add error handling for idn_to_ascii in mta-sts.php Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> * Add database error handling for alias domain query Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> * Add ACME certificate support for MTA-STS on alias domains Query alias_domain table to find aliases with MTA-STS enabled target domains and request certificates for mta-sts. subdomains. Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> * compose: bump image tag to 1.95 * Add MTA-STS DNS records display for alias domains in UI When viewing an alias domain's DNS diagnostics, check if the target domain has MTA-STS enabled and display the required DNS records for the alias domain. Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com> Co-authored-by: DerLinkman --- data/Dockerfiles/acme/acme.sh | 19 +++++++++++++++++++ data/web/inc/ajax/dns_diagnostics.php | 11 ++++++++++- data/web/mta-sts.php | 25 ++++++++++++++++++++++++- docker-compose.yml | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh index 69b18bc1f..2aab8e7e3 100755 --- a/data/Dockerfiles/acme/acme.sh +++ b/data/Dockerfiles/acme/acme.sh @@ -246,6 +246,25 @@ while true; do done VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}") done + + # Fetch alias domains where target domain has MTA-STS enabled + if [[ ${AUTODISCOVER_SAN} == "y" ]]; then + SQL_ALIAS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ad.alias_domain FROM alias_domain ad INNER JOIN mta_sts m ON ad.target_domain = m.domain WHERE ad.active = 1 AND m.active = 1" -Bs) + if [[ $? -eq 0 ]]; then + while read alias_domain; do + if [[ -z "${alias_domain}" ]]; then + # ignore empty lines + continue + fi + # Only add mta-sts subdomain for alias domains + if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then + if check_domain "mta-sts.${alias_domain}"; then + VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}") + fi + fi + done <<< "${SQL_ALIAS_DOMAINS}" + fi + fi fi if check_domain ${MAILCOW_HOSTNAME}; then diff --git a/data/web/inc/ajax/dns_diagnostics.php b/data/web/inc/ajax/dns_diagnostics.php index b48239e10..95e34e886 100644 --- a/data/web/inc/ajax/dns_diagnostics.php +++ b/data/web/inc/ajax/dns_diagnostics.php @@ -129,7 +129,16 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm ); } - $mta_sts = mailbox('get', 'mta_sts', $domain); + // Check if domain is an alias domain and get target domain's MTA-STS + $alias_domain_details = mailbox('get', 'alias_domain_details', $domain); + $mta_sts_domain = $domain; + + if ($alias_domain_details !== false && !empty($alias_domain_details['target_domain'])) { + // This is an alias domain, check target domain for MTA-STS + $mta_sts_domain = $alias_domain_details['target_domain']; + } + + $mta_sts = mailbox('get', 'mta_sts', $mta_sts_domain); if (count($mta_sts) > 0 && $mta_sts['active'] == 1) { if (!in_array($domain, $alias_domains)) { $records[] = array( diff --git a/data/web/mta-sts.php b/data/web/mta-sts.php index 650b8b583..0c6f1a248 100644 --- a/data/web/mta-sts.php +++ b/data/web/mta-sts.php @@ -7,7 +7,30 @@ if (!isset($_SERVER['HTTP_HOST']) || strpos($_SERVER['HTTP_HOST'], 'mta-sts.') ! } $host = preg_replace('/:[0-9]+$/', '', $_SERVER['HTTP_HOST']); -$domain = str_replace('mta-sts.', '', $host); +$domain = idn_to_ascii(strtolower(str_replace('mta-sts.', '', $host)), 0, INTL_IDNA_VARIANT_UTS46); + +// Validate domain or return 404 on error +if ($domain === false || empty($domain)) { + http_response_code(404); + exit; +} + +// Check if domain is an alias domain and resolve to target domain +try { + $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain"); + $stmt->execute(array(':domain' => $domain)); + $alias_row = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($alias_row !== false && !empty($alias_row['target_domain'])) { + // This is an alias domain, use the target domain for MTA-STS lookup + $domain = $alias_row['target_domain']; + } +} catch (PDOException $e) { + // On database error, return 404 + http_response_code(404); + exit; +} + $mta_sts = mailbox('get', 'mta_sts', $domain); if (count($mta_sts) == 0 || diff --git a/docker-compose.yml b/docker-compose.yml index fccd9ee4e..f09afca2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -465,7 +465,7 @@ services: condition: service_started unbound-mailcow: condition: service_healthy - image: ghcr.io/mailcow/acme:1.94 + image: ghcr.io/mailcow/acme:1.95 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: From c060c205d3ab8c6a16504b688ec2fd8806b1f1a8 Mon Sep 17 00:00:00 2001 From: bluewalk Date: Sun, 21 Dec 2025 16:56:16 +0100 Subject: [PATCH 17/26] Fixes issue #6489 --- data/web/autoconfig.php | 4 ++-- data/web/inc/vars.inc.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/data/web/autoconfig.php b/data/web/autoconfig.php index 6a528d4a2..bb4a55cb6 100644 --- a/data/web/autoconfig.php +++ b/data/web/autoconfig.php @@ -29,8 +29,8 @@ header('Content-Type: application/xml'); %EMAILDOMAIN% - A mailcow mail server - mail server + + diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 9f3208e3d..f206ad633 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -33,6 +33,8 @@ if ($https_port === FALSE) { //$https_port = 1234; // Other settings => $autodiscover_config = array( + 'displayName' => 'A mailcow mail server', + 'displayShortName' => 'mail server', // General autodiscover service type: "activesync" or "imap" // emClient uses autodiscover, but does not support ActiveSync. mailcow excludes emClient from ActiveSync. // With SOGo disabled, the type will always fallback to imap. CalDAV and CardDAV will be excluded, too. From 70101d1187a99ade3133e740c93af198ac7f1bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20S=C3=BCtterlin?= Date: Thu, 1 Jan 2026 16:48:33 +0100 Subject: [PATCH 18/26] fix: Password for mobileconfig that conforms to password-complexity policy --- data/web/inc/functions.inc.php | 36 ++++++++++++++++++++++++++++++++++ data/web/mobileconfig.php | 11 ++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 1947ec465..23b8d701d 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -205,6 +205,42 @@ function password_complexity($_action, $_data = null) { break; } } + +function password_generate(){ + $password_complexity = password_complexity('get'); + $min_length = max(16, intval($password_complexity['length'])); + + $lowercase = range('a', 'z'); + $uppercase = range('A', 'Z'); + $digits = range(0, 9); + $special_chars = str_split('!@#$%^&*()?='); + + $password = [ + $lowercase[random_int(0, count($lowercase) - 1)], + $uppercase[random_int(0, count($uppercase) - 1)], + $digits[random_int(0, count($digits) - 1)], + $special_chars[random_int(0, count($special_chars) - 1)], + ]; + + $all = array_merge($lowercase, $uppercase, $digits, $special_chars); + + while (count($password) < $min_length) { + $password[] = $all[random_int(0, count($all) - 1)]; + } + + // Cryptographically secure shuffle using Fisher-Yates algorithm + $count = count($password); + for ($i = $count - 1; $i > 0; $i--) { + $j = random_int(0, $i); + $temp = $password[$i]; + $password[$i] = $password[$j]; + $password[$j] = $temp; + } + + return implode('', $password); + +} + function password_check($password1, $password2) { $password_complexity = password_complexity('get'); diff --git a/data/web/mobileconfig.php b/data/web/mobileconfig.php index 44aaa30ae..7c0ead7f5 100644 --- a/data/web/mobileconfig.php +++ b/data/web/mobileconfig.php @@ -34,15 +34,15 @@ catch(PDOException $e) { if (isset($_GET['only_email'])) { $onlyEmailAccount = true; - $description = 'IMAP'; + $description = 'IMAP'; } else { $onlyEmailAccount = false; - $description = 'IMAP, CalDAV, CardDAV'; + $description = 'IMAP, CalDAV, CardDAV'; } if (isset($_GET['app_password'])) { $app_password = true; $description .= ' with application password'; - + if (strpos($_SERVER['HTTP_USER_AGENT'], 'iPad') !== FALSE) $platform = 'iPad'; elseif (strpos($_SERVER['HTTP_USER_AGENT'], 'iPhone') !== FALSE) @@ -51,8 +51,9 @@ if (isset($_GET['app_password'])) { $platform = 'Mac'; else $platform = $_SERVER['HTTP_USER_AGENT']; - - $password = bin2hex(openssl_random_pseudo_bytes(16)); + + $password = password_generate(); + $attr = array( 'app_name' => $platform, 'app_passwd' => $password, From 71fa3ecebc9e11ee9936ed5bdf69bfcb90d9c1dc Mon Sep 17 00:00:00 2001 From: milkmaker Date: Wed, 7 Jan 2026 17:22:01 +0100 Subject: [PATCH 19/26] update postscreen_access.cidr (#6987) --- data/conf/postfix/postscreen_access.cidr | 101 ++++++++--------------- 1 file changed, 34 insertions(+), 67 deletions(-) diff --git a/data/conf/postfix/postscreen_access.cidr b/data/conf/postfix/postscreen_access.cidr index 694b98636..9ff9f6265 100644 --- a/data/conf/postfix/postscreen_access.cidr +++ b/data/conf/postfix/postscreen_access.cidr @@ -1,6 +1,6 @@ -# Whitelist generated by Postwhite v3.4 on Mon Dec 1 00:24:43 UTC 2025 +# Whitelist generated by Postwhite v3.4 on Thu Jan 1 00:24:01 UTC 2026 # https://github.com/stevejenkins/postwhite/ -# 2186 total rules +# 2105 total rules 2a00:1450:4000::/36 permit 2a01:111:f400::/48 permit 2a01:111:f403:2800::/53 permit @@ -54,8 +54,8 @@ 8.36.116.0/24 permit 8.39.144.0/24 permit 12.130.86.238 permit -13.107.213.69 permit -13.107.246.69 permit +13.107.213.38 permit +13.107.246.38 permit 13.108.16.0/20 permit 13.110.208.0/21 permit 13.110.209.0/24 permit @@ -65,7 +65,6 @@ 13.111.191.0/24 permit 13.216.7.111 permit 13.216.54.180 permit -13.247.164.219 permit 15.200.21.50 permit 15.200.44.248 permit 15.200.201.185 permit @@ -296,14 +295,6 @@ 52.94.124.0/28 permit 52.95.48.152/29 permit 52.95.49.88/29 permit -52.96.91.34 permit -52.96.111.82 permit -52.96.172.98 permit -52.96.222.194 permit -52.96.222.226 permit -52.96.223.2 permit -52.96.228.130 permit -52.96.229.242 permit 52.100.0.0/15 permit 52.102.0.0/16 permit 52.103.0.0/17 permit @@ -397,19 +388,8 @@ 64.207.219.143 permit 64.233.160.0/19 permit 65.52.80.137 permit -65.54.121.120/29 permit 65.55.29.77 permit -65.55.33.64/28 permit 65.55.42.224/28 permit -65.55.52.224/27 permit -65.55.78.128/25 permit -65.55.81.48/28 permit -65.55.94.0/25 permit -65.55.113.64/26 permit -65.55.126.0/25 permit -65.55.174.0/25 permit -65.55.178.128/27 permit -65.55.234.192/26 permit 65.110.161.77 permit 65.123.29.213 permit 65.123.29.220 permit @@ -529,7 +509,6 @@ 69.169.224.0/20 permit 69.171.232.0/24 permit 69.171.244.0/23 permit -70.37.151.128/25 permit 70.42.149.35 permit 72.3.185.0/24 permit 72.14.192.0/18 permit @@ -654,12 +633,18 @@ 81.169.146.245 permit 81.169.146.246 permit 81.223.46.0/27 permit +82.165.159.2 permit +82.165.159.3 permit +82.165.159.4 permit 82.165.159.12 permit 82.165.159.13 permit 82.165.159.14 permit +82.165.159.34 permit +82.165.159.35 permit 82.165.159.40 permit 82.165.159.41 permit 82.165.159.42 permit +82.165.159.45 permit 82.165.159.130 permit 82.165.159.131 permit 85.9.206.169 permit @@ -715,8 +700,6 @@ 91.198.2.0/24 permit 91.211.240.0/22 permit 94.236.119.0/26 permit -94.245.112.0/27 permit -94.245.112.10/31 permit 95.131.104.0/21 permit 95.217.114.154 permit 96.43.144.0/20 permit @@ -1354,11 +1337,6 @@ 108.179.144.0/20 permit 109.224.244.0/24 permit 109.237.142.0/24 permit -111.221.23.128/25 permit -111.221.26.0/27 permit -111.221.66.0/25 permit -111.221.69.128/25 permit -111.221.112.0/21 permit 112.19.199.64/29 permit 112.19.242.64/29 permit 116.214.12.47 permit @@ -1420,6 +1398,7 @@ 129.153.194.228 permit 129.154.255.129 permit 129.158.56.255 permit +129.158.62.153 permit 129.159.22.159 permit 129.159.87.137 permit 129.213.195.191 permit @@ -1441,16 +1420,6 @@ 134.170.143.0/24 permit 134.170.174.0/24 permit 135.84.216.0/22 permit -136.143.160.0/24 permit -136.143.161.0/24 permit -136.143.162.0/24 permit -136.143.176.0/24 permit -136.143.177.0/24 permit -136.143.178.49 permit -136.143.182.0/23 permit -136.143.184.0/24 permit -136.143.188.0/24 permit -136.143.190.0/23 permit 136.146.128.0/20 permit 136.147.128.0/20 permit 136.147.135.0/24 permit @@ -1468,6 +1437,8 @@ 139.138.58.119 permit 139.180.17.0/24 permit 140.238.148.191 permit +141.148.55.217 permit +141.148.91.244 permit 141.148.159.229 permit 141.193.32.0/23 permit 141.193.184.32/27 permit @@ -1513,6 +1484,7 @@ 149.72.234.184 permit 149.72.248.236 permit 149.97.173.180 permit +150.136.21.199 permit 150.230.98.160 permit 151.145.38.14 permit 152.67.105.195 permit @@ -1522,17 +1494,7 @@ 155.248.220.138 permit 155.248.234.149 permit 155.248.237.141 permit -157.55.9.128/25 permit -157.55.11.0/25 permit -157.55.49.0/25 permit -157.55.61.0/24 permit -157.55.157.128/25 permit -157.55.225.0/25 permit -157.56.24.0/25 permit 157.56.120.128/26 permit -157.56.232.0/21 permit -157.56.240.0/20 permit -157.56.248.0/21 permit 157.58.30.128/25 permit 157.58.196.96/29 permit 157.58.249.3 permit @@ -1582,6 +1544,9 @@ 163.114.135.16 permit 163.116.128.0/17 permit 163.192.116.87 permit +163.192.125.176 permit +163.192.196.146 permit +163.192.204.161 permit 164.152.23.32 permit 164.152.25.241 permit 164.177.132.168/30 permit @@ -1614,6 +1579,7 @@ 168.245.12.252 permit 168.245.46.9 permit 168.245.127.231 permit +170.9.232.254 permit 170.10.128.0/24 permit 170.10.129.0/24 permit 170.10.132.56/29 permit @@ -1623,7 +1589,6 @@ 173.0.84.224/27 permit 173.0.94.244/30 permit 173.194.0.0/16 permit -173.194.0.0/17 permit 173.203.79.182 permit 173.203.81.39 permit 173.224.161.128/25 permit @@ -1852,7 +1817,6 @@ 204.14.232.64/28 permit 204.14.234.64/28 permit 204.75.142.0/24 permit -204.79.197.212 permit 204.92.114.187 permit 204.92.114.203 permit 204.92.114.204/31 permit @@ -1878,23 +1842,13 @@ 206.165.246.80/29 permit 206.191.224.0/19 permit 206.246.157.1 permit -207.46.4.128/25 permit 207.46.22.35 permit 207.46.50.72 permit 207.46.50.82 permit -207.46.50.192/26 permit -207.46.50.224 permit 207.46.52.71 permit 207.46.52.79 permit -207.46.58.128/25 permit -207.46.116.128/29 permit -207.46.132.128/27 permit -207.46.198.0/25 permit -207.46.200.0/27 permit 207.67.38.0/24 permit 207.67.98.192/27 permit -207.68.176.0/26 permit -207.68.176.96/27 permit 207.97.204.96/29 permit 207.126.144.0/20 permit 207.171.160.0/19 permit @@ -1993,11 +1947,19 @@ 212.82.111.228/31 permit 212.82.111.230 permit 212.123.28.40 permit +212.227.15.3 permit +212.227.15.4 permit +212.227.15.5 permit +212.227.15.6 permit 212.227.15.7 permit 212.227.15.8 permit +212.227.15.14 permit 212.227.15.15 permit 212.227.15.18 permit 212.227.15.19 permit +212.227.15.25 permit +212.227.15.26 permit +212.227.15.29 permit 212.227.15.44 permit 212.227.15.45 permit 212.227.15.46 permit @@ -2005,11 +1967,17 @@ 212.227.15.50 permit 212.227.15.52 permit 212.227.15.53 permit +212.227.15.54 permit +212.227.15.55 permit 212.227.17.1 permit 212.227.17.2 permit 212.227.17.7 permit +212.227.17.11 permit +212.227.17.12 permit 212.227.17.16 permit 212.227.17.17 permit +212.227.17.18 permit +212.227.17.19 permit 212.227.17.20 permit 212.227.17.21 permit 212.227.17.22 permit @@ -2035,8 +2003,6 @@ 213.199.128.145 permit 213.199.138.181 permit 213.199.138.191 permit -213.199.161.128/27 permit -213.199.177.0/26 permit 216.17.150.242 permit 216.17.150.251 permit 216.24.224.0/20 permit @@ -2064,7 +2030,6 @@ 216.39.62.60/31 permit 216.39.62.136/29 permit 216.39.62.144/31 permit -216.58.192.0/19 permit 216.66.217.240/29 permit 216.71.138.33 permit 216.71.152.207 permit @@ -2094,6 +2059,8 @@ 216.205.24.0/24 permit 216.221.160.0/19 permit 216.239.32.0/19 permit +217.72.192.77 permit +217.72.192.78 permit 217.77.141.52 permit 217.77.141.59 permit 217.175.194.0/24 permit From e727620bd39a17284d3eabc05a74cabfbcd3709e Mon Sep 17 00:00:00 2001 From: milkmaker Date: Wed, 7 Jan 2026 17:23:31 +0100 Subject: [PATCH 20/26] Translations update from Weblate (#7002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Web] Updated lang.zh-cn.json Co-authored-by: ガラスのような夢 * [Web] Updated lang.pl-pl.json Co-authored-by: Monika Bark --------- Co-authored-by: ガラスのような夢 Co-authored-by: Monika Bark --- data/web/lang/lang.pl-pl.json | 17 ++++++++++++++--- data/web/lang/lang.zh-cn.json | 7 +++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/data/web/lang/lang.pl-pl.json b/data/web/lang/lang.pl-pl.json index d78d46f7a..9ca696adb 100644 --- a/data/web/lang/lang.pl-pl.json +++ b/data/web/lang/lang.pl-pl.json @@ -675,7 +675,7 @@ "timeout1": "Limit czasu połączenia z serwerem zdalnym", "timeout2": "Limit czasu połączenia z serwerem lokalnym", "validate_save": "Zatwierdź i zapisz", - "pushover_info": "Ustawienia powiadomień push będą miały zastosowanie do wszystkich czystych (niespamowych) wiadomości dostarczanych do %s, w tym aliasów (współdzielonych, niewspółdzielonych, oznaczonych)", + "pushover_info": "Ustawienia powiadomień push będą miały zastosowanie do wszystkich czystych (niespamowych) wiadomości dostarczanych do %s w tym aliasów (współdzielonych, niewspółdzielonych, oznaczonych).", "mailbox_quota_def": "Domyślny limit skrzynki pocztowej", "mailbox_relayhost_info": "Dotyczy wyłącznie skrzynki pocztowej i bezpośrednich aliasów, nadpisuje ustawienie serwera pośredniczącego (relayhost) dla domeny.", "maxbytespersecond": "Max. Ilość bajtów na sekundę
(0 = unlimited)", @@ -687,7 +687,17 @@ "mbox_rl_info": "Ten limit szybkości dotyczy nazwy logowania SASL i odpowiada dowolnemu adresowi „from” używanemu przez zalogowanego użytkownika. Limit szybkości dla skrzynki pocztowej nadpisuje limit szybkości dla całej domeny.", "nexthop": "Następny hop", "private_comment": "Prywatny komentarz", - "public_comment": "Komentarz publiczny" + "public_comment": "Komentarz publiczny", + "mta_sts": "Konfiguruj MTA-STS", + "mta_sts_info": "MTA-STS to standard wymuszający dostarczanie poczty elektronicznej pomiędzy serwerami pocztowymi z użyciem TLS oraz ważnych certyfikatów.\n\nJest stosowany wtedy, gdy użycie DANE nie jest możliwe z powodu braku lub nieobsługiwanego DNSSEC.\n\n
Uwaga: Jeżeli domena odbiorcza obsługuje DANE z DNSSEC, DANE jest zawsze preferowane — MTA-STS działa wyłącznie jako mechanizm zapasowy.", + "mta_sts_version": "Wersja.", + "mta_sts_version_info": "Określa wersję standardu MTA-STS — obecnie jedyną prawidłową wartością jest STSv1..", + "mta_sts_mode": "Tryb.", + "mta_sts_mode_info": "Dostępne są trzy tryby do wyboru:\n
  • testing – polityka jest wyłącznie monitorowana, a naruszenia nie mają wpływu na dostarczanie poczty.
  • enforce – polityka jest ściśle egzekwowana; połączenia bez ważnego TLS są odrzucane.
  • none – polityka jest publikowana, lecz nie jest stosowana.
.", + "mta_sts_max_age": "Maksymalny czas obowiązywania.", + "mta_sts_max_age_info": "Czas (w sekundach) przechowywania polityki w cache przez serwery odbierające..", + "mta_sts_mx": "serwer MX.", + "mta_sts_mx_info": "Umożliwia wysyłanie poczty wyłącznie do jawnie wymienionych nazw hostów serwerów pocztowych; wysyłający MTA sprawdza, czy nazwa hosta MX w DNS odpowiada liście z polityki, i zezwala na dostarczenie tylko przy użyciu ważnego certyfikatu TLS (ochrona przed atakami MITM).." }, "footer": { "cancel": "Anuluj", @@ -1179,7 +1189,8 @@ "waiting": "Oczekuje", "with_app_password": "z hasłem aplikacji", "year": "rok", - "years": "lata" + "years": "lata", + "spam_aliases_info": "Alias antyspamowy to tymczasowy adres e-mail, który może być używany do ochrony właściwych adresów pocztowych.
Opcjonalnie można ustawić czas wygaśnięcia, po którym alias zostanie automatycznie dezaktywowany, co pozwala skutecznie pozbyć się nadużywanych lub ujawnionych adresów." }, "warning": { "session_ua": "Nieprawidłowy token formularza: Błąd walidacji User-Agent", diff --git a/data/web/lang/lang.zh-cn.json b/data/web/lang/lang.zh-cn.json index 94473a405..f31d865cb 100644 --- a/data/web/lang/lang.zh-cn.json +++ b/data/web/lang/lang.zh-cn.json @@ -1321,7 +1321,7 @@ "sogo_profile_reset": "重置 SOGo 个人资料", "sogo_profile_reset_help": "此操作会不可恢复地删除用户的 SOGo 个人资料并删除所有联系人和日历数据。", "sogo_profile_reset_now": "立即重置个人资料", - "spam_aliases": "临时邮箱别名", + "spam_aliases": "垃圾邮件别名", "spam_score_reset": "重置为服务器默认值", "spamfilter": "垃圾邮件过滤器", "spamfilter_behavior": "分数", @@ -1381,7 +1381,10 @@ "protocols": "协议", "authentication": "认证", "tfa_info": "两步验证有助于保护您的账户安全。启用后,对于不支持两步验证的应用程序或服务(例如邮件客户端),需要使用应用专用密码进行登录。", - "overview": "概览" + "overview": "概览", + "expire_never": "永不过期", + "forever": "永久", + "spam_aliases_info": "垃圾邮件别名是一种临时电子邮件地址,可用于保护真实电子邮件地址。
还可以选择设置过期时间,以便在设定的时间后自动停用别名,从而有效地销毁被滥用或泄露的地址。" }, "warning": { "cannot_delete_self": "不能删除已登录的用户", From c485968e7f525c99ae4eaa2b0499ffa145a81d8e Mon Sep 17 00:00:00 2001 From: Stefan Morgenthaler Date: Wed, 14 Jan 2026 11:42:15 +0100 Subject: [PATCH 21/26] feat: allow preset of passwords via environment vars Signed-off-by: Stefan Morgenthaler --- generate_config.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/generate_config.sh b/generate_config.sh index 393d2fced..de15eac17 100755 --- a/generate_config.sh +++ b/generate_config.sh @@ -186,13 +186,13 @@ DBNAME=mailcow DBUSER=mailcow # Please use long, random alphanumeric strings (A-Za-z0-9) -DBPASS=$(LC_ALL=C /dev/null | head -c 28) -DBROOT=$(LC_ALL=C /dev/null | head -c 28) +DBPASS=${MAILCOW_DBPASS:-$(LC_ALL=C /dev/null | head -c 28)} +DBROOT=${MAILCOW_DBROOT:-$(LC_ALL=C /dev/null | head -c 28)} # ------------------------------ # REDIS configuration # ------------------------------ -REDISPASS=$(LC_ALL=C /dev/null | head -c 28) +REDISPASS=${MAILCOW_REDISPASS:-$(LC_ALL=C /dev/null | head -c 28)} # ------------------------------ # HTTP/S Bindings From 0999c9e9ab7d817dd8da17964e58502089757afe Mon Sep 17 00:00:00 2001 From: milkmaker Date: Fri, 23 Jan 2026 22:02:55 +0100 Subject: [PATCH 22/26] Translations update from Weblate (#7014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Web] Updated lang.zh-cn.json Co-authored-by: 雨 * [Web] Updated lang.pl-pl.json Co-authored-by: Monika Bark Co-authored-by: milkmaker --------- Co-authored-by: 雨 Co-authored-by: Monika Bark --- data/web/lang/lang.pl-pl.json | 20 ++++++++++++++++++-- data/web/lang/lang.zh-cn.json | 4 ++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/data/web/lang/lang.pl-pl.json b/data/web/lang/lang.pl-pl.json index 9ca696adb..758d0cb40 100644 --- a/data/web/lang/lang.pl-pl.json +++ b/data/web/lang/lang.pl-pl.json @@ -697,7 +697,16 @@ "mta_sts_max_age": "Maksymalny czas obowiązywania.", "mta_sts_max_age_info": "Czas (w sekundach) przechowywania polityki w cache przez serwery odbierające..", "mta_sts_mx": "serwer MX.", - "mta_sts_mx_info": "Umożliwia wysyłanie poczty wyłącznie do jawnie wymienionych nazw hostów serwerów pocztowych; wysyłający MTA sprawdza, czy nazwa hosta MX w DNS odpowiada liście z polityki, i zezwala na dostarczenie tylko przy użyciu ważnego certyfikatu TLS (ochrona przed atakami MITM).." + "mta_sts_mx_info": "Umożliwia wysyłanie poczty wyłącznie do jawnie wymienionych nazw hostów serwerów pocztowych; wysyłający MTA sprawdza, czy nazwa hosta MX w DNS odpowiada liście z polityki, i zezwala na dostarczenie tylko przy użyciu ważnego certyfikatu TLS (ochrona przed atakami MITM)..", + "mta_sts_mx_notice": "Dopuszcza się podanie wielu serwerów MX, rozdzielonych przecinkami..", + "none_inherit": "Brak /Dziedzicz", + "password_recovery_email": "Email do odzyskiwania hasła", + "pushover": "Pushover", + "pushover_evaluate_x_prio": "Eskaluj wiadomości o wysokim priorytecie [X-Priority: 1]", + "pushover_only_x_prio": "Uwzględniaj wyłącznie wiadomości o wysokim priorytecie [X-Priority: 1]", + "pushover_sender_array": "Uwzględniaj wyłącznie następujące adresy e-mail nadawców (oddzielone przecinkami)", + "pushover_sender_regex": "Bierz pod uwagę następujący regex nadawcy", + "pushover_text": "Tekst powiadomienia" }, "footer": { "cancel": "Anuluj", @@ -854,7 +863,14 @@ "template": "Szablon", "tls_map_dest": "Miejsce docelowe", "tls_map_dest_info": "Przykłady: example.org, .example.org, [mail.example.org]:25", - "tls_map_parameters": "Parametry" + "tls_map_parameters": "Parametry", + "add_recipient_map_entry": "Dodaj mapę odbiorców", + "add_template": "Dodaj szablon", + "add_tls_policy_map": "Dodaj mapę polityk TLS", + "address_rewriting": "Przepisywanie adresów", + "alias_domain_alias_hint": "Aliasy nie są automatycznie stosowane do aliasów domen. Adres aliasu my-alias@domain nie obejmuje adresu my-alias@alias-domain (gdzie „alias-domain” jest przykładową domeną aliasową dla „domain”).\n
Aby przekierować pocztę do zewnętrznej skrzynki, użyj filtra Sieve (zob. kartę „Filtry” lub SOGo → Przekazywanie). Skorzystaj z opcji „Rozszerz alias na domeny aliasowe”, aby automatycznie dodać brakujące aliasy.", + "alias_domain_backupmx": "Domena aliasowa nieaktywna dla domeny przekaźnikowej", + "all_domains": "Wszystkie domeny" }, "quarantine": { "action": "Działanie", diff --git a/data/web/lang/lang.zh-cn.json b/data/web/lang/lang.zh-cn.json index f31d865cb..fe225a5df 100644 --- a/data/web/lang/lang.zh-cn.json +++ b/data/web/lang/lang.zh-cn.json @@ -582,13 +582,13 @@ "username": "用户名", "container_disabled": "容器已被停止或禁用", "container_running": "运行中", - "cores": "核心数", + "cores": "核", "memory": "内存", "error_show_ip": "无法解析公网IP地址", "show_ip": "显示公网IP", "update_available": "有可用更新", "update_failed": "无法检查更新", - "architecture": "结构", + "architecture": "架构", "container_stopped": "已停止", "current_time": "系统时间", "timezone": "时区", From 382ee34d0e9abbe009254f330b4465cdec4e39df Mon Sep 17 00:00:00 2001 From: milkmaker Date: Mon, 26 Jan 2026 20:15:47 +0100 Subject: [PATCH 23/26] [Web] Updated lang.hu-hu.json (#7020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sándor --- data/web/lang/lang.hu-hu.json | 117 ++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/data/web/lang/lang.hu-hu.json b/data/web/lang/lang.hu-hu.json index 2f78d5b21..e51748c48 100644 --- a/data/web/lang/lang.hu-hu.json +++ b/data/web/lang/lang.hu-hu.json @@ -295,7 +295,9 @@ "user_quicklink": "Gyorshivatkozás elrejtése a Felhasználói bejelentkezési oldalra", "validate_license_now": "GUID érvényesítése a licenszszerverrel szemben", "yes": "✓", - "success": "Siker" + "success": "Siker", + "login_page": "Belépő oldal", + "needs_restart": "újraindítást igényel" }, "edit": { "active": "Aktív", @@ -1070,7 +1072,7 @@ "post_domain_add": "A \"sogo-mailcow\" SOGo konténert újra kell indítani egy új tartomány hozzáadása után!

Kiegészítésképpen a tartományok DNS-konfigurációját is felül kell vizsgálni. A DNS-konfiguráció jóváhagyása után indítsa újra az \"acme-mailcow\"-t, hogy automatikusan generáljon tanúsítványokat az új tartományhoz (autoconfig.<domain>, autodiscover.<domain>).
Ez a lépés opcionális, és 24 óránként megismétlődik.", "dry": "Szinkronizálás szimulálása", "inactive": "Inaktív", - "kind": "Kedves", + "kind": "Típus", "mailbox_quota_m": "Maximális kvóta postafiókonként (MiB)", "mailbox_username": "Felhasználónév (az e-mail cím bal oldali része)", "max_aliases": "Max. lehetséges álnevek", @@ -1092,9 +1094,9 @@ "exclude": "Objektumok kizárása (regex)", "full_name": "Teljes név", "gal": "Globális címlista", - "goto_ham": "Tanulj sonkaként", + "goto_ham": "Tanítás valódi levélként", "goto_null": "Leveleket csendben eldobni", - "goto_spam": "Tanuld spamként", + "goto_spam": "Tanítás spamként", "syncjob_hint": "Ne feledje, hogy a jelszavakat egyszerű szöveges formában kell elmenteni!", "target_address": "Továbbítási címek", "target_address_info": "Teljes e-mail cím(ek) (vesszővel elválasztva).", @@ -1102,7 +1104,7 @@ "comment_info": "A privát megjegyzés nem látható a felhasználó számára, míg a nyilvános megjegyzés tooltip-ként jelenik meg, amikor a felhasználó áttekintésében a megjegyzésre mutat.", "custom_params": "Egyéni paraméterek", "gal_info": "A GAL tartalmazza a tartomány összes objektumát, és egyetlen felhasználó sem szerkesztheti. A SOGo-ban a Szabad/Elfoglalt információ hiányzik, ha ki van kapcsolva! Indítsa újra a SOGo-t a változások alkalmazásához.", - "hostname": "Házigazda", + "hostname": "Hoszt", "backup_mx_options": "Továbbítási opciók", "custom_params_hint": "Megfelelő: --param=xy, Rossz: --param xy", "delete1": "Törlés a forrásból, ha befejeződött", @@ -1140,6 +1142,109 @@ "sieve_type": "Szűrő típusa", "skipcrossduplicates": "Duplikált üzenetek átugrása mappák között (érkezési sorrendben)", "subscribeall": "Feliratkozás minden mappára", - "syncjob": "Szinkronizálási feladat hozzáadása" + "syncjob": "Szinkronizálási feladat hozzáadása", + "internal": "Belső", + "internal_info": "Belső álnevek csak a saját domain vagy domain álnév számára elérhető." + }, + "danger": { + "access_denied": "Hozzáférés megtagatva vagy nem megfelelő űrlap adat", + "alias_domain_invalid": "Az alias domain %s érvénytelen", + "alias_empty": "Az alias cím nem lehet üres", + "alias_goto_identical": "Az alias és a goto cím nem lehetnek azonosak", + "alias_invalid": "Az alias cím %s érvénytelen", + "aliasd_targetd_identical": "Az alias tartomány nem lehet azonos a céltartománnyal: %s", + "aliases_in_use": "A maximális aliasoknak nagyobbnak vagy egyenlőnek kell lenniük mint %d", + "app_name_empty": "Az alkalmazás neve nem lehet üres", + "app_passwd_id_invalid": "Alkalmazás jelszó ID %s érvénytelen", + "authsource_in_use": "A személyazonosság szolgáltatót nem lehet megváltoztatni vagy törölni, mivel ez jelenleg használatban van legalább 1 felhasználónál.", + "bcc_empty": "BCC cél nem lehet üres", + "bcc_exists": "A %s típushoz létezik egy %s típusú BCC térkép.", + "bcc_must_be_email": "A BCC cél %s nem érvényes e-mail cím", + "comment_too_long": "Túl hosszú megjegyzés, max 160 karakter megengedett", + "cors_invalid_method": "Érvénytelen Allow-Method lett megadva", + "cors_invalid_origin": "Érvénytelen Allow-Origin lett megadva", + "defquota_empty": "A postafiókonkénti alapértelmezett kvóta nem lehet 0.", + "demo_mode_enabled": "Demo mód engedélyezve", + "description_invalid": "A %s erőforrás leírása érvénytelen", + "dkim_domain_or_sel_exists": "A \"%s\" DKIM-kulcs létezik, és nem lesz felülírva", + "dkim_domain_or_sel_invalid": "DKIM tartomány vagy szelektor érvénytelen: %s", + "domain_cannot_match_hostname": "A tartomány nem egyezik a hostnévvel", + "domain_exists": "A %s domain már létezik", + "domain_invalid": "A domain név üres vagy érvénytelen", + "domain_not_empty": "Nem lehet eltávolítani a nem üres domaint %s", + "domain_not_found": "Nem található domain %s", + "domain_quota_m_in_use": "A domain kvótának nagyobbnak vagy egyenlőnek kell lennie %s MiB-nál", + "extended_sender_acl_denied": "hiányzó ACL külső küldő cím beállításához", + "extra_acl_invalid": "A \"%s\" külső feladó címe érvénytelen", + "extra_acl_invalid_domain": "Külső feladó \"%s\" érvénytelen tartományt használ", + "fido2_verification_failed": "FIDO2 ellenőrzés sikertelen: %s", + "file_open_error": "A fájl nem nyitható meg írásra", + "filter_type": "Rossz szűrőtípus", + "from_invalid": "A feladó nem lehet üres", + "generic_server_error": "Váratlan szerver hiba keletkezett. Vedd fel a kapcsolatot az adminisztrátorral.", + "global_filter_write_error": "Nem tudott szűrőfájlt írni: %s", + "global_map_invalid": "Globális térkép azonosítója %s érvénytelen", + "global_map_write_error": "Nem tudott globális térképet írni ID %s: %s", + "goto_empty": "Egy alias címnek legalább egy érvényes goto címet kell tartalmaznia.", + "goto_invalid": "Goto cím %s érvénytelen", + "ham_learn_error": "Ham tanulási hiba: %s", + "iam_test_connection": "Kapcsolódás sikertelen", + "imagick_exception": "Hiba: Kép olvasása közben Imagick hiba keletkezett", + "img_dimensions_exceeded": "A kép meghaladja a maximális méretet", + "img_invalid": "A képfájlt nem lehet érvényesíteni", + "img_size_exceeded": "A kép meghaladja a maximális fájl méretet", + "img_tmp_missing": "A képfájlt nem lehet érvényesíteni: Ideiglenes fájl nem található", + "invalid_bcc_map_type": "Érvénytelen a BCC térkép típusa", + "invalid_destination": "A \"%s\" célállomás formátum érvénytelen", + "invalid_filter_type": "Érvénytelen szűrőtípus", + "invalid_host": "Érvénytelen host megadva: %s", + "invalid_mime_type": "Érvénytelen mime típus", + "invalid_nexthop": "A következő ugrás formátuma érvénytelen", + "invalid_nexthop_authenticated": "A következő ugrás más hitelesítő adatokkal létezik, kérjük, először frissítse a meglévő hitelesítő adatokat ehhez a következő ugráshoz.", + "invalid_recipient_map_new": "Érvénytelen új címzett megadása: %s", + "invalid_recipient_map_old": "Érvénytelen eredeti címzett van megadva: %s", + "invalid_reset_token": "Érvénytelen visszaállító kulcs", + "ip_list_empty": "Az engedélyezett IP-k listája nem lehet üres", + "is_alias": "%s már ismert álnév címként", + "is_alias_or_mailbox": "%s már ismert alias, egy postafiók vagy egy alias tartományból kiterjesztett alias cím.", + "is_spam_alias": "%s már ismert ideiglenes alias cím (spam alias cím)", + "last_key": "Az utolsó kulcs nem törölhető, kérjük, helyette deaktiválja a TFA-t.", + "login_failed": "A bejelentkezés sikertelen", + "mailbox_defquota_exceeds_mailbox_maxquota": "Az alapértelmezett kvóta meghaladja a maximális kvótakorlátot", + "mailbox_invalid": "A postafiók neve érvénytelen", + "mailbox_quota_exceeded": "A kvóta meghaladja a tartományi korlátot (max. %d MiB)", + "mailbox_quota_exceeds_domain_quota": "A maximális kvóta meghaladja a tartományi kvótakorlátot", + "mailbox_quota_left_exceeded": "Nincs elég hely (maradék hely: %d MiB)", + "mailboxes_in_use": "A maximális postafiókoknak nagyobbnak vagy egyenlőnek kell lenniük %d-vel.", + "malformed_username": "Hibás felhasználónév", + "map_content_empty": "A térkép tartalma nem lehet üres", + "max_age_invalid": "Maximális kor %s érvénytelen", + "max_alias_exceeded": "Max. aliasok túllépése", + "max_mailbox_exceeded": "Max. postafiókok túllépése (%d %d-ből %d)", + "max_quota_in_use": "A postafiók kvótának nagyobbnak vagy egyenlőnek kell lennie %d MiB-nél", + "maxquota_empty": "A postafiókonkénti maximális kvóta nem lehet 0.", + "mode_invalid": "%s mód érvénytelen", + "mx_invalid": "%s MX rekord érvénytelen", + "mysql_error": "MySQL hiba: %s", + "network_host_invalid": "Érvénytelen hálózat vagy állomás: %s", + "next_hop_interferes": "%s zavarja a nexthop %s-t", + "next_hop_interferes_any": "Egy meglévő következő ugrás zavarja a %s-t.", + "nginx_reload_failed": "Az Nginx újratöltése sikertelen: %s", + "no_user_defined": "Nincs felhasználó által meghatározott", + "object_exists": "Az objektum %s már létezik", + "object_is_not_numeric": "Az érték %s nem numerikus", + "password_complexity": "A jelszó nem felel meg a szabályzatnak", + "password_empty": "A jelszó nem lehet üres", + "password_mismatch": "A megerősítő jelszó nem egyezik", + "password_reset_invalid_user": "A fiók nem található vagy nem lett megadva visszaállításhoz email cím", + "password_reset_na": "A jelszó visszaállítás jelenleg nem elérhető. Vedd fel a kapcsolatot az adminisztrátorral.", + "policy_list_from_exists": "A megadott nevű rekord létezik", + "policy_list_from_invalid": "A rekord érvénytelen formátumú", + "private_key_error": "Privát kulcs hiba: %s", + "pushover_credentials_missing": "Pushover token és/vagy kulcs hiányzik", + "pushover_key": "A pushover kulcs rossz formátumú", + "pushover_token": "A Pushover token rossz formátumú", + "quota_not_0_not_numeric": "A kvótának numerikusnak és >= 0-nak kell lennie.", + "recipient_map_entry_exists": "Létezik egy \"%s\" címzett-térkép bejegyzés" } } From c06112b26e6f41c7f9d5c2ea2549b266e66a44e3 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:05:51 +0100 Subject: [PATCH 24/26] [Postfix] Configurable send permissions for alias addresses --- data/Dockerfiles/postfix/postfix.sh | 3 ++ data/web/inc/functions.mailbox.inc.php | 45 ++++++++++++++++++++------ data/web/inc/init_db.inc.php | 3 +- data/web/lang/lang.de-de.json | 3 ++ data/web/lang/lang.en-gb.json | 3 ++ data/web/templates/edit/alias.twig | 7 +++- data/web/templates/edit/mailbox.twig | 24 ++++++++------ data/web/templates/modals/mailbox.twig | 7 +++- docker-compose.yml | 2 +- 9 files changed, 75 insertions(+), 22 deletions(-) diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index 0a8ed736e..51927ea11 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -329,14 +329,17 @@ query = SELECT goto FROM alias SELECT id FROM alias WHERE address='%s' AND (active='1' OR active='2') + AND sender_allowed='1' ), ( SELECT id FROM alias WHERE address='@%d' AND (active='1' OR active='2') + AND sender_allowed='1' ) ) ) AND active='1' + AND sender_allowed='1' AND (domain IN (SELECT domain FROM domain WHERE domain='%d' diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index d8e4e178a..a4147ee91 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -695,6 +695,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $gotos = array_map('trim', preg_split( "/( |,|;|\n)/", $_data['goto'])); $internal = intval($_data['internal']); $active = intval($_data['active']); + $sender_allowed = intval($_data['sender_allowed']); $sogo_visible = intval($_data['sogo_visible']); $goto_null = intval($_data['goto_null']); $goto_spam = intval($_data['goto_spam']); @@ -850,8 +851,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ); continue; } - $stmt = $pdo->prepare("INSERT INTO `alias` (`address`, `public_comment`, `private_comment`, `goto`, `domain`, `sogo_visible`, `internal`, `active`) - VALUES (:address, :public_comment, :private_comment, :goto, :domain, :sogo_visible, :internal, :active)"); + $stmt = $pdo->prepare("INSERT INTO `alias` (`address`, `public_comment`, `private_comment`, `goto`, `domain`, `sogo_visible`, `internal`, `sender_allowed`, `active`) + VALUES (:address, :public_comment, :private_comment, :goto, :domain, :sogo_visible, :internal, :sender_allowed, :active)"); if (!filter_var($address, FILTER_VALIDATE_EMAIL) === true) { $stmt->execute(array( ':address' => '@'.$domain, @@ -862,6 +863,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':sogo_visible' => $sogo_visible, ':internal' => $internal, + ':sender_allowed' => $sender_allowed, ':active' => $active )); } @@ -874,6 +876,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':domain' => $domain, ':sogo_visible' => $sogo_visible, ':internal' => $internal, + ':sender_allowed' => $sender_allowed, ':active' => $active )); } @@ -2501,6 +2504,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { if (!empty($is_now)) { $internal = (isset($_data['internal'])) ? intval($_data['internal']) : $is_now['internal']; $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; + $sender_allowed = (isset($_data['sender_allowed'])) ? intval($_data['sender_allowed']) : $is_now['sender_allowed']; $sogo_visible = (isset($_data['sogo_visible'])) ? intval($_data['sogo_visible']) : $is_now['sogo_visible']; $goto_null = (isset($_data['goto_null'])) ? intval($_data['goto_null']) : 0; $goto_spam = (isset($_data['goto_spam'])) ? intval($_data['goto_spam']) : 0; @@ -2686,6 +2690,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { `goto` = :goto, `sogo_visible`= :sogo_visible, `internal`= :internal, + `sender_allowed`= :sender_allowed, `active`= :active WHERE `id` = :id"); $stmt->execute(array( @@ -2696,6 +2701,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':goto' => $goto, ':sogo_visible' => $sogo_visible, ':internal' => $internal, + ':sender_allowed' => $sender_allowed, ':active' => $active, ':id' => $is_now['id'] )); @@ -3185,9 +3191,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } if (isset($_data['sender_acl'])) { // Get sender_acl items set by admin + $current_sender_acls = mailbox('get', 'sender_acl_handles', $username); $sender_acl_admin = array_merge( - mailbox('get', 'sender_acl_handles', $username)['sender_acl_domains']['ro'], - mailbox('get', 'sender_acl_handles', $username)['sender_acl_addresses']['ro'] + $current_sender_acls['sender_acl_domains']['ro'], + $current_sender_acls['sender_acl_addresses']['ro'] ); // Get sender_acl items from POST array // Set sender_acl_domain_admin to empty array if sender_acl contains "default" to trigger a reset @@ -3275,16 +3282,25 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':username' => $username )); - $fixed_sender_aliases = mailbox('get', 'sender_acl_handles', $username)['fixed_sender_aliases']; + $sender_acl_handles = mailbox('get', 'sender_acl_handles', $username); + $fixed_sender_aliases_allowed = $sender_acl_handles['fixed_sender_aliases_allowed']; + $fixed_sender_aliases_blocked = $sender_acl_handles['fixed_sender_aliases_blocked']; + foreach ($sender_acl_merged as $sender_acl) { $domain = ltrim($sender_acl, '@'); if (is_valid_domain_name($domain)) { $sender_acl = '@' . $domain; } - // Don't add if allowed by alias - if (in_array($sender_acl, $fixed_sender_aliases)) { + + // Always add to sender_acl table to create explicit permission + // Skip only if it's in allowed list (would be redundant) + // But DO add if it's in blocked list (creates override) + if (in_array($sender_acl, $fixed_sender_aliases_allowed)) { + // Skip: already allowed by sender_allowed=1, no need for sender_acl entry continue; } + + // Add to sender_acl (either override for blocked aliases, or grant for selectable ones) $stmt = $pdo->prepare("INSERT INTO `sender_acl` (`send_as`, `logged_in_as`) VALUES (:sender_acl, :username)"); $stmt->execute(array( @@ -4160,13 +4176,22 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $data['sender_acl_addresses']['rw'] = array(); $data['sender_acl_addresses']['selectable'] = array(); $data['fixed_sender_aliases'] = array(); + $data['fixed_sender_aliases_allowed'] = array(); + $data['fixed_sender_aliases_blocked'] = array(); $data['external_sender_aliases'] = array(); - // Fixed addresses - $stmt = $pdo->prepare("SELECT `address` FROM `alias` WHERE `goto` REGEXP :goto AND `address` NOT LIKE '@%'"); + // Fixed addresses - split by sender_allowed status + $stmt = $pdo->prepare("SELECT `address`, `sender_allowed` FROM `alias` WHERE `goto` REGEXP :goto AND `address` NOT LIKE '@%'"); $stmt->execute(array(':goto' => '(^|,)'.preg_quote($_data, '/').'($|,)')); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($row = array_shift($rows)) { + // Keep old array for backward compatibility $data['fixed_sender_aliases'][] = $row['address']; + // Split into allowed/blocked for proper display + if ($row['sender_allowed'] == '1') { + $data['fixed_sender_aliases_allowed'][] = $row['address']; + } else { + $data['fixed_sender_aliases_blocked'][] = $row['address']; + } } $stmt = $pdo->prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `alias_domain_alias` FROM `mailbox`, `alias_domain` WHERE `alias_domain`.`target_domain` = `mailbox`.`domain` @@ -4726,6 +4751,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { `internal`, `active`, `sogo_visible`, + `sender_allowed`, `created`, `modified` FROM `alias` @@ -4759,6 +4785,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $aliasdata['active_int'] = $row['active']; $aliasdata['sogo_visible'] = $row['sogo_visible']; $aliasdata['sogo_visible_int'] = $row['sogo_visible']; + $aliasdata['sender_allowed'] = $row['sender_allowed']; $aliasdata['created'] = $row['created']; $aliasdata['modified'] = $row['modified']; if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $aliasdata['domain'])) { diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index ffaf12093..37589bac3 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "10312025_0525"; + $db_version = "16122025_1230"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -185,6 +185,7 @@ function init_db_schema() "public_comment" => "TEXT", "sogo_visible" => "TINYINT(1) NOT NULL DEFAULT '1'", "internal" => "TINYINT(1) NOT NULL DEFAULT '0'", + "sender_allowed" => "TINYINT(1) NOT NULL DEFAULT '1'", "active" => "TINYINT(1) NOT NULL DEFAULT '1'" ), "keys" => array( diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 762c055af..b86879ded 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -73,6 +73,7 @@ "inactive": "Inaktiv", "internal": "Intern", "internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.", + "sender_allowed": "Als dieser Alias senden erlauben", "kind": "Art", "mailbox_quota_def": "Standard-Quota einer Mailbox", "mailbox_quota_m": "Max. Speicherplatz pro Mailbox (MiB)", @@ -694,6 +695,8 @@ "inactive": "Inaktiv", "internal": "Intern", "internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.", + "sender_allowed": "Als dieser Alias senden erlauben", + "sender_allowed_info": "Wenn deaktiviert, kann dieser Alias nur E-Mails empfangen. Verwenden Sie Sender-ACL, um bestimmten Postfächern die Berechtigung zum Senden zu erteilen.", "kind": "Art", "last_modified": "Zuletzt geändert", "lookup_mx": "Ziel mit MX vergleichen (Regex, etwa .*\\.google\\.com, um alle Ziele mit MX *google.com zu routen)", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 1e8525957..79175e578 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -73,6 +73,7 @@ "inactive": "Inactive", "internal": "Internal", "internal_info": "Internal aliases are only accessible from the own domain or alias domains.", + "sender_allowed": "Allow to send as this alias", "kind": "Kind", "mailbox_quota_def": "Default mailbox quota", "mailbox_quota_m": "Max. quota per mailbox (MiB)", @@ -694,6 +695,8 @@ "inactive": "Inactive", "internal": "Internal", "internal_info": "Internal aliases are only accessible from the own domain or alias domains.", + "sender_allowed": "Allow to send as this alias", + "sender_allowed_info": "If disabled, this alias can only receive mail. Use sender ACL to override and grant specific mailboxes permission to send.", "kind": "Kind", "last_modified": "Last modified", "lookup_mx": "Destination is a regular expression to match against MX name (.*\\.google\\.com to route all mail targeted to a MX ending in google.com over this hop)", diff --git a/data/web/templates/edit/alias.twig b/data/web/templates/edit/alias.twig index 64a6e706b..85a946baf 100644 --- a/data/web/templates/edit/alias.twig +++ b/data/web/templates/edit/alias.twig @@ -7,6 +7,7 @@
+ {% if not skip_sogo %} {% endif %} @@ -39,7 +40,11 @@
- {{ lang.edit.internal_info }} + {{ lang.edit.internal_info }} +
+ +
+ {{ lang.edit.sender_allowed_info }}
diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index a223a7500..0b83dc5bf 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -85,14 +85,6 @@ {{ lang.edit.dont_check_sender_acl|format(domain) }} {% endfor %} - {% for alias in sender_acl_handles.sender_acl_addresses.ro %} - - {% endfor %} - {% for alias in sender_acl_handles.fixed_sender_aliases %} - - {% endfor %} {% for domain in sender_acl_handles.sender_acl_domains.rw %} {% endfor %} {% for address in sender_acl_handles.sender_acl_addresses.rw %} - + {% if address in sender_acl_handles.fixed_sender_aliases_allowed or address in sender_acl_handles.fixed_sender_aliases_blocked %} + + {% else %} + + {% endif %} {% endfor %} {% for address in sender_acl_handles.sender_acl_addresses.selectable %} {% endfor %} + {% for alias in sender_acl_handles.fixed_sender_aliases_allowed %} + {% if alias not in sender_acl_handles.sender_acl_addresses.rw %} + + {% endif %} + {% endfor %} + {% for alias in sender_acl_handles.fixed_sender_aliases_blocked %} + {% if alias not in sender_acl_handles.sender_acl_addresses.rw %} + + {% endif %} + {% endfor %}
{{ lang.edit.sender_acl_disabled|raw }}
{{ lang.edit.sender_acl_info|raw }} diff --git a/data/web/templates/modals/mailbox.twig b/data/web/templates/modals/mailbox.twig index 1e8ee53fa..2e0f589d0 100644 --- a/data/web/templates/modals/mailbox.twig +++ b/data/web/templates/modals/mailbox.twig @@ -778,6 +778,7 @@ +
@@ -809,7 +810,11 @@
- {{ lang.edit.internal_info }} + {{ lang.edit.internal_info }} +
+ +
+ {{ lang.edit.sender_allowed_info }}
diff --git a/docker-compose.yml b/docker-compose.yml index f09afca2a..1fc28af6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -339,7 +339,7 @@ services: - dovecot postfix-mailcow: - image: ghcr.io/mailcow/postfix:3.7.11 + image: ghcr.io/mailcow/postfix:3.7.11-1 depends_on: mysql-mailcow: condition: service_started From 56ea4302ed13ac9aa344dc840689c4dd6ac76b68 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:49:33 +0100 Subject: [PATCH 25/26] [Web] Allow admins to limit EAS and DAV access for mailbox users --- data/conf/dovecot/auth/mailcowauth.php | 15 +++-- data/web/autodiscover.php | 2 +- data/web/inc/functions.auth.inc.php | 57 ++++++------------- data/web/inc/functions.mailbox.inc.php | 20 +++++++ data/web/inc/init_db.inc.php | 4 +- data/web/inc/prerequisites.inc.php | 2 +- data/web/inc/triggers.admin.inc.php | 2 +- data/web/inc/triggers.domainadmin.inc.php | 2 +- data/web/inc/triggers.user.inc.php | 2 +- data/web/inc/vars.inc.php | 6 ++ data/web/js/site/mailbox.js | 32 +++++++++++ data/web/sogo-auth.php | 14 +++-- .../web/templates/edit/mailbox-templates.twig | 2 + data/web/templates/edit/mailbox.twig | 2 + data/web/templates/modals/mailbox.twig | 4 ++ data/web/templates/user/tab-user-auth.twig | 2 + 16 files changed, 111 insertions(+), 57 deletions(-) diff --git a/data/conf/dovecot/auth/mailcowauth.php b/data/conf/dovecot/auth/mailcowauth.php index 06c0bd995..d1c7381f6 100644 --- a/data/conf/dovecot/auth/mailcowauth.php +++ b/data/conf/dovecot/auth/mailcowauth.php @@ -80,14 +80,21 @@ if ($isSOGoRequest) { } if ($result === false){ // If it's a SOGo Request, don't check for protocol access - $service = ($isSOGoRequest) ? false : array($post['service'] => true); - $result = apppass_login($post['username'], $post['password'], $service, array( + if ($isSOGoRequest) { + $service = 'SOGO'; + $post['service'] = 'NONE'; + } else { + $service = $post['service']; + } + + $result = apppass_login($post['username'], $post['password'], array( + 'service' => $post['service'], 'is_internal' => true, 'remote_addr' => $post['real_rip'] )); if ($result) { - error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); - set_sasl_log($post['username'], $post['real_rip'], $post['service']); + error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']); + set_sasl_log($post['username'], $post['real_rip'], $service); } } if ($result === false){ diff --git a/data/web/autodiscover.php b/data/web/autodiscover.php index 224f94f71..d3cda4004 100644 --- a/data/web/autodiscover.php +++ b/data/web/autodiscover.php @@ -79,7 +79,7 @@ if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { exit(0); } -$login_role = check_login($login_user, $login_pass, array('eas' => TRUE)); +$login_role = check_login($login_user, $login_pass, array('service' => 'EAS')); if ($login_role === "user") { header("Content-Type: application/xml"); diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index 059dd4cd9..3903ba642 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -1,10 +1,11 @@ fetch(PDO::FETCH_ASSOC); if (!empty($row)) { - // check if user has access to service (imap, smtp, pop3, sieve) if service is set + // check if user has access to service (imap, smtp, pop3, sieve, dav, eas) if service is set $row['attributes'] = json_decode($row['attributes'], true); - if (isset($service)) { - $key = strtolower($service) . "_access"; + if ($extra['service'] != 'NONE') { + $key = strtolower($extra['service']) . "_access"; if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { return false; } @@ -253,8 +240,8 @@ function user_login($user, $pass, $extra = null){ // check if user has access to service (imap, smtp, pop3, sieve) if service is set $row['attributes'] = json_decode($row['attributes'], true); - if (isset($service)) { - $key = strtolower($service) . "_access"; + if ($extra['service'] != 'NONE') { + $key = strtolower($extra['service']) . "_access"; if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { return false; } @@ -408,7 +395,7 @@ function user_login($user, $pass, $extra = null){ return false; } -function apppass_login($user, $pass, $app_passwd_data, $extra = null){ +function apppass_login($user, $pass, $extra = null){ global $pdo; $is_internal = $extra['is_internal']; @@ -424,20 +411,8 @@ function apppass_login($user, $pass, $app_passwd_data, $extra = null){ return false; } - $protocol = false; - if ($app_passwd_data['eas']){ - $protocol = 'eas'; - } else if ($app_passwd_data['dav']){ - $protocol = 'dav'; - } else if ($app_passwd_data['smtp']){ - $protocol = 'smtp'; - } else if ($app_passwd_data['imap']){ - $protocol = 'imap'; - } else if ($app_passwd_data['sieve']){ - $protocol = 'sieve'; - } else if ($app_passwd_data['pop3']){ - $protocol = 'pop3'; - } else if (!$is_internal) { + $extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service']; + if (!$is_internal && $extra['service'] == 'NONE') { return false; } @@ -458,7 +433,7 @@ function apppass_login($user, $pass, $app_passwd_data, $extra = null){ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as $row) { - if ($protocol && $row[$protocol . '_access'] != '1'){ + if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){ continue; } diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index d8e4e178a..eb7d82dff 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -1075,6 +1075,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $_data['pop3_access'] = (in_array('pop3', $_data['protocol_access'])) ? 1 : 0; $_data['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : 0; $_data['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0; + $_data['eas_access'] = (in_array('eas', $_data['protocol_access'])) ? 1 : 0; + $_data['dav_access'] = (in_array('dav', $_data['protocol_access'])) ? 1 : 0; } $active = (isset($_data['active'])) ? intval($_data['active']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['active']); $force_pw_update = (isset($_data['force_pw_update'])) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']); @@ -1085,6 +1087,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $pop3_access = (isset($_data['pop3_access'])) ? intval($_data['pop3_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['pop3_access']); $smtp_access = (isset($_data['smtp_access'])) ? intval($_data['smtp_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['smtp_access']); $sieve_access = (isset($_data['sieve_access'])) ? intval($_data['sieve_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sieve_access']); + $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']); @@ -1103,6 +1107,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { 'pop3_access' => strval($pop3_access), 'smtp_access' => strval($smtp_access), 'sieve_access' => strval($sieve_access), + 'eas_access' => strval($eas_access), + 'dav_access' => strval($dav_access), 'relayhost' => strval($relayhost), 'passwd_update' => time(), 'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']), @@ -1721,12 +1727,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $attr['pop3_access'] = (in_array('pop3', $_data['protocol_access'])) ? 1 : 0; $attr['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : 0; $attr['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0; + $attr['eas_access'] = (in_array('eas', $_data['protocol_access'])) ? 1 : 0; + $attr['dav_access'] = (in_array('dav', $_data['protocol_access'])) ? 1 : 0; } else { $attr['imap_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['imap_access']); $attr['pop3_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['pop3_access']); $attr['smtp_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['smtp_access']); $attr['sieve_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['sieve_access']); + $attr['eas_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['eas_access']); + $attr['dav_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['dav_access']); } if (isset($_data['acl'])) { $_data['acl'] = (array)$_data['acl']; @@ -3043,6 +3053,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $_data['pop3_access'] = (in_array('pop3', $_data['protocol_access'])) ? 1 : 0; $_data['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : 0; $_data['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0; + $_data['eas_access'] = (in_array('eas', $_data['protocol_access'])) ? 1 : 0; + $_data['dav_access'] = (in_array('dav', $_data['protocol_access'])) ? 1 : 0; } if (!empty($is_now)) { $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; @@ -3052,6 +3064,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { (int)$pop3_access = (isset($_data['pop3_access']) && hasACLAccess("protocol_access")) ? intval($_data['pop3_access']) : intval($is_now['attributes']['pop3_access']); (int)$smtp_access = (isset($_data['smtp_access']) && hasACLAccess("protocol_access")) ? intval($_data['smtp_access']) : intval($is_now['attributes']['smtp_access']); (int)$sieve_access = (isset($_data['sieve_access']) && hasACLAccess("protocol_access")) ? intval($_data['sieve_access']) : intval($is_now['attributes']['sieve_access']); + (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']); (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']; @@ -3335,6 +3349,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { `attributes` = JSON_SET(`attributes`, '$.pop3_access', :pop3_access), `attributes` = JSON_SET(`attributes`, '$.relayhost', :relayhost), `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`, '$.recovery_email', :recovery_email), `attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash) WHERE `username` = :username"); @@ -3349,6 +3365,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':pop3_access' => $pop3_access, ':sieve_access' => $sieve_access, ':smtp_access' => $smtp_access, + ':eas_access' => $eas_access, + ':dav_access' => $dav_access, ':recovery_email' => $pw_recovery_email, ':relayhost' => $relayhost, ':username' => $username, @@ -3731,6 +3749,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $attr['pop3_access'] = (in_array('pop3', $_data['protocol_access'])) ? 1 : 0; $attr['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : 0; $attr['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0; + $attr['eas_access'] = (in_array('eas', $_data['protocol_access'])) ? 1 : 0; + $attr['dav_access'] = (in_array('dav', $_data['protocol_access'])) ? 1 : 0; } else { foreach ($is_now as $key => $value){ diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index ffaf12093..67e98a5a8 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "10312025_0525"; + $db_version = "28012026_1000"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -1394,6 +1394,8 @@ function init_db_schema() $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.imap_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.imap_access') IS NULL;"); $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.pop3_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.pop3_access') IS NULL;"); $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.smtp_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.smtp_access') IS NULL;"); + $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.eas_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.eas_access') IS NULL;"); + $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.dav_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.dav_access') IS NULL;"); $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.mailbox_format', \"maildir:\") WHERE JSON_VALUE(`attributes`, '$.mailbox_format') IS NULL;"); $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.quarantine_notification', \"never\") WHERE JSON_VALUE(`attributes`, '$.quarantine_notification') IS NULL;"); $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.quarantine_category', \"reject\") WHERE JSON_VALUE(`attributes`, '$.quarantine_category') IS NULL;"); diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php index deb5da8fa..198e675aa 100644 --- a/data/web/inc/prerequisites.inc.php +++ b/data/web/inc/prerequisites.inc.php @@ -121,7 +121,7 @@ class mailcowPdo extends OAuth2\Storage\Pdo { $this->config['user_table'] = 'mailbox'; } public function checkUserCredentials($username, $password) { - if (check_login($username, $password) == 'user') { + if (check_login($username, $password, array("role" => "user", "service" => "NONE")) == 'user') { return true; } return false; diff --git a/data/web/inc/triggers.admin.inc.php b/data/web/inc/triggers.admin.inc.php index df46a459c..2a02ba511 100644 --- a/data/web/inc/triggers.admin.inc.php +++ b/data/web/inc/triggers.admin.inc.php @@ -44,7 +44,7 @@ if (isset($_GET["cancel_tfa_login"])) { if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) { $login_user = strtolower(trim($_POST["login_user"])); - $as = check_login($login_user, $_POST["pass_user"], false, array("role" => "admin")); + $as = check_login($login_user, $_POST["pass_user"], array("role" => "admin", "service" => "MAILCOWUI")); if ($as == "admin") { session_regenerate_id(true); diff --git a/data/web/inc/triggers.domainadmin.inc.php b/data/web/inc/triggers.domainadmin.inc.php index a9f913688..764d9009b 100644 --- a/data/web/inc/triggers.domainadmin.inc.php +++ b/data/web/inc/triggers.domainadmin.inc.php @@ -55,7 +55,7 @@ if (isset($_GET["cancel_tfa_login"])) { if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) { $login_user = strtolower(trim($_POST["login_user"])); - $as = check_login($login_user, $_POST["pass_user"], false, array("role" => "domain_admin")); + $as = check_login($login_user, $_POST["pass_user"], array("role" => "domain_admin", "service" => "MAILCOWUI")); if ($as == "domainadmin") { session_regenerate_id(true); diff --git a/data/web/inc/triggers.user.inc.php b/data/web/inc/triggers.user.inc.php index 36176c694..cc33596f9 100644 --- a/data/web/inc/triggers.user.inc.php +++ b/data/web/inc/triggers.user.inc.php @@ -119,7 +119,7 @@ if (isset($_GET["cancel_tfa_login"])) { if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) { $login_user = strtolower(trim($_POST["login_user"])); - $as = check_login($login_user, $_POST["pass_user"], false, array("role" => "user")); + $as = check_login($login_user, $_POST["pass_user"], array("role" => "user", "service" => "MAILCOWUI")); if ($as == "user") { set_user_loggedin_session($login_user); diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 9f3208e3d..dc163d629 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -215,6 +215,12 @@ $MAILBOX_DEFAULT_ATTRIBUTES['smtp_access'] = true; // Mailbox has sieve access by default $MAILBOX_DEFAULT_ATTRIBUTES['sieve_access'] = true; +// Mailbox has ActiveSync/EAS access by default +$MAILBOX_DEFAULT_ATTRIBUTES['eas_access'] = true; + +// Mailbox has CalDAV/CardDAV (DAV) access by default +$MAILBOX_DEFAULT_ATTRIBUTES['dav_access'] = true; + // Mailbox receives notifications about... // "add_header" - mail that was put into the Junk folder // "reject" - mail that was rejected diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index df61f8720..7010077db 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -352,6 +352,12 @@ $(document).ready(function() { if (template.sieve_access == 1){ protocol_access.push("sieve"); } + if (template.eas_access == 1){ + protocol_access.push("eas"); + } + if (template.dav_access == 1){ + protocol_access.push("dav"); + } $('#protocol_access').selectpicker('val', protocol_access); var acl = []; @@ -933,6 +939,8 @@ jQuery(function($){ item.imap_access = ''; item.smtp_access = ''; item.sieve_access = ''; + item.eas_access = ''; + item.dav_access = ''; if (item.attributes.quarantine_notification === 'never') { item.quarantine_notification = lang.never; } else if (item.attributes.quarantine_notification === 'hourly') { @@ -1096,6 +1104,18 @@ jQuery(function($){ defaultContent: '', className: 'none' }, + { + title: 'EAS', + data: 'eas_access', + defaultContent: '', + className: 'none' + }, + { + title: 'DAV', + data: 'dav_access', + defaultContent: '', + className: 'none' + }, { title: lang.quarantine_notification, data: 'quarantine_notification', @@ -1209,6 +1229,8 @@ jQuery(function($){ item.attributes.imap_access = '' + (item.attributes.imap_access == 1 ? '1' : '0') + ''; item.attributes.smtp_access = '' + (item.attributes.smtp_access == 1 ? '1' : '0') + ''; item.attributes.sieve_access = '' + (item.attributes.sieve_access == 1 ? '1' : '0') + ''; + item.attributes.eas_access = '' + (item.attributes.eas_access == 1 ? '1' : '0') + ''; + item.attributes.dav_access = '' + (item.attributes.dav_access == 1 ? '1' : '0') + ''; item.attributes.sogo_access = '' + (item.attributes.sogo_access == 1 ? '1' : '0') + ''; if (item.attributes.quarantine_notification === 'never') { item.attributes.quarantine_notification = lang.never; @@ -1317,6 +1339,16 @@ jQuery(function($){ data: 'attributes.sieve_access', defaultContent: '', }, + { + title: 'EAS', + data: 'attributes.eas_access', + defaultContent: '', + }, + { + title: 'DAV', + data: 'attributes.dav_access', + defaultContent: '', + }, { title: 'SOGO', data: 'attributes.sogo_access', diff --git a/data/web/sogo-auth.php b/data/web/sogo-auth.php index 962627baf..2da28d4d4 100644 --- a/data/web/sogo-auth.php +++ b/data/web/sogo-auth.php @@ -12,18 +12,21 @@ $session_var_pass = 'sogo-sso-pass'; if (isset($_SERVER['PHP_AUTH_USER'])) { // load prerequisites only when required require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; + $username = $_SERVER['PHP_AUTH_USER']; $password = $_SERVER['PHP_AUTH_PW']; - $is_eas = false; - $is_dav = false; + + // Determine service type for protocol access check + $service = 'NONE'; $original_uri = isset($_SERVER['HTTP_X_ORIGINAL_URI']) ? $_SERVER['HTTP_X_ORIGINAL_URI'] : ''; if (preg_match('/^(\/SOGo|)\/dav.*/', $original_uri) === 1) { - $is_dav = true; + $service = 'DAV'; } elseif (preg_match('/^(\/SOGo|)\/Microsoft-Server-ActiveSync.*/', $original_uri) === 1) { - $is_eas = true; + $service = 'EAS'; } - $login_check = check_login($username, $password, array('dav' => $is_dav, 'eas' => $is_eas)); + + $login_check = check_login($username, $password, array('service' => $service)); if ($login_check === 'user') { header("X-User: $username"); header("X-Auth: Basic ".base64_encode("$username:$password")); @@ -57,7 +60,6 @@ elseif (isset($_GET['login'])) { $_SESSION['mailcow_cc_role'] = "user"; } // update sasl logs - $service = ($app_passwd_data['eas'] === true) ? 'EAS' : 'DAV'; $stmt = $pdo->prepare("REPLACE INTO sasl_log (`service`, `app_password`, `username`, `real_rip`) VALUES ('SSO', 0, :username, :remote_addr)"); $stmt->execute(array( ':username' => $login, diff --git a/data/web/templates/edit/mailbox-templates.twig b/data/web/templates/edit/mailbox-templates.twig index 65a83cd2a..ddc139586 100644 --- a/data/web/templates/edit/mailbox-templates.twig +++ b/data/web/templates/edit/mailbox-templates.twig @@ -108,6 +108,8 @@ + +
diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index a223a7500..294957ee7 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -281,6 +281,8 @@ + + diff --git a/data/web/templates/modals/mailbox.twig b/data/web/templates/modals/mailbox.twig index 1e8ee53fa..b35c0caa8 100644 --- a/data/web/templates/modals/mailbox.twig +++ b/data/web/templates/modals/mailbox.twig @@ -148,6 +148,8 @@ + + @@ -335,6 +337,8 @@ + + diff --git a/data/web/templates/user/tab-user-auth.twig b/data/web/templates/user/tab-user-auth.twig index bace33489..171545d6c 100644 --- a/data/web/templates/user/tab-user-auth.twig +++ b/data/web/templates/user/tab-user-auth.twig @@ -55,6 +55,8 @@ {% if mailboxdata.attributes.smtp_access == 1 %}
SMTP
{% else %}
SMTP
{% endif %} {% if mailboxdata.attributes.sieve_access == 1 %}
Sieve
{% else %}
Sieve
{% endif %} {% if mailboxdata.attributes.pop3_access == 1 %}
POP3
{% else %}
POP3
{% endif %} + {% if mailboxdata.attributes.eas_access == 1 %}
ActiveSync
{% else %}
ActiveSync
{% endif %} + {% if mailboxdata.attributes.dav_access == 1 %}
CalDAV/CardDAV
{% else %}
CalDAV/CardDAV
{% endif %} From c3d841340cc11c89c95b2d908938b315552c5dd5 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:28:36 +0100 Subject: [PATCH 26/26] [Dovecot][PHP][SOGo] Update Images --- docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1fc28af6a..75cca872d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,7 +117,7 @@ services: - rspamd php-fpm-mailcow: - image: ghcr.io/mailcow/phpfpm:8.2.29 + image: ghcr.io/mailcow/phpfpm:8.2.29-1 command: "php-fpm -d date.timezone=${TZ} -d expose_php=0" depends_on: - redis-mailcow @@ -200,7 +200,7 @@ services: - phpfpm sogo-mailcow: - image: ghcr.io/mailcow/sogo:5.12.4 + image: ghcr.io/mailcow/sogo:5.12.4-1 environment: - DBNAME=${DBNAME} - DBUSER=${DBUSER} @@ -252,7 +252,7 @@ services: - sogo dovecot-mailcow: - image: ghcr.io/mailcow/dovecot:2.3.21.1 + image: ghcr.io/mailcow/dovecot:2.3.21.1-1 depends_on: - mysql-mailcow - netfilter-mailcow