Sfoglia il codice sorgente

[Postfix] Finally here: MX based transport map routing; Sorry it took years, Patrik
[Web] Small fixes

andryyy 4 anni fa
parent
commit
8a83587800

+ 13 - 0
data/Dockerfiles/postfix/postfix.sh

@@ -337,6 +337,19 @@ query = SELECT goto FROM alias
       AND alias_domain.active='1'
       AND alias_domain.active='1'
 EOF
 EOF
 
 
+# MX based routing
+cat <<EOF > /opt/postfix/conf/sql/mysql_mbr_access_maps.cf
+# Autogenerated by mailcow
+user = ${DBUSER}
+password = ${DBPASS}
+hosts = unix:/var/run/mysqld/mysqld.sock
+dbname = ${DBNAME}
+query = SELECT CONCAT('FILTER smtp_via_transport_maps:', nexthop) as transport FROM transports
+  WHERE '%s' REGEXP destination
+    AND active='1'
+    AND is_mx_based='1';
+EOF
+
 # Reject sasl usernames with smtp disabled
 # Reject sasl usernames with smtp disabled
 cat <<EOF > /opt/postfix/conf/sql/mysql_sasl_access_maps.cf
 cat <<EOF > /opt/postfix/conf/sql/mysql_sasl_access_maps.cf
 # Autogenerated by mailcow
 # Autogenerated by mailcow

+ 2 - 0
data/conf/postfix/main.cf

@@ -78,6 +78,7 @@ postscreen_non_smtp_command_enable = no
 postscreen_pipelining_enable = no
 postscreen_pipelining_enable = no
 proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf,
 proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf,
   proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_access_maps.cf,
   proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_access_maps.cf,
+  proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
   proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
   proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
   $sender_dependent_default_transport_maps,
   $sender_dependent_default_transport_maps,
   $smtp_tls_policy_maps,
   $smtp_tls_policy_maps,
@@ -116,6 +117,7 @@ smtpd_hard_error_limit = ${stress?1}${stress:5}
 smtpd_helo_required = yes
 smtpd_helo_required = yes
 smtpd_proxy_timeout = 600s
 smtpd_proxy_timeout = 600s
 smtpd_recipient_restrictions = check_sasl_access proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_access_maps.cf,
 smtpd_recipient_restrictions = check_sasl_access proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_access_maps.cf,
+  check_recipient_mx_access proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
   permit_sasl_authenticated,
   permit_sasl_authenticated,
   permit_mynetworks,
   permit_mynetworks,
   check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
   check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,

+ 12 - 12
data/web/admin.php

@@ -431,19 +431,19 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
         <legend><?=$lang['admin']['add_relayhost'];?></legend>
         <legend><?=$lang['admin']['add_relayhost'];?></legend>
         <p class="help-block"><?=$lang['admin']['add_relayhost_hint'];?></p>
         <p class="help-block"><?=$lang['admin']['add_relayhost_hint'];?></p>
         <div class="row">
         <div class="row">
-          <div class="col-md-6">
+          <div class="col-md-8">
             <form class="form" data-id="rlyhost" role="form" method="post">
             <form class="form" data-id="rlyhost" role="form" method="post">
               <div class="form-group">
               <div class="form-group">
                 <label for="rlyhost_hostname"><?=$lang['admin']['host'];?></label>
                 <label for="rlyhost_hostname"><?=$lang['admin']['host'];?></label>
-                <input class="form-control input-sm" id="rlyhost_hostname" name="hostname" placeholder='[0.0.0.0], [0.0.0.0]:25, host:25, host, [host]:25' required>
+                <input class="form-control" id="rlyhost_hostname" name="hostname" placeholder='[0.0.0.0], [0.0.0.0]:25, host:25, host, [host]:25' required>
               </div>
               </div>
               <div class="form-group">
               <div class="form-group">
                 <label for="rlyhost_username"><?=$lang['admin']['username'];?></label>
                 <label for="rlyhost_username"><?=$lang['admin']['username'];?></label>
-                <input class="form-control input-sm" id="rlyhost_username" name="username">
+                <input class="form-control" id="rlyhost_username" name="username">
               </div>
               </div>
               <div class="form-group">
               <div class="form-group">
                 <label for="rlyhost_password"><?=$lang['admin']['password'];?></label>
                 <label for="rlyhost_password"><?=$lang['admin']['password'];?></label>
-                <input class="form-control input-sm" id="rlyhost_password" name="password">
+                <input class="form-control" id="rlyhost_password" name="password">
               </div>
               </div>
               <button class="btn btn-default" data-action="add_item" data-id="rlyhost" data-api-url='add/relayhost' data-api-attr='{}' href="#"><i class="bi bi-plus-lg"></i> <?=$lang['admin']['add'];?></button>
               <button class="btn btn-default" data-action="add_item" data-id="rlyhost" data-api-url='add/relayhost' data-api-attr='{}' href="#"><i class="bi bi-plus-lg"></i> <?=$lang['admin']['add'];?></button>
             </form>
             </form>
@@ -474,29 +474,29 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
         <legend><?=$lang['admin']['add_transport'];?></legend>
         <legend><?=$lang['admin']['add_transport'];?></legend>
         <p class="help-block"><?=$lang['admin']['add_transports_hint'];?></p>
         <p class="help-block"><?=$lang['admin']['add_transports_hint'];?></p>
         <div class="row">
         <div class="row">
-          <div class="col-md-6">
+          <div class="col-md-8">
             <form class="form" data-id="transport" role="form" method="post">
             <form class="form" data-id="transport" role="form" method="post">
               <div class="form-group">
               <div class="form-group">
                 <label for="transport_destination"><?=$lang['admin']['destination'];?></label>
                 <label for="transport_destination"><?=$lang['admin']['destination'];?></label>
-                <input class="form-control input-sm" id="transport_destination" name="destination" placeholder='<?=$lang['admin']['transport_dest_format'];?>' required>
+                <input class="form-control" id="transport_destination" name="destination" placeholder='<?=$lang['admin']['transport_dest_format'];?>' required>
               </div>
               </div>
               <div class="form-group">
               <div class="form-group">
                 <label for="transport_nexthop"><?=$lang['admin']['nexthop'];?></label>
                 <label for="transport_nexthop"><?=$lang['admin']['nexthop'];?></label>
