diff --git a/.github/workflows/close_old_issues_and_prs.yml b/.github/workflows/close_old_issues_and_prs.yml
index 855684df9..13dbac279 100644
--- a/.github/workflows/close_old_issues_and_prs.yml
+++ b/.github/workflows/close_old_issues_and_prs.yml
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Mark/Close Stale Issues and Pull Requests 🗑️
- uses: actions/stale@v10.3.0
+ uses: actions/stale@v11.0.0
with:
repo-token: ${{ secrets.STALE_ACTION_PAT }}
days-before-stale: 60
diff --git a/.github/workflows/image_builds.yml b/.github/workflows/image_builds.yml
index c8ec7ccae..aa56b0381 100644
--- a/.github/workflows/image_builds.yml
+++ b/.github/workflows/image_builds.yml
@@ -27,7 +27,7 @@ jobs:
- "watchdog-mailcow"
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Setup Docker
run: |
curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh
diff --git a/.github/workflows/pr_to_nightly.yml b/.github/workflows/pr_to_nightly.yml
index 0b0738e93..8d6b2524d 100644
--- a/.github/workflows/pr_to_nightly.yml
+++ b/.github/workflows/pr_to_nightly.yml
@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Run the Action
- uses: devops-infra/action-pull-request@v1.2.1
+ uses: devops-infra/action-pull-request@v1.4.0
with:
github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}
diff --git a/.github/workflows/rebuild_backup_image.yml b/.github/workflows/rebuild_backup_image.yml
index b18d339ba..4490afee1 100644
--- a/.github/workflows/rebuild_backup_image.yml
+++ b/.github/workflows/rebuild_backup_image.yml
@@ -13,7 +13,7 @@ jobs:
packages: write
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
diff --git a/.github/workflows/update_postscreen_access_list.yml b/.github/workflows/update_postscreen_access_list.yml
index 066616aee..ed63281c7 100644
--- a/.github/workflows/update_postscreen_access_list.yml
+++ b/.github/workflows/update_postscreen_access_list.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Generate postscreen_access.cidr
run: |
diff --git a/README.md b/README.md
index 40288f10e..63d0b6480 100644
--- a/README.md
+++ b/README.md
@@ -23,10 +23,15 @@ A big thank you to everyone supporting us on GitHub Sponsors—your contribution
+
### 50$/Month Sponsors

