Browse Source

[Web] Add MTA-STS support

FreddleSpl0it 2 months ago
parent
commit
d1d1bc2c30

+ 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;

+ 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"])

+ 26 - 0
data/web/inc/ajax/dns_diagnostics.php

@@ -142,6 +142,32 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
     state_optional
   );
 
+  $mta_sts = mailbox('get', 'mta_sts', $domain);
+  if (count($mta_sts) > 0 && $mta_sts['active'] == 1) {
+    $mta_sts_record = dns_get_record('_mta-sts.' . $domain, DNS_TXT);
+    $mta_sts_correct = "v={$mta_sts['version']};id={$mta_sts['id']};";
+    $state = state_missing;
+
+    if (!empty($mta_sts_record)) {
+        foreach ($mta_sts_record as $record) {
+            if (isset($record['txt']) && trim($record['txt']) === $mta_sts_correct) {
+                $state = state_good;
+                break;
+            }
+        }
+        if ($state !== state_good) {
+            $state = state_nomatch;
+        }
+    }
+
+    $records[] = array(
+        '_mta-sts.' . $domain,
+        'TXT',
+        $mta_sts_correct,
+        $state
+    );
+  }
+
   if (!empty($dkim = dkim('details', $domain))) {
     $records[] = array(
       $dkim['dkim_selector'] . '._domainkey.' . $domain,

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

@@ -1400,6 +1400,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           = time();
+
+          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', 'stsv2'))) {
+            $_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) {
+            $_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'];
@@ -3741,6 +3815,116 @@ 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);
+            $id           = time();
+
+            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'];
+
+            } 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', 'stsv2'))) {
+              $_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) {
+              $_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();
@@ -5029,6 +5213,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)) {
@@ -5414,6 +5612,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);

+ 10 - 0
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,12 @@
         "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_version": "Version",
+        "mta_sts_mode": "Modus",
+        "mta_sts_max_age": "Max-Age",
+        "mta_sts_mx": "MX Server",
+        "mta_sts_mx_info": "Es können mehrere MX Server angegeben werden (getrennt durch Komma).",
         "multiple_bookings": "Mehrfaches Buchen",
         "nexthop": "Next Hop",
         "none_inherit": "Keine Auswahl / Erben",

+ 10 - 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,12 @@
         "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_version": "Version",
+        "mta_sts_mode": "Modus",
+        "mta_sts_max_age": "Max age",
+        "mta_sts_mx": "MX server",
+        "mta_sts_mx_info": "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";
+}
+
+?>

+ 64 - 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,69 @@
             </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">
+                <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">{{ 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>
+                        <option value="stsv2"{% if mta_sts.version == 'STSv2' %} selected{% endif %}>STSv2</option>
+                      </select>
+                    </div>
+                  </div>
+                  <div class="row mb-2">
+                    <label class="control-label col-sm-2" for="mode">{{ 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">{{ 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">{{ 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_info|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">