浏览代码

Merge pull request #5332 from mailcow/staging

2023-07
Patrick Schult 2 年之前
父节点
当前提交
23fc54f2cf
共有 70 个文件被更改,包括 1126 次插入653 次删除
  1. 1 1
      .github/workflows/check_prs_if_on_staging.yml
  2. 5 2
      data/Dockerfiles/dockerapi/Dockerfile
  3. 1 1
      data/Dockerfiles/dockerapi/docker-entrypoint.sh
  4. 0 549
      data/Dockerfiles/dockerapi/dockerapi.py
  5. 260 0
      data/Dockerfiles/dockerapi/main.py
  6. 487 0
      data/Dockerfiles/dockerapi/modules/DockerApi.py
  7. 0 0
      data/Dockerfiles/dockerapi/modules/__init__.py
  8. 1 1
      data/conf/nginx/includes/site-defaults.conf
  9. 36 0
      data/conf/rspamd/local.d/composites.conf
  10. 6 2
      data/conf/rspamd/lua/rspamd.local.lua
  11. 6 0
      data/web/admin.php
  12. 47 1
      data/web/api/openapi.yaml
  13. 1 2
      data/web/api/swagger-initializer.js
  14. 0 0
      data/web/api/swagger-ui-bundle.js
  15. 0 0
      data/web/api/swagger-ui-bundle.js.map
  16. 0 0
      data/web/api/swagger-ui-es-bundle-core.js
  17. 0 0
      data/web/api/swagger-ui-es-bundle-core.js.map
  18. 0 0
      data/web/api/swagger-ui-es-bundle.js
  19. 0 0
      data/web/api/swagger-ui-es-bundle.js.map
  20. 0 0
      data/web/api/swagger-ui-standalone-preset.js
  21. 0 0
      data/web/api/swagger-ui-standalone-preset.js.map
  22. 0 0
      data/web/api/swagger-ui.css
  23. 0 0
      data/web/api/swagger-ui.css.map
  24. 0 0
      data/web/api/swagger-ui.js
  25. 0 0
      data/web/api/swagger-ui.js.map
  26. 11 0
      data/web/inc/functions.docker.inc.php
  27. 117 2
      data/web/inc/functions.inc.php
  28. 13 7
      data/web/inc/functions.mailbox.inc.php
  29. 1 1
      data/web/js/site/debug.js
  30. 17 14
      data/web/json_api.php
  31. 4 0
      data/web/lang/lang.de-de.json
  32. 6 0
      data/web/lang/lang.en-gb.json
  33. 35 2
      data/web/templates/admin/tab-config-admins.twig
  34. 2 2
      data/web/templates/admin/tab-config-customize.twig
  35. 2 2
      data/web/templates/admin/tab-config-dkim.twig
  36. 6 6
      data/web/templates/admin/tab-config-f2b.twig
  37. 2 2
      data/web/templates/admin/tab-config-fwdhosts.twig
  38. 2 2
      data/web/templates/admin/tab-config-oauth2.twig
  39. 2 2
      data/web/templates/admin/tab-config-password-policy.twig
  40. 2 2
      data/web/templates/admin/tab-config-quarantine.twig
  41. 2 2
      data/web/templates/admin/tab-config-quota.twig
  42. 2 2
      data/web/templates/admin/tab-config-rsettings.twig
  43. 2 2
      data/web/templates/admin/tab-config-rspamd.twig
  44. 2 2
      data/web/templates/admin/tab-globalfilter-regex.twig
  45. 2 2
      data/web/templates/admin/tab-ldap.twig
  46. 3 3
      data/web/templates/admin/tab-routing.twig
  47. 2 2
      data/web/templates/admin/tab-sys-mails.twig
  48. 2 2
      data/web/templates/debug.twig
  49. 1 1
      data/web/templates/mailbox/tab-bcc.twig
  50. 1 1
      data/web/templates/mailbox/tab-domain-aliases.twig
  51. 1 1
      data/web/templates/mailbox/tab-domains.twig
  52. 1 1
      data/web/templates/mailbox/tab-filters.twig
  53. 1 1
      data/web/templates/mailbox/tab-mailboxes.twig
  54. 1 1
      data/web/templates/mailbox/tab-mbox-aliases.twig
  55. 1 1
      data/web/templates/mailbox/tab-resources.twig
  56. 1 1
      data/web/templates/mailbox/tab-syncjobs.twig
  57. 1 1
      data/web/templates/mailbox/tab-templates-domains.twig
  58. 1 1
      data/web/templates/mailbox/tab-templates-mbox.twig
  59. 1 1
      data/web/templates/mailbox/tab-tls-policy.twig
  60. 2 2
      data/web/templates/user/AppPasswds.twig
  61. 2 2
      data/web/templates/user/Pushover.twig
  62. 2 2
      data/web/templates/user/SpamAliases.twig
  63. 2 2
      data/web/templates/user/Spamfilter.twig
  64. 2 2
      data/web/templates/user/Syncjobs.twig
  65. 2 2
      data/web/templates/user/tab-user-auth.twig
  66. 5 5
      data/web/templates/user/tab-user-details.twig
  67. 2 2
      data/web/templates/user/tab-user-settings.twig
  68. 2 1
      docker-compose.yml
  69. 3 1
      helper-scripts/nextcloud.sh
  70. 1 1
      update.sh

+ 1 - 1
.github/workflows/check_prs_if_on_staging.yml

@@ -10,7 +10,7 @@ jobs:
     if: github.event.pull_request.base.ref != 'staging' #check if the target branch is not staging
     steps:
       - name: Send message
-        uses: thollander/actions-comment-pull-request@v2.3.1
+        uses: thollander/actions-comment-pull-request@v2.4.0
         with:
           GITHUB_TOKEN: ${{ secrets.CHECKIFPRISSTAGING_ACTION_PAT }}
           message: |

+ 5 - 2
data/Dockerfiles/dockerapi/Dockerfile

@@ -14,9 +14,12 @@ RUN apk add --update --no-cache python3 \
   uvicorn \
   aiodocker \
   docker \
-  redis 
+  aioredis 
+RUN mkdir /app/modules
 
 COPY docker-entrypoint.sh /app/
-COPY dockerapi.py /app/
+COPY main.py /app/main.py
+COPY modules/ /app/modules/
 
 ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
+CMD exec python main.py

+ 1 - 1
data/Dockerfiles/dockerapi/docker-entrypoint.sh

@@ -6,4 +6,4 @@
   -subj /CN=dockerapi/O=mailcow \
   -addext subjectAltName=DNS:dockerapi`
 
-`uvicorn --host 0.0.0.0 --port 443 --ssl-certfile=/app/dockerapi_cert.pem --ssl-keyfile=/app/dockerapi_key.pem dockerapi:app`
+exec "$@"

+ 0 - 549
data/Dockerfiles/dockerapi/dockerapi.py

@@ -1,549 +0,0 @@
-from fastapi import FastAPI, Response, Request
-import aiodocker
-import docker
-import psutil
-import sys
-import re
-import time
-import os
-import json
-import asyncio
-import redis
-import platform
-from datetime import datetime
-import logging
-from logging.config import dictConfig
-
-
-log_config = {
-    "version": 1,
-    "disable_existing_loggers": False,
-    "formatters": {
-        "default": {
-            "()": "uvicorn.logging.DefaultFormatter",
-            "fmt": "%(levelprefix)s %(asctime)s %(message)s",
-            "datefmt": "%Y-%m-%d %H:%M:%S",
-
-        },
-    },
-    "handlers": {
-        "default": {
-            "formatter": "default",
-            "class": "logging.StreamHandler",
-            "stream": "ext://sys.stderr",
-        },
-    },
-    "loggers": {
-        "api-logger": {"handlers": ["default"], "level": "INFO"},
-    },
-}
-dictConfig(log_config)
-
-containerIds_to_update = []
-host_stats_isUpdating = False
-app = FastAPI()
-logger = logging.getLogger('api-logger')
-
-
-@app.get("/host/stats")
-async def get_host_update_stats():
-  global host_stats_isUpdating
-
-  if host_stats_isUpdating == False:
-    asyncio.create_task(get_host_stats())
-    host_stats_isUpdating = True
-
-  while True:
-    if redis_client.exists('host_stats'):
-      break
-    await asyncio.sleep(1.5)
-
-
-  stats = json.loads(redis_client.get('host_stats'))
-  return Response(content=json.dumps(stats, indent=4), media_type="application/json")
-
-@app.get("/containers/{container_id}/json")
-async def get_container(container_id : str):
-  if container_id and container_id.isalnum():
-    try:
-      for container in (await async_docker_client.containers.list()):
-        if container._id == container_id:
-          container_info = await container.show()
-          return Response(content=json.dumps(container_info, indent=4), media_type="application/json")
-     
-      res = {
-        "type": "danger",
-        "msg": "no container found"
-      }
-      return Response(content=json.dumps(res, indent=4), media_type="application/json")
-    except Exception as e:
-      res = {
-        "type": "danger",
-        "msg": str(e)
-      }
-      return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  else:
-    res = {
-      "type": "danger",
-      "msg": "no or invalid id defined"
-    }
-    return Response(content=json.dumps(res, indent=4), media_type="application/json")
-
-@app.get("/containers/json")
-async def get_containers():
-  containers = {}
-  try:
-    for container in (await async_docker_client.containers.list()):
-      container_info = await container.show()
-      containers.update({container_info['Id']: container_info})
-    return Response(content=json.dumps(containers, indent=4), media_type="application/json")
-  except Exception as e:
-    res = {
-      "type": "danger",
-      "msg": str(e)
-    }
-    return Response(content=json.dumps(res, indent=4), media_type="application/json")
-
-@app.post("/containers/{container_id}/{post_action}")
-async def post_containers(container_id : str, post_action : str, request: Request):
-  try : 
-    request_json = await request.json()
-  except Exception as err:
-    request_json = {}
-
-  if container_id and container_id.isalnum() and post_action:
-    try:
-      """Dispatch container_post api call"""
-      if post_action == 'exec':
-        if not request_json or not 'cmd' in request_json:
-          res = {
-            "type": "danger",
-            "msg": "cmd is missing"
-          }
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-        if not request_json or not 'task' in request_json:
-          res = {
-            "type": "danger",
-            "msg": "task is missing"
-          }
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-
-        api_call_method_name = '__'.join(['container_post', str(post_action), str(request_json['cmd']), str(request_json['task']) ])
-      else:
-        api_call_method_name = '__'.join(['container_post', str(post_action) ])
-
-      docker_utils = DockerUtils(sync_docker_client)
-      api_call_method = getattr(docker_utils, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
-
-
-      logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
-      return api_call_method(container_id, request_json)
-    except Exception as e:
-      logger.error("error - container_post: %s" % str(e))
-      res = {
-        "type": "danger",
-        "msg": str(e)
-      }
-      return Response(content=json.dumps(res, indent=4), media_type="application/json")
-
-  else:
-    res = {
-      "type": "danger",
-      "msg": "invalid container id or missing action"
-    }
-    return Response(content=json.dumps(res, indent=4), media_type="application/json")
-
-@app.post("/container/{container_id}/stats/update")
-async def post_container_update_stats(container_id : str):
-  global containerIds_to_update
-
-  # start update task for container if no task is running
-  if container_id not in containerIds_to_update:
-    asyncio.create_task(get_container_stats(container_id))
-    containerIds_to_update.append(container_id)
-
-  while True:
-    if redis_client.exists(container_id + '_stats'):
-      break
-    await asyncio.sleep(1.5)
-
-  stats = json.loads(redis_client.get(container_id + '_stats'))
-  return Response(content=json.dumps(stats, indent=4), media_type="application/json")
-
-
-
-
-class DockerUtils:
-  def __init__(self, docker_client):
-    self.docker_client = docker_client
-
-  # api call: container_post - post_action: stop
-  def container_post__stop(self, container_id, request_json):
-    for container in self.docker_client.containers.list(all=True, filters={"id": container_id}):
-      container.stop()
-
-    res = { 'type': 'success', 'msg': 'command completed successfully'}
-    return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: start
-  def container_post__start(self, container_id, request_json):
-    for container in self.docker_client.containers.list(all=True, filters={"id": container_id}):
-      container.start()
-
-    res = { 'type': 'success', 'msg': 'command completed successfully'}
-    return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: restart
-  def container_post__restart(self, container_id, request_json):
-    for container in self.docker_client.containers.list(all=True, filters={"id": container_id}):
-      container.restart()
-
-    res = { 'type': 'success', 'msg': 'command completed successfully'}
-    return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: top
-  def container_post__top(self, container_id, request_json):
-    for container in self.docker_client.containers.list(all=True, filters={"id": container_id}):
-      res = { 'type': 'success', 'msg': container.top()}
-      return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: stats
-  def container_post__stats(self, container_id, request_json):
-    for container in self.docker_client.containers.list(all=True, filters={"id": container_id}):
-      for stat in container.stats(decode=True, stream=True):
-        res = { 'type': 'success', 'msg': stat}
-        return Response(content=json.dumps(res, indent=4), media_type="application/json")
-
-  # api call: container_post - post_action: exec - cmd: mailq - task: delete
-  def container_post__exec__mailq__delete(self, container_id, request_json):
-    if 'items' in request_json:
-      r = re.compile("^[0-9a-fA-F]+$")
-      filtered_qids = filter(r.match, request_json['items'])
-      if filtered_qids:
-        flagged_qids = ['-d %s' % i for i in filtered_qids]
-        sanitized_string = str(' '.join(flagged_qids));
-        for container in self.docker_client.containers.list(filters={"id": container_id}):
-          postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
-          return exec_run_handler('generic', postsuper_r)
-
-
-  # api call: container_post - post_action: exec - cmd: mailq - task: hold
-  def container_post__exec__mailq__hold(self, container_id, request_json):
-    if 'items' in request_json:
-      r = re.compile("^[0-9a-fA-F]+$")
-      filtered_qids = filter(r.match, request_json['items'])
-      if filtered_qids:
-        flagged_qids = ['-h %s' % i for i in filtered_qids]
-        sanitized_string = str(' '.join(flagged_qids));
-        for container in self.docker_client.containers.list(filters={"id": container_id}):
-          postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
-          return exec_run_handler('generic', postsuper_r)
-
-  # api call: container_post - post_action: exec - cmd: mailq - task: cat
-  def container_post__exec__mailq__cat(self, container_id, request_json):
-    if 'items' in request_json:
-      r = re.compile("^[0-9a-fA-F]+$")
-      filtered_qids = filter(r.match, request_json['items'])
-      if filtered_qids:
-        sanitized_string = str(' '.join(filtered_qids));
-
-        for container in self.docker_client.containers.list(filters={"id": container_id}):
-          postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
-        if not postcat_return:
-          postcat_return = 'err: invalid'
-        return exec_run_handler('utf8_text_only', postcat_return)
-
-   # api call: container_post - post_action: exec - cmd: mailq - task: unhold
-  def container_post__exec__mailq__unhold(self, container_id, request_json):
-    if 'items' in request_json:
-      r = re.compile("^[0-9a-fA-F]+$")
-      filtered_qids = filter(r.match, request_json['items'])
-      if filtered_qids:
-        flagged_qids = ['-H %s' % i for i in filtered_qids]
-        sanitized_string = str(' '.join(flagged_qids));
-        for container in self.docker_client.containers.list(filters={"id": container_id}):
-          postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
-          return exec_run_handler('generic', postsuper_r)
-
-  # api call: container_post - post_action: exec - cmd: mailq - task: deliver
-  def container_post__exec__mailq__deliver(self, container_id, request_json):
-    if 'items' in request_json:
-      r = re.compile("^[0-9a-fA-F]+$")
-      filtered_qids = filter(r.match, request_json['items'])
-      if filtered_qids:
-        flagged_qids = ['-i %s' % i for i in filtered_qids]
-        for container in self.docker_client.containers.list(filters={"id": container_id}):
-          for i in flagged_qids:
-            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")
-          
-  # api call: container_post - post_action: exec - cmd: mailq - task: list
-  def container_post__exec__mailq__list(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
-      return exec_run_handler('utf8_text_only', mailq_return)
-  # api call: container_post - post_action: exec - cmd: mailq - task: flush
-  def container_post__exec__mailq__flush(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
-      return exec_run_handler('generic', postqueue_r)
-  # api call: container_post - post_action: exec - cmd: mailq - task: super_delete
-  def container_post__exec__mailq__super_delete(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
-      return exec_run_handler('generic', postsuper_r)
-  # api call: container_post - post_action: exec - cmd: system - task: fts_rescan
-  def container_post__exec__system__fts_rescan(self, container_id, request_json):
-    if 'username' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
-        if rescan_return.exit_code == 0:
-          res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-        else:
-          res = { 'type': 'warning', 'msg': 'fts_rescan error'}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-    if 'all' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
-        if rescan_return.exit_code == 0:
-          res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-        else:
-          res = { 'type': 'warning', 'msg': 'fts_rescan error'}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: exec - cmd: system - task: df
-  def container_post__exec__system__df(self, container_id, request_json):
-    if 'dir' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
-        if df_return.exit_code == 0:
-          return df_return.output.decode('utf-8').rstrip()
-        else:
-          return "0,0,0,0,0,0"
-  # api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
-  def container_post__exec__system__mysql_upgrade(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
-      if sql_return.exit_code == 0:
-        matched = False
-        for line in sql_return.output.decode('utf-8').split("\n"):
-          if 'is already upgraded to' in line:
-            matched = True
-        if matched:
-          res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-        else:
-          container.restart()
-          res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-      else:
-        res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
-        return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
-  def container_post__exec__system__mysql_tzinfo_to_sql(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
-      if sql_return.exit_code == 0:
-        res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
-        return Response(content=json.dumps(res, indent=4), media_type="application/json")
-      else:
-        res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
-        return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  # api call: container_post - post_action: exec - cmd: reload - task: dovecot
-  def container_post__exec__reload__dovecot(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
-      return exec_run_handler('generic', reload_return)
-  # api call: container_post - post_action: exec - cmd: reload - task: postfix
-  def container_post__exec__reload__postfix(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
-      return exec_run_handler('generic', reload_return)
-  # api call: container_post - post_action: exec - cmd: reload - task: nginx
-  def container_post__exec__reload__nginx(self, container_id, request_json):
-    for container in self.docker_client.containers.list(filters={"id": container_id}):
-      reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
-      return exec_run_handler('generic', reload_return)
-  # api call: container_post - post_action: exec - cmd: sieve - task: list
-  def container_post__exec__sieve__list(self, container_id, request_json):
-    if 'username' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
-        return exec_run_handler('utf8_text_only', sieve_return)
-  # api call: container_post - post_action: exec - cmd: sieve - task: print
-  def container_post__exec__sieve__print(self, container_id, request_json):
-    if 'username' in request_json and 'script_name' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        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 exec_run_handler('utf8_text_only', sieve_return)
-  # api call: container_post - post_action: exec - cmd: maildir - task: cleanup
-  def container_post__exec__maildir__cleanup(self, container_id, request_json):
-    if 'maildir' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        sane_name = re.sub(r'\W+', '', request_json['maildir'])
-        vmail_name = request_json['maildir'].replace("'", "'\\''")
-        cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
-        index_name = request_json['maildir'].split("/")
-        if len(index_name) > 1:
-          index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
-          cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
-          cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
-        else:
-          cmd = ["/bin/bash", "-c", cmd_vmail]
-        maildir_cleanup = container.exec_run(cmd, user='vmail')
-        return exec_run_handler('generic', maildir_cleanup)
-  # api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
-  def container_post__exec__rspamd__worker_password(self, container_id, request_json):
-    if 'raw' in request_json:
-      for container in self.docker_client.containers.list(filters={"id": container_id}):
-        cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
-        cmd_response = exec_cmd_container(container, cmd, user="_rspamd")
-
-        matched = False
-        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())
-            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 = exec_cmd_container(container, cmd, user="_rspamd")
-            if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
-              container.restart()
-              matched = True
-        if matched:
-          res = { 'type': 'success', 'msg': 'command completed successfully' }
-          logger.info('success changing Rspamd password')
-          return Response(content=json.dumps(res, indent=4), media_type="application/json")
-        else:
-          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")
-
-
-def exec_cmd_container(container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
-
-  def recv_socket_data(c_socket, timeout):
-    c_socket.setblocking(0)
-    total_data=[]
-    data=''
-    begin=time.time()
-    while True:
-      if total_data and time.time()-begin > timeout:
-        break
-      elif time.time()-begin > timeout*2:
-        break
-      try:
-        data = c_socket.recv(8192)
-        if data:
-          total_data.append(data.decode('utf-8'))
-          #change the beginning time for measurement
-          begin=time.time()
-        else:
-          #sleep for sometime to indicate a gap
-          time.sleep(0.1)
-          break
-      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"):
-      cmd = cmd + "\n"
-    socket.send(cmd.encode('utf-8'))
-    data = recv_socket_data(socket, timeout)
-    socket.close()
-    return data
-  except Exception as e:
-    logger.error("error - exec_cmd_container: %s" % str(e))
-    traceback.print_exc(file=sys.stdout)
-def exec_run_handler(type, output):
-  if type == 'generic':
-    if output.exit_code == 0:
-      res = { 'type': 'success', 'msg': 'command completed successfully' }
-      return Response(content=json.dumps(res, indent=4), media_type="application/json")
-    else:
-      res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
-      return Response(content=json.dumps(res, indent=4), media_type="application/json")
-  if type == 'utf8_text_only':
-    return Response(content=output.output.decode('utf-8'), media_type="text/plain")
-
-async def get_host_stats(wait=5):
-  global host_stats_isUpdating
-
-  try:
-    system_time = datetime.now()
-    host_stats = {
-      "cpu": {
-        "cores": psutil.cpu_count(),
-        "usage": psutil.cpu_percent()
-      },
-      "memory": {
-        "total": psutil.virtual_memory().total,
-        "usage": psutil.virtual_memory().percent,
-        "swap": psutil.swap_memory()
-      },
-      "uptime": time.time() - psutil.boot_time(),
-      "system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
-      "architecture": platform.machine()
-    }
-
-    redis_client.set('host_stats', json.dumps(host_stats), ex=10)
-  except Exception as e:
-    res = {
-      "type": "danger",
-      "msg": str(e)
-    }
-
-  await asyncio.sleep(wait)
-  host_stats_isUpdating = False
-  
-async def get_container_stats(container_id, wait=5, stop=False):
-  global containerIds_to_update
-
-  if container_id and container_id.isalnum():
-    try:
-      for container in (await async_docker_client.containers.list()):
-        if container._id == container_id:
-          res = await container.stats(stream=False)
-
-          if redis_client.exists(container_id + '_stats'):
-            stats = json.loads(redis_client.get(container_id + '_stats'))
-          else:
-            stats = []
-          stats.append(res[0])
-          if len(stats) > 3:
-            del stats[0]
-          redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
-    except Exception as e:
-      res = {
-        "type": "danger",
-        "msg": str(e)
-      }
-  else:
-    res = {
-      "type": "danger",
-      "msg": "no or invalid id defined"
-    }
-
-  await asyncio.sleep(wait)
-  if stop == True:
-    # update task was called second time, stop
-    containerIds_to_update.remove(container_id)
-  else:
-    # call update task a second time
-    await get_container_stats(container_id, wait=0, stop=True)
-
-
-
-if os.environ['REDIS_SLAVEOF_IP'] != "":
-  redis_client = redis.Redis(host=os.environ['REDIS_SLAVEOF_IP'], port=os.environ['REDIS_SLAVEOF_PORT'], db=0)
-else:
-  redis_client = redis.Redis(host='redis-mailcow', port=6379, db=0)
-
-sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
-async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
-
-logger.info('DockerApi started')

+ 260 - 0
data/Dockerfiles/dockerapi/main.py

@@ -0,0 +1,260 @@
+import os
+import sys
+import uvicorn
+import json
+import uuid
+import async_timeout
+import asyncio
+import aioredis
+import aiodocker
+import docker
+import logging
+from logging.config import dictConfig
+from fastapi import FastAPI, Response, Request
+from modules.DockerApi import DockerApi
+
+dockerapi = None
+app = FastAPI()
+
+# Define Routes
+@app.get("/host/stats")
+async def get_host_update_stats():
+  global dockerapi
+
+  if dockerapi.host_stats_isUpdating == False:
+    asyncio.create_task(dockerapi.get_host_stats())
+    dockerapi.host_stats_isUpdating = True
+
+  while True:
+    if await dockerapi.redis_client.exists('host_stats'):
+      break
+    await asyncio.sleep(1.5)
+
+  stats = json.loads(await dockerapi.redis_client.get('host_stats'))
+  return Response(content=json.dumps(stats, indent=4), media_type="application/json")
+
+@app.get("/containers/{container_id}/json")
+async def get_container(container_id : str):
+  global dockerapi
+
+  if container_id and container_id.isalnum():
+    try:
+      for container in (await dockerapi.async_docker_client.containers.list()):
+        if container._id == container_id:
+          container_info = await container.show()
+          return Response(content=json.dumps(container_info, indent=4), media_type="application/json")
+     
+      res = {
+        "type": "danger",
+        "msg": "no container found"
+      }
+      return Response(content=json.dumps(res, indent=4), media_type="application/json")
+    except Exception as e:
+      res = {
+        "type": "danger",
+        "msg": str(e)
+      }
+      return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  else:
+    res = {
+      "type": "danger",
+      "msg": "no or invalid id defined"
+    }
+    return Response(content=json.dumps(res, indent=4), media_type="application/json")
+
+@app.get("/containers/json")
+async def get_containers():
+  global dockerapi
+
+  containers = {}
+  try:
+    for container in (await dockerapi.async_docker_client.containers.list()):
+      container_info = await container.show()
+      containers.update({container_info['Id']: container_info})
+    return Response(content=json.dumps(containers, indent=4), media_type="application/json")
+  except Exception as e:
+    res = {
+      "type": "danger",
+      "msg": str(e)
+    }
+    return Response(content=json.dumps(res, indent=4), media_type="application/json")
+
+@app.post("/containers/{container_id}/{post_action}")
+async def post_containers(container_id : str, post_action : str, request: Request):
+  global dockerapi
+
+  try : 
+    request_json = await request.json()
+  except Exception as err:
+    request_json = {}
+
+  if container_id and container_id.isalnum() and post_action:
+    try:
+      """Dispatch container_post api call"""
+      if post_action == 'exec':
+        if not request_json or not 'cmd' in request_json:
+          res = {
+            "type": "danger",
+            "msg": "cmd is missing"
+          }
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+        if not request_json or not 'task' in request_json:
+          res = {
+            "type": "danger",
+            "msg": "task is missing"
+          }
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+
+        api_call_method_name = '__'.join(['container_post', str(post_action), str(request_json['cmd']), str(request_json['task']) ])
+      else:
+        api_call_method_name = '__'.join(['container_post', str(post_action) ])
+
+      api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
+
+      dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
+      return api_call_method(request_json, container_id=container_id)
+    except Exception as e:
+      dockerapi.logger.error("error - container_post: %s" % str(e))
+      res = {
+        "type": "danger",
+        "msg": str(e)
+      }
+      return Response(content=json.dumps(res, indent=4), media_type="application/json")
+
+  else:
+    res = {
+      "type": "danger",
+      "msg": "invalid container id or missing action"
+    }
+    return Response(content=json.dumps(res, indent=4), media_type="application/json")
+
+@app.post("/container/{container_id}/stats/update")
+async def post_container_update_stats(container_id : str):
+  global dockerapi
+
+  # start update task for container if no task is running
+  if container_id not in dockerapi.containerIds_to_update:
+    asyncio.create_task(dockerapi.get_container_stats(container_id))
+    dockerapi.containerIds_to_update.append(container_id)
+
+  while True:
+    if await dockerapi.redis_client.exists(container_id + '_stats'):
+      break
+    await asyncio.sleep(1.5)
+
+  stats = json.loads(await dockerapi.redis_client.get(container_id + '_stats'))
+  return Response(content=json.dumps(stats, indent=4), media_type="application/json")
+
+# Events
+@app.on_event("startup")
+async def startup_event():
+  global dockerapi
+
+  # Initialize a custom logger
+  logger = logging.getLogger("dockerapi")
+  logger.setLevel(logging.INFO)
+  # Configure the logger to output logs to the terminal
+  handler = logging.StreamHandler()
+  handler.setLevel(logging.INFO)
+  formatter = logging.Formatter("%(levelname)s:     %(message)s")
+  handler.setFormatter(formatter)
+  logger.addHandler(handler)
+
+  logger.info("Init APP")
+
+  # Init redis client
+  if os.environ['REDIS_SLAVEOF_IP'] != "":
+    redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0")
+  else:
+    redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0")
+
+  # Init docker clients
+  sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
+  async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
+
+  dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
+
+  logger.info("Subscribe to redis channel")
+  # Subscribe to redis channel
+  dockerapi.pubsub = redis.pubsub()
+  await dockerapi.pubsub.subscribe("MC_CHANNEL")
+  asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
+
+@app.on_event("shutdown")
+async def shutdown_event():
+  global dockerapi
+
+  # Close docker connections
+  dockerapi.sync_docker_client.close()
+  await dockerapi.async_docker_client.close()
+
+  # Close redis
+  await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
+  await dockerapi.redis_client.close()
+
+# PubSub Handler
+async def handle_pubsub_messages(channel: aioredis.client.PubSub):
+  global dockerapi
+
+  while True:
+    try:
+      async with async_timeout.timeout(1):
+        message = await channel.get_message(ignore_subscribe_messages=True)
+        if message is not None:
+          # Parse message
+          data_json = json.loads(message['data'].decode('utf-8'))
+          dockerapi.logger.info(f"PubSub Received - {json.dumps(data_json)}")
+
+          # Handle api_call
+          if 'api_call' in data_json:
+            # api_call: container_post
+            if data_json['api_call'] == "container_post":
+              if 'post_action' in data_json and 'container_name' in data_json:
+                try:
+                  """Dispatch container_post api call"""
+                  request_json = {}
+                  if data_json['post_action'] == 'exec':
+                    if 'request' in data_json:
+                      request_json = data_json['request']
+                      if 'cmd' in request_json:
+                        if 'task' in request_json:
+                          api_call_method_name = '__'.join(['container_post', str(data_json['post_action']), str(request_json['cmd']), str(request_json['task']) ])
+                        else:
+                          dockerapi.logger.error("api call: task missing")
+                      else:
+                        dockerapi.logger.error("api call: cmd missing")
+                    else:
+                      dockerapi.logger.error("api call: request missing")
+                  else:
+                    api_call_method_name = '__'.join(['container_post', str(data_json['post_action'])])
+
+                  if api_call_method_name:
+                    api_call_method = getattr(dockerapi, api_call_method_name)
+                    if api_call_method:
+                      dockerapi.logger.info("api call: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
+                      api_call_method(request_json, container_name=data_json['container_name'])
+                    else:
+                      dockerapi.logger.error("api call not found: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
+                except Exception as e:
+                  dockerapi.logger.error("container_post: %s" % str(e))
+              else:
+                dockerapi.logger.error("api call: missing container_name, post_action or request")
+            else:
+              dockerapi.logger.error("Unknwon PubSub recieved - %s" % json.dumps(data_json))
+          else:
+            dockerapi.logger.error("Unknwon PubSub recieved - %s" % json.dumps(data_json))
+              
+        await asyncio.sleep(0.01)
+    except asyncio.TimeoutError:
+      pass
+
+if __name__ == '__main__':
+  uvicorn.run(
+    app,
+    host="0.0.0.0",
+    port=443,
+    ssl_certfile="/app/dockerapi_cert.pem",
+    ssl_keyfile="/app/dockerapi_key.pem",
+    log_level="info",
+    loop="none"
+  )

+ 487 - 0
data/Dockerfiles/dockerapi/modules/DockerApi.py

@@ -0,0 +1,487 @@
+import psutil
+import sys
+import os
+import re
+import time
+import json
+import asyncio
+import platform
+from datetime import datetime
+from fastapi import FastAPI, Response, Request
+
+class DockerApi:
+  def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
+    self.redis_client = redis_client
+    self.sync_docker_client = sync_docker_client
+    self.async_docker_client = async_docker_client
+    self.logger = logger
+
+    self.host_stats_isUpdating = False
+    self.containerIds_to_update = []
+
+  # api call: container_post - post_action: stop
+  def container_post__stop(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(all=True, filters=filters):
+      container.stop()
+
+    res = { 'type': 'success', 'msg': 'command completed successfully'}
+    return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: start
+  def container_post__start(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(all=True, filters=filters):
+      container.start()
+
+    res = { 'type': 'success', 'msg': 'command completed successfully'}
+    return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: restart
+  def container_post__restart(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(all=True, filters=filters):
+      container.restart()
+
+    res = { 'type': 'success', 'msg': 'command completed successfully'}
+    return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: top
+  def container_post__top(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(all=True, filters=filters):
+      res = { 'type': 'success', 'msg': container.top()}
+      return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: stats
+  def container_post__stats(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(all=True, filters=filters):
+      for stat in container.stats(decode=True, stream=True):
+        res = { 'type': 'success', 'msg': stat}
+        return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: exec - cmd: mailq - task: delete
+  def container_post__exec__mailq__delete(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 'items' in request_json:
+      r = re.compile("^[0-9a-fA-F]+$")
+      filtered_qids = filter(r.match, request_json['items'])
+      if filtered_qids:
+        flagged_qids = ['-d %s' % i for i in filtered_qids]
+        sanitized_string = str(' '.join(flagged_qids))
+        for container in self.sync_docker_client.containers.list(filters=filters):
+          postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
+          return self.exec_run_handler('generic', postsuper_r)
+  # api call: container_post - post_action: exec - cmd: mailq - task: hold
+  def container_post__exec__mailq__hold(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 'items' in request_json:
+      r = re.compile("^[0-9a-fA-F]+$")
+      filtered_qids = filter(r.match, request_json['items'])
+      if filtered_qids:
+        flagged_qids = ['-h %s' % i for i in filtered_qids]
+        sanitized_string = str(' '.join(flagged_qids))
+        for container in self.sync_docker_client.containers.list(filters=filters):
+          postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
+          return self.exec_run_handler('generic', postsuper_r)
+  # api call: container_post - post_action: exec - cmd: mailq - task: cat
+  def container_post__exec__mailq__cat(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 'items' in request_json:
+      r = re.compile("^[0-9a-fA-F]+$")
+      filtered_qids = filter(r.match, request_json['items'])
+      if filtered_qids:
+        sanitized_string = str(' '.join(filtered_qids))
+
+        for container in self.sync_docker_client.containers.list(filters=filters):
+          postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
+        if not postcat_return:
+          postcat_return = 'err: invalid'
+        return self.exec_run_handler('utf8_text_only', postcat_return)
+  # api call: container_post - post_action: exec - cmd: mailq - task: unhold
+  def container_post__exec__mailq__unhold(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 'items' in request_json:
+      r = re.compile("^[0-9a-fA-F]+$")
+      filtered_qids = filter(r.match, request_json['items'])
+      if filtered_qids:
+        flagged_qids = ['-H %s' % i for i in filtered_qids]
+        sanitized_string = str(' '.join(flagged_qids))
+        for container in self.sync_docker_client.containers.list(filters=filters):
+          postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
+          return self.exec_run_handler('generic', postsuper_r)
+  # api call: container_post - post_action: exec - cmd: mailq - task: deliver
+  def container_post__exec__mailq__deliver(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 'items' in request_json:
+      r = re.compile("^[0-9a-fA-F]+$")
+      filtered_qids = filter(r.match, request_json['items'])
+      if filtered_qids:
+        flagged_qids = ['-i %s' % i for i in filtered_qids]
+        for container in self.sync_docker_client.containers.list(filters=filters):
+          for i in flagged_qids:
+            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")        
+  # 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:
+      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):
+      mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
+      return self.exec_run_handler('utf8_text_only', mailq_return)
+  # api call: container_post - post_action: exec - cmd: mailq - task: flush
+  def container_post__exec__mailq__flush(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):
+      postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
+      return self.exec_run_handler('generic', postqueue_r)
+  # api call: container_post - post_action: exec - cmd: mailq - task: super_delete
+  def container_post__exec__mailq__super_delete(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):
+      postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
+      return self.exec_run_handler('generic', postsuper_r)
+  # api call: container_post - post_action: exec - cmd: system - task: fts_rescan
+  def container_post__exec__system__fts_rescan(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 'username' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
+        if rescan_return.exit_code == 0:
+          res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+        else:
+          res = { 'type': 'warning', 'msg': 'fts_rescan error'}
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+    if 'all' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
+        if rescan_return.exit_code == 0:
+          res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+        else:
+          res = { 'type': 'warning', 'msg': 'fts_rescan error'}
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: exec - cmd: system - task: df
+  def container_post__exec__system__df(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 'dir' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
+        if df_return.exit_code == 0:
+          return df_return.output.decode('utf-8').rstrip()
+        else:
+          return "0,0,0,0,0,0"
+  # api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
+  def container_post__exec__system__mysql_upgrade(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):
+      sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
+      if sql_return.exit_code == 0:
+        matched = False
+        for line in sql_return.output.decode('utf-8').split("\n"):
+          if 'is already upgraded to' in line:
+            matched = True
+        if matched:
+          res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+        else:
+          container.restart()
+          res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+      else:
+        res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
+        return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
+  def container_post__exec__system__mysql_tzinfo_to_sql(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):
+      sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
+      if sql_return.exit_code == 0:
+        res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
+        return Response(content=json.dumps(res, indent=4), media_type="application/json")
+      else:
+        res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
+        return Response(content=json.dumps(res, indent=4), media_type="application/json")
+  # api call: container_post - post_action: exec - cmd: reload - task: dovecot
+  def container_post__exec__reload__dovecot(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):
+      reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
+      return self.exec_run_handler('generic', reload_return)
+  # api call: container_post - post_action: exec - cmd: reload - task: postfix
+  def container_post__exec__reload__postfix(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):
+      reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
+      return self.exec_run_handler('generic', reload_return)
+  # api call: container_post - post_action: exec - cmd: reload - task: nginx
+  def container_post__exec__reload__nginx(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):
+      reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
+      return self.exec_run_handler('generic', reload_return)
+  # api call: container_post - post_action: exec - cmd: sieve - task: list
+  def container_post__exec__sieve__list(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 'username' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
+        return self.exec_run_handler('utf8_text_only', sieve_return)
+  # api call: container_post - post_action: exec - cmd: sieve - task: print
+  def container_post__exec__sieve__print(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 '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("'", "'\\''") + "'"]  
+        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
+  def container_post__exec__maildir__cleanup(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 'maildir' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        sane_name = re.sub(r'\W+', '', request_json['maildir'])
+        vmail_name = request_json['maildir'].replace("'", "'\\''")
+        cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
+        index_name = request_json['maildir'].split("/")
+        if len(index_name) > 1:
+          index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
+          cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
+          cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
+        else:
+          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: rspamd - task: worker_password
+  def container_post__exec__rspamd__worker_password(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 'raw' in request_json:
+      for container in self.sync_docker_client.containers.list(filters=filters):
+        cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
+        cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
+
+        matched = False
+        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())
+            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")
+            if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
+              container.restart()
+              matched = True
+        if matched:
+          res = { 'type': 'success', 'msg': 'command completed successfully' }
+          self.logger.info('success changing Rspamd password')
+          return Response(content=json.dumps(res, indent=4), media_type="application/json")
+        else:
+          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")
+
+  # Collect host stats
+  async def get_host_stats(self, wait=5):
+    try:
+      system_time = datetime.now()
+      host_stats = {
+        "cpu": {
+          "cores": psutil.cpu_count(),
+          "usage": psutil.cpu_percent()
+        },
+        "memory": {
+          "total": psutil.virtual_memory().total,
+          "usage": psutil.virtual_memory().percent,
+          "swap": psutil.swap_memory()
+        },
+        "uptime": time.time() - psutil.boot_time(),
+        "system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
+        "architecture": platform.machine()
+      }
+
+      await self.redis_client.set('host_stats', json.dumps(host_stats), ex=10)
+    except Exception as e:
+      res = {
+        "type": "danger",
+        "msg": str(e)
+      }
+
+    await asyncio.sleep(wait)
+    self.host_stats_isUpdating = False
+  # Collect container stats
+  async def get_container_stats(self, container_id, wait=5, stop=False):
+    if container_id and container_id.isalnum():
+      try:
+        for container in (await self.async_docker_client.containers.list()):
+          if container._id == container_id:
+            res = await container.stats(stream=False)
+
+            if await self.redis_client.exists(container_id + '_stats'):
+              stats = json.loads(await self.redis_client.get(container_id + '_stats'))
+            else:
+              stats = []
+            stats.append(res[0])
+            if len(stats) > 3:
+              del stats[0]
+            await self.redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
+      except Exception as e:
+        res = {
+          "type": "danger",
+          "msg": str(e)
+        }
+    else:
+      res = {
+        "type": "danger",
+        "msg": "no or invalid id defined"
+      }
+
+    await asyncio.sleep(wait)
+    if stop == True:
+      # update task was called second time, stop
+      self.containerIds_to_update.remove(container_id)
+    else:
+      # call update task a second time
+      await self.get_container_stats(container_id, wait=0, stop=True)
+
+  def exec_cmd_container(self, container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
+    def recv_socket_data(c_socket, timeout):
+      c_socket.setblocking(0)
+      total_data=[]
+      data=''
+      begin=time.time()
+      while True:
+        if total_data and time.time()-begin > timeout:
+          break
+        elif time.time()-begin > timeout*2:
+          break
+        try:
+          data = c_socket.recv(8192)
+          if data:
+            total_data.append(data.decode('utf-8'))
+            #change the beginning time for measurement
+            begin=time.time()
+          else:
+            #sleep for sometime to indicate a gap
+            time.sleep(0.1)
+            break
+        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"):
+        cmd = cmd + "\n"
+      socket.send(cmd.encode('utf-8'))
+      data = recv_socket_data(socket, timeout)
+      socket.close()
+      return data
+    except Exception as e:
+      self.logger.error("error - exec_cmd_container: %s" % str(e))
+      traceback.print_exc(file=sys.stdout)
+
+  def exec_run_handler(self, type, output):
+    if type == 'generic':
+      if output.exit_code == 0:
+        res = { 'type': 'success', 'msg': 'command completed successfully' }
+        return Response(content=json.dumps(res, indent=4), media_type="application/json")
+      else:
+        res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
+        return Response(content=json.dumps(res, indent=4), media_type="application/json")
+    if type == 'utf8_text_only':
+      return Response(content=output.output.decode('utf-8'), media_type="text/plain")

+ 0 - 0
data/Dockerfiles/dockerapi/modules/__init__.py


+ 1 - 1
data/conf/nginx/includes/site-defaults.conf

@@ -114,7 +114,7 @@
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_redirect off;
-      error_page 403 /_rspamderror.php;
+      error_page 401 /_rspamderror.php;
     }
     proxy_pass       http://rspamd:11334/;
     proxy_set_header Host      $http_host;

+ 36 - 0
data/conf/rspamd/local.d/composites.conf

@@ -68,3 +68,39 @@ WL_FWD_HOST {
 ENCRYPTED_CHAT {
   expression = "CHAT_VERSION_HEADER & ENCRYPTED_PGP";
 }
+
+CLAMD_SPAM_FOUND {
+  expression = "CLAM_SECI_SPAM & !MAILCOW_WHITE";
+  description = "Probably Spam, Securite Spam Flag set through ClamAV";
+  score = 5;
+}
+
+CLAMD_BAD_PDF {
+  expression = "CLAM_SECI_PDF & !MAILCOW_WHITE";
+  description = "Bad PDF Found, Securite bad PDF Flag set through ClamAV";
+  score = 8;
+}
+
+CLAMD_BAD_JPG {
+  expression = "CLAM_SECI_JPG & !MAILCOW_WHITE";
+  description = "Bad JPG Found, Securite bad JPG Flag set through ClamAV";
+  score = 8;
+}
+
+CLAMD_ASCII_MALWARE {
+  expression = "CLAM_SECI_ASCII & !MAILCOW_WHITE";
+  description = "ASCII malware found, Securite ASCII malware Flag set through ClamAV";
+  score = 8;
+}
+
+CLAMD_HTML_MALWARE {
+  expression = "CLAM_SECI_HTML & !MAILCOW_WHITE";
+  description = "HTML malware found, Securite HTML malware Flag set through ClamAV";
+  score = 8;
+}
+
+CLAMD_JS_MALWARE {
+  expression = "CLAM_SECI_JS & !MAILCOW_WHITE";
+  description = "JS malware found, Securite JS malware Flag set through ClamAV";
+  score = 8;
+}

+ 6 - 2
data/conf/rspamd/lua/rspamd.local.lua

@@ -340,6 +340,10 @@ rspamd_config:register_symbol({
       if not bcc_dest then
         return -- stop
       end
+      -- dot stuff content before sending
+      local email_content = tostring(task:get_content())
+      email_content = string.gsub(email_content, "\r\n%.", "\r\n..")
+      -- send mail
       lua_smtp.sendmail({
         task = task,
         host = os.getenv("IPV4_NETWORK") .. '.253',
@@ -347,8 +351,8 @@ rspamd_config:register_symbol({
         from = task:get_from(stp)[1].addr,
         recipients = bcc_dest,
         helo = 'bcc',
-        timeout = 10,
-      }, task:get_content(), sendmail_cb)
+        timeout = 20,
+      }, email_content, sendmail_cb)
     end
 
     -- determine from

+ 6 - 0
data/web/admin.php

@@ -80,6 +80,11 @@ foreach ($RSPAMD_MAPS['regex'] as $rspamd_regex_desc => $rspamd_regex_map) {
   ];
 }
 
+// cors settings
+$cors_settings = cors('get');
+$cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allowed_origins']);
+$cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']);
+
 $template = 'admin.twig';
 $template_data = [
   'tfa_data' => $tfa_data,
@@ -106,6 +111,7 @@ $template_data = [
   'ip_check' => customize('get', 'ip_check'),
   'password_complexity' => password_complexity('get'),
   'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
+  'cors_settings' => $cors_settings,
   'lang_admin' => json_encode($lang['admin']),
   'lang_datatables' => json_encode($lang['datatables'])
 ];

+ 47 - 1
data/web/api/openapi.yaml

@@ -1,4 +1,4 @@
-openapi: 3.0.0
+openapi: 3.1.0
 info:
   description: >-
     mailcow is complete e-mailing solution with advanced antispam, antivirus,
@@ -5602,6 +5602,50 @@ paths:
       description: You can list all mailboxes existing in system for a specific domain.
       operationId: Get mailboxes of a domain
       summary: Get mailboxes of a domain
+  /api/v1/edit/cors:
+    post:
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                    - type: "success"
+                      log: ["cors", "edit", {"allowed_origins": ["*", "mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}]
+                      msg: "cors_headers_edited"
+          description: OK
+          headers: { }
+      tags:
+        - Cross-Origin Resource Sharing (CORS)
+      description: >-
+        This endpoint allows you to manage Cross-Origin Resource Sharing (CORS) settings for the API. 
+        CORS is a security feature implemented by web browsers to prevent unauthorized cross-origin requests. 
+        By editing the CORS settings, you can specify which domains and which methods are permitted to access the API resources from outside the mailcow domain.
+      operationId: Edit Cross-Origin Resource Sharing (CORS) settings
+      requestBody:
+        content:
+          application/json:
+            schema:
+              example:
+                attr:
+                  allowed_origins: ["*", "mail.mailcow.tld"]
+                  allowed_methods: ["POST", "GET", "DELETE", "PUT"]
+              properties:
+                attr:
+                  type: object
+                  properties:
+                    allowed_origins:
+                      type: array
+                      items:
+                        type: string
+                    allowed_methods:
+                      type: array
+                      items:
+                        type: string
+      summary: Edit Cross-Origin Resource Sharing (CORS) settings
 
 tags:
   - name: Domains
@@ -5646,3 +5690,5 @@ tags:
     description: Get the status of your cow
   - name: Ratelimits
     description: Edit domain ratelimits
+  - name: Cross-Origin Resource Sharing (CORS)
+    description: Manage Cross-Origin Resource Sharing (CORS) settings

+ 1 - 2
data/web/api/swagger-initializer.js

@@ -1,6 +1,6 @@
 window.onload = function() {
   // Begin Swagger UI call region
-  const ui = SwaggerUIBundle({
+  window.ui = SwaggerUIBundle({
     urls: [{url: "/api/openapi.yaml", name: "mailcow API"}],
     dom_id: '#swagger-ui',
     deepLinking: true,
@@ -15,5 +15,4 @@ window.onload = function() {
   });
   // End Swagger UI call region
 
-  window.ui = ui;
 };

文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-bundle.js


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-bundle.js.map


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-es-bundle-core.js


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-es-bundle-core.js.map


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-es-bundle.js


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-es-bundle.js.map


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-standalone-preset.js


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui-standalone-preset.js.map


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui.css


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui.css.map


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui.js


文件差异内容过多而无法显示
+ 0 - 0
data/web/api/swagger-ui.js.map


+ 11 - 0
data/web/inc/functions.docker.inc.php

@@ -192,5 +192,16 @@ function docker($action, $service_name = null, $attr1 = null, $attr2 = null, $ex
       }
       return false;
     break;
+    case 'broadcast':
+      $request = array(
+        "api_call" => "container_post",
+        "container_name" => $service_name,
+        "post_action" => $attr1,
+        "request" => $attr2
+      );
+
+      $redis->publish("MC_CHANNEL", json_encode($request));
+      return true;
+    break;
   }
 }

+ 117 - 2
data/web/inc/functions.inc.php

@@ -526,8 +526,9 @@ function logger($_data = false) {
           ':remote' => get_remote_ip()
         ));
       }
-      catch (Exception $e) {
-        // Do nothing
+      catch (PDOException $e) {
+        # handle the exception here, as the exception handler function results in a white page
+        error_log($e->getMessage(), 0);
       }
     }
   }
@@ -2131,6 +2132,120 @@ function rspamd_ui($action, $data = null) {
     break;
   }
 }
+function cors($action, $data = null) {
+  global $redis;
+
+  switch ($action) {
+    case "edit":
+      if ($_SESSION['mailcow_cc_role'] != "admin") {
+        $_SESSION['return'][] =  array(
+          'type' => 'danger',
+          'log' => array(__FUNCTION__, $action, $data),
+          'msg' => 'access_denied'
+        );
+        return false;
+      }    
+
+      $allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array($_SERVER['SERVER_NAME']);
+      $allowed_origins = !is_array($allowed_origins) ? array_filter(array_map('trim', explode("\n", $allowed_origins))) : $allowed_origins;
+      foreach ($allowed_origins as $origin) {
+        if (!filter_var($origin, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) && $origin != '*') {
+          $_SESSION['return'][] = array(
+            'type' => 'danger',
+            'log' => array(__FUNCTION__, $action, $data),
+            'msg' => 'cors_invalid_origin'
+          );
+          return false;
+        }
+      }
+
+      $allowed_methods = isset($data['allowed_methods']) ? $data['allowed_methods'] : array('GET', 'POST', 'PUT', 'DELETE');
+      $allowed_methods  = !is_array($allowed_methods) ? array_map('trim', preg_split( "/( |,|;|\n)/", $allowed_methods)) : $allowed_methods;
+      $available_methods = array('GET', 'POST', 'PUT', 'DELETE');
+      foreach ($allowed_methods as $method) {
+        if (!in_array($method, $available_methods)) {
+          $_SESSION['return'][] = array(
+            'type' => 'danger',
+            'log' => array(__FUNCTION__, $action, $data),
+            'msg' => 'cors_invalid_method'
+          );
+          return false;
+        }
+      }
+
+      try {
+        $redis->hMSet('CORS_SETTINGS', array(
+          'allowed_origins' => implode(', ', $allowed_origins),
+          'allowed_methods' => implode(', ', $allowed_methods)
+        ));   
+      } catch (RedisException $e) {
+        $_SESSION['return'][] = array(
+          'type' => 'danger',
+          'log' => array(__FUNCTION__, $action, $data),
+          'msg' => array('redis_error', $e)
+        );
+        return false;
+      }
+
+      $_SESSION['return'][] = array(
+        'type' => 'success',
+        'log' => array(__FUNCTION__, $action, $data),
+        'msg' => 'cors_headers_edited'
+      );
+      return true;
+    break;
+    case "get":
+      try {
+        $cors_settings                  = $redis->hMGet('CORS_SETTINGS', array('allowed_origins', 'allowed_methods'));
+      } catch (RedisException $e) {
+        $_SESSION['return'][] = array(
+          'type' => 'danger',
+          'log' => array(__FUNCTION__, $action, $data),
+          'msg' => array('redis_error', $e)
+        );
+      }
+
+      $cors_settings                    = !$cors_settings ? array('allowed_origins' => $_SERVER['SERVER_NAME'], 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings;
+      $cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? $_SERVER['SERVER_NAME'] : $cors_settings['allowed_origins'];
+      $cors_settings['allowed_methods'] = empty($cors_settings['allowed_methods']) ? 'GET, POST, PUT, DELETE, OPTION' : $cors_settings['allowed_methods'];
+
+      return $cors_settings;
+    break;
+    case "set_headers":
+      $cors_settings = cors('get');
+      // check if requested origin is in allowed origins
+      $allowed_origins = explode(', ', $cors_settings['allowed_origins']);
+      $cors_settings['allowed_origins'] = $allowed_origins[0];
+      if (in_array('*', $allowed_origins)){
+        $cors_settings['allowed_origins'] = '*';
+      } else if (in_array($_SERVER['HTTP_ORIGIN'], $allowed_origins)) {
+        $cors_settings['allowed_origins'] = $_SERVER['HTTP_ORIGIN'];
+      }
+      // always allow OPTIONS for preflight request
+      $cors_settings["allowed_methods"] = empty($cors_settings["allowed_methods"]) ? 'OPTIONS' : $cors_settings["allowed_methods"] . ', ' . 'OPTIONS';
+
+      header('Access-Control-Allow-Origin: ' . $cors_settings['allowed_origins']);
+      header('Access-Control-Allow-Methods: '. $cors_settings['allowed_methods']);
+      header('Access-Control-Allow-Headers: Accept, Content-Type, X-Api-Key, Origin');
+
+      // Access-Control settings requested, this is just a preflight request
+      if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS' && 
+        isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']) &&
+        isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
+  
+        $allowed_methods = explode(', ', $cors_settings["allowed_methods"]);
+        if (in_array($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'], $allowed_methods, true))
+          // method allowed send 200 OK
+          http_response_code(200);
+        else
+          // method not allowed send 405 METHOD NOT ALLOWED
+          http_response_code(405);
+
+        exit;
+      }
+    break;
+  }
+}
 
 function get_logs($application, $lines = false) {
   if ($lines === false) {

+ 13 - 7
data/web/inc/functions.mailbox.inc.php

@@ -4930,13 +4930,19 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             if (!empty($mailbox_details['domain']) && !empty($mailbox_details['local_part'])) {
               $maildir = $mailbox_details['domain'] . '/' . $mailbox_details['local_part'];
               $exec_fields = array('cmd' => 'maildir', 'task' => 'cleanup', 'maildir' => $maildir);
-              $maildir_gc = json_decode(docker('post', 'dovecot-mailcow', 'exec', $exec_fields), true);
-              if ($maildir_gc['type'] != 'success') {
-                $_SESSION['return'][] = array(
-                  'type' => 'warning',
-                  'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-                  'msg' => 'Could not move maildir to garbage collector: ' . $maildir_gc['msg']
-                );
+
+              if (getenv("CLUSTERMODE") == "replication") {
+                // broadcast to each dovecot container
+                docker('broadcast', 'dovecot-mailcow', 'exec', $exec_fields);
+              } else {
+                $maildir_gc = json_decode(docker('post', 'dovecot-mailcow', 'exec', $exec_fields), true);
+                if ($maildir_gc['type'] != 'success') {
+                  $_SESSION['return'][] = array(
+                    'type' => 'warning',
+                    'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                    'msg' => 'Could not move maildir to garbage collector: ' . $maildir_gc['msg']
+                  );
+                }
               }
             }
             else {

+ 1 - 1
data/web/js/site/debug.js

@@ -1173,7 +1173,7 @@ jQuery(function($){
 
     if (table = $('#' + log_table).DataTable()) {
       var heading = $('#' + log_table).closest('.card').find('.card-header');
-      var load_rows = (table.data().length + 1) + '-' + (table.data().length + new_nrows)
+      var load_rows = (table.data().count() + 1) + '-' + (table.data().count() + new_nrows)
 
       $.get('/api/v1/get/logs/' + log_url + '/' + load_rows).then(function(data){
         if (data.length === undefined) { mailcow_alert_box(lang.no_new_rows, "info"); return; }

+ 17 - 14
data/web/json_api.php

@@ -2,9 +2,9 @@
 /*
    see /api
 */
-
-header('Content-Type: application/json');
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+cors("set_headers");
+header('Content-Type: application/json');
 error_reporting(0);
 
 function api_log($_data) {
@@ -288,18 +288,18 @@ if (isset($_GET['query'])) {
         case "domain-admin":
           process_add_return(domain_admin('add', $attr));
         break;
-        case "sso":
-          switch ($object) {
-            case "domain-admin":
-              $data = domain_admin_sso('issue', $attr);
-              if($data) {
-                echo json_encode($data);
-                exit(0);
-              }
-              process_add_return($data);
-            break;
-          }
-        break;
+        case "sso":
+          switch ($object) {
+            case "domain-admin":
+              $data = domain_admin_sso('issue', $attr);
+              if($data) {
+                echo json_encode($data);
+                exit(0);
+              }
+              process_add_return($data);
+            break;
+          }
+        break;
         case "admin":
           process_add_return(admin('add', $attr));
         break;
@@ -1946,6 +1946,9 @@ if (isset($_GET['query'])) {
             process_edit_return(edit_user_account($attr));
           }
         break;
+        case "cors":
+          process_edit_return(cors('edit', $attr));
+        break;
         // return no route found if no case is matched
         default:
           http_response_code(404);

+ 4 - 0
data/web/lang/lang.de-de.json

@@ -147,6 +147,7 @@
         "change_logo": "Logo ändern",
         "configuration": "Konfiguration",
         "convert_html_to_text": "Konvertiere HTML zu reinem Text",
+        "cors_settings": "CORS Einstellungen",
         "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",
         "customize": "UI-Anpassung",
@@ -358,6 +359,8 @@
         "bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
         "bcc_must_be_email": "BCC-Ziel %s ist keine gültige E-Mail-Adresse",
         "comment_too_long": "Kommentarfeld darf maximal 160 Zeichen enthalten",
+        "cors_invalid_method": "Allow-Methods enthält eine ungültige Methode",
+        "cors_invalid_origin": "Allow-Origins enthält eine ungültige Origin",
         "defquota_empty": "Standard-Quota darf nicht 0 sein",
         "demo_mode_enabled": "Demo Mode ist aktiviert",
         "description_invalid": "Ressourcenbeschreibung für %s ist ungültig",
@@ -998,6 +1001,7 @@
         "bcc_deleted": "BCC-Map-Einträge gelöscht: %s",
         "bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
         "bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
+        "cors_headers_edited": "CORS Einstellungen wurden erfolgreich gespeichert",
         "db_init_complete": "Datenbankinitialisierung abgeschlossen",
         "delete_filter": "Filter-ID %s wurde gelöscht",
         "delete_filters": "Filter gelöscht: %s",

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

@@ -133,6 +133,8 @@
         "admins": "Administrators",
         "admins_ldap": "LDAP Administrators",
         "advanced_settings": "Advanced settings",
+        "allowed_methods": "Access-Control-Allow-Methods",
+        "allowed_origins": "Access-Control-Allow-Origin",
         "api_allow_from": "Allow API access from these IPs/CIDR network notations",
         "api_info": "The API is a work in progress. The documentation can be found at <a href=\"/api\">/api</a>",
         "api_key": "API key",
@@ -149,6 +151,7 @@
         "change_logo": "Change logo",
         "configuration": "Configuration",
         "convert_html_to_text": "Convert HTML to plain text",
+        "cors_settings": "CORS Settings",
         "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",
         "customize": "Customize",
@@ -358,6 +361,8 @@
         "bcc_exists": "A BCC map %s exists for type %s",
         "bcc_must_be_email": "BCC destination %s is not a valid email address",
         "comment_too_long": "Comment too long, max 160 chars allowed",
+        "cors_invalid_method": "Invalid Allow-Method specified",
+        "cors_invalid_origin": "Invalid Allow-Origin specified",
         "defquota_empty": "Default quota per mailbox must not be 0.",
         "demo_mode_enabled": "Demo Mode is enabled",
         "description_invalid": "Resource description for %s is invalid",
@@ -1005,6 +1010,7 @@
         "bcc_deleted": "BCC map entries deleted: %s",
         "bcc_edited": "BCC map entry %s edited",
         "bcc_saved": "BCC map entry saved",
+        "cors_headers_edited": "CORS settings have been saved",
         "db_init_complete": "Database initialization completed",
         "delete_filter": "Deleted filters ID %s",
         "delete_filters": "Deleted filters: %s",

+ 35 - 2
data/web/templates/admin/tab-config-admins.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade show active" id="tab-config-admins" role="tabpanel" aria-labelledby="tab-config-admins">
+<div class="tab-pane fade show active" id="tab-config-admins" role="tabpanel" aria-labelledby="tab-config-admins">
   <div class="card mb-4">
     <div class="card-header bg-danger text-white d-flex fs-5">
       <button class="btn d-md-none text-white flex-grow-1 text-start" data-bs-target="#collapse-tab-config-admins" data-bs-toggle="collapse" aria-controls="collapse-tab-config-admins">
@@ -97,6 +97,39 @@
           <div class="col-lg-12">
             <p class="text-muted">{{ lang.admin.api_info|raw }}</p>
           </div>
+          <div class="col-lg-12">
+            <div class="card mb-3">
+              <div class="card-header">
+                <h4 class="card-title"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.cors_settings }}</h4>
+              </div>
+              <div class="card-body">
+                <form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" data-id="editcors" method="post">
+                  <div class="row mb-4">
+                    <label class="control-label col-sm-2 mb-4" for="allowed_origins">{{ lang.admin.allowed_origins }}</label>
+                    <div class="col-sm-9 mb-4">
+                      <textarea class="form-control textarea-code" rows="7" name="allowed_origins" id="allowed_origins">{{ cors_settings.allowed_origins }}</textarea>
+                    </div>
+                  </div>
+                  <div class="row mb-4">
+                    <label class="control-label col-sm-2" for="allowed_methods">{{ lang.admin.allowed_methods }}</label>
+                    <div class="col-sm-9">
+                      <select name="allowed_methods" id="allowed_methods" multiple class="form-control">
+                        <option value="POST"{% if "POST" in cors_settings.allowed_methods  %} selected{% endif %}>POST</option>
+                        <option value="GET"{% if "GET" in cors_settings.allowed_methods  %} selected{% endif %}>GET</option>
+                        <option value="DELETE"{% if "DELETE" in cors_settings.allowed_methods  %} selected{% endif %}>DELETE</option>
+                        <option value="PUT"{% if "PUT" in cors_settings.allowed_methods %} selected{% endif %}>PUT</option>
+                      </select>
+                    </div>
+                  </div>
+                  <div class="row mb-4">
+                    <div class="offset-sm-2 col-sm-9">
+                      <button class="btn btn-sm visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-success" data-item="cors" data-api-url="edit/cors" data-id="editcors" data-action="edit_selected" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
+                    </div>
+                  </div>
+                </form>
+              </div>
+            </div>
+          </div>
           <div class="col-lg-6">
             <div class="card mb-3">
               <div class="card-header">
@@ -194,7 +227,7 @@
 
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-dadmins" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-dadmins">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-dadmins" data-bs-toggle="collapse" aria-controls="collapse-tab-config-dadmins">
         {{ lang.admin.domain_admins }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.domain_admins }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-customize.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-customize" role="tabpanel" aria-labelledby="tab-config-customize">
+<div class="tab-pane fade" id="tab-config-customize" role="tabpanel" aria-labelledby="tab-config-customize">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-customize" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-customize">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-customize" data-bs-toggle="collapse" aria-controls="collapse-tab-config-customize">
         {{ lang.admin.customize }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.customize }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-dkim.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-dkim" role="tabpanel" aria-labelledby="tab-config-dkim">
+<div class="tab-pane fade" id="tab-config-dkim" role="tabpanel" aria-labelledby="tab-config-dkim">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-dkim" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-dkim">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-dkim" data-bs-toggle="collapse" aria-controls="collapse-tab-config-dkim">
         {{ lang.admin.dkim_keys }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.dkim_keys }}</span>

+ 6 - 6
data/web/templates/admin/tab-config-f2b.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-f2b" role="tabpanel" aria-labelledby="tab-config-f2b">
+<div class="tab-pane fade" id="tab-config-f2b" role="tabpanel" aria-labelledby="tab-config-f2b">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-f2b" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-f2b">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-f2b" data-bs-toggle="collapse" aria-controls="collapse-tab-config-f2b">
         {{ lang.admin.f2b_parameters }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.f2b_parameters }}</span>
@@ -92,16 +92,16 @@
       {% endif %}
       {% for active_ban in f2b_data.active_bans %}
         <p>
-          <span class="badge fs-5 bg-info" style="padding:4px;font-size:85%;">
+          <span class="badge fs-5 bg-info py-0">
             <i class="bi bi-funnel-fill"></i>
             <a href="https://bgp.he.net/ip/{{ active_ban.ip }}" target="_blank" style="color:white">
               {{ active_ban.network }}
             </a>
             ({{ active_ban.banned_until }}) -
             {% if active_ban.queued_for_unban == 0 %}
-            <a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"unban"}' href="#">[{{ lang.admin.queue_unban }}]</a>
-            <a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"whitelist"}' href="#">[whitelist]</a>
-            <a data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"blacklist"}' href="#">[blacklist (<b>needs restart</b>)]</a>
+            <a class="btn btn-lg btn-link p-0 text-info" data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"unban"}' href="#">[{{ lang.admin.queue_unban }}]</a>
+            <a class="btn btn-lg btn-link p-0 text-info" data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"whitelist"}' href="#">[whitelist]</a>
+            <a class="btn btn-lg btn-link p-0 text-info" data-action="edit_selected" data-item="{{ active_ban.network }}" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"blacklist"}' href="#">[blacklist (<b>needs restart</b>)]</a>
             {% else %}
             <i>{{ lang.admin.unban_pending }}</i>
             {% endif %}

+ 2 - 2
data/web/templates/admin/tab-config-fwdhosts.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-fwdhosts" role="tabpanel" aria-labelledby="tab-config-fwdhosts">
+<div class="tab-pane fade" id="tab-config-fwdhosts" role="tabpanel" aria-labelledby="tab-config-fwdhosts">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-fwdhosts" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-fwdhosts">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-fwdhosts" data-bs-toggle="collapse" aria-controls="collapse-tab-config-fwdhosts">
         {{ lang.admin.forwarding_hosts }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.forwarding_hosts }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-oauth2.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-oauth2" role="tabpanel" aria-labelledby="tab-config-oauth2">
+<div class="tab-pane fade" id="tab-config-oauth2" role="tabpanel" aria-labelledby="tab-config-oauth2">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-oauth2" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-oauth2">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-oauth2" data-bs-toggle="collapse" aria-controls="collapse-tab-config-oauth2">
         {{ lang.admin.oauth2_apps }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.oauth2_apps }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-password-policy.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-password-policy" role="tabpanel" aria-labelledby="tab-config-password-policy">
+<div class="tab-pane fade" id="tab-config-password-policy" role="tabpanel" aria-labelledby="tab-config-password-policy">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-password-policy" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-password-policy">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-password-policy" data-bs-toggle="collapse" aria-controls="collapse-tab-config-password-policy">
         {{ lang.admin.password_policy }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.password_policy }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-quarantine.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-quarantine" role="tabpanel" aria-labelledby="tab-config-quarantine">
+<div class="tab-pane fade" id="tab-config-quarantine" role="tabpanel" aria-labelledby="tab-config-quarantine">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-quarantine" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-quarantine">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-quarantine" data-bs-toggle="collapse" aria-controls="collapse-tab-config-quarantine">
         {{ lang.admin.quarantine }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.quarantine }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-quota.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-quota" role="tabpanel" aria-labelledby="tab-config-quota">
+<div class="tab-pane fade" id="tab-config-quota" role="tabpanel" aria-labelledby="tab-config-quota">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-quota" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-quota">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-quota" data-bs-toggle="collapse" aria-controls="collapse-tab-config-quota">
         {{ lang.admin.quota_notifications }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.quota_notifications }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-rsettings.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-rsettings" role="tabpanel" aria-labelledby="tab-config-rsettings">
+<div class="tab-pane fade" id="tab-config-rsettings" role="tabpanel" aria-labelledby="tab-config-rsettings">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-rsettings" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-rsettings">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-rsettings" data-bs-toggle="collapse" aria-controls="collapse-tab-config-rsettings">
         {{ lang.admin.rspamd_settings_map }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.rspamd_settings_map }}</span>

+ 2 - 2
data/web/templates/admin/tab-config-rspamd.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-rspamd" role="tabpanel" aria-labelledby="tab-config-rspamd">
+<div class="tab-pane fade" id="tab-config-rspamd" role="tabpanel" aria-labelledby="tab-config-rspamd">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-rspamd" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-rspamd">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-rspamd" data-bs-toggle="collapse" aria-controls="collapse-tab-config-rspamd">
         Rspamd UI
       </button>
       <span class="d-none d-md-block">Rspamd UI</span>

+ 2 - 2
data/web/templates/admin/tab-globalfilter-regex.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-globalfilter-regex" role="tabpanel" aria-labelledby="tab-globalfilter-regex">
+<div class="tab-pane fade" id="tab-globalfilter-regex" role="tabpanel" aria-labelledby="tab-globalfilter-regex">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-regex" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-regex">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-regex" data-bs-toggle="collapse" aria-controls="collapse-tab-config-regex">
         {{ lang.admin.rspamd_global_filters }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.rspamd_global_filters }}</span>

+ 2 - 2
data/web/templates/admin/tab-ldap.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-config-ldap-admins" role="tabpanel" aria-labelledby="tab-config-ldap-admins">
+<div class="tab-pane fade" id="tab-config-ldap-admins" role="tabpanel" aria-labelledby="tab-config-ldap-admins">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-ldap-admins" data-bs-toggle="collapse" aria-controls="ollapse-tab-config-ldap-admins">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-ldap-admins" data-bs-toggle="collapse" aria-controls="collapse-tab-config-ldap-admins">
         {{ lang.admin.admins_ldap }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.admins_ldap }}</span>

+ 3 - 3
data/web/templates/admin/tab-routing.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-routing" role="tabpanel" aria-labelledby="tab-routing">
+<div class="tab-pane fade" id="tab-routing" role="tabpanel" aria-labelledby="tab-routing">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-routing" data-bs-toggle="collapse" aria-controls="ollapse-tab-routing">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-routing" data-bs-toggle="collapse" aria-controls="collapse-tab-routing">
         {{ lang.admin.relayhosts }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.relayhosts }}</span>
@@ -47,7 +47,7 @@
 
   <div class="card mb-4">
     <div class="card-header d-flex">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-maps" data-bs-toggle="collapse" aria-controls="ollapse-tab-maps">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-maps" data-bs-toggle="collapse" aria-controls="collapse-tab-maps">
         {{ lang.admin.transport_maps }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.transport_maps }}</span>

+ 2 - 2
data/web/templates/admin/tab-sys-mails.twig

@@ -1,7 +1,7 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-sys-mails" role="tabpanel" aria-labelledby="tab-sys-mails">
+<div class="tab-pane fade" id="tab-sys-mails" role="tabpanel" aria-labelledby="tab-sys-mails">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-sys-mails" data-bs-toggle="collapse" aria-controls="ollapse-tab-sys-mails">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-sys-mails" data-bs-toggle="collapse" aria-controls="collapse-tab-sys-mails">
         {{ lang.admin.sys_mails }}
       </button>
       <span class="d-none d-md-block">{{ lang.admin.sys_mails }}</span>

+ 2 - 2
data/web/templates/debug.twig

@@ -497,8 +497,8 @@
             <legend>{{ lang.debug.history_all_servers }}</legend><hr />
             <a class="btn btn-sm btn-secondary dropdown-toggle mb-4" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
             <ul class="dropdown-menu">
-              <li><a class="dropdown-item add_log_lines" data-post-process="rspamd_history" data-table="rspamd_history" data-log-url="rspamd_history" data-nrows="100" href="#">+ 100</a></li>
-              <li><a class="dropdown-item add_log_lines" data-post-process="rspamd_history" data-table="rspamd_history" data-log-url="rspamd_history" data-nrows="1000" href="#">+ 1000</a></li>
+              <li><a class="dropdown-item add_log_lines" data-post-process="rspamd_history" data-table="rspamd_history" data-log-url="rspamd-history" data-nrows="100" href="#">+ 100</a></li>
+              <li><a class="dropdown-item add_log_lines" data-post-process="rspamd_history" data-table="rspamd_history" data-log-url="rspamd-history" data-nrows="1000" href="#">+ 1000</a></li>
               <li class="table_collapse_option"><hr class="dropdown-divider"></li>
               <li class="table_collapse_option"><a class="dropdown-item" data-datatables-expand="rspamd_history" data-table="rspamd_history" href="#">{{ lang.datatables.expand_all }}</a></li>
               <li class="table_collapse_option"><a class="dropdown-item" data-datatables-collapse="rspamd_history" data-table="rspamd_history" href="#">{{ lang.datatables.collapse_all }}</a></li>

+ 1 - 1
data/web/templates/mailbox/tab-bcc.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-bcc" role="tabpanel" aria-labelledby="tab-bcc">
+<div class="tab-pane fade" id="tab-bcc" role="tabpanel" aria-labelledby="tab-bcc">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-bcc" data-bs-toggle="collapse" aria-controls="collapse-tab-bcc">

+ 1 - 1
data/web/templates/mailbox/tab-domain-aliases.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-domain-aliases" role="tabpanel" aria-labelledby="tab-domain-aliases">
+<div class="tab-pane fade" id="tab-domain-aliases" role="tabpanel" aria-labelledby="tab-domain-aliases">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-domain-aliases" data-bs-toggle="collapse" aria-controls="collapse-tab-domain-aliases">

+ 1 - 1
data/web/templates/mailbox/tab-domains.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade show active" id="tab-domains" role="tabpanel" aria-labelledby="tab-domains">
+<div class="tab-pane fade show active" id="tab-domains" role="tabpanel" aria-labelledby="tab-domains">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-sm-block d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-domains" data-bs-toggle="collapse" aria-controls="collapse-tab-domains">

+ 1 - 1
data/web/templates/mailbox/tab-filters.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-filters" role="tabpanel" aria-labelledby="tab-filters">
+<div class="tab-pane fade" id="tab-filters" role="tabpanel" aria-labelledby="tab-filters">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-filters" data-bs-toggle="collapse" aria-controls="collapse-tab-filters">

+ 1 - 1
data/web/templates/mailbox/tab-mailboxes.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-mailboxes" role="tabpanel" aria-labelledby="tab-mailboxes">
+<div class="tab-pane fade" id="tab-mailboxes" role="tabpanel" aria-labelledby="tab-mailboxes">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-mailboxes" data-bs-toggle="collapse" aria-controls="collapse-tab-mailboxes">

+ 1 - 1
data/web/templates/mailbox/tab-mbox-aliases.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-mbox-aliases" role="tabpanel" aria-labelledby="tab-mbox-aliases">
+<div class="tab-pane fade" id="tab-mbox-aliases" role="tabpanel" aria-labelledby="tab-mbox-aliases">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-mbox-aliases" data-bs-toggle="collapse" aria-controls="collapse-tab-mbox-aliases">

+ 1 - 1
data/web/templates/mailbox/tab-resources.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-resources" role="tabpanel" aria-labelledby="tab-resources">
+<div class="tab-pane fade" id="tab-resources" role="tabpanel" aria-labelledby="tab-resources">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-resources" data-bs-toggle="collapse" aria-controls="collapse-tab-resources">

+ 1 - 1
data/web/templates/mailbox/tab-syncjobs.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-syncjobs"  role="tabpanel" aria-labelledby="tab-syncjobs">
+<div class="tab-pane fade" id="tab-syncjobs"  role="tabpanel" aria-labelledby="tab-syncjobs">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-syncjobs" data-bs-toggle="collapse" aria-controls="collapse-tab-syncjobs">

+ 1 - 1
data/web/templates/mailbox/tab-templates-domains.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade show" id="tab-templates-domains" role="tabpanel" aria-labelledby="tab-templates-domains">
+<div class="tab-pane fade show" id="tab-templates-domains" role="tabpanel" aria-labelledby="tab-templates-domains">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-sm-block d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-templates-domains" data-bs-toggle="collapse" aria-controls="collapse-tab-templates-domains">

+ 1 - 1
data/web/templates/mailbox/tab-templates-mbox.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade show" id="tab-templates-mbox" role="tabpanel" aria-labelledby="tab-templates-mbox">
+<div class="tab-pane fade show" id="tab-templates-mbox" role="tabpanel" aria-labelledby="tab-templates-mbox">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-sm-block d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-templates-mbox" data-bs-toggle="collapse" aria-controls="collapse-tab-templates-mbox">

+ 1 - 1
data/web/templates/mailbox/tab-tls-policy.twig

@@ -1,4 +1,4 @@
-<div role="tabpanel" class="tab-pane fade{% if mailcow_cc_role != 'admin' %} d-none{% endif %}" id="tab-tls-policy" role="tabpanel" aria-labelledby="tab-tls-policy">
+<div class="tab-pane fade{% if mailcow_cc_role != 'admin' %} d-none{% endif %}" id="tab-tls-policy" role="tabpanel" aria-labelledby="tab-tls-policy">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-tls-policy" data-bs-toggle="collapse" aria-controls="collapse-tab-tls-policy">

+ 2 - 2
data/web/templates/user/AppPasswds.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="AppPasswds" role="tabpanel" aria-labelledby="AppPasswds">
+<div class="tab-pane fade" id="AppPasswds" role="tabpanel" aria-labelledby="AppPasswds">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-AppPasswds" data-bs-toggle="collapse" aria-controls="collapse-tab-AppPasswds">
         {{ lang.user.app_passwds }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.app_passwds }}
+      <span class="d-none d-md-block">{{ lang.user.app_passwds }}</span>
     </div>
     <div id="collapse-tab-AppPasswds" class="card-body collapse" data-bs-parent="#user-content">
       <div class="mass-actions-user mb-4">

+ 2 - 2
data/web/templates/user/Pushover.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="Pushover" role="tabpanel" aria-labelledby="Pushover">
+<div class="tab-pane fade" id="Pushover" role="tabpanel" aria-labelledby="Pushover">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-Pushover" data-bs-toggle="collapse" aria-controls="collapse-tab-Pushover">
         Pushover API
       </button>
-      <span class="d-none d-md-block">Pushover API
+      <span class="d-none d-md-block">Pushover API</span>
     </div>
     <div id="collapse-tab-Pushover" class="card-body collapse" data-bs-parent="#user-content">
       <form data-id="pushover" class="form well" method="post">

+ 2 - 2
data/web/templates/user/SpamAliases.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="SpamAliases" role="tabpanel" aria-labelledby="SpamAliases">
+<div class="tab-pane fade" id="SpamAliases" role="tabpanel" aria-labelledby="SpamAliases">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-SpamAliases" data-bs-toggle="collapse" aria-controls="collapse-tab-SpamAliases">
         {{ lang.user.spam_aliases }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.spam_aliases }}
+      <span class="d-none d-md-block">{{ lang.user.spam_aliases }}</span>
     </div>
     <div id="collapse-tab-SpamAliases" class="card-body collapse" data-bs-parent="#user-content">
       <div class="row">

+ 2 - 2
data/web/templates/user/Spamfilter.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="Spamfilter" role="tabpanel" aria-labelledby="Spamfilter">
+<div class="tab-pane fade" id="Spamfilter" role="tabpanel" aria-labelledby="Spamfilter">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-Spamfilter" data-bs-toggle="collapse" aria-controls="collapse-tab-Spamfilter">
         {{ lang.user.spamfilter }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.spamfilter }}
+      <span class="d-none d-md-block">{{ lang.user.spamfilter }}</span>
     </div>
     <div id="collapse-tab-Spamfilter" class="card-body collapse" data-bs-parent="#user-content">
       <h4>{{ lang.user.spamfilter_behavior }}</h4>

+ 2 - 2
data/web/templates/user/Syncjobs.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="Syncjobs" role="tabpanel" aria-labelledby="Syncjobs">
+<div class="tab-pane fade" id="Syncjobs" role="tabpanel" aria-labelledby="Syncjobs">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-Syncjobs" data-bs-toggle="collapse" aria-controls="collapse-tab-Syncjobs">
         {{ lang.user.sync_jobs }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.sync_jobs }}
+      <span class="d-none d-md-block">{{ lang.user.sync_jobs }}</span>
     </div>
     <div id="collapse-tab-Syncjobs" class="card-body collapse" data-bs-parent="#user-content">      
       <div class="mass-actions-user mb-4">

+ 2 - 2
data/web/templates/user/tab-user-auth.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade in active show" id="tab-user-auth" role="tabpanel" aria-labelledby="tab-user-auth">
+<div class="tab-pane fade in active show" id="tab-user-auth" role="tabpanel" aria-labelledby="tab-user-auth">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-user-auth" data-bs-toggle="collapse" aria-controls="collapse-tab-user-auth">
         {{ lang.user.mailbox_general }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.mailbox_general }}
+      <span class="d-none d-md-block">{{ lang.user.mailbox_general }}</span>
     </div>
     <div id="collapse-tab-user-auth" class="card-body collapse" data-bs-parent="#user-content">
       {% if mailboxdata.attributes.force_pw_update == '1' %}

+ 5 - 5
data/web/templates/user/tab-user-details.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-user-details" role="tabpanel" aria-labelledby="tab-user-details">
+<div class="tab-pane fade" id="tab-user-details" role="tabpanel" aria-labelledby="tab-user-details">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-user-details" data-bs-toggle="collapse" aria-controls="collapse-tab-user-details">
         {{ lang.user.mailbox_details }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.mailbox_details }}
+      <span class="d-none d-md-block">{{ lang.user.mailbox_details }}</span>
     </div>
     <div id="collapse-tab-user-details" class="card-body collapse" data-bs-parent="#user-content">
       <div class="row">
@@ -46,7 +46,7 @@
         <div class="col-sm-8 col-md-9 col-12">
           <p>
             {% if user_get_alias_details.aliases_also_send_as == '*' %}
-              {{ lang.user.sender_acl_disabled }}
+              {{ lang.user.sender_acl_disabled | raw }}
             {% elseif user_get_alias_details.aliases_also_send_as %}
               {{ user_get_alias_details.aliases_also_send_as }}
             {% else %}
@@ -58,13 +58,13 @@
       <div class="row">
         <div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4">{{ lang.user.aliases_send_as_all }}:</div>
         <div class="col-sm-8 col-md-9 col-12">
-          <p>{% if not user_get_alias_details.aliases_send_as_all %}<i class="bi bi-x-lg"></i>{% endif %}</p>
+          <p>{% if not user_get_alias_details.aliases_send_as_all %}<i class="bi bi-x-lg"></i>{% else %}{{ user_get_alias_details.aliases_send_as_all }}{% endif %}</p>
         </div>
       </div>
       <div class="row">
         <div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4">{{ lang.user.is_catch_all }}:</div>
         <div class="col-sm-8 col-md-9 col-12">
-          <p>{% if not user_get_alias_details.is_catch_all %}<i class="bi bi-x-lg"></i>{% endif %}</p>
+          <p>{% if not user_get_alias_details.is_catch_all %}<i class="bi bi-x-lg"></i>{% else %}{{ user_get_alias_details.is_catch_all }}{% endif %}</p>
         </div>
       </div>
     </div>

+ 2 - 2
data/web/templates/user/tab-user-settings.twig

@@ -1,10 +1,10 @@
-<div role="tabpanel" class="tab-pane fade" id="tab-user-settings" role="tabpanel" aria-labelledby="tab-user-settings">
+<div class="tab-pane fade" id="tab-user-settings" role="tabpanel" aria-labelledby="tab-user-settings">
   <div class="card mb-4">
     <div class="card-header d-flex fs-5">
       <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-user-settings" data-bs-toggle="collapse" aria-controls="collapse-tab-user-settings">
         {{ lang.user.mailbox_settings }}
       </button>
-      <span class="d-none d-md-block">{{ lang.user.mailbox_settings }}
+      <span class="d-none d-md-block">{{ lang.user.mailbox_settings }}</span>
     </div>
     <div id="collapse-tab-user-settings" class="card-body collapse" data-bs-parent="#user-content">
       {# Show tagging options #}

+ 2 - 1
docker-compose.yml

@@ -162,6 +162,7 @@ services:
         - DEV_MODE=${DEV_MODE:-n}
         - DEMO_MODE=${DEMO_MODE:-n}
         - WEBAUTHN_ONLY_TRUSTED_VENDORS=${WEBAUTHN_ONLY_TRUSTED_VENDORS:-n}
+        - CLUSTERMODE=${CLUSTERMODE:-}
       restart: always
       networks:
         mailcow-network:
@@ -510,7 +511,7 @@ services:
             - watchdog
 
     dockerapi-mailcow:
-      image: mailcow/dockerapi:2.04
+      image: mailcow/dockerapi:2.05
       security_opt:
         - label=disable
       restart: always

+ 3 - 1
helper-scripts/nextcloud.sh

@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 # renovate: datasource=github-releases depName=nextcloud/server versioning=semver extractVersion=^v(?<version>.*)$
-NEXTCLOUD_VERSION=26.0.2
+NEXTCLOUD_VERSION=27.0.1
 
 echo -ne "Checking prerequisites..."
 sleep 1
@@ -178,6 +178,8 @@ elif [[ ${NC_INSTALL} == "y" ]]; then
     /web/nextcloud/occ --no-warnings config:system:set mail_domain --value=${MAILCOW_HOSTNAME}; \
     /web/nextcloud/occ --no-warnings config:system:set mail_smtphost --value=postfix; \
     /web/nextcloud/occ --no-warnings config:system:set mail_smtpport --value=588; \
+    /web/nextcloud/occ --no-warnings config:system:set mail_smtpstreamoptions ssl verify_peer --value=false --type=boolean
+    /web/nextcloud/occ --no-warnings config:system:set mail_smtpstreamoptions ssl verify_peer_name --value=false --type=boolean
     /web/nextcloud/occ --no-warnings db:convert-filecache-bigint -n"
 
     # Not installing by default, broke too often

+ 1 - 1
update.sh

@@ -876,7 +876,7 @@ if grep -q 'SYSCTL_IPV6_DISABLED=1' mailcow.conf; then
   echo '!! IMPORTANT !!'
   echo
   echo 'SYSCTL_IPV6_DISABLED was removed due to complications. IPv6 can be disabled by editing "docker-compose.yml" and setting "enable_ipv6: true" to "enable_ipv6: false".'
-  echo 'This setting will only be active after a complete shutdown of mailcow by running $COMPOSE_COMMAND down followed by $COMPOSE_COMMAND up -d".'
+  echo "This setting will only be active after a complete shutdown of mailcow by running $COMPOSE_COMMAND down followed by $COMPOSE_COMMAND up -d."
   echo
   echo '!! IMPORTANT !!'
   echo

部分文件因为文件数量过多而无法显示