git subrepo commit (merge) mailcow/src/mailcow-dockerized

subrepo:
  subdir:   "mailcow/src/mailcow-dockerized"
  merged:   "81fb18894"
upstream:
  origin:   "https://github.com/mailcow/mailcow-dockerized.git"
  branch:   "master"
  commit:   "4ca02ecee"
git-subrepo:
  version:  "0.4.6"
  origin:   "???"
  commit:   "???"

Update to upstream 2026-10.  Local patches re-applied by hand
(no Unbound DNS for containers, IPv6 off by default, fake-MySQL
stubs in dockerapi, Postfix overrides, no .env symlink); dropped
the unused dovecot and clamd Dockerfile patches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Change-Id: I2c2c194d1d436b841358685f5f94aef09eb19ae8
diff --git a/mailcow/src/mailcow-dockerized/data/web/json_api.php b/mailcow/src/mailcow-dockerized/data/web/json_api.php
index 28f8cac..fc34b25 100644
--- a/mailcow/src/mailcow-dockerized/data/web/json_api.php
+++ b/mailcow/src/mailcow-dockerized/data/web/json_api.php
@@ -47,6 +47,12 @@
   }

 }

 

+// Block requests not intended for direct API use by checking the 'Sec-Fetch-Dest' header.

+if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') {

+  header('HTTP/1.1 403 Forbidden');

+  exit;

+}

+

 if (isset($_GET['query'])) {

 

   $query = explode('/', $_GET['query']);

@@ -85,6 +91,11 @@
     if ($action == 'delete') {

       $_POST['items'] = $request;

     }

+

+    // search

+    if ($action == 'search') {

+      // placeholder for search, as the request body is already decoded and available in $requestDecoded

+    }

   }

   api_log($_POST);

 

@@ -163,6 +174,8 @@
               exit;

             }

             fido2(array("action" => "register", "registration" => $data));

+            // Release pending_tfa_setup session hold

+            unset($_SESSION['pending_tfa_setup']);

             $return = new stdClass();

             $return->success = true;

             echo json_encode($return);

@@ -318,6 +331,9 @@
         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);

@@ -328,81 +344,6 @@
           exit();

       }

     break;

-    case "process":

-      // only allow POST requests to process API endpoints

-      if ($_SERVER['REQUEST_METHOD'] != 'POST') {

-        http_response_code(405);

-        echo json_encode(array(

-            'type' => 'error',

-            'msg' => 'only POST method is allowed'

-        ));

-        exit();

-      }

-      switch ($category) {

-        case "fido2-args":

-          header('Content-Type: application/json');

-          $post = trim(file_get_contents('php://input'));

-          if ($post) {

-            $post = json_decode($post);

-          }

-          $clientDataJSON = base64_decode($post->clientDataJSON);

-          $authenticatorData = base64_decode($post->authenticatorData);

-          $signature = base64_decode($post->signature);

-          $id = base64_decode($post->id);

-          $challenge = $_SESSION['challenge'];

-          $process_fido2 = fido2(array("action" => "get_by_b64cid", "cid" => $post->id));

-          if ($process_fido2['pub_key'] === false) {

-            $return = new stdClass();

-            $return->success = false;

-            echo json_encode($return);

-            exit;

-          }

-          try {

-            $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $process_fido2['pub_key'], $challenge, null, $GLOBALS['FIDO2_UV_FLAG_LOGIN'], $GLOBALS['FIDO2_USER_PRESENT_FLAG']);

-          }

-          catch (Throwable $ex) {

-            unset($process_fido2);

-            $return = new stdClass();

-            $return->success = false;

-            echo json_encode($return);

-            exit;

-          }

-          $return = new stdClass();

-          $return->success = true;

-          $stmt = $pdo->prepare("SELECT `superadmin` FROM `admin` WHERE `username` = :username");

-          $stmt->execute(array(':username' => $process_fido2['username']));

-          $obj_props = $stmt->fetch(PDO::FETCH_ASSOC);

-          if ($obj_props['superadmin'] === 1) {

-            $_SESSION["mailcow_cc_role"] = "admin";

-          }

-          elseif ($obj_props['superadmin'] === 0) {

-            $_SESSION["mailcow_cc_role"] = "domainadmin";

-          }

-          else {

-            $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :username");

-            $stmt->execute(array(':username' => $process_fido2['username']));

-            $row = $stmt->fetch(PDO::FETCH_ASSOC);

-            if ($row['username'] == $process_fido2['username']) {

-              $_SESSION["mailcow_cc_role"] = "user";

-            }

-          }

-          if (empty($_SESSION["mailcow_cc_role"])) {

-            session_unset();

-            session_destroy();

-            exit;

-          }

-          $_SESSION["mailcow_cc_username"] = $process_fido2['username'];

-          $_SESSION["fido2_cid"] = $process_fido2['cid'];

-          unset($_SESSION["challenge"]);

-          $_SESSION['return'][] =  array(

-            'type' => 'success',

-            'log' => array("fido2_login"),

-            'msg' => array('logged_in_as', $process_fido2['username'])

-          );

-          echo json_encode($return);

-        break;

-      }

-    break;

     case "get":

       function process_get_return($data, $object = true) {

         if ($object === true) {

@@ -503,16 +444,6 @@
           print(json_encode($getArgs));

           $_SESSION['challenge'] = $WebAuthn->getChallenge();

           return;

-        break;          

-        case "fail2ban":

-          if (!isset($_SESSION['mailcow_cc_role'])){

-            switch ($object) {

-              case 'banlist':

-                header('Content-Type: text/plain');

-                echo fail2ban('banlist', 'get', $extra);

-              break;

-            }

-          }

         break;

       }

       if (isset($_SESSION['mailcow_cc_role'])) {

@@ -533,47 +464,6 @@
 

           case "domain":

             switch ($object) {

-              case "datatables":

-                $table = ['domain', 'd'];

-                $primaryKey = 'domain';

-                $columns = [

-                  ['db' => 'domain', 'dt' => 2],

-                  ['db' => 'aliases', 'dt' => 3, 'order_subquery' => "SELECT COUNT(*) FROM `alias` WHERE (`domain`= `d`.`domain` OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = `d`.`domain`)) AND `address` NOT IN (SELECT `username` FROM `mailbox`)"],

-                  ['db' => 'mailboxes', 'dt' => 4, 'order_subquery' => "SELECT COUNT(*) FROM `mailbox` WHERE `mailbox`.`domain` = `d`.`domain` AND (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)"],

-                  ['db' => 'quota', 'dt' => 5, 'order_subquery' => "SELECT COALESCE(SUM(`mailbox`.`quota`), 0) FROM `mailbox` WHERE `mailbox`.`domain` = `d`.`domain` AND (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)"],

-                  ['db' => 'stats', 'dt' => 6, 'dummy' => true, 'order_subquery' => "SELECT SUM(bytes) FROM `quota2` WHERE `quota2`.`username` IN (SELECT `username` FROM `mailbox` WHERE `domain` = `d`.`domain`)"],

-                  ['db' => 'defquota', 'dt' => 7],

-                  ['db' => 'maxquota', 'dt' => 8],

-                  ['db' => 'backupmx', 'dt' => 10],

-                  ['db' => 'tags', 'dt' => 14, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_domain` AS `td` ON `td`.`domain` = `d`.`domain`', 'where_column' => '`td`.`tag_name`']],

-                  ['db' => 'active', 'dt' => 15],

-                ];

-

-                require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/lib/ssp.class.php';

-                global $pdo;

-                if($_SESSION['mailcow_cc_role'] === 'admin') {

-                  $data = SSP::simple($_GET, $pdo, $table, $primaryKey, $columns);

-                } elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {

-                  $data = SSP::complex($_GET, $pdo, $table, $primaryKey, $columns,

-                    'INNER JOIN domain_admins as da ON da.domain = d.domain',

-                    [

-                      'condition' => 'da.active = 1 and da.username = :username',

-                      'bindings' => ['username' => $_SESSION['mailcow_cc_username']]

-                    ]);

-                }

-

-                if (!empty($data['data'])) {

-                  $domainsData = [];

-                  foreach ($data['data'] as $domain) {

-                    if ($details = mailbox('get', 'domain_details', $domain[2])) {

-                      $domainsData[] = $details;

-                    }

-                  }

-                  $data['data'] = $domainsData;

-                }

-

-                process_get_return($data);

-              break;

               case "all":

                 $tags = null;

                 if (isset($_GET['tags']) && $_GET['tags'] != '')

@@ -947,6 +837,17 @@
                 }

                 echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';

               break;

+              case "cron":

+                // 0 is first record, so empty is fine

+                if (isset($extra)) {

+                  $extra = preg_replace('/[^\d\-]/i', '', $extra);

+                  $logs = get_logs('cron-mailcow', $extra);

+                }

+                else {

+                  $logs = get_logs('cron-mailcow');

+                }

+                echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';

+              break;

               case "postfix":

                 // 0 is first record, so empty is fine

                 if (isset($extra)) {

@@ -1062,45 +963,6 @@
           break;

           case "mailbox":

             switch ($object) {

-              case "datatables":

-                $table = ['mailbox', 'm'];

-                $primaryKey = 'username';

-                $columns = [

-                  ['db' => 'username', 'dt' => 2],

-                  ['db' => 'quota', 'dt' => 3],

-                  ['db' => 'last_mail_login', 'dt' => 4, 'dummy' => true, 'order_subquery' => "SELECT MAX(`datetime`) FROM `sasl_log` WHERE `service` != 'SSO' AND `username` = `m`.`username`"],

-                  ['db' => 'last_pw_change', 'dt' => 5, 'dummy' => true, 'order_subquery' => "JSON_EXTRACT(attributes, '$.passwd_update')"],

-                  ['db' => 'in_use', 'dt' => 6, 'dummy' => true, 'order_subquery' => "(SELECT SUM(bytes) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`) / `m`.`quota`"],

-                  ['db' => 'messages', 'dt' => 17, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],

-                  ['db' => 'tags', 'dt' => 20, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],

-                  ['db' => 'active', 'dt' => 21]

-                ];

-

-                require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/lib/ssp.class.php';

-                global $pdo;

-                if($_SESSION['mailcow_cc_role'] === 'admin') {

-                  $data = SSP::complex($_GET, $pdo, $table, $primaryKey, $columns, null, "(`m`.`kind` = '' OR `m`.`kind` = NULL)");

-                } elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {

-                  $data = SSP::complex($_GET, $pdo, $table, $primaryKey, $columns,

-                    'INNER JOIN domain_admins as da ON da.domain = m.domain',

-                    [

-                      'condition' => "(`m`.`kind` = '' OR `m`.`kind` = NULL) AND `da`.`active` = 1 AND `da`.`username` = :username",

-                      'bindings' => ['username' => $_SESSION['mailcow_cc_username']]

-                    ]);

-                }

-

-                if (!empty($data['data'])) {

-                  $mailboxData = [];

-                  foreach ($data['data'] as $mailbox) {

-                    if ($details = mailbox('get', 'mailbox_details', $mailbox[2])) {

-                      $mailboxData[] = $details;

-                    }

-                  }

-                  $data['data'] = $mailboxData;

-                }

-

-                process_get_return($data);

-              break;

               case "all":

               case "reduced":

                 $tags = null;

@@ -1414,10 +1276,6 @@
           break;

           case "fail2ban":

             switch ($object) {

-              case 'banlist':

-                header('Content-Type: text/plain');

-                echo fail2ban('banlist', 'get', $extra);

-              break;

               default:

                 $data = fail2ban('get');

                 process_get_return($data);

@@ -1635,23 +1493,6 @@
                   );

                   echo json_encode($temp, JSON_UNESCAPED_SLASHES);

                 break;

-                case "solr":

-                  $solr_status = solr_status();

-                  $solr_size = ($solr_status['status']['dovecot-fts']['index']['size']);

-                  $solr_documents = ($solr_status['status']['dovecot-fts']['index']['numDocs']);

-                  if (strtolower(getenv('SKIP_SOLR')) != 'n') {

-                    $solr_enabled = false;

-                  }

-                  else {

-                    $solr_enabled = true;

-                  }

-                  echo json_encode(array(

-                    'type' => 'info',

-                    'solr_enabled' => $solr_enabled,

-                    'solr_size' => $solr_size,

-                    'solr_documents' => $solr_documents

-                  ));

-                break;

                 case "host":

                   if (!$extra){

                     $stats = docker("host_stats");

@@ -1691,6 +1532,13 @@
               $score = array("score" => preg_replace("/\s+/", "", $score));

             process_get_return($score);

           break;

+          case "identity-provider":

+            if($_SESSION['mailcow_cc_role'] === 'admin') {

+              process_get_return($iam_settings);

+            } else {

+              process_get_return(null);

+            }

+          break;

         break;

         // return no route found if no case is matched

         default:

@@ -1703,6 +1551,136 @@
         }

       }

     break;

+    case "search":

+      function process_search_return($return) {

+        if ($return === false) {

+          echo json_encode(array(

+            'type' => 'error',

+            'msg' => 'Cannot get item'

+          ));

+        }

+        else {

+          echo json_encode($return, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);

+        }

+      }

+      // only allow POST requests to SEARCH API endpoints

+      if ($_SERVER['REQUEST_METHOD'] != 'POST') {

+        http_response_code(405);

+        echo json_encode(array(

+          'type' => 'error',

+          'msg' => 'only POST method is allowed'

+        ));

+        exit();

+      }

+

+      // Load SSP class

+      require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/lib/ssp.class.php';

+      global $pdo;

+

+      switch ($category) {

+        case "domain":

+          $table = ['domain', 'd'];

+          $primaryKey = 'domain';

+          $columns = [

+            ['db' => 'domain', 'dt' => 2],

+            ['db' => 'aliases', 'dt' => 3, 'order_subquery' => "SELECT COUNT(*) FROM `alias` WHERE (`domain`= `d`.`domain` OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = `d`.`domain`)) AND `address` NOT IN (SELECT `username` FROM `mailbox`)"],

+            ['db' => 'mailboxes', 'dt' => 4, 'order_subquery' => "SELECT COUNT(*) FROM `mailbox` WHERE `mailbox`.`domain` = `d`.`domain` AND (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)"],

+            ['db' => 'quota', 'dt' => 5, 'order_subquery' => "SELECT COALESCE(SUM(`mailbox`.`quota`), 0) FROM `mailbox` WHERE `mailbox`.`domain` = `d`.`domain` AND (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)"],

+            ['db' => 'stats', 'dt' => 6, 'dummy' => true, 'order_subquery' => "SELECT SUM(bytes) FROM `quota2` WHERE `quota2`.`username` IN (SELECT `username` FROM `mailbox` WHERE `domain` = `d`.`domain`)"],

+            ['db' => 'defquota', 'dt' => 7],

+            ['db' => 'maxquota', 'dt' => 8],

+            ['db' => 'backupmx', 'dt' => 10],

+            ['db' => 'tags', 'dt' => 14, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_domain` AS `td` ON `td`.`domain` = `d`.`domain`', 'where_column' => '`td`.`tag_name`']],

+            ['db' => 'active', 'dt' => 15],

+          ];

+

+          if($_SESSION['mailcow_cc_role'] === 'admin') {

+            $data = SSP::simple($requestDecoded, $pdo, $table, $primaryKey, $columns);

+          } elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {

+            $data = SSP::complex($requestDecoded, $pdo, $table, $primaryKey, $columns,

+              'INNER JOIN domain_admins as da ON da.domain = d.domain',

+              [

+                'condition' => 'da.active = 1 and da.username = :username',

+                'bindings' => ['username' => $_SESSION['mailcow_cc_username']]

+              ]);

+          } else {

+            http_response_code(403);

+            echo json_encode(array(

+              'type' => 'error',

+              'msg' => 'Insufficient permissions'

+            ));

+            exit();

+          }

+

+          if (!empty($data['data'])) {

+            $domainsData = [];

+            foreach ($data['data'] as $domain) {

+              if ($details = mailbox('get', 'domain_details', $domain[2])) {

+                $domainsData[] = $details;

+              }

+            }

+            $data['data'] = $domainsData;

+          }

+

+          process_search_return($data);

+        break;

+

+        case "mailbox":

+          $table = ['mailbox', 'm'];

+          $primaryKey = 'username';

+          $columns = [

+            ['db' => 'username', 'dt' => 2],

+            ['db' => 'quota', 'dt' => 3],

+            ['db' => 'last_mail_login', 'dt' => 4, 'dummy' => true, 'order_subquery' => "SELECT MAX(`datetime`) FROM `sasl_log` WHERE `service` != 'SSO' AND `username` = `m`.`username`"],

+            ['db' => 'last_pw_change', 'dt' => 5, 'dummy' => true, 'order_subquery' => "JSON_EXTRACT(attributes, '$.passwd_update')"],

+            ['db' => 'in_use', 'dt' => 6, 'dummy' => true, 'order_subquery' => "(SELECT SUM(bytes) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`) / `m`.`quota`"],

+            ['db' => 'name', 'dt' => 7],

+            ['db' => 'messages', 'dt' => 20, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],

+            ['db' => 'tags', 'dt' => 23, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],

+            ['db' => 'active', 'dt' => 24],

+          ];

+

+          if($_SESSION['mailcow_cc_role'] === 'admin') {

+            $data = SSP::complex($requestDecoded, $pdo, $table, $primaryKey, $columns, null,

+              "(`m`.`kind` = '' OR `m`.`kind` = NULL)");

+          } elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {

+            $data = SSP::complex($requestDecoded, $pdo, $table, $primaryKey, $columns,

+              'INNER JOIN domain_admins as da ON da.domain = m.domain',

+              [

+                'condition' => "(`m`.`kind` = '' OR `m`.`kind` = NULL) AND `da`.`active` = 1 AND `da`.`username` = :username",

+                'bindings' => ['username' => $_SESSION['mailcow_cc_username']]

+              ]);

+          } else {

+            http_response_code(403);

+            echo json_encode(array(

+              'type' => 'error',

+              'msg' => 'Insufficient permissions'

+            ));

+            exit();

+          }

+

+          if (!empty($data['data'])) {

+            $mailboxData = [];

+            foreach ($data['data'] as $mailbox) {

+              if ($details = mailbox('get', 'mailbox_details', $mailbox[2])) {

+                $mailboxData[] = $details;

+              }

+            }

+            $data['data'] = $mailboxData;

+          }

+

+          process_search_return($data);

+        break;

+

+        default:

+          http_response_code(404);

+          echo json_encode(array(

+            'type' => 'error',

+            'msg' => 'Invalid search category'

+          ));

+        break;

+      }

+    break;

     case "delete":

       if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) {

         http_response_code(403);

@@ -1844,6 +1822,9 @@
         case "rlhash":

           echo ratelimit('delete', null, implode($items));

         break;

+        case "identity-provider":

+          process_delete_return(identity_provider('delete'));

+        break;

         // return no route found if no case is matched

         default:

           http_response_code(404);

@@ -1967,7 +1948,6 @@
         case "quota_notification_bcc":

           process_edit_return(quota_notification_bcc('edit', $attr));

         break;

-        break;

         case "mailq":

           process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));

         break;

@@ -2015,6 +1995,9 @@
         case "rl-mbox":

           process_edit_return(ratelimit('edit', 'mailbox', array_merge(array('object' => $items), $attr)));

         break;

+        case "rename-mbox":

+          process_edit_return(mailbox('edit', 'mailbox_rename', array_merge(array('mailbox' => $items), $attr)));

+        break;

         case "user-acl":

           process_edit_return(acl('edit', 'user', array_merge(array('username' => $items), $attr)));

         break;

@@ -2052,6 +2035,12 @@
         case "ip_check":

           process_edit_return(customize('edit', 'ip_check', $attr));

         break;

+        case "custom_login":

+          process_edit_return(customize('edit', 'custom_login', $attr));

+        break;

+        case "ui_theme":

+          process_edit_return(customize('edit', 'ui_theme', $attr));

+        break;

         case "self":

           if ($_SESSION['mailcow_cc_role'] == "domainadmin") {

             process_edit_return(domain_admin('edit', $attr));

@@ -2063,6 +2052,21 @@
         case "cors":

           process_edit_return(cors('edit', $attr));

         break;

+        case "identity-provider":

+          process_edit_return(identity_provider('edit', $attr));

+        break;

+        case "identity-provider-test":

+          process_edit_return(identity_provider('test', $attr));

+        break;

+        case "cors":

+          process_edit_return(cors('edit', $attr));

+        break;

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