فهرست منبع

Merge branch 'staging' into nightly

DerLinkman 1 هفته پیش
والد
کامیت
0cfcde673c

+ 1 - 0
.gitignore

@@ -75,3 +75,4 @@ refresh_images.sh
 update_diffs/
 create_cold_standby.sh
 !data/conf/nginx/mailcow_auth.conf
+data/conf/postfix/postfix-tlspol

+ 1 - 1
data/Dockerfiles/acme/acme.sh

@@ -206,7 +206,7 @@ while true; do
 
   if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
   # Fetch certs for autoconfig and autodiscover subdomains
-  ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig')
+  ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig' 'mta-sts')
   fi
 
   if [[ ${SKIP_IP_CHECK} != "y" ]]; then

+ 49 - 0
data/Dockerfiles/postfix-tlspol/Dockerfile

@@ -0,0 +1,49 @@
+FROM golang:1.25-bookworm AS builder
+WORKDIR /src
+
+ENV CGO_ENABLED=0 \
+    GO111MODULE=on \
+    VERSION=1.8.14
+
+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
+LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
+
+ARG DEBIAN_FRONTEND=noninteractive
+ENV LC_ALL=C
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+	ca-certificates \
+	dirmngr \
+  	dnsutils \
+	iputils-ping \
+	sudo \
+	supervisor \
+	redis-tools \
+	syslog-ng \
+	syslog-ng-core \
+	syslog-ng-mod-redis \
+  	tzdata \
+	&& rm -rf /var/lib/apt/lists/* \
+	&& touch /etc/default/locale
+
+COPY supervisord.conf /etc/supervisor/supervisord.conf
+COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
+COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
+COPY postfix-tlspol.sh /opt/postfix-tlspol.sh
+COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
+COPY docker-entrypoint.sh /docker-entrypoint.sh
+COPY --from=builder /src/postfix-tlspol/build/postfix-tlspol /usr/local/bin/postfix-tlspol
+
+RUN chmod +x /opt/postfix-tlspol.sh \
+  /usr/local/sbin/stop-supervisor.sh \
+  /docker-entrypoint.sh
+RUN rm -rf /tmp/* /var/tmp/*
+
+ENTRYPOINT ["/docker-entrypoint.sh"]
+
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

+ 7 - 0
data/Dockerfiles/postfix-tlspol/docker-entrypoint.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+
+if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
+  cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
+fi
+
+exec "$@"

+ 52 - 0
data/Dockerfiles/postfix-tlspol/postfix-tlspol.sh

@@ -0,0 +1,52 @@
+#!/bin/bash
+
+LOGLVL=info
+
+if [ ${DEV_MODE} != "n" ]; then
+  echo -e "\e[31mEnabling debug mode\e[0m"
+  set -x
+  LOGLVL=debug
+fi
+
+[[ ! -d /etc/postfix-tlspol ]] && mkdir -p /etc/postfix-tlspol
+[[ ! -d /var/lib/postfix-tlspol ]] && mkdir -p /var/lib/postfix-tlspol
+
+until dig +short mailcow.email > /dev/null; do
+  echo "Waiting for DNS..."
+  sleep 1
+done
+
+# Do not attempt to write to slave
+if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
+  export REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
+else
+  export REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
+fi
+
+until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
+  echo "Waiting for Redis..."
+  sleep 2
+done
+
+echo "Waiting for Postfix..."
+until ping postfix -c1 > /dev/null; do
+  sleep 1
+done
+echo "Postfix OK"
+
+cat <<EOF > /etc/postfix-tlspol/config.yaml
+server:
+  address: 0.0.0.0:8642
+
+  log-level: ${LOGLVL}
+
+  prefetch: true
+
+  cache-file: /var/lib/postfix-tlspol/cache.db
+
+dns:
+  # must support DNSSEC
+  address: 127.0.0.11:53
+EOF
+
+/usr/local/bin/postfix-tlspol -config /etc/postfix-tlspol/config.yaml

+ 8 - 0
data/Dockerfiles/postfix-tlspol/stop-supervisor.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+printf "READY\n";
+
+while read line; do
+  echo "Processing Event: $line" >&2;
+  kill -3 $(cat "/var/run/supervisord.pid")
+done < /dev/stdin

+ 25 - 0
data/Dockerfiles/postfix-tlspol/supervisord.conf

@@ -0,0 +1,25 @@
+[supervisord]
+pidfile=/var/run/supervisord.pid
+nodaemon=true
+user=root
+
+[program:syslog-ng]
+command=/usr/sbin/syslog-ng --foreground  --no-caps
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+autostart=true
+
+[program:postfix-tlspol]
+startsecs=10
+autorestart=true
+command=/opt/postfix-tlspol.sh
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[eventlistener:processes]
+command=/usr/local/sbin/stop-supervisor.sh
+events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL

+ 45 - 0
data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf

@@ -0,0 +1,45 @@
+@version: 3.38
+@include "scl.conf"
+options {
+  chain_hostnames(off);
+  flush_lines(0);
+  use_dns(no);
+  dns_cache(no);
+  use_fqdn(no);
+  owner("root"); group("adm"); perm(0640);
+  stats_freq(0);
+  bad_hostname("^gconfd$");
+};
+source s_src {
+  unix-stream("/dev/log");
+  internal();
+};
+destination d_stdout { pipe("/dev/stdout"); };
+destination d_redis_ui_log {
+  redis(
+    host("`REDIS_SLAVEOF_IP`")
+    persist-name("redis1")
+    port(`REDIS_SLAVEOF_PORT`)
+    auth("`REDISPASS`")
+    command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
+  );
+};
+filter f_mail { facility(mail); };
+# start
+# overriding warnings are still displayed when the entrypoint runs its initial check
+# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
+# Some other warnings are ignored
+filter f_ignore {
+  not match("overriding earlier entry" value("MESSAGE"));
+  not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
+  not match("no SASL support" value("MESSAGE"));
+  not facility (local0, local1, local2, local3, local4, local5, local6, local7);
+};
+# end
+log {
+  source(s_src);
+  filter(f_ignore);
+  destination(d_stdout);
+  filter(f_mail);
+  destination(d_redis_ui_log);
+};

+ 45 - 0
data/Dockerfiles/postfix-tlspol/syslog-ng.conf

@@ -0,0 +1,45 @@
+@version: 3.38
+@include "scl.conf"
+options {
+  chain_hostnames(off);
+  flush_lines(0);
+  use_dns(no);
+  dns_cache(no);
+  use_fqdn(no);
+  owner("root"); group("adm"); perm(0640);
+  stats_freq(0);
+  bad_hostname("^gconfd$");
+};
+source s_src {
+  unix-stream("/dev/log");
+  internal();
+};
+destination d_stdout { pipe("/dev/stdout"); };
+destination d_redis_ui_log {
+  redis(
+    host("redis-mailcow")
+    persist-name("redis1")
+    port(6379)
+    auth("`REDISPASS`")
+    command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
+  );
+};
+filter f_mail { facility(mail); };
+# start
+# overriding warnings are still displayed when the entrypoint runs its initial check
+# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
+# Some other warnings are ignored
+filter f_ignore {
+  not match("overriding earlier entry" value("MESSAGE"));
+  not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
+  not match("no SASL support" value("MESSAGE"));
+  not facility (local0, local1, local2, local3, local4, local5, local6, local7);
+};
+# end
+log {
+  source(s_src);
+  filter(f_ignore);
+  destination(d_stdout);
+  filter(f_mail);
+  destination(d_redis_ui_log);
+};

+ 2 - 2
data/Dockerfiles/postfix/Dockerfile

@@ -1,9 +1,9 @@
 FROM debian:bookworm-slim
 
-LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
+LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
-ENV LC_ALL C
+ENV LC_ALL=C
 
 RUN dpkg-divert --local --rename --add /sbin/initctl \
 	&& ln -sf /bin/true /sbin/initctl \

+ 1 - 1
data/Dockerfiles/postfix/postfix.sh

@@ -524,4 +524,4 @@ if [[ $? != 0 ]]; then
 else
   postfix -c /opt/postfix/conf start
   sleep 126144000
-fi
+fi

+ 10 - 2
data/conf/nginx/templates/nginx.conf.j2

@@ -48,13 +48,21 @@ http {
         listen {{ HTTP_PORT }} default_server;
         listen [::]:{{ HTTP_PORT }} default_server;
 
-        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
+        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
 
         if ( $request_uri ~* "%0A|%0D" ) { return 403; }
         location ^~ /.well-known/acme-challenge/ {
             allow all;
             default_type "text/plain";
         }
+        location ^~ /.well-known/mta-sts.txt {
+            allow all;
+            fastcgi_split_path_info ^(.+\.php)(/.+)$;
+            fastcgi_pass {{ PHPFPMHOST }}:9002;
+            include /etc/nginx/fastcgi_params;
+            fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php;
+            fastcgi_param PATH_INFO $fastcgi_path_info;
+        }
         location / {
             return 301 https://$host$uri$is_args$args;
         }
@@ -82,7 +90,7 @@ http {
         ssl_certificate /etc/ssl/mail/cert.pem;
         ssl_certificate_key /etc/ssl/mail/key.pem;
 
-        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.*;
+        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.*;
 
         include /etc/nginx/includes/sites-default.conf;
     }

+ 8 - 0
data/conf/nginx/templates/sites-default.conf.j2

@@ -76,6 +76,14 @@ location ^~ /.well-known/acme-challenge/ {
     allow all;
     default_type "text/plain";
 }
+location ^~ /.well-known/mta-sts.txt {
+    allow all;
+    fastcgi_split_path_info ^(.+\.php)(/.+)$;
+    fastcgi_pass {{ PHPFPMHOST }}:9002;
+    include /etc/nginx/fastcgi_params;
+    fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php;
+    fastcgi_param PATH_INFO $fastcgi_path_info;
+}
 
 rewrite ^/.well-known/caldav$ /SOGo/dav/ permanent;
 rewrite ^/.well-known/carddav$ /SOGo/dav/ permanent;

+ 1 - 1
data/conf/postfix/main.cf

@@ -152,7 +152,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
+smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY
 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 "*"

+ 7 - 0
data/web/edit.php

@@ -48,6 +48,12 @@ if (isset($_SESSION['mailcow_cc_role'])) {
           $rl = ratelimit('get', 'domain', $domain);
           $rlyhosts = relayhost('get');
           $domain_footer = mailbox('get', 'domain_wide_footer', $domain);
+          $mta_sts = mailbox('get', 'mta_sts', $domain);
+          if (count($mta_sts) == 0) {
+            $mta_sts = false;
+          } elseif (isset($mta_sts['mx'])) {
+            $mta_sts['mx'] = implode(',', $mta_sts['mx']);
+          }
           $template = 'edit/domain.twig';
           $template_data = [
             'acl' => $_SESSION['acl'],
@@ -58,6 +64,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
             'dkim' => dkim('details', $domain),
             'domain_details' => $result,
             'domain_footer' => $domain_footer,
+            'mta_sts' => $mta_sts,
             'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
             'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
             'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"])

+ 39 - 7
data/web/inc/ajax/dns_diagnostics.php

@@ -71,6 +71,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
   // Init records array
   $spf_link = '<a href="http://www.open-spf.org/SPF_Record_Syntax/" target="_blank">SPF Record Syntax</a><br />';
   $dmarc_link = '<a href="https://www.kitterman.com/dmarc/assistant.html" target="_blank">DMARC Assistant</a>';
+  $mtasts_report_link = '<a href="https://mxtoolbox.com/dmarc/smtp-tls/how-to-setup-smtp-tls-reports" target="_blank">TLS Report Record Syntax</a>';
 
   $records = array();
 
@@ -128,6 +129,27 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
     );
   }
 
+  $mta_sts = mailbox('get', 'mta_sts', $domain);
+  if (count($mta_sts) > 0 && $mta_sts['active'] == 1) {
+    if (!in_array($domain, $alias_domains)) {
+      $records[] = array(
+        'mta-sts.' . $domain,
+        'CNAME',
+        $mailcow_hostname
+      );
+    }
+    $records[] = array(
+        '_mta-sts.' . $domain,
+        'TXT',
+        "v={$mta_sts['version']};id={$mta_sts['id']};",
+    );
+    $records[] = array(
+        '_smtp._tls.' . $domain,
+        'TXT',
+        $mtasts_report_link,
+    );
+  }
+
   $records[] = array(
     $domain,
     'TXT',
@@ -341,15 +363,25 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
         }
 
         foreach ($currents as &$current) {
+          if ($current['type'] == "TXT" &&
+              stripos(strtolower($current['txt']), 'v=sts') === 0) {
+            if (strtolower($current[$data_field[$current['type']]]) == strtolower($record[2])) {
+              $state = state_good;
+            }
+            else {
+              $state = state_nomatch;
+            }
+            $state .= '<br />' . $current[$data_field[$current['type']]];
+          }
           if ($current['type'] == 'TXT' &&
-          stripos($current['txt'], 'v=dmarc') === 0 &&
-          $record[2] == $dmarc_link) {
+              stripos($current['txt'], 'v=dmarc') === 0 &&
+              $record[2] == $dmarc_link) {
             $current['txt'] = str_replace(' ', '', $current['txt']);
             $state = $current[$data_field[$current['type']]] . state_optional;
           }
           elseif ($current['type'] == 'TXT' &&
-          stripos($current['txt'], 'v=spf') === 0 &&
-          $record[2] == $spf_link) {
+                  stripos($current['txt'], 'v=spf') === 0 &&
+                  $record[2] == $spf_link) {
             $state = state_nomatch;
             $rslt = get_spf_allowed_hosts($record[0], true);
             if (in_array($ip, $rslt) && in_array(expand_ipv6($ip6), $rslt)) {
@@ -358,8 +390,8 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
             $state .= '<br />' . $current[$data_field[$current['type']]] . state_optional;
           }
           elseif ($current['type'] == 'TXT' &&
-          stripos($current['txt'], 'v=dkim') === 0 &&
-          stripos($record[2], 'v=dkim') === 0) {
+                  stripos($current['txt'], 'v=dkim') === 0 &&
+                  stripos($record[2], 'v=dkim') === 0) {
             preg_match('/v=DKIM1;.*k=rsa;.*p=([^;]*).*/i', $current[$data_field[$current['type']]], $dkim_matches_current);
             preg_match('/v=DKIM1;.*k=rsa;.*p=([^;]*).*/i', $record[2], $dkim_matches_good);
             if ($dkim_matches_current[1] == $dkim_matches_good[1]) {
@@ -367,7 +399,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
             }
           }
           elseif ($current['type'] != 'TXT' &&
-          isset($data_field[$current['type']]) && $state != state_good) {
+                  isset($data_field[$current['type']]) && $state != state_good) {
             $state = state_nomatch;
             if ($current[$data_field[$current['type']]] == $record[2]) {
               $state = state_good;

+ 211 - 0
data/web/inc/functions.mailbox.inc.php

@@ -1401,6 +1401,80 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
 
           return mailbox('add', 'mailbox', $mailbox_attributes);
         break;
+        case 'mta_sts':
+          $domain       = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46);
+          $version      = strtolower($_data['version']);
+          $mode         = strtolower($_data['mode']);
+          $mx           = explode(",", preg_replace('/\s+/', '', $_data['mx']));
+          $max_age      = intval($_data['max_age']);
+          $active       = (intval($_data['active']) == 1) ? 1 : 0;
+          $id           = date('YmdHis');
+
+          if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+              'msg' => 'access_denied'
+            );
+            return false;
+          }
+          if (empty($version) || !in_array($version, array('stsv1'))) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+              'msg' => array('version_invalid', htmlspecialchars($domain))
+            );
+            return false;
+          }
+          if (empty($mode) || !in_array($mode, array('enforce', 'testing', 'none'))) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+              'msg' => array('mode_invalid', htmlspecialchars($domain))
+            );
+            return false;
+          }
+          if (empty($max_age) || $max_age < 0 || $max_age > 31536000) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+              'msg' => array('max_age_invalid', htmlspecialchars($domain))
+            );
+            return false;
+          }
+          foreach ($mx as $index => $mx_domain) {
+            $mx_domain = idn_to_ascii(strtolower(trim($mx_domain)), 0, INTL_IDNA_VARIANT_UTS46);
+            if (!is_valid_domain_name($mx_domain)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+                'msg' => array('mx_invalid', htmlspecialchars($mx_domain))
+              );
+              return false;
+            }
+          }
+
+          try {
+            $stmt = $pdo->prepare("INSERT INTO `mta_sts` (`id`, `domain`, `version`, `mode`, `mx`, `max_age`, `active`)
+              VALUES (:id, :domain, :version, :mode, :mx, :max_age, :active)");
+            $stmt->execute(array(
+              ':id' => $id,
+              ':domain' => $domain,
+              ':version' => $version,
+              ':mode' => $mode,
+              ':mx' => implode(",", $mx),
+              ':max_age' => $max_age,
+              ':active' => $active
+            ));
+          } catch (PDOException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data),
+              'msg' => $e->getMessage()
+            );
+            return false;
+          }
+        break;
         case 'resource':
           $domain             = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46);
           $description        = $_data['description'];
