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/Dockerfiles/dockerapi/modules/DockerApi.py b/mailcow/src/mailcow-dockerized/data/Dockerfiles/dockerapi/modules/DockerApi.py
index d78a3de..e37cd56 100644
--- a/mailcow/src/mailcow-dockerized/data/Dockerfiles/dockerapi/modules/DockerApi.py
+++ b/mailcow/src/mailcow-dockerized/data/Dockerfiles/dockerapi/modules/DockerApi.py
@@ -159,7 +159,7 @@
             postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
             # todo: check each exit code
           res = { 'type': 'success', 'msg': 'Scheduled immediate delivery'}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")        
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
   # api call: container_post - post_action: exec - cmd: mailq - task: list
   def container_post__exec__mailq__list(self, request_json, **kwargs):
     if 'container_id' in kwargs:
@@ -285,7 +285,7 @@
 
     if 'username' in request_json and 'script_name' in request_json:
       for container in self.sync_docker_client.containers.list(filters=filters):
-        cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]  
+        cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
         sieve_return = container.exec_run(cmd)
         return self.exec_run_handler('utf8_text_only', sieve_return)
   # api call: container_post - post_action: exec - cmd: maildir - task: cleanup
@@ -309,6 +309,30 @@
           cmd = ["/bin/bash", "-c", cmd_vmail]
         maildir_cleanup = container.exec_run(cmd, user='vmail')
         return self.exec_run_handler('generic', maildir_cleanup)
+  # api call: container_post - post_action: exec - cmd: maildir - task: move
+  def container_post__exec__maildir__move(self, request_json, **kwargs):
+    if 'container_id' in kwargs:
+      filters = {"id": kwargs['container_id']}
+    elif 'container_name' in kwargs:
+      filters = {"name": kwargs['container_name']}
+
+    if 'old_maildir' in request_json and 'new_maildir' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        vmail_name = request_json['old_maildir'].replace("'", "'\\''")
+        new_vmail_name = request_json['new_maildir'].replace("'", "'\\''")
+        cmd_vmail = f"if [[ -d '/var/vmail/{vmail_name}' ]]; then /bin/mv '/var/vmail/{vmail_name}' '/var/vmail/{new_vmail_name}'; fi"
+
+        index_name = request_json['old_maildir'].split("/")
+        new_index_name = request_json['new_maildir'].split("/")
+        if len(index_name) > 1 and len(new_index_name) > 1:
+          index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
+          new_index_name = new_index_name[1].replace("'", "'\\''") + "@" + new_index_name[0].replace("'", "'\\''")
+          cmd_vmail_index = f"if [[ -d '/var/vmail_index/{index_name}' ]]; then /bin/mv '/var/vmail_index/{index_name}' '/var/vmail_index/{new_index_name}_index'; fi"
+          cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
+        else:
+          cmd = ["/bin/bash", "-c", cmd_vmail]
+        maildir_move = container.exec_run(cmd, user='vmail')
+        return self.exec_run_handler('generic', maildir_move)
   # api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
   def container_post__exec__rspamd__worker_password(self, request_json, **kwargs):
     if 'container_id' in kwargs:
@@ -325,8 +349,8 @@
         for line in cmd_response.split("\n"):
           if '$2$' in line:
             hash = line.strip()
-            hash_out = re.search('\$2\$.+$', hash).group(0)
-            rspamd_passphrase_hash = re.sub('[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
+            hash_out = re.search(r'\$2\$.+$', hash).group(0)
+            rspamd_passphrase_hash = re.sub(r'[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
             rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
             cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
             cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
@@ -341,6 +365,121 @@
           self.logger.error('failed changing Rspamd password')
           res = { 'type': 'danger', 'msg': 'command did not complete' }
           return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: exec - cmd: sogo - task: rename
+  def container_post__exec__sogo__rename_user(self, request_json, **kwargs):
+    if 'container_id' in kwargs:
+      filters = {"id": kwargs['container_id']}
+    elif 'container_name' in kwargs:
+      filters = {"name": kwargs['container_name']}
+
+    if 'old_username' in request_json and 'new_username' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        old_username = request_json['old_username'].replace("'", "'\\''")
+        new_username = request_json['new_username'].replace("'", "'\\''")
+
+        sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo')
+        return self.exec_run_handler('generic', sogo_return)
+  # api call: container_post - post_action: exec - cmd: doveadm - task: get_acl
+  def container_post__exec__doveadm__get_acl(self, request_json, **kwargs):
+    if 'container_id' in kwargs:
+      filters = {"id": kwargs['container_id']}
+    elif 'container_name' in kwargs:
+      filters = {"name": kwargs['container_name']}
+
+    for container in self.sync_docker_client.containers.list(filters=filters):
+      id = request_json['id'].replace("'", "'\\''")
+
+      shared_folders = container.exec_run(["/bin/bash", "-c", f"doveadm mailbox list -u '{id}'"])
+      shared_folders = shared_folders.output.decode('utf-8')
+      shared_folders = shared_folders.splitlines()
+
+      formatted_acls = []
+      mailbox_seen = []
+      for shared_folder in shared_folders:
+        if "Shared" not in shared_folder:
+          mailbox = shared_folder.replace("'", "'\\''")
+          if mailbox in mailbox_seen:
+            continue
+
+          acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
+          acls = acls.output.decode('utf-8').strip().splitlines()
+          if len(acls) >= 2:
+            for acl in acls[1:]:
+              user_id, rights = acl.split(maxsplit=1)
+              user_id = user_id.split('=')[1]
+              mailbox_seen.append(mailbox)
+              formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() })
+        elif "Shared" in shared_folder and "/" in shared_folder:
+          shared_folder = shared_folder.split("/")
+          if len(shared_folder) < 3:
+            continue
+
+          user = shared_folder[1].replace("'", "'\\''")
+          mailbox = '/'.join(shared_folder[2:]).replace("'", "'\\''")
+          if mailbox in mailbox_seen:
+            continue
+
+          acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
+          acls = acls.output.decode('utf-8').strip().splitlines()
+          if len(acls) >= 2:
+            for acl in acls[1:]:
+              user_id, rights = acl.split(maxsplit=1)
+              user_id = user_id.split('=')[1].replace("'", "'\\''")
+              if user_id == id and mailbox not in mailbox_seen:
+                mailbox_seen.append(mailbox)
+                formatted_acls.append({ 'user': user, 'id': id, 'mailbox': mailbox, 'rights': rights.split() })
+
+      return Response(content=json.dumps(formatted_acls, indent=4), media_type="application/json")
+  # api call: container_post - post_action: exec - cmd: doveadm - task: delete_acl
+  def container_post__exec__doveadm__delete_acl(self, request_json, **kwargs):
+    if 'container_id' in kwargs:
+      filters = {"id": kwargs['container_id']}
+    elif 'container_name' in kwargs:
+      filters = {"name": kwargs['container_name']}
+
+    for container in self.sync_docker_client.containers.list(filters=filters):
+      user = request_json['user'].replace("'", "'\\''")
+      mailbox = request_json['mailbox'].replace("'", "'\\''")
+      id = request_json['id'].replace("'", "'\\''")
+
+      if user and mailbox and id:
+        acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"])
+        return self.exec_run_handler('generic', acl_delete_return)
+  # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
+  def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
+    if 'container_id' in kwargs:
+      filters = {"id": kwargs['container_id']}
+    elif 'container_name' in kwargs:
+      filters = {"name": kwargs['container_name']}
+
+    for container in self.sync_docker_client.containers.list(filters=filters):
+      user = request_json['user'].replace("'", "'\\''")
+      mailbox = request_json['mailbox'].replace("'", "'\\''")
+      id = request_json['id'].replace("'", "'\\''")
+      rights = ""
+
+      available_rights = [
+        "admin",
+        "create",
+        "delete",
+        "expunge",
+        "insert",
+        "lookup",
+        "post",
+        "read",
+        "write",
+        "write-deleted",
+        "write-seen"
+      ]
+      for right in request_json['rights']:
+        right = right.replace("'", "'\\''").lower()
+        if right in available_rights:
+          rights += right + " "
+
+      if user and mailbox and id and rights:
+        acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' 'user={id}' {rights}"])
+        return self.exec_run_handler('generic', acl_set_return)
+
 
   # Collect host stats
   async def get_host_stats(self, wait=5):
@@ -429,7 +568,7 @@
         except:
           pass
       return ''.join(total_data)
-      
+
     try :
       socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
       if not cmd.endswith("\n"):