-                <input class="form-control input-sm" id="transport_nexthop" name="nexthop" placeholder='host:25, host, [host]:25, [0.0.0.0]:25' required>
+                <input class="form-control" id="transport_nexthop" name="nexthop" placeholder='host:25, host, [host]:25, [0.0.0.0]:25' required>
               </div>
               </div>
               <div class="form-group">
               <div class="form-group">
                 <label for="transport_username"><?=$lang['admin']['username'];?></label>
                 <label for="transport_username"><?=$lang['admin']['username'];?></label>
-                <input class="form-control input-sm" id="transport_username" name="username">
+                <input class="form-control" id="transport_username" name="username">
               </div>
               </div>
               <div class="form-group">
               <div class="form-group">
                 <label for="transport_password"><?=$lang['admin']['password'];?></label>
                 <label for="transport_password"><?=$lang['admin']['password'];?></label>
-                <input class="form-control input-sm" id="transport_password" name="password">
+                <input class="form-control" id="transport_password" name="password">
               </div>
               </div>
-              <!-- <div class="form-group">
+              <div class="form-group">
                 <label>
                 <label>
-                  <input type="checkbox" name="lookup_mx" value="1"> <?=$lang['admin']['lookup_mx'];?>
+                  <input type="checkbox" name="is_mx_based" value="1"> <?=$lang['admin']['lookup_mx'];?>
                 </label>
                 </label>
-              </div> -->
+              </div>
               <div class="form-group">
               <div class="form-group">
                 <label>
                 <label>
                   <input type="checkbox" name="active" value="1"> <?=$lang['admin']['active'];?>
                   <input type="checkbox" name="active" value="1"> <?=$lang['admin']['active'];?>

+ 3 - 0
data/web/css/build/008-mailcow.css

@@ -241,3 +241,6 @@ table.footable>tbody>tr.footable-empty>td {
 legend > [class^="bi-"]::before, legend > [class*=" bi-"]::before {
 legend > [class^="bi-"]::before, legend > [class*=" bi-"]::before {
   vertical-align: 0em !important;
   vertical-align: 0em !important;
 }
 }
+code {
+  font-size: inherit;
+}

+ 8 - 0
data/web/edit.php

@@ -1095,6 +1095,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
           <h4><?=$lang['edit']['resource'];?></h4>
           <h4><?=$lang['edit']['resource'];?></h4>
           <form class="form-horizontal" role="form" method="post" data-id="edittransport">
           <form class="form-horizontal" role="form" method="post" data-id="edittransport">
             <input type="hidden" value="0" name="active">
             <input type="hidden" value="0" name="active">
+            <input type="hidden" value="0" name="is_mx_based">
             <div class="form-group">
             <div class="form-group">
               <label class="control-label col-sm-2" for="destination"><?=$lang['add']['destination'];?></label>
               <label class="control-label col-sm-2" for="destination"><?=$lang['add']['destination'];?></label>
               <div class="col-sm-10">
               <div class="col-sm-10">
@@ -1119,6 +1120,13 @@ if (isset($_SESSION['mailcow_cc_role'])) {
                 <input type="text" data-hibp="true" class="form-control" name="password" value="<?=htmlspecialchars($result['password'], ENT_QUOTES, 'UTF-8');?>">
                 <input type="text" data-hibp="true" class="form-control" name="password" value="<?=htmlspecialchars($result['password'], ENT_QUOTES, 'UTF-8');?>">
               </div>
               </div>
             </div>
             </div>
+            <div class="form-group">
+              <div class="col-sm-offset-2 col-sm-10">
+                <div class="checkbox">
+                <label><input type="checkbox" value="1" name="is_mx_based" <?=($result['is_mx_based']=="1") ? "checked" : null;?>> <?=$lang['edit']['lookup_mx'];?></label>
+                </div>
+              </div>
+            </div>
             <div class="form-group">
             <div class="form-group">
               <div class="col-sm-offset-2 col-sm-10">
               <div class="col-sm-offset-2 col-sm-10">
                 <div class="checkbox">
                 <div class="checkbox">

+ 35 - 10
data/web/inc/functions.transports.inc.php

@@ -192,7 +192,7 @@ function transport($_action, $_data = null) {
       }
       }
       $destinations  = array_map('trim', preg_split( "/( |,|;|\n)/", $_data['destination']));
       $destinations  = array_map('trim', preg_split( "/( |,|;|\n)/", $_data['destination']));
       $active = intval($_data['active']);
       $active = intval($_data['active']);
-      $lookup_mx = intval($_data['lookup_mx']);
+      $is_mx_based = intval($_data['is_mx_based']);
       $nexthop = trim($_data['nexthop']);
       $nexthop = trim($_data['nexthop']);
       if (filter_var($nexthop, FILTER_VALIDATE_IP)) {
       if (filter_var($nexthop, FILTER_VALIDATE_IP)) {
         $nexthop = '[' . $nexthop . ']';
         $nexthop = '[' . $nexthop . ']';
@@ -238,7 +238,16 @@ function transport($_action, $_data = null) {
               continue;
               continue;
             }
             }
             // ".domain" is a valid destination, "..domain" is not
             // ".domain" is a valid destination, "..domain" is not
-            if (empty($dest) || (is_valid_domain_name(preg_replace('/^' . preg_quote('.', '/') . '/', '', $dest)) === false && $dest != '*' && filter_var($dest, FILTER_VALIDATE_EMAIL) === false)) {
+            if ($is_mx_based == 0 && (empty($dest) || (is_valid_domain_name(preg_replace('/^' . preg_quote('.', '/') . '/', '', $dest)) === false && $dest != '*' && filter_var($dest, FILTER_VALIDATE_EMAIL) === false))) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_data_log),
+                'msg' => array('invalid_destination', $dest)
+              );
+              unset($destinations[$d_ix]);
+              continue;
+            }
+            if ($is_mx_based == 1 && (empty($dest) || @preg_match('/' . $dest . '/', null) === false)) {
               $_SESSION['return'][] = array(
               $_SESSION['return'][] = array(
                 'type' => 'danger',
                 'type' => 'danger',
                 'log' => array(__FUNCTION__, $_action, $_data_log),
                 'log' => array(__FUNCTION__, $_action, $_data_log),
@@ -275,14 +284,14 @@ function transport($_action, $_data = null) {
         }
         }
       }
       }
       foreach ($destinations as $insert_dest) {
       foreach ($destinations as $insert_dest) {
-        $stmt = $pdo->prepare("INSERT INTO `transports` (`nexthop`, `destination`, `username` , `password`,  `lookup_mx`, `active`)
-          VALUES (:nexthop, :destination, :username, :password, :lookup_mx, :active)");
+        $stmt = $pdo->prepare("INSERT INTO `transports` (`nexthop`, `destination`, `is_mx_based`, `username` , `password`,  `active`)
+          VALUES (:nexthop, :destination, :is_mx_based, :username, :password, :active)");
         $stmt->execute(array(
         $stmt->execute(array(
           ':nexthop' => $nexthop,
           ':nexthop' => $nexthop,
           ':destination' => $insert_dest,
           ':destination' => $insert_dest,
+          ':is_mx_based' => $is_mx_based,
           ':username' => $username,
           ':username' => $username,
           ':password' => str_replace(':', '\:', $password),
           ':password' => str_replace(':', '\:', $password),
-          ':lookup_mx' => $lookup_mx,
           ':active' => $active
           ':active' => $active
         ));
         ));
       }
       }
@@ -318,7 +327,7 @@ function transport($_action, $_data = null) {
           $nexthop = (!empty($_data['nexthop'])) ? trim($_data['nexthop']) : $is_now['nexthop'];
           $nexthop = (!empty($_data['nexthop'])) ? trim($_data['nexthop']) : $is_now['nexthop'];
           $username = (isset($_data['username'])) ? trim($_data['username']) : $is_now['username'];
           $username = (isset($_data['username'])) ? trim($_data['username']) : $is_now['username'];
           $password = (isset($_data['password'])) ? trim($_data['password']) : $is_now['password'];
           $password = (isset($_data['password'])) ? trim($_data['password']) : $is_now['password'];
-          $lookup_mx   = (isset($_data['lookup_mx']) && $_data['lookup_mx'] != '') ? intval($_data['lookup_mx']) : $is_now['lookup_mx'];
+          $is_mx_based = (isset($_data['is_mx_based']) && $_data['is_mx_based'] != '') ? intval($_data['is_mx_based']) : $is_now['is_mx_based'];
           $active   = (isset($_data['active']) && $_data['active'] != '') ? intval($_data['active']) : $is_now['active'];
           $active   = (isset($_data['active']) && $_data['active'] != '') ? intval($_data['active']) : $is_now['active'];
         }
         }
         else {
         else {
@@ -353,6 +362,22 @@ function transport($_action, $_data = null) {
             }
             }
           }
           }
         }
         }
+        if ($is_mx_based == 0 && (empty($destination) || (is_valid_domain_name(preg_replace('/^' . preg_quote('.', '/') . '/', '', $destination)) === false && $destination != '*' && filter_var($destination, FILTER_VALIDATE_EMAIL) === false))) {
+          $_SESSION['return'][] = array(
+            'type' => 'danger',
+            'log' => array(__FUNCTION__, $_action, $_data_log),
+            'msg' => array('invalid_destination', $destination)
+          );
+          return false;
+        }
+        if ($is_mx_based == 1 && (empty($destination) || @preg_match('/' . $destination . '/', null) === false)) {
+          $_SESSION['return'][] = array(
+            'type' => 'danger',
+            'log' => array(__FUNCTION__, $_action, $_data_log),
+            'msg' => array('invalid_destination', $destination)
+          );
+          return false;
+        }
         if (isset($next_hop_matches[1])) {
         if (isset($next_hop_matches[1])) {
           if (in_array($next_hop_clean, $existing_nh)) {
           if (in_array($next_hop_clean, $existing_nh)) {
             $_SESSION['return'][] = array(
             $_SESSION['return'][] = array(
@@ -381,19 +406,19 @@ function transport($_action, $_data = null) {
         try {
         try {
           $stmt = $pdo->prepare("UPDATE `transports` SET
           $stmt = $pdo->prepare("UPDATE `transports` SET
             `destination` = :destination,
             `destination` = :destination,
+            `is_mx_based` = :is_mx_based,
             `nexthop` = :nexthop,
             `nexthop` = :nexthop,
             `username` = :username,
             `username` = :username,
             `password` = :password,
             `password` = :password,
-            `lookup_mx` = :lookup_mx,
             `active` = :active
             `active` = :active
               WHERE `id` = :id");
               WHERE `id` = :id");
           $stmt->execute(array(
           $stmt->execute(array(
             ':id' => $id,
             ':id' => $id,
             ':destination' => $destination,
             ':destination' => $destination,
+            ':is_mx_based' => $is_mx_based,
             ':nexthop' => $nexthop,
             ':nexthop' => $nexthop,
             ':username' => $username,
             ':username' => $username,
             ':password' => $password,
             ':password' => $password,
-            ':lookup_mx' => $lookup_mx,
             ':active' => $active
             ':active' => $active
           ));
           ));
           $stmt = $pdo->prepare("UPDATE `transports` SET
           $stmt = $pdo->prepare("UPDATE `transports` SET
@@ -456,7 +481,7 @@ function transport($_action, $_data = null) {
         return false;
         return false;
       }
       }
       $transports = array();
       $transports = array();
-      $stmt = $pdo->query("SELECT `id`, `destination`, `nexthop`, `username` FROM `transports`");
+      $stmt = $pdo->query("SELECT `id`, `is_mx_based`, `destination`, `nexthop`, `username` FROM `transports`");
       $transports = $stmt->fetchAll(PDO::FETCH_ASSOC);
       $transports = $stmt->fetchAll(PDO::FETCH_ASSOC);
       return $transports;
       return $transports;
     break;
     break;
@@ -466,12 +491,12 @@ function transport($_action, $_data = null) {
       }
       }
       $transportdata = array();
       $transportdata = array();
       $stmt = $pdo->prepare("SELECT `id`,
       $stmt = $pdo->prepare("SELECT `id`,
+        `is_mx_based`,
         `destination`,
         `destination`,
         `nexthop`,
         `nexthop`,
         `username`,
         `username`,
         `password`,
         `password`,
         `active`,
         `active`,
-        `lookup_mx`,
         CONCAT(LEFT(`password`, 3), '...') AS `password_short`
         CONCAT(LEFT(`password`, 3), '...') AS `password_short`
           FROM `transports`
           FROM `transports`
             WHERE `id` = :id");
             WHERE `id` = :id");

+ 4 - 4
data/web/inc/init_db.inc.php

@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
   try {
     global $pdo;
     global $pdo;
 
 
-    $db_version = "25052021_0900";
+    $db_version = "27052021_2000";
 
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -152,9 +152,9 @@ function init_db_schema() {
           "id" => "INT NOT NULL AUTO_INCREMENT",
           "id" => "INT NOT NULL AUTO_INCREMENT",
           "destination" => "VARCHAR(255) NOT NULL",
           "destination" => "VARCHAR(255) NOT NULL",
           "nexthop" => "VARCHAR(255) NOT NULL",
           "nexthop" => "VARCHAR(255) NOT NULL",
-          "username" => "VARCHAR(255) NOT NULL",
-          "password" => "VARCHAR(255) NOT NULL",
-          "lookup_mx" => "TINYINT(1) NOT NULL DEFAULT '1'",
+          "username" => "VARCHAR(255) NOT NULL DEFAULT ''",
+          "password" => "VARCHAR(255) NOT NULL DEFAULT ''",
+          "is_mx_based" => "TINYINT(1) NOT NULL DEFAULT '0'",
           "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
           "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
         ),
         ),
         "keys" => array(
         "keys" => array(

+ 1 - 1
data/web/js/build/014-mailcow.js

@@ -103,7 +103,7 @@ $(document).ready(function() {
       $(".hibp-out").after(res);
       $(".hibp-out").after(res);
     }
     }
   });
   });
-  $('[data-hibp]').after('<p class="small haveibeenpwned"> Check against haveibeenpwned.com</p><span class="hibp-out"></span>');
+  $('[data-hibp]').after('<p class="small haveibeenpwned"><i class="bi bi-shield-fill-exclamation"></i> Check against haveibeenpwned.com</p><span class="hibp-out"></span>');
   $('[data-hibp]').on('input', function() {
   $('[data-hibp]').on('input', function() {
     out_field = $(this).next('.haveibeenpwned').next('.hibp-out').text('').attr('class', 'hibp-out');
     out_field = $(this).next('.haveibeenpwned').next('.hibp-out').text('').attr('class', 'hibp-out');
   });
   });

+ 15 - 7
data/web/js/site/admin.js

@@ -187,9 +187,9 @@ jQuery(function($){
         {"name":"id","type":"text","title":"ID","style":{"width":"50px"}},
         {"name":"id","type":"text","title":"ID","style":{"width":"50px"}},
         {"name":"hostname","type":"text","title":lang.host,"style":{"width":"250px"}},
         {"name":"hostname","type":"text","title":lang.host,"style":{"width":"250px"}},
         {"name":"username","title":lang.username,"breakpoints":"xs sm"},
         {"name":"username","title":lang.username,"breakpoints":"xs sm"},
-        {"name":"in_use_by","title":lang.in_use_by,"style":{"width":"110px"}, "type": "text","breakpoints":"xs sm"},
+        {"name":"in_use_by","title":lang.in_use_by,"style":{"min-width":"200px","width":"200px"}, "type": "text","breakpoints":"xs sm"},
         {"name":"active","filterable": false,"style":{"maxWidth":"80px","width":"80px"},"title":lang.active,"formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':0==value&&'<i class="bi bi-x-lg"></i>';}},
         {"name":"active","filterable": false,"style":{"maxWidth":"80px","width":"80px"},"title":lang.active,"formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':0==value&&'<i class="bi bi-x-lg"></i>';}},
-        {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","maxWidth":"220px","width":"220px"},"type":"html","title":lang.action,"breakpoints":"xs sm md"}
+        {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","min-width":"250px","width":"250px"},"type":"html","title":lang.action,"breakpoints":"xs sm md"}
       ],
       ],
       "rows": $.ajax({
       "rows": $.ajax({
         dataType: 'json',
         dataType: 'json',
@@ -213,11 +213,11 @@ jQuery(function($){
       "columns": [
       "columns": [
         {"name":"chkbox","title":"","style":{"maxWidth":"60px","width":"60px"},"filterable": false,"sortable": false,"type":"html"},
         {"name":"chkbox","title":"","style":{"maxWidth":"60px","width":"60px"},"filterable": false,"sortable": false,"type":"html"},
         {"name":"id","type":"text","title":"ID","style":{"width":"50px"}},
         {"name":"id","type":"text","title":"ID","style":{"width":"50px"}},
-        {"name":"destination","type":"text","title":lang.destination,"style":{"width":"250px"}},
-        {"name":"nexthop","type":"text","title":lang.nexthop,"style":{"width":"250px"}},
+        {"name":"destination","type":"text","title":lang.destination,"style":{"min-width":"300px","width":"300px"}},
+        {"name":"nexthop","type":"text","title":lang.nexthop,"style":{"min-width":"200px","width":"200px"}},
         {"name":"username","title":lang.username,"breakpoints":"xs sm"},
         {"name":"username","title":lang.username,"breakpoints":"xs sm"},
         {"name":"active","filterable": false,"style":{"maxWidth":"80px","width":"80px"},"title":lang.active,"formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':0==value&&'<i class="bi bi-x-lg"></i>';}},
         {"name":"active","filterable": false,"style":{"maxWidth":"80px","width":"80px"},"title":lang.active,"formatter": function(value){return 1==value?'<i class="bi bi-check-lg"></i>':0==value&&'<i class="bi bi-x-lg"></i>';}},
-        {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","maxWidth":"220px","width":"220px"},"type":"html","title":lang.action,"breakpoints":"xs sm md"}
+        {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","min-width":"250px","width":"250px"},"type":"html","title":lang.action,"breakpoints":"xs sm md"}
       ],
       ],
       "rows": $.ajax({
       "rows": $.ajax({
         dataType: 'json',
         dataType: 'json',
@@ -233,7 +233,12 @@ jQuery(function($){
       "empty": lang.empty,
       "empty": lang.empty,
       "paging": {"enabled": true,"limit": 5,"size": log_pagination_size},
       "paging": {"enabled": true,"limit": 5,"size": log_pagination_size},
       "sorting": {"enabled": true},
       "sorting": {"enabled": true},
-      "toggleSelector": "table tbody span.footable-toggle"
+      "toggleSelector": "table tbody span.footable-toggle",
+      "on": {
+        "ready.ft.table": function(e, ft){
+          $('.mx-info').tooltip();
+        }
+      }
     });
     });
   }
   }
   function draw_queue() {
   function draw_queue() {
@@ -288,8 +293,11 @@ jQuery(function($){
       });
       });
     } else if (table == 'transportstable') {
     } else if (table == 'transportstable') {
       $.each(data, function (i, item) {
       $.each(data, function (i, item) {
+        if (item.is_mx_based) {
+          item.destination = '<i class="bi bi-info-circle-fill text-info mx-info" data-toggle="tooltip" title="' + lang.is_mx_based + '"></i> <code>' + item.destination + '</code>';
+        }
         if (item.username) {
         if (item.username) {
-          item.username = '<span style="border-left:3px solid #' + intToRGB(hashCode(item.nexthop)) + ';padding-left:5px;">' + item.username + '</span>';
+          item.username = '<i style="color:#' + intToRGB(hashCode(item.nexthop)) + ';" class="bi bi-square-fill"></i> ' + item.username;
         }
         }
         item.action = '<div class="btn-group">' +
         item.action = '<div class="btn-group">' +
           '<a href="#" data-toggle="modal" data-target="#testTransportModal" data-transport-id="' + encodeURI(item.id) + '" data-transport-type="transport-map" class="btn btn-xs btn-default"><i class="bi bi-caret-right-fill"></i> Test</a>' +
           '<a href="#" data-toggle="modal" data-target="#testTransportModal" data-transport-id="' + encodeURI(item.id) + '" data-transport-type="transport-map" class="btn btn-xs btn-default"><i class="bi bi-caret-right-fill"></i> Test</a>' +

+ 9 - 7
data/web/lang/lang.de.json

@@ -156,7 +156,7 @@
         "change_logo": "Logo ändern",
         "change_logo": "Logo ändern",
         "configuration": "Konfiguration",
         "configuration": "Konfiguration",
         "convert_html_to_text": "Konvertiere HTML zu reinem Text",
         "convert_html_to_text": "Konvertiere HTML zu reinem Text",
-        "credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Host.",
+        "credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.",
         "customer_id": "Kunde",
         "customer_id": "Kunde",
         "customize": "UI-Anpassung",
         "customize": "UI-Anpassung",
         "delete_queue": "Alle löschen",
         "delete_queue": "Alle löschen",
@@ -210,6 +210,7 @@
         "html": "HTML",
         "html": "HTML",
         "import": "Importieren",
         "import": "Importieren",
         "import_private_key": "Private Key importieren",
         "import_private_key": "Private Key importieren",
+        "is_mx_based": "MX-basiert",
         "in_use_by": "Verwendet von",
         "in_use_by": "Verwendet von",
         "inactive": "Inaktiv",
         "inactive": "Inaktiv",
         "include_exclude": "Ein- und Ausschlüsse",
         "include_exclude": "Ein- und Ausschlüsse",
@@ -220,7 +221,7 @@
         "link": "Link",
         "link": "Link",
         "loading": "Bitte warten...",
         "loading": "Bitte warten...",
         "logo_info": "Die hochgeladene Grafik wird für die Navigationsleiste auf eine Höhe von 40px skaliert. Für die Darstellung auf der Login-Maske beträgt die skalierte Breite maximal 250px. Eine frei skalierbare Grafik (etwa SVG) wird empfohlen.",
         "logo_info": "Die hochgeladene Grafik wird für die Navigationsleiste auf eine Höhe von 40px skaliert. Für die Darstellung auf der Login-Maske beträgt die skalierte Breite maximal 250px. Eine frei skalierbare Grafik (etwa SVG) wird empfohlen.",
-        "lookup_mx": "Ziel gegen MX prüfen (etwa .outlook.com, um alle Ziele mit MX *.outlook.com zu routen)",
+        "lookup_mx": "Ziel mit MX vergleichen (Regex, etwa <code>.*google\\.com</code>, um alle Ziele mit MX *google.com zu routen)",
         "main_name": "\"mailcow UI\" Name",
         "main_name": "\"mailcow UI\" Name",
         "merged_vars_hint": "Ausgegraute Reihen wurden aus der Datei <code>vars.(local.)inc.php</code> gelesen und können hier nicht verändert werden.",
         "merged_vars_hint": "Ausgegraute Reihen wurden aus der Datei <code>vars.(local.)inc.php</code> gelesen und können hier nicht verändert werden.",
         "message": "Nachricht",
         "message": "Nachricht",
@@ -232,7 +233,7 @@
         "no_record": "Kein Eintrag",
         "no_record": "Kein Eintrag",
         "oauth2_client_id": "Client ID",
         "oauth2_client_id": "Client ID",
         "oauth2_client_secret": "Client Secret",
         "oauth2_client_secret": "Client Secret",
-        "oauth2_info": "Die OAuth2 Implementierung unterstützt den Grant Type \"Authorization Code\" mit Refresh Tokens.<br>\r\nDer Server wird automatisch einen neuen Refresh Token ausstellen, sobald ein vorheriger Token gegen einen Access Token eingetauscht wurde.<br><br>\r\n Der Standard Scope lautet <i>profile</i>. Nur Mailbox-Benutzer können sich gegen OAuth2 authentifizieren. Wird kein Scope angegeben, verwendet das System per Standard <i>profile</i>.<br>\r\n Der <i>state</i> Parameter wird im Zuge des Autorisierungsprozesses benötigt.<br><br>\r\nDie Pfade für die OAuth2 API lauten wie folgt: <br>\r\n<ul>\r\n  <li>Authorization Endpoint: <code>/oauth/authorize</code></li>\r\n  <li>Token Endpoint: <code>/oauth/token</code></li>\r\n  <li>Resource Page:  <code>/oauth/profile</code></li>\r\n</ul>\r\nDie Regenerierung des Client Secrets wird vorhandene Authorization Codes nicht invalidieren, dennoch wird der Renew des Access Tokens durch einen Refresh Token nicht mehr gelingen.<br><br>\r\nDas Entfernen aller Client Tokens verursacht die umgehende Terminierung aller aktiven OAuth2 Sessions. Clients müssen sich erneut gegen die OAuth2 Anwendung authentifizieren.",
+        "oauth2_info": "Die OAuth2 Implementierung unterstützt den Grant Type \"Authorization Code\" mit Refresh Tokens.<br>\r\nDer Server wird automatisch einen neuen Refresh Token ausstellen, sobald ein vorheriger Token gegen einen Access Token eingetauscht wurde.<br><br>\r\n&#8226; Der Standard Scope lautet <i>profile</i>. Nur Mailbox-Benutzer können sich gegen OAuth2 authentifizieren. Wird kein Scope angegeben, verwendet das System per Standard <i>profile</i>.<br>\r\n&#8226; Der <i>state</i> Parameter wird im Zuge des Autorisierungsprozesses benötigt.<br><br>\r\nDie Pfade für die OAuth2 API lauten wie folgt: <br>\r\n<ul>\r\n  <li>Authorization Endpoint: <code>/oauth/authorize</code></li>\r\n  <li>Token Endpoint: <code>/oauth/token</code></li>\r\n  <li>Resource Page:  <code>/oauth/profile</code></li>\r\n</ul>\r\nDie Regenerierung des Client Secrets wird vorhandene Authorization Codes nicht invalidieren, dennoch wird der Renew des Access Tokens durch einen Refresh Token nicht mehr gelingen.<br><br>\r\nDas Entfernen aller Client Tokens verursacht die umgehende Terminierung aller aktiven OAuth2 Sessions. Clients müssen sich erneut gegen die OAuth2 Anwendung authentifizieren.",
         "oauth2_redirect_uri": "Redirect-URI",
         "oauth2_redirect_uri": "Redirect-URI",
         "oauth2_renew_secret": "Neues Client Secret generieren",
         "oauth2_renew_secret": "Neues Client Secret generieren",
         "oauth2_revoke_tokens": "Alle Client Tokens entfernen",
         "oauth2_revoke_tokens": "Alle Client Tokens entfernen",
@@ -323,10 +324,10 @@
         "title": "Title",
         "title": "Title",
         "title_name": "\"mailcow UI\" Webseiten Titel",
         "title_name": "\"mailcow UI\" Webseiten Titel",
         "to_top": "Nach oben",
         "to_top": "Nach oben",
-        "transport_dest_format": "Syntax: example.org, .example.org, *, box@example.org (mehrere Werte getrennt durch Komma einzugeben)",
+        "transport_dest_format": "Regex oder Syntax: example.org, .example.org, *, box@example.org (getrennt durch Komma einzugeben)",
         "transport_maps": "Transport-Maps",
         "transport_maps": "Transport-Maps",
-        "transports_hint": "→ Transport-Maps <b>überwiegen</b> senderabhängige Transport Maps.<br>\r\n→ Transport-Maps ignorieren Mailbox-Einstellungen für ausgehende Verschlüsselung. Eine serverweite TLS-Richtlinie wird jedoch angewendet.<br>\r\n Der Transport erfolgt immer via \"smtp:\", verwendet TLS wenn angeboten und unterstützt kein wrapped TLS (SMTPS).<br>\r\n Adressen, die mit \"/localhost$/\" übereinstimmen, werden immer via \"local:\" transportiert, daher sind sie von einer Zieldefinition \"*\" ausgeschlossen.<br>\r\n Die Authentifizierung wird anhand des \"Next hop\" Parameters ermittelt. Hierbei würde bei einem beispielhaften Wert \"[host]:25\" immer zuerst \"host\" abfragt und <b>erst im Anschluss</b> \"[host]:25\". Dieses Verhalten schließt die <b>gleichzeitige Verwendung</b> von Einträgen der Art \"host\" sowie \"[host]:25\" aus.",
-        "transport_test_rcpt_info": " Die Verwendung von null@hosted.mailcow.de testet das Relay gegen ein fremdes Ziel.",
+        "transports_hint": "&#8226; Transport-Maps <b>überwiegen</b> senderabhängige Transport Maps.<br>\r\n&#8226; MX-basierte Transporte werden bevorzugt.<br>\r\n&#8226; Transport-Maps ignorieren Mailbox-Einstellungen für ausgehende Verschlüsselung. Eine serverweite TLS-Richtlinie wird jedoch angewendet.<br>\r\n&#8226; Der Transport erfolgt immer via \"smtp:\", verwendet TLS wenn angeboten und unterstützt kein wrapped TLS (SMTPS).<br>\r\n&#8226; Adressen, die mit \"/localhost$/\" übereinstimmen, werden immer via \"local:\" transportiert, daher sind sie von einer Zieldefinition \"*\" ausgeschlossen.<br>\r\n&#8226; Die Authentifizierung wird anhand des \"Next hop\" Parameters ermittelt. Hierbei würde bei einem beispielhaften Wert \"[host]:25\" immer zuerst \"host\" abfragt und <b>erst im Anschluss</b> \"[host]:25\". Dieses Verhalten schließt die <b>gleichzeitige Verwendung</b> von Einträgen der Art \"host\" sowie \"[host]:25\" aus.",
+        "transport_test_rcpt_info": "&#8226; Die Verwendung von null@hosted.mailcow.de testet das Relay gegen ein fremdes Ziel.",
         "ui_footer": "Footer (HTML zulässig)",
         "ui_footer": "Footer (HTML zulässig)",
         "ui_header_announcement": "Ankündigungen",
         "ui_header_announcement": "Ankündigungen",
         "ui_header_announcement_active": "Ankündigung aktivieren",
         "ui_header_announcement_active": "Ankündigung aktivieren",
@@ -555,6 +556,7 @@
         "hostname": "Servername",
         "hostname": "Servername",
         "inactive": "Inaktiv",
         "inactive": "Inaktiv",
         "kind": "Art",
         "kind": "Art",
+        "lookup_mx": "Ziel mit MX vergleichen (Regex, etwa <code>.*google\\.com</code>, um alle Ziele mit MX *google.com zu routen)",
         "mailbox": "Mailbox bearbeiten",
         "mailbox": "Mailbox bearbeiten",
         "mailbox_relayhost_info": "Wird auf eine Mailbox und direkte Alias-Adressen angewendet. Überschreibt die Einstellung einer Domain.",
         "mailbox_relayhost_info": "Wird auf eine Mailbox und direkte Alias-Adressen angewendet. Überschreibt die Einstellung einer Domain.",
         "mailbox_quota_def": "Standard-Quota einer Mailbox",
         "mailbox_quota_def": "Standard-Quota einer Mailbox",
@@ -762,7 +764,7 @@
         "running": "In Ausführung",
         "running": "In Ausführung",
         "set_postfilter": "Als Postfilter markieren",
         "set_postfilter": "Als Postfilter markieren",
         "set_prefilter": "Als Prefilter markieren",
         "set_prefilter": "Als Prefilter markieren",
-        "sieve_info": "Es können mehrere Filter pro Benutzer existieren, aber nur ein Filter eines Typs (Pre-/Postfilter) kann gleichzeitig aktiv sein.<br>\r\nDie Ausführung erfolgt in nachstehender Reihenfolge. Ein fehlgeschlagenes Script sowie der Befehl \"keep;\" stoppen die weitere Verarbeitung <b>nicht</b>. Änderungen an globalen Sieve-Filtern bewirken einen Neustart von Dovecot.<br><br>Global sieve prefilter → Prefilter → User scripts → Postfilter → Global sieve postfilter",
+        "sieve_info": "Es können mehrere Filter pro Benutzer existieren, aber nur ein Filter eines Typs (Pre-/Postfilter) kann gleichzeitig aktiv sein.<br>\r\nDie Ausführung erfolgt in nachstehender Reihenfolge. Ein fehlgeschlagenes Script sowie der Befehl \"keep;\" stoppen die weitere Verarbeitung <b>nicht</b>. Änderungen an globalen Sieve-Filtern bewirken einen Neustart von Dovecot.<br><br>Global sieve prefilter &#8226; Prefilter &#8226; User scripts &#8226; Postfilter &#8226; Global sieve postfilter",
         "sieve_preset_1": "E-Mails mit potenziell gefährlichen Dateitypen abweisen",
         "sieve_preset_1": "E-Mails mit potenziell gefährlichen Dateitypen abweisen",
         "sieve_preset_2": "E-Mail eines bestimmten Absenders immer als gelesen markieren",
         "sieve_preset_2": "E-Mail eines bestimmten Absenders immer als gelesen markieren",
         "sieve_preset_3": "Lautlos löschen, weitere Ausführung von Filtern verhindern",
         "sieve_preset_3": "Lautlos löschen, weitere Ausführung von Filtern verhindern",

+ 9 - 7
data/web/lang/lang.en.json

@@ -154,7 +154,7 @@
         "change_logo": "Change logo",
         "change_logo": "Change logo",
         "configuration": "Configuration",
         "configuration": "Configuration",
         "convert_html_to_text": "Convert HTML to plain text",
         "convert_html_to_text": "Convert HTML to plain text",
-        "credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching nexthop column.",
+        "credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.",
         "customer_id": "Customer ID",
         "customer_id": "Customer ID",
         "customize": "Customize",
         "customize": "Customize",
         "delete_queue": "Delete all",
         "delete_queue": "Delete all",
@@ -208,6 +208,7 @@
         "html": "HTML",
         "html": "HTML",
         "import": "Import",
         "import": "Import",
         "import_private_key": "Import private key",
         "import_private_key": "Import private key",
+        "is_mx_based": "MX based",
         "in_use_by": "In use by",
         "in_use_by": "In use by",
         "inactive": "Inactive",
         "inactive": "Inactive",
         "include_exclude": "Include/Exclude",
         "include_exclude": "Include/Exclude",
@@ -218,7 +219,7 @@
         "link": "Link",
         "link": "Link",
         "loading": "Please wait...",
         "loading": "Please wait...",
         "logo_info": "Your image will be scaled to a height of 40px for the top navigation bar and a max. width of 250px for the start page. A scalable graphic is highly recommended.",
         "logo_info": "Your image will be scaled to a height of 40px for the top navigation bar and a max. width of 250px for the start page. A scalable graphic is highly recommended.",
-        "lookup_mx": "Match destination against MX (.outlook.com to route all mail targeted to a MX *.outlook.com over this hop)",
+        "lookup_mx": "Destination is a regular expression to match against MX name (<code>.*google\\.com</code> to route all mail targeted to a MX ending in google.com over this hop)",
         "main_name": "\"mailcow UI\" name",
         "main_name": "\"mailcow UI\" name",
         "merged_vars_hint": "Greyed out rows were merged from <code>vars.(local.)inc.php</code> and cannot be modified.",
         "merged_vars_hint": "Greyed out rows were merged from <code>vars.(local.)inc.php</code> and cannot be modified.",
         "message": "Message",
         "message": "Message",
@@ -230,7 +231,7 @@
         "no_record": "No record",
         "no_record": "No record",
         "oauth2_client_id": "Client ID",
         "oauth2_client_id": "Client ID",
         "oauth2_client_secret": "Client secret",
         "oauth2_client_secret": "Client secret",
-        "oauth2_info": "The OAuth2 implementation supports the grant type \"Authorization Code\" and issues refresh tokens.<br>\r\nThe server also automatically issues new refresh tokens, after a refresh token has been used.<br><br>\r\n The default scope is <i>profile</i>. Only mailbox users can be authenticated against OAuth2. If the scope parameter is omitted, it falls back to <i>profile</i>.<br>\r\n The <i>state</i> parameter is required to be sent by the client as part of the authorize request.<br><br>\r\nPaths for requests to the OAuth2 API: <br>\r\n<ul>\r\n  <li>Authorization endpoint: <code>/oauth/authorize</code></li>\r\n  <li>Token endpoint: <code>/oauth/token</code></li>\r\n  <li>Resource page:  <code>/oauth/profile</code></li>\r\n</ul>\r\nRegenerating the client secret will not expire existing authorization codes, but they will fail to renew their token.<br><br>\r\nRevoking client tokens will cause immediate termination of all active sessions. All clients need to re-authenticate.",
+        "oauth2_info": "The OAuth2 implementation supports the grant type \"Authorization Code\" and issues refresh tokens.<br>\r\nThe server also automatically issues new refresh tokens, after a refresh token has been used.<br><br>\r\n&#8226; The default scope is <i>profile</i>. Only mailbox users can be authenticated against OAuth2. If the scope parameter is omitted, it falls back to <i>profile</i>.<br>\r\n&#8226; The <i>state</i> parameter is required to be sent by the client as part of the authorize request.<br><br>\r\nPaths for requests to the OAuth2 API: <br>\r\n<ul>\r\n  <li>Authorization endpoint: <code>/oauth/authorize</code></li>\r\n  <li>Token endpoint: <code>/oauth/token</code></li>\r\n  <li>Resource page:  <code>/oauth/profile</code></li>\r\n</ul>\r\nRegenerating the client secret will not expire existing authorization codes, but they will fail to renew their token.<br><br>\r\nRevoking client tokens will cause immediate termination of all active sessions. All clients need to re-authenticate.",
         "oauth2_redirect_uri": "Redirect URI",
         "oauth2_redirect_uri": "Redirect URI",
         "oauth2_renew_secret": "Generate new client secret",
         "oauth2_renew_secret": "Generate new client secret",
         "oauth2_revoke_tokens": "Revoke all client tokens",
         "oauth2_revoke_tokens": "Revoke all client tokens",
@@ -321,10 +322,10 @@
         "title": "Title",
         "title": "Title",
         "title_name": "\"mailcow UI\" website title",
         "title_name": "\"mailcow UI\" website title",
         "to_top": "Back to top",
         "to_top": "Back to top",
-        "transport_dest_format": "Syntax: example.org, .example.org, *, box@example.org (multiple values can be comma-separated)",
+        "transport_dest_format": "Regex or syntax: example.org, .example.org, *, box@example.org (multiple values can be comma-separated)",
         "transport_maps": "Transport Maps",
         "transport_maps": "Transport Maps",
-        "transports_hint": "→ A transport map entry <b>overrules</b> a sender-dependent transport map</b>.<br>\r\n→ Outbound TLS policy settings per-user are ignored and can only be enforced by TLS policy map entries.<br>\r\n The transport service for defined transports is always \"smtp:\" and will therefore try TLS when offered. Wrapped TLS (SMTPS) is not supported.<br>\r\n Addresses matching \"/localhost$/\" will always be transported via \"local:\", therefore a \"*\" destination will not apply to those addresses.<br>\r\n To determine credentials for an exemplary next hop \"[host]:25\", Postfix <b>always</b> queries for \"host\" before searching for \"[host]:25\". This behavior makes it impossible to use \"host\" and \"[host]:25\" at the same time.",
-        "transport_test_rcpt_info": " Use null@hosted.mailcow.de to test relaying to a foreign destination.",
+        "transports_hint": "&#8226; A transport map entry <b>overrules</b> a sender-dependent transport map</b>.<br>\r\n&#8226; MX-based transports are preferably used.<br>\r\n&#8226; Outbound TLS policy settings per-user are ignored and can only be enforced by TLS policy map entries.<br>\r\n&#8226; The transport service for defined transports is always \"smtp:\" and will therefore try TLS when offered. Wrapped TLS (SMTPS) is not supported.<br>\r\n&#8226; Addresses matching \"/localhost$/\" will always be transported via \"local:\", therefore a \"*\" destination will not apply to those addresses.<br>\r\n&#8226; To determine credentials for an exemplary next hop \"[host]:25\", Postfix <b>always</b> queries for \"host\" before searching for \"[host]:25\". This behavior makes it impossible to use \"host\" and \"[host]:25\" at the same time.",
+        "transport_test_rcpt_info": "&#8226; Use null@hosted.mailcow.de to test relaying to a foreign destination.",
         "ui_footer": "Footer (HTML allowed)",
         "ui_footer": "Footer (HTML allowed)",
         "ui_header_announcement": "Announcements",
         "ui_header_announcement": "Announcements",
         "ui_header_announcement_active": "Set announcement active",
         "ui_header_announcement_active": "Set announcement active",
@@ -553,6 +554,7 @@
         "hostname": "Hostname",
         "hostname": "Hostname",
         "inactive": "Inactive",
         "inactive": "Inactive",
         "kind": "Kind",
         "kind": "Kind",
+        "lookup_mx": "Destination is a regular expression to match against MX name (<code>.*google\\.com</code> to route all mail targeted to a MX ending in google.com over this hop)",
         "mailbox": "Edit mailbox",
         "mailbox": "Edit mailbox",
         "mailbox_quota_def": "Default mailbox quota",
         "mailbox_quota_def": "Default mailbox quota",
         "mailbox_relayhost_info": "Applied to the mailbox and direct aliases only, does override a domain relayhost.",
         "mailbox_relayhost_info": "Applied to the mailbox and direct aliases only, does override a domain relayhost.",
@@ -760,7 +762,7 @@
         "running": "Running",
         "running": "Running",
         "set_postfilter": "Mark as postfilter",
         "set_postfilter": "Mark as postfilter",
         "set_prefilter": "Mark as prefilter",
         "set_prefilter": "Mark as prefilter",
-        "sieve_info": "You can store multiple filters per user, but only one prefilter and one postfilter can be active at the same time.<br>\r\nEach filter will be processed in the described order. Neither a failed script nor an issued \"keep;\" will stop processing of further scripts. Changes to global sieve scripts will trigger a restart of Dovecot.<br><br>Global sieve prefilter → Prefilter → User scripts → Postfilter → Global sieve postfilter",
+        "sieve_info": "You can store multiple filters per user, but only one prefilter and one postfilter can be active at the same time.<br>\r\nEach filter will be processed in the described order. Neither a failed script nor an issued \"keep;\" will stop processing of further scripts. Changes to global sieve scripts will trigger a restart of Dovecot.<br><br>Global sieve prefilter &#8226; Prefilter &#8226; User scripts &#8226; Postfilter &#8226; Global sieve postfilter",
         "sieve_preset_1": "Discard mail with probable dangerous file types",
         "sieve_preset_1": "Discard mail with probable dangerous file types",
         "sieve_preset_2": "Always mark the e-mail of a specific sender as seen",
         "sieve_preset_2": "Always mark the e-mail of a specific sender as seen",
         "sieve_preset_3": "Discard silently, stop all further sieve processing",
         "sieve_preset_3": "Discard silently, stop all further sieve processing",

+ 1 - 1
docker-compose.yml

@@ -290,7 +290,7 @@ services:
             - dovecot
             - dovecot
 
 
     postfix-mailcow:
     postfix-mailcow:
-      image: mailcow/postfix:1.62
+      image: mailcow/postfix:1.63
       depends_on:
       depends_on:
         - mysql-mailcow
         - mysql-mailcow
       volumes:
       volumes: