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