## Info, documentation and support
diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh
index 271de4fc9..6b7b74300 100755
--- a/data/Dockerfiles/acme/acme.sh
+++ b/data/Dockerfiles/acme/acme.sh
@@ -249,12 +249,33 @@ while true; do
done <<< "${SQL_DOMAINS}"
if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then
+ # Fetch all domains with an active MTA-STS policy once.
+ unset MTA_STS_ACTIVE_DOMAINS
+ declare -A MTA_STS_ACTIVE_DOMAINS
+ if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
+ SQL_MTA_STS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM mta_sts WHERE active = 1" -Bs)
+ if [[ $? -eq 0 ]]; then
+ while read mta_sts_domain; do
+ if [[ -z "${mta_sts_domain}" ]]; then
+ # ignore empty lines
+ continue
+ fi
+ MTA_STS_ACTIVE_DOMAINS["${mta_sts_domain}"]=1
+ done <<< "${SQL_MTA_STS_DOMAINS}"
+ fi
+ fi
for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
+ # Skip mta-sts subdomain unless MTA-STS is enabled (active) for this domain
+ if [[ "${SUBDOMAIN}" == "mta-sts" && -z "${MTA_STS_ACTIVE_DOMAINS[${SQL_DOMAIN}]}" ]]; then
+ log_f "MTA-STS is not enabled for ${SQL_DOMAIN} - skipping mta-sts subdomain certificate"
+ continue
+ fi
+
# Skip if subdomain matches MAILCOW_HOSTNAME
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
continue
diff --git a/data/Dockerfiles/clamd/Dockerfile b/data/Dockerfiles/clamd/Dockerfile
index e60e7eef1..5f444a8f4 100644
--- a/data/Dockerfiles/clamd/Dockerfile
+++ b/data/Dockerfiles/clamd/Dockerfile
@@ -1,7 +1,7 @@
-FROM alpine:3.21 AS builder
+FROM alpine:3.24 AS builder
WORKDIR /src
-ENV CLAMD_VERSION=1.4.2
+ENV CLAMD_VERSION=1.4.6
RUN apk upgrade --no-cache \
&& apk add --update --no-cache \
@@ -68,7 +68,7 @@ RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERS
"/clamav/etc/clamav/clamav-milter.conf.sample" > "/clamav/etc/clamav/clamav-milter.conf" || exit 1
-FROM alpine:3.21
+FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH "
diff --git a/data/Dockerfiles/nginx/Dockerfile b/data/Dockerfiles/nginx/Dockerfile
index 30297362f..d3280a33d 100644
--- a/data/Dockerfiles/nginx/Dockerfile
+++ b/data/Dockerfiles/nginx/Dockerfile
@@ -1,4 +1,4 @@
-FROM nginx:1.30.2-alpine
+FROM nginx:1.30.4-alpine
LABEL maintainer "The Infrastructure Company GmbH "
ENV PIP_BREAK_SYSTEM_PACKAGES=1
diff --git a/data/Dockerfiles/phpfpm/Dockerfile b/data/Dockerfiles/phpfpm/Dockerfile
index 7917d7f7d..ca252163a 100644
--- a/data/Dockerfiles/phpfpm/Dockerfile
+++ b/data/Dockerfiles/phpfpm/Dockerfile
@@ -7,13 +7,13 @@ ARG APCU_PECL_VERSION=5.1.28
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?.*)$
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
+ARG MAILPARSE_PECL_VERSION=3.2.0
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?.*)$
ARG MEMCACHED_PECL_VERSION=3.4.0
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?.*)$
ARG REDIS_PECL_VERSION=6.3.0
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?.*)$
-ARG COMPOSER_VERSION=2.9.5
+ARG COMPOSER_VERSION=2.10.2
RUN apk add -U --no-cache autoconf \
aspell-dev \
diff --git a/data/Dockerfiles/postfix-tlspol/Dockerfile b/data/Dockerfiles/postfix-tlspol/Dockerfile
index 68f6ecced..fa363845f 100644
--- a/data/Dockerfiles/postfix-tlspol/Dockerfile
+++ b/data/Dockerfiles/postfix-tlspol/Dockerfile
@@ -1,17 +1,17 @@
-FROM golang:1.25-bookworm AS builder
+FROM golang:1.26-trixie AS builder
WORKDIR /src
ENV CGO_ENABLED=0 \
GO111MODULE=on \
NOOPT=1 \
- VERSION=1.8.22
+ VERSION=1.11.0
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \
scripts/build.sh build-only
-FROM debian:bookworm-slim
+FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH "
ARG DEBIAN_FRONTEND=noninteractive
diff --git a/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf b/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf
index 3862a3547..022dfc3da 100644
--- a/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf
+++ b/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf
@@ -1,4 +1,4 @@
-@version: 3.38
+@version: 4.8
@include "scl.conf"
options {
chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no);
use_fqdn(no);
owner("root"); group("adm"); perm(0640);
- stats_freq(0);
+ stats(freq(0));
bad_hostname("^gconfd$");
};
source s_src {
diff --git a/data/Dockerfiles/postfix-tlspol/syslog-ng.conf b/data/Dockerfiles/postfix-tlspol/syslog-ng.conf
index 7126c1250..70c92b5c0 100644
--- a/data/Dockerfiles/postfix-tlspol/syslog-ng.conf
+++ b/data/Dockerfiles/postfix-tlspol/syslog-ng.conf
@@ -1,4 +1,4 @@
-@version: 3.38
+@version: 4.8
@include "scl.conf"
options {
chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no);
use_fqdn(no);
owner("root"); group("adm"); perm(0640);
- stats_freq(0);
+ stats(freq(0));
bad_hostname("^gconfd$");
};
source s_src {
diff --git a/data/Dockerfiles/postfix/Dockerfile b/data/Dockerfiles/postfix/Dockerfile
index 994612ec4..cf0647692 100644
--- a/data/Dockerfiles/postfix/Dockerfile
+++ b/data/Dockerfiles/postfix/Dockerfile
@@ -1,4 +1,4 @@
-FROM debian:bookworm-slim
+FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH "
diff --git a/data/Dockerfiles/postfix/syslog-ng-redis_slave.conf b/data/Dockerfiles/postfix/syslog-ng-redis_slave.conf
index 8e15932a2..bbc9420fe 100644
--- a/data/Dockerfiles/postfix/syslog-ng-redis_slave.conf
+++ b/data/Dockerfiles/postfix/syslog-ng-redis_slave.conf
@@ -1,4 +1,4 @@
-@version: 3.38
+@version: 4.8
@include "scl.conf"
options {
chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no);
use_fqdn(no);
owner("root"); group("adm"); perm(0640);
- stats_freq(0);
+ stats(freq(0));
bad_hostname("^gconfd$");
};
source s_src {
diff --git a/data/Dockerfiles/postfix/syslog-ng.conf b/data/Dockerfiles/postfix/syslog-ng.conf
index fc7d1aa0f..8f09d04a4 100644
--- a/data/Dockerfiles/postfix/syslog-ng.conf
+++ b/data/Dockerfiles/postfix/syslog-ng.conf
@@ -1,4 +1,4 @@
-@version: 3.38
+@version: 4.8
@include "scl.conf"
options {
chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no);
use_fqdn(no);
owner("root"); group("adm"); perm(0640);
- stats_freq(0);
+ stats(freq(0));
bad_hostname("^gconfd$");
};
source s_src {
diff --git a/data/Dockerfiles/rspamd/Dockerfile b/data/Dockerfiles/rspamd/Dockerfile
index d0c710428..13e56357b 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.3-1~236eb65
+ARG RSPAMD_VER=rspamd_4.1.4-1~beb659b
ARG CODENAME=trixie
ENV LC_ALL=C
diff --git a/data/Dockerfiles/sogo/Dockerfile b/data/Dockerfiles/sogo/Dockerfile
index 1c7287200..f65ec7ebd 100644
--- a/data/Dockerfiles/sogo/Dockerfile
+++ b/data/Dockerfiles/sogo/Dockerfile
@@ -1,6 +1,6 @@
# SOGo built from source to enable security patch application
# Repository: https://github.com/Alinto/sogo
-# Version: SOGo-5.12.8
+# Version: SOGo-5.12.9
#
# Applied security patches:
# -
@@ -12,8 +12,8 @@ FROM debian:bookworm
LABEL maintainer="The Infrastructure Company GmbH "
ARG DEBIAN_FRONTEND=noninteractive
-ARG SOGO_VERSION=SOGo-5.12.8
-ARG SOPE_VERSION=SOPE-5.12.8
+ARG SOGO_VERSION=SOGo-5.12.10
+ARG SOPE_VERSION=SOPE-5.12.10
# Security patches to apply (space-separated commit hashes)
ARG SOGO_SECURITY_PATCHES=""
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?.*)$
diff --git a/data/conf/dovecot/auth/passwd-verify.lua b/data/conf/dovecot/auth/passwd-verify.lua
index ea847932d..a22488ea8 100644
--- a/data/conf/dovecot/auth/passwd-verify.lua
+++ b/data/conf/dovecot/auth/passwd-verify.lua
@@ -32,8 +32,17 @@ function auth_password_verify(request, password)
-- Returning PASSDB_RESULT_PASSWORD_MISMATCH will reset the user's auth cache entry.
-- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
-- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
+
+ -- On a network-level failure (nginx unreachable, DNS failure, timeout) https.request
+ -- returns nil plus an error string, so c is not a numeric HTTP status code. Treat this
+ -- as a backend outage and keep the cache entry, rather than wiping it as a mismatch.
+ if type(c) ~= "number" then
+ dovecot.i_info("HTTP request to auth backend failed with " .. tostring(c) .. " for user " .. request.user)
+ return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable"
+ end
+
if c ~= 200 and c ~= 401 then
- dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user)
+ dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user)
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
end
diff --git a/data/conf/dovecot/global_sieve_before b/data/conf/dovecot/global_sieve_before
index 3e79ca1d3..e71be5100 100644
--- a/data/conf/dovecot/global_sieve_before
+++ b/data/conf/dovecot/global_sieve_before
@@ -1,13 +1,3 @@
# global_sieve_before script
# global_sieve_before -> user sieve_before (mailcow UI) -> user sieve_after (mailcow UI) -> global_sieve_after
-require ["mailbox", "fileinto"];
-
-if header :contains ["Chat-Version"] [""] {
- if mailboxexists "DeltaChat" {
- fileinto "DeltaChat";
- } else {
- fileinto :create "DeltaChat";
- }
- stop;
-}
diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2
index 08a85a144..861c7e560 100644
--- a/data/conf/nginx/templates/nginx.conf.j2
+++ b/data/conf/nginx/templates/nginx.conf.j2
@@ -42,12 +42,19 @@ http {
https https;
}
+ map $server_port $sogo_auth_internal {
+ 65510 "1";
+ default "";
+ }
+
{% if HTTP_REDIRECT %}
# HTTP to HTTPS redirect
server {
root /web;
listen {{ HTTP_PORT }} default_server;
+ {% if ENABLE_IPV6 %}
listen [::]:{{ HTTP_PORT }} default_server;
+ {%endif%}
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2
index 84bd8eae4..842c50a54 100644
--- a/data/conf/nginx/templates/sites-default.conf.j2
+++ b/data/conf/nginx/templates/sites-default.conf.j2
@@ -116,6 +116,9 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
+ fastcgi_param HTTP_X_REAL_IP $remote_addr;
+ # trusted internal-auth marker; empty for external clients (see nginx.conf map)
+ fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
}
diff --git a/data/conf/postfix/main.cf b/data/conf/postfix/main.cf
index f091cb3f9..4329f5487 100644
--- a/data/conf/postfix/main.cf
+++ b/data/conf/postfix/main.cf
@@ -37,7 +37,7 @@ postscreen_access_list = permit_mynetworks,
cidr:/opt/postfix/conf/postscreen_access.cidr,
tcp:127.0.0.1:10027
postscreen_bare_newline_enable = no
-postscreen_blacklist_action = drop
+postscreen_denylist_action = drop
postscreen_cache_cleanup_interval = 24h
postscreen_cache_map = proxy:btree:$data_directory/postscreen_cache
postscreen_dnsbl_action = enforce
@@ -107,8 +107,6 @@ smtpd_sender_restrictions = reject_authenticated_sender_login_mismatch,
reject_unknown_sender_domain
smtpd_soft_error_limit = 3
smtpd_tls_auth_only = yes
-smtpd_tls_dh1024_param_file = /etc/ssl/mail/dhparams.pem
-smtpd_tls_eecdh_grade = auto
smtpd_tls_exclude_ciphers = ECDHE-RSA-RC4-SHA, RC4, aNULL, DES-CBC3-SHA, ECDHE-RSA-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA
smtpd_tls_loglevel = 1
@@ -152,7 +150,7 @@ smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_sender_dependent.cf
smtp_sasl_security_options =
smtp_sasl_mechanism_filter = plain, login
-smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY
+smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERYwithTLSRPT
smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre
mail_name = Postcow
# local_transport map catches local destinations and prevents routing local dests when the next map would route "*"
diff --git a/data/conf/postfix/master.cf b/data/conf/postfix/master.cf
index d5114df28..fb49f2df2 100644
--- a/data/conf/postfix/master.cf
+++ b/data/conf/postfix/master.cf
@@ -29,7 +29,6 @@ smtps inet n - n - - smtpd
# TLS protocol can be modified by setting submission_smtpd_tls_mandatory_protocols in extra.cf
submission inet n - n - - smtpd
-o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
- -o smtpd_enforce_tls=yes
-o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols
-o tls_preempt_cipherlist=yes
@@ -38,7 +37,6 @@ submission inet n - n - - smtpd
10587 inet n - n - - smtpd
-o smtpd_upstream_proxy_protocol=haproxy
-o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
- -o smtpd_enforce_tls=yes
-o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols
-o tls_preempt_cipherlist=yes
diff --git a/data/conf/postfix/postscreen_access.cidr b/data/conf/postfix/postscreen_access.cidr
index 285ea7a81..3801fe31e 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 Fri May 1 00:43:37 UTC 2026
+# Whitelist generated by Postwhite v3.4 on Sat Aug 1 00:35:20 UTC 2026
# https://github.com/stevejenkins/postwhite/
-# 2249 total rules
+# 2256 total rules
2a00:1450:4000::/36 permit
2a00:1450:4864::/56 permit
2a01:111:f400::/48 permit
@@ -29,6 +29,7 @@
2a01:b747:3004:200::/56 permit
2a01:b747:3005:200::/56 permit
2a01:b747:3006:200::/56 permit
+2a01:b747:3007::/56 permit
2a02:a60:0:5::/64 permit
2a0f:f640::/56 permit
2c0f:fb50:4000::/36 permit
@@ -57,13 +58,14 @@
8.25.196.0/23 permit
8.36.116.0/24 permit
8.39.54.0/23 permit
+8.39.54.240/29 permit
8.39.54.250/31 permit
8.39.144.0/24 permit
8.40.222.0/23 permit
+8.40.222.240/29 permit
8.40.222.250/31 permit
+8.47.10.9 permit
12.130.86.238 permit
-13.107.213.40 permit
-13.107.246.40 permit
13.108.16.0/20 permit
13.110.208.0/21 permit
13.110.209.0/24 permit
@@ -1385,6 +1387,7 @@
117.120.16.0/21 permit
119.42.242.52/31 permit
119.42.242.156 permit
+121.244.91.31 permit
121.244.91.48 permit
121.244.91.52 permit
122.15.156.182 permit
@@ -1554,12 +1557,14 @@
147.243.128.26 permit
148.105.0.0/16 permit
148.105.8.0/21 permit
+148.116.32.128/25 permit
149.72.0.0/16 permit
149.72.234.184 permit
149.72.248.236 permit
149.97.173.180 permit
149.118.160.128/25 permit
150.136.21.199 permit
+150.171.109.72 permit
150.230.98.160 permit
151.145.38.14 permit
152.67.105.195 permit
@@ -1637,8 +1642,9 @@
164.177.132.168/30 permit
165.1.100.0/25 permit
165.173.128.0/24 permit
-165.173.180.1 permit
+165.173.180.0/24 permit
165.173.180.250/31 permit
+165.173.182.0/24 permit
165.173.182.250/31 permit
165.173.189.205 permit
166.78.68.0/22 permit
@@ -1700,6 +1706,7 @@
173.203.81.39 permit
173.224.161.128/25 permit
173.224.165.0/26 permit
+173.224.166.48/28 permit
174.36.84.8/29 permit
174.36.84.16/29 permit
174.36.84.32/29 permit
@@ -2231,7 +2238,7 @@
2001:748:400:3301::4 permit
2404:6800:4000::/36 permit
2404:6800:4864::/56 permit
-2603:1061:14:72::1 permit
+2603:1061:14:75::1 permit
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
diff --git a/data/conf/rspamd/local.d/metadata_exporter.conf b/data/conf/rspamd/local.d/metadata_exporter.conf
index daaa79b4e..cc85faacd 100644
--- a/data/conf/rspamd/local.d/metadata_exporter.conf
+++ b/data/conf/rspamd/local.d/metadata_exporter.conf
@@ -3,8 +3,7 @@ rules {
backend = "http";
url = "http://nginx:9081/pipe.php";
selector = "reject_no_global_bl";
- formatter = "default";
- meta_headers = true;
+ formatter = "multipart";
}
RLINFO {
backend = "http";
@@ -16,8 +15,7 @@ rules {
backend = "http";
url = "http://nginx:9081/pushover.php";
selector = "mailcow_rcpt";
- formatter = "json";
- meta_headers = true;
+ formatter = "multipart";
}
}
diff --git a/data/conf/rspamd/meta_exporter/pipe.php b/data/conf/rspamd/meta_exporter/pipe.php
index 003639b08..28b06d3d6 100644
--- a/data/conf/rspamd/meta_exporter/pipe.php
+++ b/data/conf/rspamd/meta_exporter/pipe.php
@@ -32,47 +32,42 @@ function parse_email($email) {
$a = strrpos($email, '@');
return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1));
}
-if (!function_exists('getallheaders')) {
- function getallheaders() {
- if (!is_array($_SERVER)) {
- return array();
- }
- $headers = array();
- foreach ($_SERVER as $name => $value) {
- if (substr($name, 0, 5) == 'HTTP_') {
- $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
- }
- }
- return $headers;
- }
+// rspamd metadata_exporter (multipart formatter):
+// - $_POST['metadata'] JSON with the rspamd metadata
+// - $_FILES['message'] raw RFC822 message
+if (empty($_POST['metadata']) || !isset($_FILES['message']) || $_FILES['message']['error'] !== UPLOAD_ERR_OK) {
+ error_log("QUARANTINE: missing multipart parts from rspamd" . PHP_EOL);
+ http_response_code(400);
+ exit;
}
-$raw_data_content = file_get_contents('php://input');
+$meta = json_decode($_POST['metadata'], true);
+if (!is_array($meta)) {
+ error_log("QUARANTINE: cannot decode metadata JSON" . PHP_EOL);
+ http_response_code(400);
+ exit;
+}
+
+$raw_data_content = file_get_contents($_FILES['message']['tmp_name']);
$raw_data = mb_convert_encoding($raw_data_content, 'HTML-ENTITIES', "UTF-8");
-$headers = getallheaders();
+$raw_size = (int)$_FILES['message']['size'];
-$qid = $headers['X-Rspamd-Qid'];
-$fuzzy = $headers['X-Rspamd-Fuzzy'];
-$subject = iconv_mime_decode($headers['X-Rspamd-Subject']);
-$score = $headers['X-Rspamd-Score'];
-$rcpts = $headers['X-Rspamd-Rcpt'];
-$user = $headers['X-Rspamd-User'];
-$ip = $headers['X-Rspamd-Ip'];
-$action = $headers['X-Rspamd-Action'];
-$sender = $headers['X-Rspamd-From'];
-$symbols = $headers['X-Rspamd-Symbols'];
-
-$raw_size = (int)$_SERVER['CONTENT_LENGTH'];
+$qid = $meta['qid'] ?? 'unknown';
+$subject = $meta['subject'] ?? '';
+$score = $meta['score'] ?? 0;
+$rcpts = $meta['rcpt'] ?? array();
+$user = $meta['user'] ?? 'unknown';
+$ip = $meta['ip'] ?? 'unknown';
+$action = $meta['action'] ?? 'no action';
+$sender = $meta['from'] ?? '';
+$symbols = json_encode($meta['symbols'] ?? array());
+$fuzzy = json_encode(is_array($meta['fuzzy'] ?? null) ? $meta['fuzzy'] : array());
if (empty($sender)) {
error_log("QUARANTINE: Unknown sender, assuming empty-env-from@localhost" . PHP_EOL);
$sender = 'empty-env-from@localhost';
}
-if ($fuzzy == 'unknown') {
- $fuzzy = '[]';
-}
-
try {
$max_size = (int)$redis->Get('Q_MAX_SIZE');
if (($max_size * 1048576) < $raw_size) {
@@ -94,7 +89,7 @@ catch (RedisException $e) {
$rcpt_final_mailboxes = array();
// Loop through all rcpts
-foreach (json_decode($rcpts, true) as $rcpt) {
+foreach ($rcpts as $rcpt) {
// Remove tag
$rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt);
diff --git a/data/conf/rspamd/meta_exporter/pushover.php b/data/conf/rspamd/meta_exporter/pushover.php
index efe5fdd52..188d0a57b 100644
--- a/data/conf/rspamd/meta_exporter/pushover.php
+++ b/data/conf/rspamd/meta_exporter/pushover.php
@@ -32,50 +32,46 @@ function parse_email($email) {
$a = strrpos($email, '@');
return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1));
}
-if (!function_exists('getallheaders')) {
- function getallheaders() {
- if (!is_array($_SERVER)) {
- return array();
- }
- $headers = array();
- foreach ($_SERVER as $name => $value) {
- if (substr($name, 0, 5) == 'HTTP_') {
- $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
- }
- }
- return $headers;
- }
+// rspamd metadata_exporter (multipart formatter): metadata JSON arrives as $_POST['metadata'].
+if (empty($_POST['metadata'])) {
+ error_log("NOTIFY: missing metadata part from rspamd" . PHP_EOL);
+ http_response_code(400);
+ exit;
}
-$headers = getallheaders();
-$json_body = json_decode(file_get_contents('php://input'));
+$meta = json_decode($_POST['metadata'], true);
+if (!is_array($meta)) {
+ error_log("NOTIFY: cannot decode metadata JSON" . PHP_EOL);
+ http_response_code(400);
+ exit;
+}
-$qid = $headers['X-Rspamd-Qid'];
-$rcpts = $headers['X-Rspamd-Rcpt'];
-$sender = $headers['X-Rspamd-From'];
-$ip = $headers['X-Rspamd-Ip'];
-$subject = iconv_mime_decode($headers['X-Rspamd-Subject']);
-$messageid= $json_body->message_id;
+$qid = $meta['qid'] ?? 'unknown';
+$rcpts = $meta['rcpt'] ?? array();
+$sender = $meta['from'] ?? '';
+$ip = $meta['ip'] ?? 'unknown';
+$subject = $meta['subject'] ?? '';
+$messageid= $meta['message_id'] ?? '';
$priority = 0;
-$symbols_array = json_decode($headers['X-Rspamd-Symbols'], true);
+$symbols_array = $meta['symbols'] ?? array();
if (is_array($symbols_array)) {
foreach ($symbols_array as $symbol) {
- if ($symbol['name'] == 'HAS_X_PRIO_ONE') {
+ if (($symbol['name'] ?? null) == 'HAS_X_PRIO_ONE') {
$priority = 1;
break;
}
}
}
-$sender_address = $json_body->header_from[0];
+$sender_address = $meta['header_from'][0] ?? '';
$sender_name = '-';
if (preg_match('/(?.*?)<(?.*?)>/i', $sender_address, $matches)) {
$sender_address = $matches['address'];
$sender_name = trim($matches['name'], '"\' ');
}
-$to_address = $json_body->header_to[0];
+$to_address = $meta['header_to'][0] ?? '';
$to_name = '-';
if (preg_match('/(?.*?)<(?.*?)>/i', $to_address, $matches)) {
$to_address = $matches['address'];
@@ -85,7 +81,7 @@ if (preg_match('/(?.*?)<(?.*?)>/i', $to_address, $matches)) {
$rcpt_final_mailboxes = array();
// Loop through all rcpts
-foreach (json_decode($rcpts, true) as $rcpt) {
+foreach ($rcpts as $rcpt) {
// Remove tag
$rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt);
diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml
index 04524afab..af313b26b 100644
--- a/data/web/api/openapi.yaml
+++ b/data/web/api/openapi.yaml
@@ -185,6 +185,9 @@ paths:
example:
username: info@domain.tld
domain: domain.tld
+ description: my time limited alias
+ validity: 8760
+ permanent: false
properties:
username:
description: 'the mailbox an alias should be created for'
@@ -192,6 +195,15 @@ paths:
domain:
description: "the domain"
type: string
+ description:
+ description: "a description for the alias, defaults to an empty string"
+ type: string
+ validity:
+ description: "how many hours the alias stays valid, 1 to 87600, defaults to 8760 (one year)"
+ type: integer
+ permanent:
+ description: "keep the alias after it expired, defaults to false"
+ type: boolean
type: object
summary: Create time limited alias
/api/v1/add/app-passwd:
@@ -1072,6 +1084,7 @@ paths:
password2: "*"
quota: "3072"
force_pw_update: "1"
+ force_tfa: "1"
tls_enforce_in: "1"
tls_enforce_out: "1"
tags: ["tag1", "tag2"]
@@ -1118,6 +1131,7 @@ paths:
password2: atedismonsin
quota: "3072"
force_pw_update: "1"
+ force_tfa: "1"
tls_enforce_in: "1"
tls_enforce_out: "1"
tags: ["tag1", "tag2"]
@@ -1151,6 +1165,9 @@ paths:
force_pw_update:
description: forces the user to update its password on first login
type: boolean
+ force_tfa:
+ description: force 2FA enrollment at login
+ type: boolean
tls_enforce_in:
description: force inbound email tls encryption
type: boolean
@@ -3628,6 +3645,7 @@ paths:
- mailbox
- active: "1"
force_pw_update: "0"
+ force_tfa: "0"
name: Full name
password: "*"
password2: "*"
@@ -3678,6 +3696,7 @@ paths:
attr:
active: "1"
force_pw_update: "0"
+ force_tfa: "0"
name: Full name
authsource: mailcow
password: ""
@@ -3701,6 +3720,9 @@ paths:
force_pw_update:
description: force user to change password on next login
type: boolean
+ force_tfa:
+ description: force 2FA enrollment at login
+ type: boolean
name:
description: Full name of the mailbox user
type: string
@@ -5375,6 +5397,7 @@ paths:
- active: "1"
attributes:
force_pw_update: "0"
+ force_tfa: "0"
mailbox_format: "maildir:"
quarantine_notification: never
sogo_access: "1"
@@ -5391,6 +5414,7 @@ paths:
quota: 3221225472
quota_used: 0
rl: false
+ sender_acl: ["otherbox@doman3.tld", "@aliasdomain.tld"]
spam_aliases: 0
username: info@doman3.tld
tags: ["tag1", "tag2"]
@@ -6388,6 +6412,7 @@ paths:
- active: "1"
attributes:
force_pw_update: "0"
+ force_tfa: "0"
mailbox_format: "maildir:"
quarantine_notification: never
sogo_access: "1"
@@ -6405,6 +6430,7 @@ paths:
quota: 3221225472
quota_used: 0
rl: false
+ sender_acl: ["otherbox@domain3.tld", "@aliasdomain.tld"]
spam_aliases: 0
username: info@domain3.tld
tags: ["tag1", "tag2"]
@@ -6427,7 +6453,7 @@ paths:
response:
value:
- type: "success"
- log: ["cors", "edit", {"allowed_origins": ["*", "mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}]
+ log: ["cors", "edit", {"allowed_origins": ["*", "https://mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}]
msg: "cors_headers_edited"
description: OK
headers: { }
@@ -6444,7 +6470,7 @@ paths:
schema:
example:
attr:
- allowed_origins: ["*", "mail.mailcow.tld"]
+ allowed_origins: ["*", "https://mail.mailcow.tld"]
allowed_methods: ["POST", "GET", "DELETE", "PUT"]
properties:
attr:
diff --git a/data/web/inc/ajax/dns_diagnostics.php b/data/web/inc/ajax/dns_diagnostics.php
index 95e34e886..5efbaa472 100644
--- a/data/web/inc/ajax/dns_diagnostics.php
+++ b/data/web/inc/ajax/dns_diagnostics.php
@@ -380,13 +380,13 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
else {
$state = state_nomatch;
}
- $state .= '
' . $current[$data_field[$current['type']]];
+ $state .= '
' . htmlspecialchars($current[$data_field[$current['type']]]);
}
if ($current['type'] == 'TXT' &&
stripos($current['txt'], 'v=dmarc') === 0 &&
$record[2] == $dmarc_link) {
$current['txt'] = str_replace(' ', '', $current['txt']);
- $state = $current[$data_field[$current['type']]] . state_optional;
+ $state = htmlspecialchars($current[$data_field[$current['type']]]) . state_optional;
}
elseif ($current['type'] == 'TXT' &&
stripos($current['txt'], 'v=spf') === 0 &&
@@ -396,7 +396,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
if (in_array($ip, $rslt) && in_array(expand_ipv6($ip6), $rslt)) {
$state = state_good;
}
- $state .= '
' . $current[$data_field[$current['type']]] . state_optional;
+ $state .= '
' . htmlspecialchars($current[$data_field[$current['type']]]) . state_optional;
}
elseif ($current['type'] == 'TXT' &&
stripos($current['txt'], 'v=dkim') === 0 &&
@@ -426,7 +426,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
if ($state == state_nomatch) {
$state = array();
foreach ($currents as $current) {
- $state[] = $current[$data_field[$current['type']]];
+ $state[] = htmlspecialchars($current[$data_field[$current['type']]]);
}
$state = implode('
', $state);
}
@@ -436,12 +436,22 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
%s |
%s |
%s |
- ', $record[0], $record[1], $record[2], $state);
+ ', htmlspecialchars($record[0]), htmlspecialchars($record[1]), $record[2], $state);
$record[3] = explode('
', $state);
}
unset($record);
+ // Make a hostname RHS absolute so it is not read relative to $ORIGIN.
+ // Already-absolute names, the SRV root target "." and IP addresses are left alone.
+ $absolutize = function($host) {
+ $host = trim($host);
+ if ($host === '' || $host === '.' || substr($host, -1) === '.' || filter_var($host, FILTER_VALIDATE_IP)) {
+ return $host;
+ }
+ return $host . '.';
+ };
+
$dns_data = sprintf("\$ORIGIN %s.\n", $domain);
foreach ($records as $record) {
if ($domain == substr($record[0], -strlen($domain))) {
@@ -462,22 +472,32 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
$val = str_replace(state_optional, '', $val);
$val = str_replace(state_good, '', $val);
if (strlen($val) > 0) {
+ // these are all TXT values, their RHS is a character string, not a name
$vals[] = sprintf("%s\tIN\t%s\t%s\n", $label, $record[1], $val);
}
}
}
else {
+ if ($record[1] == 'MX' || $record[1] == 'CNAME') {
+ $val = $absolutize($val);
+ }
+ elseif ($record[1] == 'SRV') {
+ // format here is "target port"; only the target is a name
+ $parts = explode(' ', $val, 2);
+ $parts[0] = $absolutize($parts[0]);
+ $val = implode(' ', $parts);
+ }
$vals[] = sprintf("%s\tIN\t%s\t%s\n", $label, $record[1], $val);
}
foreach ($vals as $val) {
- $dns_data .= str_replace($domain, $domain . '.', $val);
+ $dns_data .= $val;
}
}
}
?>
- .txt' type='text/csv'>Download
+ .txt' type='text/csv'>Download