@@ -3742,6 +3816,125 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
 
           return true;
         break;
+        case 'mta_sts':
+          if (!is_array($_data['domains'])) {
+            $domains = array();
+            $domains[] = $_data['domains'];
+          }
+          else {
+            $domains = $_data['domains'];
+          }
+
+          foreach ($domains as $domain) {
+            $domain       = idn_to_ascii(strtolower(trim($domain)), 0, INTL_IDNA_VARIANT_UTS46);
+
+            if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+                'msg' => 'access_denied'
+              );
+              continue;
+            }
+
+            $is_now = mailbox('get', 'mta_sts', $domain);
+            if (!empty($is_now)) {
+              $version               = (isset($_data['version'])) ? strtolower($_data['version']) : $is_now['version'];
+              $active                = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
+              $active                = ($active == 1) ? 1 : 0;
+              $mode                  = (isset($_data['mode'])) ? strtolower($_data['mode']) : $is_now['mode'];
+              $mx                    = (isset($_data['mx'])) ? explode(",", preg_replace('/\s+/', '', $_data['mx'])) : $is_now['mx'];
+              $max_age               = (isset($_data['max_age'])) ? intval($_data['max_age']) : $is_now['max_age'];
+
+              // Update ID if neccesary
+              if ($version != strtolower($is_now['version']) ||
+                  $mode != strtolower($is_now['mode']) ||
+                  $mx != $is_now['mx'] ||
+                  $max_age != $is_now['max_age']) {
+                $id           = date('YmdHis');
+              } else {
+                $id           = $is_now['id'];
+              }
+
+            } else {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'access_denied'
+              );
+              continue;
+            }
+
+            if (empty($version) || !in_array($version, array('stsv1'))) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+                'msg' => array('version_invalid', htmlspecialchars($version))
+              );
+              continue;
+            }
+            if (empty($mode) || !in_array($mode, array('enforce', 'testing', 'none'))) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+                'msg' => array('mode_invalid', htmlspecialchars($domain))
+              );
+              continue;
+            }
+            if (empty($max_age) || $max_age < 0 || $max_age > 31557600) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+                'msg' => array('max_age_invalid', htmlspecialchars($domain))
+              );
+              continue;
+            }
+            foreach ($mx as $index => $mx_domain) {
+              $mx_domain = idn_to_ascii(strtolower(trim($mx_domain)), 0, INTL_IDNA_VARIANT_UTS46);
+              $invalid_mx = false;
+              if (!is_valid_domain_name($mx_domain)) {
+                $invalid_mx = $mx_domain;
+                break;
+              }
+            }
+            if ($invalid_mx) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+                'msg' => array('mx_invalid', htmlspecialchars($invalid_mx))
+              );
+              continue;
+            }
+
+            try {
+              $stmt = $pdo->prepare("UPDATE `mta_sts` SET `id` = :id, `version` = :version, `mode` = :mode, `mx` = :mx, `max_age` = :max_age, `active` = :active WHERE `domain` = :domain");
+              $stmt->execute(array(
+                ':id' => $id,
+                ':domain' => $domain,
+                ':version' => $version,
+                ':mode' => $mode,
+                ':mx' => implode(",", $mx),
+                ':max_age' => $max_age,
+                ':active' => $active
+              ));
+            } catch (PDOException $e) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data),
+                'msg' => $e->getMessage()
+              );
+              continue;
+            }
+
+            $_SESSION['return'][] = array(
+              'type' => 'success',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr),
+              'msg' => array('object_modified', $domain)
+            );
+          }
+
+          return true;
+        break;
         case 'resource':
           if (!is_array($_data['name'])) {
             $names = array();
@@ -5030,6 +5223,20 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             return $rows;
           }
         break;
+        case 'mta_sts':
+          $stmt = $pdo->prepare("SELECT * FROM `mta_sts` WHERE `domain` = :domain");
+          $stmt->execute(array(
+            ':domain' => $_data,
+          ));
+          $row = $stmt->fetch(PDO::FETCH_ASSOC);
+          if (empty($row)){
+            return [];
+          }
+          $row['mx'] = explode(',', $row['mx']);
+          $row['version'] = strtoupper(substr($row['version'], 0, 3)) . substr($row['version'], 3);
+
+          return $row;
+        break;
         case 'resource_details':
           $resourcedata = array();
           if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
@@ -5415,6 +5622,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             $stmt->execute(array(
               ':domain' => $domain,
             ));
+            $stmt = $pdo->prepare("DELETE FROM `mta_sts` WHERE `domain` = :domain");
+            $stmt->execute(array(
+              ':domain' => $domain,
+            ));
             $stmt = $pdo->query("DELETE FROM `admin` WHERE `superadmin` = 0 AND `username` NOT IN (SELECT `username`FROM `domain_admins`);");
             $stmt = $pdo->query("DELETE FROM `da_acl` WHERE `username` NOT IN (SELECT `username`FROM `domain_admins`);");
             try {

+ 18 - 1
data/web/inc/init_db.inc.php

@@ -4,7 +4,7 @@ function init_db_schema()
   try {
     global $pdo;
 
-    $db_version = "06082025_1611";
+    $db_version = "19082025_1436";
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -475,6 +475,23 @@ function init_db_schema()
         ),
         "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
       ),
+      "mta_sts" => array(
+        "cols" => array(
+          "id" => "BIGINT NOT NULL",
+          "domain" => "VARCHAR(255) NOT NULL",
+          "version" => "VARCHAR(255) NOT NULL",
+          "mode" => "VARCHAR(255) NOT NULL",
+          "mx" => "VARCHAR(255) NOT NULL",
+          "max_age" => "VARCHAR(255) NOT NULL",
+          "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
+        ),
+        "keys" => array(
+          "primary" => array(
+            "" => array("domain")
+          )
+        ),
+        "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+      ),
       "user_acl" => array(
         "cols" => array(
           "username" => "VARCHAR(255) NOT NULL",

+ 6 - 0
data/web/json_api.php

@@ -324,6 +324,9 @@ if (isset($_GET['query'])) {
         case "app-passwd":
           process_add_return(app_passwd('add', $attr));
         break;
+        case "mta-sts":
+          process_add_return(mailbox('add', 'mta_sts', $attr));
+        break;
         // return no route found if no case is matched
         default:
           http_response_code(404);
@@ -2001,6 +2004,9 @@ if (isset($_GET['query'])) {
         case "reset-password-notification":
           process_edit_return(reset_password('edit_notification', $attr));
         break;
+        case "mta-sts":
+          process_edit_return(mailbox('edit', 'mta_sts', array_merge(array('domains' => $items), $attr)));
+        break;
         // return no route found if no case is matched
         default:
           http_response_code(404);

+ 17 - 2
data/web/lang/lang.de-de.json

@@ -482,10 +482,13 @@
         "mailboxes_in_use": "Maximale Anzahl an Mailboxen muss größer oder gleich %d sein",
         "malformed_username": "Benutzername hat ein falsches Format",
         "map_content_empty": "Inhalt darf nicht leer sein",
+        "max_age_invalid": "Maximales Alter %s ist ungültig",
         "max_alias_exceeded": "Anzahl an Alias-Adressen überschritten",
         "max_mailbox_exceeded": "Anzahl an Mailboxen überschritten (%d von %d)",
         "max_quota_in_use": "Mailbox-Speicherplatzlimit muss größer oder gleich %d MiB sein",
         "maxquota_empty": "Max. Speicherplatz pro Mailbox darf nicht 0 sein.",
+        "mode_invalid": "Modus %s ist ungültig",
+        "mx_invalid": "MX-Eintrag %s ist ungültig",
         "mysql_error": "MySQL-Fehler: %s",
         "network_host_invalid": "Netzwerk oder Host ungültig: %s",
         "next_hop_interferes": "%s verhindert das Hinzufügen von Next Hop %s",
@@ -545,6 +548,7 @@
         "username_invalid": "Benutzername %s kann nicht verwendet werden",
         "validity_missing": "Bitte geben Sie eine Gültigkeitsdauer an",
         "value_missing": "Bitte alle Felder ausfüllen",
+        "version_invalid": "Version %s ist ungültig",
         "yotp_verification_failed": "Yubico OTP-Verifizierung fehlgeschlagen: %s",
         "template_exists": "Vorlage %s existiert bereits",
         "template_id_invalid": "Vorlagen-ID %s ungültig",
@@ -703,6 +707,17 @@
         "maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
         "mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.",
         "mins_interval": "Intervall (min)",
+        "mta_sts": "MTA-STS",
+        "mta_sts_info": "<a href='https://de.wikipedia.org/wiki/STARTTLS#MTA-STS' target='_blank'>MTA-STS</a> ist ein Standard, der den E-Mail-Versand zwischen Mailservern zwingt, TLS mit gültigen Zertifikaten zu verwenden. <br>Er wird verwendet, wenn <a target='_blank' href='https://de.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities'>DANE</a> aufgrund fehlender oder nicht unterstützter DNSSEC nicht möglich ist.<br><b>Hinweis</b>: Wenn die empfangende Domain DANE mit DNSSEC unterstützt, wird DANE <b>immer</b> bevorzugt – MTA-STS fungiert nur als Fallback.",
+        "mta_sts_version": "Version",
+        "mta_sts_version_info": "Definiert die Version des MTA-STS-Standards – derzeit ist nur <code>STSv1</code> gültig.",
+        "mta_sts_mode": "Modus",
+        "mta_sts_mode_info": "Es gibt drei Modi zur Auswahl:<ul><li><em>testing</em> – Die Richtlinie wird nur überwacht, Verstöße haben keine Auswirkungen.</li><li><em>enforce</em> – Die Richtlinie wird strikt durchgesetzt, Verbindungen ohne gültiges TLS werden abgelehnt.</li><li><em>none</em> – Die Richtlinie wird veröffentlicht, aber nicht angewendet.</li></ul>",
+        "mta_sts_max_age": "Maximales Alter",
+        "mta_sts_max_age_info": "Zeit in Sekunden, die empfangende Mailserver diese Richtlinie zwischenspeichern dürfen, bevor sie erneut abgerufen wird.",
+        "mta_sts_mx": "MX-Server",
+        "mta_sts_mx_info": "Erlaubt das Senden nur an explizit aufgeführte Mailserver-Hostnamen; der sendende MTA überprüft, ob der DNS-MX-Hostname mit der Richtlinienliste übereinstimmt, und erlaubt die Zustellung nur mit einem gültigen TLS-Zertifikat (schützt vor MITM).",
+        "mta_sts_mx_notice": "Es können mehrere MX-Server angegeben werden (durch Kommas getrennt).",
         "multiple_bookings": "Mehrfaches Buchen",
         "nexthop": "Next Hop",
         "none_inherit": "Keine Auswahl / Erben",
@@ -852,7 +867,7 @@
         "add_tls_policy_map": "TLS-Richtlinieneintrag hinzufügen",
         "address_rewriting": "Adressumschreibung",
         "alias": "Alias",
-        "alias_domain_alias_hint": "Alias-Adressen werden <b>nicht</b> automatisch auch auf Domain-Alias Adressen angewendet. Eine Alias-Adresse <code>mein-alias@domain</code> bildet demnach <b>nicht</b> die Adresse <code>mein-alias@alias-domain</code> ab.<br>E-Mail-Weiterleitungen an externe Postfächer sollten über Sieve (SOGo Weiterleitung oder im Reiter \"Filter\") angelegt werden. Der Button \"Alias über Alias-Domains expandieren\" erstellt fehlende Alias-Adressen in Alias-Domains.",
+        "alias_domain_alias_hint": "Alias-Adressen werden <b>nicht</b> automatisch auch auf Domain-Alias Adressen angewendet. Eine Alias-Adresse <code>mein-alias@domain</code> bildet demnach <b>nicht</b> die Adresse <code>mein-alias@alias-domain</code> ab.<br>E-Mail-Weiterleitungen an externe Postfächer sollten über Sieve (SOGo Weiterleitung oder im Reiter Filter) angelegt werden. Der Button Alias über Alias-Domains expandieren erstellt fehlende Alias-Adressen in Alias-Domains.",
         "alias_domain_backupmx": "Alias-Domain für Relay-Domain inaktiv",
         "aliases": "Aliasse",
         "allow_from_smtp": "Nur folgende IPs für <b>SMTP</b> erlauben",
@@ -1248,7 +1263,7 @@
         "delete_ays": "Soll der Löschvorgang wirklich ausgeführt werden?",
         "direct_aliases": "Direkte Alias-Adressen",
         "direct_aliases_desc": "Nur direkte Alias-Adressen werden für benutzerdefinierte Einstellungen berücksichtigt.",
-        "direct_protocol_access": "Der Hauptbenutzer hat <b>direkten, externen Zugriff</b> auf folgende Protokolle und Anwendungen. Diese Einstellung wird vom Administrator gesteuert. App-Passwörter können verwendet werden, um individuelle Zugänge für Protokolle und Anwendungen zu erstellen.<br>Der Button \"Webmail\" kann unabhängig der Einstellung immer verwendet werden.",
+        "direct_protocol_access": "Der Hauptbenutzer hat <b>direkten, externen Zugriff</b> auf folgende Protokolle und Anwendungen. Diese Einstellung wird vom Administrator gesteuert. App-Passwörter können verwendet werden, um individuelle Zugänge für Protokolle und Anwendungen zu erstellen.<br>Der Button Webmail kann unabhängig der Einstellung immer verwendet werden.",
         "eas_reset": "ActiveSync-Geräte-Cache zurücksetzen",
         "eas_reset_help": "In vielen Fällen kann ein ActiveSync-Profil durch das Zurücksetzen des Caches repariert werden.<br><b>Vorsicht:</b> Alle Elemente werden erneut heruntergeladen!",
         "eas_reset_now": "Jetzt zurücksetzen",

+ 15 - 0
data/web/lang/lang.en-gb.json

@@ -483,10 +483,13 @@
         "mailboxes_in_use": "Max. mailboxes must be greater or equal to %d",
         "malformed_username": "Malformed username",
         "map_content_empty": "Map content cannot be empty",
+        "max_age_invalid": "Max age %s is invalid",
         "max_alias_exceeded": "Max. aliases exceeded",
         "max_mailbox_exceeded": "Max. mailboxes exceeded (%d of %d)",
         "max_quota_in_use": "Mailbox quota must be greater or equal to %d MiB",
         "maxquota_empty": "Max. quota per mailbox must not be 0.",
+        "mode_invalid": "Mode %s is invalid",
+        "mx_invalid": "MX record %s is invalid",
         "mysql_error": "MySQL error: %s",
         "network_host_invalid": "Invalid network or host: %s",
         "next_hop_interferes": "%s interferes with nexthop %s",
@@ -550,6 +553,7 @@
         "username_invalid": "Username %s cannot be used",
         "validity_missing": "Please assign a period of validity",
         "value_missing": "Please provide all values",
+        "version_invalid": "Version %s is invalid",
         "yotp_verification_failed": "Yubico OTP verification failed: %s"
     },
     "datatables": {
@@ -704,6 +708,17 @@
         "maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
         "mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.",
         "mins_interval": "Interval (min)",
+        "mta_sts": "MTA-STS",
+        "mta_sts_info": "<a href='https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security' target='_blank'>MTA-STS</a> is a standard that enforces email delivery between mail servers to use TLS with valid certificates. <br>It is used when <a target='_blank' href='https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities'>DANE</a> is not possible due to missing or unsupported DNSSEC.<br><b>Note</b>: If the receiving domain supports DANE with DNSSEC, DANE is <b>always</b> preferred – MTA-STS only acts as a fallback.",
+        "mta_sts_version": "Version",
+        "mta_sts_version_info": "Defines the version of the MTA-STS standard – currently only <code>STSv1</code> is valid." ,
+        "mta_sts_mode": "Mode",
+        "mta_sts_mode_info": "There are three modes to choose from:<ul><li><em>testing</em> – policy is only monitored, violations have no impact.</li><li><em>enforce</em> – policy is strictly enforced, connections without valid TLS are rejected.</li><li><em>none</em> – policy is published but not applied.</li></ul>",
+        "mta_sts_max_age": "Max age",
+        "mta_sts_max_age_info": "Time in seconds that receiving mail servers may cache this policy until refetching.",
+        "mta_sts_mx": "MX server",
+        "mta_sts_mx_info": "Allows sending only to explicitly listed mail server hostnames; the sending MTA checks if the DNS MX hostname matches the policy list, and only allows delivery with a valid TLS certificate (guards against MITM).",
+        "mta_sts_mx_notice": "Multiple MX servers can be specified (separated by commas).",
         "multiple_bookings": "Multiple bookings",
         "none_inherit": "None / Inherit",
         "nexthop": "Next hop",

+ 30 - 0
data/web/mta-sts.php

@@ -0,0 +1,30 @@
+<?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+
+if (!isset($_SERVER['HTTP_HOST']) || strpos($_SERVER['HTTP_HOST'], 'mta-sts.') !== 0) {
+  http_response_code(404);
+  exit;
+}
+
+$domain = str_replace('mta-sts.', '', $_SERVER['HTTP_HOST']);
+$mta_sts = mailbox('get', 'mta_sts', $domain);
+
+if (count($mta_sts) == 0 ||
+    !isset($mta_sts['version']) ||
+    !isset($mta_sts['mode']) ||
+    !isset($mta_sts['max_age']) ||
+    !isset($mta_sts['mx']) ||
+    $mta_sts['active'] != 1) {
+  http_response_code(404);
+  exit;
+}
+
+header('Content-Type: text/plain; charset=utf-8');
+echo "version: {$mta_sts['version']}\n";
+echo "mode: {$mta_sts['mode']}\n";
+echo "max_age: {$mta_sts['max_age']}\n";
+foreach ($mta_sts['mx'] as $mx) {
+  echo "mx: {$mx}\n";
+}
+
+?>

+ 77 - 0
data/web/templates/edit/domain.twig

@@ -8,6 +8,7 @@
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dratelimit">{{ lang.edit.ratelimit }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dspamfilter">{{ lang.edit.spam_filter }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dqwbcc">{{ lang.edit.quota_warning_bcc }}</button></li>
+      <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dmtasts">{{ lang.edit.mta_sts }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dfooter">{{ lang.edit.domain_footer }}</button></li>
     </ul>
     <hr class="d-none d-md-block">
@@ -278,6 +279,82 @@
             </div>
         </div>
       </div>
+      <div id="dmtasts" class="tab-pane fade" role="tabpanel" aria-labelledby="domain-mtasts">
+        <div class="card mb-4">
+            <div class="card-header d-flex d-md-none fs-5">
+              <button class="btn flex-grow-1 text-start" data-bs-target="#collapse-tab-mtasts" data-bs-toggle="collapse" aria-controls="collapse-tab-mtasts">
+                {{ lang.edit.mta_sts }} <span class="badge bg-info table-lines"></span>
+              </button>
+            </div>
+            <div id="collapse-tab-mtasts" class="card-body collapse" data-bs-parent="#domain-content">
+                <h4>{{ lang.edit.mta_sts }}</h4>
+                <p>{{ lang.edit.mta_sts_info|raw }}</p>
+                <form data-id="dommtasts" method="post">
+                  <input type="hidden" value="0" name="active">
+                  <input type="hidden" value="{{ domain }}" name="domain">
+                  <div class="row mb-2">
+                    <label class="control-label col-sm-2" for="version"> 
+                      <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.edit.mta_sts_version_info|raw }}"></i>
+                      {{ lang.edit.mta_sts_version }}
+                    </label>
+                    <div class="col-sm-10">
+                      <select data-style="btn btn-light" class="form-control" name="version" title="" required>
+                        <option value="stsv1"{% if mta_sts.version == 'STSv1' %} selected{% endif %}>STSv1</option>
+                      </select>
+                    </div>
+                  </div>
+                  <div class="row mb-2">
+                    <label class="control-label col-sm-2" for="mode">
+                    <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.edit.mta_sts_mode_info|raw }}"></i>
+                      {{ lang.edit.mta_sts_mode }}
+                    </label>
+                    <div class="col-sm-10">
+                      <select data-style="btn btn-light" class="form-control" name="mode" title="" required>
+                        <option value="enforce"{% if mta_sts.mode == 'enforce' %} selected{% endif %}>enforce</option>
+                        <option value="testing"{% if mta_sts.mode == 'testing' %} selected{% endif %}>testing</option>
+                        <option value="none"{% if mta_sts.mode == 'none' %} selected{% endif %}>none</option>
+                      </select>
+                    </div>
+                  </div>
+                  <div class="row mb-2">
+                    <label class="control-label col-sm-2" for="max_age">
+                      <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.edit.mta_sts_max_age_info|raw }}"></i>
+                      {{ lang.edit.mta_sts_max_age }}
+                    </label>
+                    <div class="col-sm-10">
+                      <input type="number" class="form-control" name="max_age" value="{{ mta_sts.max_age }}">
+                    </div>
+                  </div>
+                  <div class="row mb-2">
+                    <label class="control-label col-sm-2" for="mx">
+                      <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.edit.mta_sts_mx_info|raw }}"></i>
+                      {{ lang.edit.mta_sts_mx }}
+                    </label>
+                    <div class="col-sm-10">
+                      <textarea autocorrect="off" autocapitalize="none" class="form-control" rows="5" name="mx">{{ mta_sts.mx }}</textarea>
+                      <small class="text-muted">{{ lang.edit.mta_sts_mx_notice|raw }}</small>
+                    </div>
+                  </div>
+                  <div class="row mb-4">
+                    <div class="offset-sm-2 col-sm-10">
+                      <div class="form-check">
+                        <label><input type="checkbox" class="form-check-input" value="1" name="active"{% if mta_sts.active == '1' %} checked{% endif %}> {{ lang.edit.active }}</label>
+                      </div>
+                    </div>
+                  </div>
+                  <div class="row mb-2">
+                    <div class="offset-sm-2 col-sm-10">
+                      {% if mta_sts == false %}
+                      <button class="btn btn-xs-lg d-block d-sm-inline btn-secondary" data-action="add_item" data-id="dommtasts" data-item="{{ domain }}" data-api-url='add/mta-sts' data-api-attr='{}' href="#">{{ lang.admin.save }}</button>
+                      {% else %}
+                      <button class="btn btn-xs-lg d-block d-sm-inline btn-secondary" data-action="edit_selected" data-id="dommtasts" data-item="{{ domain }}" data-api-url='edit/mta-sts' data-api-attr='{}' href="#">{{ lang.admin.save }}</button>
+                      {% endif %}
+                    </div>
+                  </div>
+                </form>
+            </div>
+        </div>
+      </div>
       <div id="dfooter" class="tab-pane fade" role="tabpanel" aria-labelledby="domain-footer">
         <div class="card mb-4">
             <div class="card-header d-flex d-md-none fs-5">

+ 28 - 3
docker-compose.yml

@@ -251,7 +251,7 @@ services:
             - sogo
 
     dovecot-mailcow:
-      image: ghcr.io/mailcow/dovecot:nightly-29072025
+      image: ghcr.io/mailcow/dovecot:nightly-28082025
       depends_on:
         - mysql-mailcow
         - netfilter-mailcow
@@ -338,12 +338,14 @@ services:
             - dovecot
 
     postfix-mailcow:
-      image: ghcr.io/mailcow/postfix:1.80
+      image: ghcr.io/mailcow/postfix:1.81
       depends_on:
         mysql-mailcow:
           condition: service_started
         unbound-mailcow:
           condition: service_healthy
+        postfix-tlspol-mailcow:
+          condition: service_started
       volumes:
         - ./data/hooks/postfix:/hooks:Z
         - ./data/conf/postfix:/opt/postfix/conf:z
@@ -378,6 +380,28 @@ services:
           aliases:
             - postfix
 
+    postfix-tlspol-mailcow:
+      image: ghcr.io/mailcow/postfix-tlspol:1.0
+      depends_on:
+        unbound-mailcow:
+          condition: service_healthy
+      volumes:
+        - postfix-tlspol-vol-1:/var/lib/postfix-tlspol
+      environment:
+        - LOG_LINES=${LOG_LINES:-9999}
+        - TZ=${TZ}
+        - REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
+        - REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
+        - REDISPASS=${REDISPASS}
+        - DEV_MODE=${DEV_MODE:-n}
+      restart: always
+      dns:
+        - ${IPV4_NETWORK:-172.22.1}.254
+      networks:
+        mailcow-network:
+          aliases:
+            - postfix-tlspol
+
     memcached-mailcow:
       image: memcached:alpine
       restart: always
@@ -441,7 +465,7 @@ services:
           condition: service_started
         unbound-mailcow:
           condition: service_healthy
-      image: ghcr.io/mailcow/acme:nightly-29072025
+      image: ghcr.io/mailcow/acme:nightly-28082025
       dns:
         - ${IPV4_NETWORK:-172.22.1}.254
       environment:
@@ -658,6 +682,7 @@ volumes:
   redis-vol-1:
   rspamd-vol-1:
   postfix-vol-1:
+  postfix-tlspol-vol-1:
   crypt-vol-1:
   sogo-web-vol-1:
   sogo-userdata-backup-vol-1: