Browse Source

Merge branch 'staging' into feat/valkey

FreddleSpl0it 1 day ago
parent
commit
c0f06bfc52
100 changed files with 4029 additions and 702 deletions
  1. 34 29
      .github/ISSUE_TEMPLATE/Bug_report.yml
  2. 1 1
      .github/workflows/close_old_issues_and_prs.yml
  3. 1 1
      .github/workflows/image_builds.yml
  4. 2 2
      .github/workflows/pr_to_nightly.yml
  5. 9 5
      .github/workflows/rebuild_backup_image.yml
  6. 1 1
      .github/workflows/update_postscreen_access_list.yml
  7. 3 0
      .gitignore
  8. 16 0
      README.md
  9. 230 0
      _modules/scripts/core.sh
  10. 239 0
      _modules/scripts/ipv6_controller.sh
  11. 96 0
      _modules/scripts/migrate_options.sh
  12. 300 0
      _modules/scripts/new_options.sh
  13. 2 3
      data/Dockerfiles/acme/Dockerfile
  14. 4 16
      data/Dockerfiles/acme/acme.sh
  15. 2 2
      data/Dockerfiles/acme/obtain-certificate.sh
  16. 1 1
      data/Dockerfiles/backup/Dockerfile
  17. 1 1
      data/Dockerfiles/clamd/clamd.sh
  18. 1 1
      data/Dockerfiles/clamd/clamdcheck.sh
  19. 1 1
      data/Dockerfiles/dockerapi/Dockerfile
  20. 2 2
      data/Dockerfiles/dockerapi/main.py
  21. 7 3
      data/Dockerfiles/dovecot/Dockerfile
  22. 2 2
      data/Dockerfiles/dovecot/clean_q_aged.sh
  23. 9 126
      data/Dockerfiles/dovecot/docker-entrypoint.sh
  24. 2 2
      data/Dockerfiles/dovecot/imapsync_runner.pl
  25. 17 11
      data/Dockerfiles/dovecot/quarantine_notify.py
  26. 15 7
      data/Dockerfiles/dovecot/quota_notify.py
  27. 1 1
      data/Dockerfiles/netfilter/Dockerfile
  28. 1 1
      data/Dockerfiles/netfilter/docker-entrypoint.sh
  29. 106 61
      data/Dockerfiles/netfilter/main.py
  30. 19 7
      data/Dockerfiles/netfilter/modules/Logger.py
  31. 2 2
      data/Dockerfiles/nginx/bootstrap.py
  32. 1 1
      data/Dockerfiles/olefy/Dockerfile
  33. 8 1
      data/Dockerfiles/olefy/olefy.py
  34. 6 6
      data/Dockerfiles/phpfpm/Dockerfile
  35. 6 6
      data/Dockerfiles/phpfpm/docker-entrypoint.sh
  36. 50 0
      data/Dockerfiles/postfix-tlspol/Dockerfile
  37. 7 0
      data/Dockerfiles/postfix-tlspol/docker-entrypoint.sh
  38. 52 0
      data/Dockerfiles/postfix-tlspol/postfix-tlspol.sh
  39. 8 0
      data/Dockerfiles/postfix-tlspol/stop-supervisor.sh
  40. 25 0
      data/Dockerfiles/postfix-tlspol/supervisord.conf
  41. 45 0
      data/Dockerfiles/postfix-tlspol/syslog-ng-valkey_slave.conf
  42. 45 0
      data/Dockerfiles/postfix-tlspol/syslog-ng.conf
  43. 2 2
      data/Dockerfiles/postfix/Dockerfile
  44. 1 1
      data/Dockerfiles/postfix/postfix.sh
  45. 2 2
      data/Dockerfiles/rspamd/Dockerfile
  46. 23 0
      data/Dockerfiles/rspamd/docker-entrypoint.sh
  47. 1 0
      data/Dockerfiles/sogo/Dockerfile
  48. 13 105
      data/Dockerfiles/sogo/bootstrap-sogo.sh
  49. 15 0
      data/Dockerfiles/sogo/navMailcowBtns.diff
  50. 1 1
      data/Dockerfiles/unbound/Dockerfile
  51. 4 3
      data/Dockerfiles/watchdog/Dockerfile
  52. 39 0
      data/Dockerfiles/watchdog/check_dns.sh
  53. 13 13
      data/Dockerfiles/watchdog/check_mysql_slavestatus.sh
  54. 46 2
      data/Dockerfiles/watchdog/watchdog.sh
  55. 115 0
      data/conf/dovecot/auth/mailcowauth.php
  56. 57 0
      data/conf/dovecot/auth/passwd-verify.lua
  57. 9 8
      data/conf/dovecot/dovecot.conf
  58. 39 8
      data/conf/nginx/templates/nginx.conf.j2
  59. 8 0
      data/conf/nginx/templates/sites-default.conf.j2
  60. 231 0
      data/conf/phpfpm/crons/keycloak-sync.php
  61. 198 0
      data/conf/phpfpm/crons/ldap-sync.php
  62. 1 1
      data/conf/postfix/main.cf
  63. 275 42
      data/conf/postfix/postscreen_access.cidr
  64. 0 1
      data/conf/rspamd/custom/fishy_tlds.map
  65. 1 1
      data/conf/rspamd/dynmaps/aliasexp.php
  66. 35 3
      data/conf/rspamd/dynmaps/settings.php
  67. 1 1
      data/conf/rspamd/local.d/composites.conf
  68. 0 12
      data/conf/rspamd/local.d/external_services.conf
  69. 0 2
      data/conf/rspamd/local.d/redis.conf
  70. 26 16
      data/conf/rspamd/lua/rspamd.local.lua
  71. 4 1
      data/conf/rspamd/meta_exporter/pipe.php
  72. 1 1
      data/conf/rspamd/meta_exporter/pushover.php
  73. 19 0
      data/conf/sogo/custom-sogo.js
  74. 34 28
      data/conf/sogo/plist_ldap.sh
  75. 4 1
      data/conf/sogo/sogo.conf
  76. 1 1
      data/conf/valkey/valkey-conf.sh
  77. 16 4
      data/web/admin/dashboard.php
  78. 30 0
      data/web/admin/index.php
  79. 13 3
      data/web/admin/mailbox.php
  80. 12 3
      data/web/admin/queue.php
  81. 16 2
      data/web/admin/system.php
  82. 239 5
      data/web/api/openapi.yaml
  83. 1 1
      data/web/autoconfig.php
  84. 10 2
      data/web/autodiscover.php
  85. 0 0
      data/web/css/build/007-languages.min.css
  86. 0 3
      data/web/css/build/013-datatables.css
  87. 60 17
      data/web/css/build/014-mailcow.css
  88. 5 27
      data/web/css/build/015-responsive.css
  89. 0 3
      data/web/css/site/admin.css
  90. 29 0
      data/web/domainadmin/index.php
  91. 58 0
      data/web/domainadmin/mailbox.php
  92. 44 0
      data/web/domainadmin/user.php
  93. 10 1
      data/web/edit.php
  94. BIN
      data/web/favicon.png
  95. 39 7
      data/web/inc/ajax/dns_diagnostics.php
  96. 21 17
      data/web/inc/footer.inc.php
  97. 3 3
      data/web/inc/functions.acl.inc.php
  98. 12 40
      data/web/inc/functions.app_passwd.inc.php
  99. 740 0
      data/web/inc/functions.auth.inc.php
  100. 72 5
      data/web/inc/functions.customize.inc.php

+ 34 - 29
.github/ISSUE_TEMPLATE/Bug_report.yml

@@ -11,70 +11,74 @@ body:
         required: true
   - type: checkboxes
     attributes:
-      label: I've found a bug and checked that ...
-      description: Prior to placing the issue, please check following:** *(fill out each checkbox with an `X` once done)*
+      label: Checklist prior issue creation
+      description: Prior to creating the issue...
       options:
-      - label: ... I understand that not following the below instructions will result in immediate closure and/or deletion of my issue.
+      - label: I understand that failure to follow below instructions may cause this issue to be closed.
         required: true
-      - label: ... I have understood that this bug report is dedicated for bugs, and not for support-related inquiries.
+      - label: I understand that vague, incomplete or inaccurate information may cause this issue to be closed.
         required: true
-      - label: ... I have understood that answers are voluntary and community-driven, and not commercial support.
+      - label: I understand that this form is intended solely for reporting software bugs and not for support-related inquiries.
         required: true
-      - label: ... I have verified that my issue has not been already answered in the past. I also checked previous [issues](https://github.com/mailcow/mailcow-dockerized/issues).
+      - label: I understand that all responses are voluntary and community-driven, and do not constitute commercial support.
+        required: true
+      - label: I confirm that I have reviewed previous [issues](https://github.com/mailcow/mailcow-dockerized/issues) to ensure this matter has not already been addressed.
+        required: true
+      - label: I confirm that my environment meets all [prerequisite requirements](https://docs.mailcow.email/getstarted/prerequisite-system/) as specified in the official documentation.
         required: true
   - type: textarea
     attributes:
       label: Description
-      description: Please provide a brief description of the bug in 1-2 sentences. If applicable, add screenshots to help explain your problem. Very useful for bugs in mailcow UI.
-      render: plain text
-    validations:
-      required: true
-  - type: textarea
-    attributes:
-      label: "Logs:"
-      description: "Please take a look at the [official documentation](https://docs.mailcow.email/troubleshooting/debug-logs/) and post the last few lines of logs, when the error occurs. For example, docker container logs of affected containers. This will be automatically formatted into code, so no need for backticks."
-      render: plain text
+      description: Please provide a brief description of the bug. If applicable, add screenshots to help explain your problem. (Very useful for bugs in mailcow UI.)
     validations:
       required: true
   - type: textarea
     attributes:
       label: "Steps to reproduce:"
       description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
-      render: plain text
       placeholder: |-
         1. ...
         2. ...
         3. ...
     validations:
       required: true
+  - type: textarea
+    attributes:
+      label: "Logs:"
+      description: "Please take a look at the [official documentation](https://docs.mailcow.email/troubleshooting/debug-logs/) and post the last few lines of logs, when the error occurs. For example, docker container logs of affected containers. This will be automatically formatted into code, so no need for backticks."
+      render: plain text
+    validations:
+      required: true
   - type: markdown
     attributes:
       value: |
         ## System information
-        ### In this stage we would kindly ask you to attach general system information about your setup.
+        In this stage we would kindly ask you to attach general system information about your setup.
   - type: dropdown
     attributes:
       label: "Which branch are you using?"
-      description: "#### `git rev-parse --abbrev-ref HEAD`"
+      description: "#### Run: `git rev-parse --abbrev-ref HEAD`"
       multiple: false
       options:
-        - master
+        - master (stable)
+        - staging
         - nightly
     validations:
       required: true
   - type: dropdown
     attributes:
       label: "Which architecture are you using?"
-      description: "#### `uname -m`"
+      description: "#### Run: `uname -m`"
       multiple: false
       options:
-        - x86
+        - x86_64
         - ARM64 (aarch64)
     validations:
       required: true
   - type: input
     attributes:
       label: "Operating System:"
+      description: "#### Run: `lsb_release -ds`"
       placeholder: "e.g. Ubuntu 22.04 LTS"
     validations:
       required: true
@@ -93,43 +97,44 @@ body:
   - type: input
     attributes:
       label: "Virtualization technology:"
-      placeholder: "KVM, VMware, Xen, etc - **LXC and OpenVZ are not supported**"
+      description: "LXC and OpenVZ are not supported!"
+      placeholder: "KVM, VMware ESXi, Xen, etc"
     validations:
       required: true
   - type: input
     attributes:
       label: "Docker version:"
-      description: "#### `docker version`"
+      description: "#### Run: `docker version`"
       placeholder: "20.10.21"
     validations:
       required: true
   - type: input
     attributes:
       label: "docker-compose version or docker compose version:"
-      description: "#### `docker-compose version` or `docker compose version`"
+      description: "#### Run: `docker-compose version` or `docker compose version`"
       placeholder: "v2.12.2"
     validations:
       required: true
   - type: input
     attributes:
       label: "mailcow version:"
-      description: "#### ```git describe --tags `git rev-list --tags --max-count=1` ```"
-      placeholder: "2022-08"
+      description: "#### Run: ```git describe --tags `git rev-list --tags --max-count=1` ```"
+      placeholder: "2022-08x"
     validations:
       required: true
   - type: input
     attributes:
       label: "Reverse proxy:"
-      placeholder: "e.g. Nginx/Traefik"
+      placeholder: "e.g. nginx/Traefik, or none"
     validations:
       required: true
   - type: textarea
     attributes:
       label: "Logs of git diff:"
-      description: "#### Output of `git diff origin/master`, any other changes to the code? If so, **please post them**:"
+      description: "#### Output of `git diff origin/master`, any other changes to the code? Sanitize if needed. If so, **please post them**:"
       render: plain text
     validations:
-      required: true
+      required: false
   - type: textarea
     attributes:
       label: "Logs of iptables -L -vn:"

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

@@ -14,7 +14,7 @@ jobs:
       pull-requests: write
     steps:
       - name: Mark/Close Stale Issues and Pull Requests 🗑️
-        uses: actions/stale@v9.1.0
+        uses: actions/stale@v10.1.0
         with:
           repo-token: ${{ secrets.STALE_ACTION_PAT }}
           days-before-stale: 60

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

@@ -27,7 +27,7 @@ jobs:
           - "watchdog-mailcow"
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - name: Setup Docker
         run: |
           curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh

+ 2 - 2
.github/workflows/pr_to_nightly.yml

@@ -8,11 +8,11 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           fetch-depth: 0
       - name: Run the Action
-        uses: devops-infra/action-pull-request@v0.6.0
+        uses: devops-infra/action-pull-request@v0.6.1
         with:
           github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
           title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}

+ 9 - 5
.github/workflows/rebuild_backup_image.yml

@@ -9,9 +9,11 @@ on:
 jobs:
   docker_image_build:
     runs-on: ubuntu-latest
+    permissions:
+      packages: write
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
 
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v3
@@ -19,11 +21,13 @@ jobs:
       - name: Set up Docker Buildx
         uses: docker/setup-buildx-action@v3
 
-      - name: Login to Docker Hub
+      - name: Login to GHCR
+        if: github.event_name != 'pull_request'
         uses: docker/login-action@v3
         with:
-          username: ${{ secrets.BACKUPIMAGEBUILD_ACTION_DOCKERHUB_USERNAME }}
-          password: ${{ secrets.BACKUPIMAGEBUILD_ACTION_DOCKERHUB_TOKEN }}
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
 
       - name: Build and push
         uses: docker/build-push-action@v6
@@ -32,4 +36,4 @@ jobs:
           platforms: linux/amd64,linux/arm64
           file: data/Dockerfiles/backup/Dockerfile
           push: true
-          tags: mailcow/backup:latest
+          tags: ghcr.io/mailcow/backup:latest

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

@@ -15,7 +15,7 @@ jobs:
    runs-on: ubuntu-latest
    steps:
     - name: Checkout
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
 
     - name: Generate postscreen_access.cidr
       run: |

+ 3 - 0
.gitignore

@@ -45,6 +45,7 @@ data/conf/rspamd/local.d/*
 data/conf/rspamd/override.d/*
 data/conf/sogo/custom-theme.js
 data/conf/sogo/plist_ldap
+data/conf/sogo/plist_ldap.sh
 data/conf/sogo/sieve.creds
 data/conf/sogo/cron.creds
 data/conf/sogo/custom-fulllogo.svg
@@ -73,3 +74,5 @@ rebuild-images.sh
 refresh_images.sh
 update_diffs/
 create_cold_standby.sh
+!data/conf/nginx/mailcow_auth.conf
+data/conf/postfix/postfix-tlspol

+ 16 - 0
README.md

@@ -13,6 +13,22 @@ You can also [get a SAL](https://www.servercow.de/mailcow?lang=en#sal) which is
 
 Or just spread the word: moo.
 
+## Many thanks to our GitHub Sponsors ❤️
+A big thank you to everyone supporting us on GitHub Sponsors—your contributions mean the world to us! Special thanks to the following amazing supporters:
+
+### 100$/Month Sponsors
+  <a href="https://www.colba.net/" target=_blank><img
+    src="https://avatars.githubusercontent.com/u/204464723" height="58"
+  /></a>
+  <a href="https://www.maehdros.com/" target=_blank><img
+    src="https://avatars.githubusercontent.com/u/173894712" height="58"
+  /></a>
+
+### 50$/Month Sponsors
+  <a href="https://github.com/vnukhr" target=_blank><img
+    src="https://avatars.githubusercontent.com/u/7805987?s=52&v=4" height="58"
+  /></a>
+
 ## Info, documentation and support
 
 Please see [the official documentation](https://docs.mailcow.email/) for installation and support instructions. 🐄

+ 230 - 0
_modules/scripts/core.sh

@@ -0,0 +1,230 @@
+#!/usr/bin/env bash
+# _modules/scripts/core.sh
+# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
+# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
+
+# ANSI color for red errors
+RED='\e[31m'
+GREEN='\e[32m'
+YELLOW='\e[33m'
+BLUE='\e[34m'
+MAGENTA='\e[35m'
+LIGHT_RED='\e[91m'
+LIGHT_GREEN='\e[92m'
+NC='\e[0m'
+
+caller="${BASH_SOURCE[1]##*/}"
+
+get_installed_tools(){
+    for bin in openssl curl docker git awk sha1sum grep cut jq; do
+        if [[ -z $(command -v ${bin}) ]]; then
+          echo "Error: Cannot find command '${bin}'. Cannot proceed."
+          echo "Solution: Please review system requirements and install requirements. Then, re-run the script."
+          echo "See System Requirements: https://docs.mailcow.email/getstarted/install/"
+          echo "Exiting..."
+          exit 1
+        fi
+    done
+
+    if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\"${NC}"; exit 1; fi
+    # This will also cover sort
+    if cp --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\"${NC}"; exit 1; fi
+    if sed --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox sed detected, please install gnu sed, \"apk add --no-cache --upgrade sed\"${NC}"; exit 1; fi
+}
+
+get_docker_version(){
+    # Check Docker Version (need at least 24.X)
+    docker_version=$(docker version --format '{{.Server.Version}}' | cut -d '.' -f 1)
+}
+
+get_compose_type(){
+    if docker compose > /dev/null 2>&1; then
+        if docker compose version --short | grep -e "^2." -e "^v2." > /dev/null 2>&1; then
+            COMPOSE_VERSION=native
+            COMPOSE_COMMAND="docker compose"
+            if [[ "$caller" == "update.sh" ]]; then
+                sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
+            fi
+            echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
+            echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
+            sleep 2
+            echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
+        else
+            echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
+            echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
+            exit 1
+        fi
+    elif docker-compose > /dev/null 2>&1; then
+    if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
+        if docker-compose version --short | grep "^2." > /dev/null 2>&1; then
+            COMPOSE_VERSION=standalone
+            COMPOSE_COMMAND="docker-compose"
+            if [[ "$caller" == "update.sh" ]]; then
+                sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
+            fi
+            echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
+            echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
+            sleep 2
+            echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
+        else
+            echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
+            echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
+            exit 1
+        fi
+    fi
+    else
+        echo -e "\e[31mCannot find Docker Compose.\e[0m"
+        echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
+        exit 1
+    fi
+}
+
+detect_bad_asn() {
+  echo -e "\e[33mDetecting if your IP is listed on Spamhaus Bad ASN List...\e[0m"
+  response=$(curl --connect-timeout 15 --max-time 30 -s -o /dev/null -w "%{http_code}" "https://asn-check.mailcow.email")
+  if [ "$response" -eq 503 ]; then
+    if [ -z "$SPAMHAUS_DQS_KEY" ]; then
+      echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
+      echo -e "\e[33mmailcow did not detected a value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf!\e[0m"
+      sleep 2
+      echo ""
+      echo -e "\e[33mTo use the Spamhaus DNS Blocklists again, you will need to create a FREE account for their Data Query Service (DQS) at: https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account\e[0m"
+      echo -e "\e[33mOnce done, enter your DQS API key in mailcow.conf and mailcow will do the rest for you!\e[0m"
+      echo ""
+      sleep 2
+    else
+      echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
+      echo -e "\e[32mmailcow detected a Value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf. Postfix will use DQS with the given API key...\e[0m"
+    fi
+  elif [ "$response" -eq 200 ]; then
+    echo -e "\e[33mCheck completed! Your IP is \e[32mclean\e[0m"
+  elif [ "$response" -eq 429 ]; then
+    echo -e "\e[33mCheck completed! \e[31mYour IP seems to be rate limited on the ASN Check service... please try again later!\e[0m"
+  else
+    echo -e "\e[31mCheck failed! \e[0mMaybe a DNS or Network problem?\e[0m"
+  fi
+}
+
+check_online_status() {
+  CHECK_ONLINE_DOMAINS=('https://github.com' 'https://hub.docker.com')
+  for domain in "${CHECK_ONLINE_DOMAINS[@]}"; do
+    if timeout 6 curl --head --silent --output /dev/null ${domain}; then
+      return 0
+    fi
+  done
+  return 1
+}
+
+prefetch_images() {
+  [[ -z ${BRANCH} ]] && { echo -e "\e[33m\nUnknown branch...\e[0m"; exit 1; }
+  git fetch origin #${BRANCH}
+  while read image; do
+    RET_C=0
+    until docker pull "${image}"; do
+      RET_C=$((RET_C + 1))
+      echo -e "\e[33m\nError pulling $image, retrying...\e[0m"
+      [ ${RET_C} -gt 3 ] && { echo -e "\e[31m\nToo many failed retries, exiting\e[0m"; exit 1; }
+      sleep 1
+    done
+  done < <(git show "origin/${BRANCH}:docker-compose.yml" | grep "image:" | awk '{ gsub("image:","", $3); print $2 }')
+}
+
+docker_garbage() {
+  SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )"
+  IMGS_TO_DELETE=()
+
+  declare -A IMAGES_INFO
+  COMPOSE_IMAGES=($(grep -oP "image: \K(ghcr\.io/)?mailcow.+" "${SCRIPT_DIR}/docker-compose.yml"))
+
+  for existing_image in $(docker images --format "{{.ID}}:{{.Repository}}:{{.Tag}}" | grep -E '(mailcow/|ghcr\.io/mailcow/)'); do
+      ID=$(echo "$existing_image" | cut -d ':' -f 1)
+      REPOSITORY=$(echo "$existing_image" | cut -d ':' -f 2)
+      TAG=$(echo "$existing_image" | cut -d ':' -f 3)
+
+      if [[ "$REPOSITORY" == "mailcow/backup" || "$REPOSITORY" == "ghcr.io/mailcow/backup" ]]; then
+          if [[ "$TAG" != "<none>" ]]; then
+              continue
+          fi
+      fi
+
+      if [[ " ${COMPOSE_IMAGES[@]} " =~ " ${REPOSITORY}:${TAG} " ]]; then
+          continue
+      else
+          IMGS_TO_DELETE+=("$ID")
+          IMAGES_INFO["$ID"]="$REPOSITORY:$TAG"
+      fi
+  done
+
+  if [[ ! -z ${IMGS_TO_DELETE[*]} ]]; then
+      echo "The following unused mailcow images were found:"
+      for id in "${IMGS_TO_DELETE[@]}"; do
+          echo "    ${IMAGES_INFO[$id]} ($id)"
+      done
+
+      if [ -z "$FORCE" ]; then
+          read -r -p "Do you want to delete them to free up some space? [y/N] " response
+          if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+              docker rmi ${IMGS_TO_DELETE[*]}
+          else
+              echo "OK, skipped."
+          fi
+      else
+          echo "Running in forced mode! Force removing old mailcow images..."
+          docker rmi ${IMGS_TO_DELETE[*]}
+      fi
+      echo -e "\e[32mFurther cleanup...\e[0m"
+      echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
+  fi
+}
+
+in_array() {
+  local e match="$1"
+  shift
+  for e; do [[ "$e" == "$match" ]] && return 0; done
+  return 1
+}
+
+detect_major_update() {
+  if [ ${BRANCH} == "master" ]; then
+    # Array with major versions
+    # Add major versions here
+    MAJOR_VERSIONS=(
+      "2025-02"
+      "2025-03"
+      "2025-09"
+    )
+
+    current_version=""
+    if [[ -f "${SCRIPT_DIR}/data/web/inc/app_info.inc.php" ]]; then
+      current_version=$(grep 'MAILCOW_GIT_VERSION' ${SCRIPT_DIR}/data/web/inc/app_info.inc.php | sed -E 's/.*MAILCOW_GIT_VERSION="([^"]+)".*/\1/')
+    fi
+    if [[ -z "$current_version" ]]; then
+      return 1
+    fi
+    release_url="https://github.com/mailcow/mailcow-dockerized/releases/tag"
+
+    updates_to_apply=()
+
+    for version in "${MAJOR_VERSIONS[@]}"; do
+      if [[ "$current_version" < "$version" ]]; then
+        updates_to_apply+=("$version")
+      fi
+    done
+
+    if [[ ${#updates_to_apply[@]} -gt 0 ]]; then
+      echo -e "\e[33m\nMAJOR UPDATES to be applied:\e[0m"
+      for update in "${updates_to_apply[@]}"; do
+        echo "$update - $release_url/$update"
+      done
+
+      echo -e "\nPlease read the release notes before proceeding."
+      read -p "Do you want to proceed with the update? [y/n] " response
+      if [[ "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+        echo "Proceeding with the update..."
+      else
+        echo "Update canceled. Exiting."
+        exit 1
+      fi
+    fi
+  fi
+}

+ 239 - 0
_modules/scripts/ipv6_controller.sh

@@ -0,0 +1,239 @@
+#!/usr/bin/env bash
+# _modules/scripts/ipv6_controller.sh
+# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
+# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
+
+# 1) Check if the host supports IPv6
+get_ipv6_support() {
+  # ---- helper: probe external IPv6 connectivity without DNS ----
+  _probe_ipv6_connectivity() {
+    # Use literal, always-on IPv6 echo responders (no DNS required)
+    local PROBE_IPS=("2001:4860:4860::8888" "2606:4700:4700::1111")
+    local ip rc=1
+
+    for ip in "${PROBE_IPS[@]}"; do
+      if command -v ping6 &>/dev/null; then
+        ping6 -c1 -W2 "$ip" &>/dev/null || ping6 -c1 -w2 "$ip" &>/dev/null
+        rc=$?
+      elif command -v ping &>/dev/null; then
+        ping -6 -c1 -W2 "$ip" &>/dev/null || ping -6 -c1 -w2 "$ip" &>/dev/null
+        rc=$?
+      else
+        rc=1
+      fi
+      [[ $rc -eq 0 ]] && return 0
+    done
+    return 1
+  }
+
+  if [[ ! -f /proc/net/if_inet6 ]] || grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null; then
+    DETECTED_IPV6=false
+    echo -e "${YELLOW}IPv6 not detected on host – ${LIGHT_RED}IPv6 is administratively disabled${YELLOW}.${NC}"
+    return
+  fi
+
+  if ip -6 route show default 2>/dev/null | grep -qE '^default'; then
+    echo -e "${YELLOW}Default IPv6 route found – testing external IPv6 connectivity...${NC}"
+    if _probe_ipv6_connectivity; then
+      DETECTED_IPV6=true
+      echo -e "IPv6 detected on host – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
+    else
+      DETECTED_IPV6=false
+      echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
+    fi
+    return
+  fi
+
+  if ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'; then
+    DETECTED_IPV6=false
+    echo -e "${YELLOW}Global IPv6 address present but no default route – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
+    return
+  fi
+
+  if ip -6 addr show scope link 2>/dev/null | grep -q 'inet6'; then
+    echo -e "${YELLOW}Only link-local IPv6 addresses found – testing external IPv6 connectivity...${NC}"
+    if _probe_ipv6_connectivity; then
+      DETECTED_IPV6=true
+      echo -e "External IPv6 connectivity available – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
+    else
+      DETECTED_IPV6=false
+      echo -e "${YELLOW}Only link-local IPv6 present and no external connectivity – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
+    fi
+    return
+  fi
+
+  DETECTED_IPV6=false
+  echo -e "${YELLOW}IPv6 not detected on host – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
+}
+
+# 2) Ensure Docker daemon.json has (or create) the required IPv6 settings
+docker_daemon_edit(){
+  DOCKER_DAEMON_CONFIG="/etc/docker/daemon.json"
+  DOCKER_MAJOR=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
+  MISSING=()
+
+  _has_kv() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*$2" "$DOCKER_DAEMON_CONFIG" 2>/dev/null; }
+
+  if [[ -f "$DOCKER_DAEMON_CONFIG" ]]; then
+
+    # reject empty or whitespace-only file immediately
+    if [[ ! -s "$DOCKER_DAEMON_CONFIG" ]] || ! grep -Eq '[{}]' "$DOCKER_DAEMON_CONFIG"; then
+      echo -e "${RED}ERROR: $DOCKER_DAEMON_CONFIG exists but is empty or contains no JSON braces – please initialize it with valid JSON (e.g. {}).${NC}"
+      exit 1
+    fi
+
+    # Validate JSON if jq is present
+    if command -v jq &>/dev/null && ! jq empty "$DOCKER_DAEMON_CONFIG" &>/dev/null; then
+      echo -e "${RED}ERROR: Invalid JSON in $DOCKER_DAEMON_CONFIG – please correct manually.${NC}"
+      exit 1
+    fi
+
+    # Gather missing keys
+    ! _has_kv ipv6 true && MISSING+=("ipv6: true")
+
+    # For Docker < 28, keep requiring fixed-cidr-v6 (default bridge needs it on old engines)
+    if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
+      ! grep -Eq '"fixed-cidr-v6"[[:space:]]*:[[:space:]]*".+"' "$DOCKER_DAEMON_CONFIG" \
+                                && MISSING+=('fixed-cidr-v6: "fd00:dead:beef:c0::/80"')
+    fi
+
+    # For Docker < 27, ip6tables needed and was tied to experimental in older releases
+    if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
+      _has_kv ipv6 true && ! _has_kv ip6tables true && MISSING+=("ip6tables: true")
+      ! _has_kv experimental true && MISSING+=("experimental: true")
+    fi
+
+    # Fix if needed
+    if ((${#MISSING[@]}>0)); then
+      echo -e "${MAGENTA}Your daemon.json is missing: ${YELLOW}${MISSING[*]}${NC}"
+      if [[ -n "$FORCE" ]]; then
+        ans=Y
+      else
+        read -p "Would you like to update $DOCKER_DAEMON_CONFIG now? [Y/n] " ans
+        ans=${ans:-Y}
+      fi
+
+      if [[ $ans =~ ^[Yy]$ ]]; then
+        cp "$DOCKER_DAEMON_CONFIG" "${DOCKER_DAEMON_CONFIG}.bak"
+        if command -v jq &>/dev/null; then
+          TMP=$(mktemp)
+          # Base filter: ensure ipv6 = true
+          JQ_FILTER='.ipv6 = true'
+
+          # Add fixed-cidr-v6 only for Docker < 28
+          if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
+            JQ_FILTER+=' | .["fixed-cidr-v6"] = (.["fixed-cidr-v6"] // "fd00:dead:beef:c0::/80")'
+          fi
+
+          # Add ip6tables/experimental only for Docker < 27
+          if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
+            JQ_FILTER+=' | .ip6tables = true | .experimental = true'
+          fi
+
+          jq "$JQ_FILTER" "$DOCKER_DAEMON_CONFIG" >"$TMP" && mv "$TMP" "$DOCKER_DAEMON_CONFIG"
+          echo -e "${LIGHT_GREEN}daemon.json updated. Restarting Docker...${NC}"
+          (command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
+          echo -e "${YELLOW}Docker restarted.${NC}"
+        else
+          echo -e "${RED}Please install jq or manually update daemon.json and restart Docker.${NC}"
+          exit 1
+        fi
+      else
+        echo -e "${YELLOW}User declined Docker update – please insert these changes manually:${NC}"
+        echo "${MISSING[*]}"
+        exit 1
+      fi
+    fi
+
+  else
+    # Create new daemon.json if missing
+    if [[ -n "$FORCE" ]]; then
+      ans=Y
+    else
+      read -p "$DOCKER_DAEMON_CONFIG not found. Create it with IPv6 settings? [Y/n] " ans
+      ans=${ans:-Y}
+    fi
+
+    if [[ $ans =~ ^[Yy]$ ]]; then
+      mkdir -p "$(dirname "$DOCKER_DAEMON_CONFIG")"
+      if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
+        cat > "$DOCKER_DAEMON_CONFIG" <<EOF
+{
+  "ipv6": true,
+  "fixed-cidr-v6": "fd00:dead:beef:c0::/80",
+  "ip6tables": true,
+  "experimental": true
+}
+EOF
+      elif [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
+        cat > "$DOCKER_DAEMON_CONFIG" <<EOF
+{
+  "ipv6": true,
+  "fixed-cidr-v6": "fd00:dead:beef:c0::/80"
+}
+EOF
+      else
+        # Docker 28+: ipv6 works without fixed-cidr-v6
+        cat > "$DOCKER_DAEMON_CONFIG" <<EOF
+{
+  "ipv6": true
+}
+EOF
+      fi
+      echo -e "${GREEN}Created $DOCKER_DAEMON_CONFIG with IPv6 settings.${NC}"
+      echo "Restarting Docker..."
+      (command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
+      echo "Docker restarted."
+    else
+      echo "User declined to create daemon.json – please manually merge the docker daemon with these configs:"
+      echo "${MISSING[*]}"
+      exit 1
+    fi
+  fi
+}
+
+# 3) Main wrapper for generate_config.sh and update.sh
+configure_ipv6() {
+  # detect manual override if mailcow.conf is present
+  if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
+    MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2)
+  elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then
+    MANUAL_SETTING="$ENABLE_IPV6"
+  else
+    MANUAL_SETTING=""
+  fi
+
+  get_ipv6_support
+
+  # if user manually set it, check for mismatch
+  if [[ "$DETECTED_IPV6" != "true" ]]; then
+    if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
+      if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
+        sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
+      else
+        echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
+      fi
+    else
+      export IPV6_BOOL=false
+    fi
+    echo "Skipping Docker IPv6 configuration because host does not support IPv6."
+    echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6."
+    echo "IPv6 configuration complete: ENABLE_IPV6=false"
+    sleep 2
+    return
+  fi
+
+  docker_daemon_edit
+
+  if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
+    if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
+      sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF"
+    else
+      echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF"
+    fi
+  else
+    export IPV6_BOOL=true
+  fi
+
+  echo "IPv6 configuration complete: ENABLE_IPV6=true"
+}

+ 96 - 0
_modules/scripts/migrate_options.sh

@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# _modules/scripts/migrate_options.sh
+# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
+# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
+
+migrate_config_options() {
+
+  sed -i --follow-symlinks '$a\' mailcow.conf
+
+  KEYS=(
+    SOLR_HEAP
+    SKIP_SOLR
+    SOLR_PORT
+    FLATCURVE_EXPERIMENTAL
+    DISABLE_IPv6
+    ACME_CONTACT
+  )
+
+  for key in "${KEYS[@]}"; do
+    if grep -q "${key}" mailcow.conf; then
+      case "${key}" in
+        SOLR_HEAP)
+          echo "Removing ${key} in mailcow.conf"
+          sed -i '/# Solr heap size in MB\b/d' mailcow.conf
+          sed -i '/# Solr is a prone to run\b/d' mailcow.conf
+          sed -i '/SOLR_HEAP\b/d' mailcow.conf
+          ;;
+        SKIP_SOLR)
+          echo "Removing ${key} in mailcow.conf"
+          sed -i '/\bSkip Solr on low-memory\b/d' mailcow.conf
+          sed -i '/\bSolr is disabled by default\b/d' mailcow.conf
+          sed -i '/\bDisable Solr or\b/d' mailcow.conf
+          sed -i '/\bSKIP_SOLR\b/d' mailcow.conf
+          ;;
+        SOLR_PORT)
+          echo "Removing ${key} in mailcow.conf"
+          sed -i '/\bSOLR_PORT\b/d' mailcow.conf
+          ;;
+        FLATCURVE_EXPERIMENTAL)
+          echo "Removing ${key} in mailcow.conf"
+          sed -i '/\bFLATCURVE_EXPERIMENTAL\b/d' mailcow.conf
+          ;;
+        DISABLE_IPv6)
+          echo "Migrating ${key} to ENABLE_IPv6 in mailcow.conf"
+          local old=$(grep '^DISABLE_IPv6=' "mailcow.conf" | cut -d'=' -f2)
+          local new
+          if [[ "$old" == "y" ]]; then
+            new="false"
+          else
+            new="true"
+          fi
+          sed -i '/^DISABLE_IPv6=/d' "mailcow.conf"
+          echo "ENABLE_IPV6=$new" >> "mailcow.conf"
+          ;;
+        ACME_CONTACT)
+          echo "Deleting obsoleted ${key} in mailcow.conf"
+          sed -i '/^# Lets Encrypt registration contact information/d' mailcow.conf
+          sed -i '/^# Optional: Leave empty for none/d' mailcow.conf
+          sed -i '/^# This value is only used on first order!/d' mailcow.conf
+          sed -i '/^# Setting it at a later point will require the following steps:/d' mailcow.conf
+          sed -i '/^# https:\/\/docs.mailcow.email\/troubleshooting\/debug-reset_tls\//d' mailcow.conf
+          sed -i '/^ACME_CONTACT=.*/d' mailcow.conf
+          sed -i '/^#ACME_CONTACT=.*/d' mailcow.conf
+          ;;
+      esac
+    fi
+  done
+
+  solr_volume=$(docker volume ls -qf name=^${COMPOSE_PROJECT_NAME}_solr-vol-1)
+  if [[ -n $solr_volume ]]; then
+    echo -e "\e[34mSolr has been replaced within mailcow since 2025-01.\nThe volume $solr_volume is unused.\e[0m"
+    sleep 1
+    if [ ! "$FORCE" ]; then
+      read -r -p "Remove $solr_volume? [y/N] " response
+      if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+        echo -e "\e[33mRemoving $solr_volume...\e[0m"
+        docker volume rm $solr_volume || echo -e "\e[31mFailed to remove. Remove it manually!\e[0m"
+        echo -e "\e[32mSuccessfully removed $solr_volume!\e[0m"
+      else
+        echo -e "Not removing $solr_volume. Run \`docker volume rm $solr_volume\` manually if needed."
+      fi
+    else
+      echo -e "\e[33mForce removing $solr_volume...\e[0m"
+      docker volume rm $solr_volume || echo -e "\e[31mFailed to remove. Remove it manually!\e[0m"
+      echo -e "\e[32mSuccessfully removed $solr_volume!\e[0m"
+    fi
+  fi
+
+  # Delete old fts.conf before forced switch to flatcurve to ensure update is working properly
+  FTS_CONF_PATH="${SCRIPT_DIR}/data/conf/dovecot/conf.d/fts.conf"
+  if [[ -f "$FTS_CONF_PATH" ]]; then
+    if grep -q "Autogenerated by mailcow" "$FTS_CONF_PATH"; then
+      rm -rf $FTS_CONF_PATH
+    fi
+  fi
+}

+ 300 - 0
_modules/scripts/new_options.sh

@@ -0,0 +1,300 @@
+#!/usr/bin/env bash
+# _modules/scripts/new_options.sh
+# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
+# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
+
+adapt_new_options() {
+
+  CONFIG_ARRAY=(
+  "AUTODISCOVER_SAN"
+  "SKIP_LETS_ENCRYPT"
+  "SKIP_SOGO"
+  "USE_WATCHDOG"
+  "WATCHDOG_NOTIFY_EMAIL"
+  "WATCHDOG_NOTIFY_WEBHOOK"
+  "WATCHDOG_NOTIFY_WEBHOOK_BODY"
+  "WATCHDOG_NOTIFY_BAN"
+  "WATCHDOG_NOTIFY_START"
+  "WATCHDOG_EXTERNAL_CHECKS"
+  "WATCHDOG_SUBJECT"
+  "SKIP_CLAMD"
+  "SKIP_OLEFY"
+  "SKIP_IP_CHECK"
+  "ADDITIONAL_SAN"
+  "DOVEADM_PORT"
+  "IPV4_NETWORK"
+  "IPV6_NETWORK"
+  "LOG_LINES"
+  "SNAT_TO_SOURCE"
+  "SNAT6_TO_SOURCE"
+  "COMPOSE_PROJECT_NAME"
+  "DOCKER_COMPOSE_VERSION"
+  "SQL_PORT"
+  "API_KEY"
+  "API_KEY_READ_ONLY"
+  "API_ALLOW_FROM"
+  "MAILDIR_GC_TIME"
+  "MAILDIR_SUB"
+  "ACL_ANYONE"
+  "FTS_HEAP"
+  "FTS_PROCS"
+  "SKIP_FTS"
+  "ENABLE_SSL_SNI"
+  "ALLOW_ADMIN_EMAIL_LOGIN"
+  "SKIP_HTTP_VERIFICATION"
+  "SOGO_EXPIRE_SESSION"
+  "SOGO_URL_ENCRYPTION_KEY"
+  "REDIS_PORT"
+  "REDISPASS"
+  "DOVECOT_MASTER_USER"
+  "DOVECOT_MASTER_PASS"
+  "MAILCOW_PASS_SCHEME"
+  "ADDITIONAL_SERVER_NAMES"
+  "WATCHDOG_VERBOSE"
+  "WEBAUTHN_ONLY_TRUSTED_VENDORS"
+  "SPAMHAUS_DQS_KEY"
+  "SKIP_UNBOUND_HEALTHCHECK"
+  "DISABLE_NETFILTER_ISOLATION_RULE"
+  "HTTP_REDIRECT"
+  "ENABLE_IPV6"
+  )
+
+  sed -i --follow-symlinks '$a\' mailcow.conf
+  for option in ${CONFIG_ARRAY[@]}; do
+    if grep -q "${option}" mailcow.conf; then
+      continue
+    fi
+
+    echo "Adding new option \"${option}\" to mailcow.conf"
+
+    case "${option}" in
+        AUTODISCOVER_SAN)
+            echo '# Obtain certificates for autodiscover.* and autoconfig.* domains.' >> mailcow.conf
+            echo '# This can be useful to switch off in case you are in a scenario where a reverse proxy already handles those.' >> mailcow.conf
+            echo '# There are mixed scenarios where ports 80,443 are occupied and you do not want to share certs' >> mailcow.conf
+            echo '# between services. So acme-mailcow obtains for maildomains and all web-things get handled' >> mailcow.conf
+            echo '# in the reverse proxy.' >> mailcow.conf
+            echo 'AUTODISCOVER_SAN=y' >> mailcow.conf
+            ;;
+
+        DOCKER_COMPOSE_VERSION)
+            echo "# Used Docker Compose version" >> mailcow.conf
+            echo "# Switch here between native (compose plugin) and standalone" >> mailcow.conf
+            echo "# For more informations take a look at the mailcow docs regarding the configuration options." >> mailcow.conf
+            echo "# Normally this should be untouched but if you decided to use either of those you can switch it manually here." >> mailcow.conf
+            echo "# Please be aware that at least one of those variants should be installed on your machine or mailcow will fail." >> mailcow.conf
+            echo "" >> mailcow.conf
+            echo "DOCKER_COMPOSE_VERSION=${DOCKER_COMPOSE_VERSION}" >> mailcow.conf
+            ;;
+
+        DOVEADM_PORT)
+            echo "DOVEADM_PORT=127.0.0.1:19991" >> mailcow.conf
+            ;;
+
+        LOG_LINES)
+            echo '# Max log lines per service to keep in Redis logs' >> mailcow.conf
+            echo "LOG_LINES=9999" >> mailcow.conf
+            ;;
+        IPV4_NETWORK)
+            echo '# Internal IPv4 /24 subnet, format n.n.n. (expands to n.n.n.0/24)' >> mailcow.conf
+            echo "IPV4_NETWORK=172.22.1" >> mailcow.conf
+            ;;
+        IPV6_NETWORK)
+            echo '# Internal IPv6 subnet in fc00::/7' >> mailcow.conf
+            echo "IPV6_NETWORK=fd4d:6169:6c63:6f77::/64" >> mailcow.conf
+            ;;
+        SQL_PORT)
+            echo '# Bind SQL to 127.0.0.1 on port 13306' >> mailcow.conf
+            echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf
+            ;;
+        API_KEY)
+            echo '# Create or override API key for web UI' >> mailcow.conf
+            echo "#API_KEY=" >> mailcow.conf
+            ;;
+        API_KEY_READ_ONLY)
+            echo '# Create or override read-only API key for web UI' >> mailcow.conf
+            echo "#API_KEY_READ_ONLY=" >> mailcow.conf
+            ;;
+        API_ALLOW_FROM)
+            echo '# Must be set for API_KEY to be active' >> mailcow.conf
+            echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf
+            echo "#API_ALLOW_FROM=" >> mailcow.conf
+            ;;
+        SNAT_TO_SOURCE)
+            echo '# Use this IPv4 for outgoing connections (SNAT)' >> mailcow.conf
+            echo "#SNAT_TO_SOURCE=" >> mailcow.conf
+            ;;
+        SNAT6_TO_SOURCE)
+            echo '# Use this IPv6 for outgoing connections (SNAT)' >> mailcow.conf
+            echo "#SNAT6_TO_SOURCE=" >> mailcow.conf
+            ;;
+        MAILDIR_GC_TIME)
+            echo '# Garbage collector cleanup' >> mailcow.conf
+            echo '# Deleted domains and mailboxes are moved to /var/vmail/_garbage/timestamp_sanitizedstring' >> mailcow.conf
+            echo '# How long should objects remain in the garbage until they are being deleted? (value in minutes)' >> mailcow.conf
+            echo '# Check interval is hourly' >> mailcow.conf
+            echo 'MAILDIR_GC_TIME=1440' >> mailcow.conf
+            ;;
+        ACL_ANYONE)
+            echo '# Set this to "allow" to enable the anyone pseudo user. Disabled by default.' >> mailcow.conf
+            echo '# When enabled, ACL can be created, that apply to "All authenticated users"' >> mailcow.conf
+            echo '# This should probably only be activated on mail hosts, that are used exclusively by one organisation.' >> mailcow.conf
+            echo '# Otherwise a user might share data with too many other users.' >> mailcow.conf
+            echo 'ACL_ANYONE=disallow' >> mailcow.conf
+            ;;
+        FTS_HEAP)
+            echo '# Dovecot Indexing (FTS) Process maximum heap size in MB, there is no recommendation, please see Dovecot docs.' >> mailcow.conf
+            echo '# Flatcurve is used as FTS Engine. It is supposed to be pretty efficient in CPU and RAM consumption.' >> mailcow.conf
+            echo '# Please always monitor your Resource consumption!' >> mailcow.conf
+            echo "FTS_HEAP=128" >> mailcow.conf
+            ;;
+        SKIP_FTS)
+            echo '# Skip FTS (Fulltext Search) for Dovecot on low-memory, low-threaded systems or if you simply want to disable it.' >> mailcow.conf
+            echo "# Dovecot inside mailcow use Flatcurve as FTS Backend." >> mailcow.conf
+            echo "SKIP_FTS=y" >> mailcow.conf
+            ;;
+        FTS_PROCS)
+            echo '# Controls how many processes the Dovecot indexing process can spawn at max.' >> mailcow.conf
+            echo '# Too many indexing processes can use a lot of CPU and Disk I/O' >> mailcow.conf
+            echo '# Please visit: https://doc.dovecot.org/configuration_manual/service_configuration/#indexer-worker for more informations' >> mailcow.conf
+            echo "FTS_PROCS=1" >> mailcow.conf
+            ;;
+        ENABLE_SSL_SNI)
+            echo '# Create seperate certificates for all domains - y/n' >> mailcow.conf
+            echo '# this will allow adding more than 100 domains, but some email clients will not be able to connect with alternative hostnames' >> mailcow.conf
+            echo '# see https://wiki.dovecot.org/SSL/SNIClientSupport' >> mailcow.conf
+            echo "ENABLE_SSL_SNI=n" >> mailcow.conf
+            ;;
+        SKIP_SOGO)
+            echo '# Skip SOGo: Will disable SOGo integration and therefore webmail, DAV protocols and ActiveSync support (experimental, unsupported, not fully implemented) - y/n' >> mailcow.conf
+            echo "SKIP_SOGO=n" >> mailcow.conf
+            ;;
+        MAILDIR_SUB)
+            echo '# MAILDIR_SUB defines a path in a users virtual home to keep the maildir in. Leave empty for updated setups.' >> mailcow.conf
+            echo "#MAILDIR_SUB=Maildir" >> mailcow.conf
+            echo "MAILDIR_SUB=" >> mailcow.conf
+            ;;
+        WATCHDOG_NOTIFY_WEBHOOK)
+            echo '# Send notifications to a webhook URL that receives a POST request with the content type "application/json".' >> mailcow.conf
+            echo '# You can use this to send notifications to services like Discord, Slack and others.' >> mailcow.conf
+            echo '#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' >> mailcow.conf
+            ;;
+        WATCHDOG_NOTIFY_WEBHOOK_BODY)
+            echo '# JSON body included in the webhook POST request. Needs to be in single quotes.' >> mailcow.conf
+            echo '# Following variables are available: SUBJECT, BODY' >> mailcow.conf
+            WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
+            echo "#WATCHDOG_NOTIFY_WEBHOOK_BODY='${WEBHOOK_BODY}'" >> mailcow.conf
+            ;;
+        WATCHDOG_NOTIFY_BAN)
+            echo '# Notify about banned IP. Includes whois lookup.' >> mailcow.conf
+            echo "WATCHDOG_NOTIFY_BAN=y" >> mailcow.conf
+            ;;
+        WATCHDOG_NOTIFY_START)
+            echo '# Send a notification when the watchdog is started.' >> mailcow.conf
+            echo "WATCHDOG_NOTIFY_START=y" >> mailcow.conf
+            ;;
+        WATCHDOG_SUBJECT)
+            echo '# Subject for watchdog mails. Defaults to "Watchdog ALERT" followed by the error message.' >> mailcow.conf
+            echo "#WATCHDOG_SUBJECT=" >> mailcow.conf
+            ;;
+        WATCHDOG_EXTERNAL_CHECKS)
+            echo '# Checks if mailcow is an open relay. Requires a SAL. More checks will follow.' >> mailcow.conf
+            echo '# No data is collected. Opt-in and anonymous.' >> mailcow.conf
+            echo '# Will only work with unmodified mailcow setups.' >> mailcow.conf
+            echo "WATCHDOG_EXTERNAL_CHECKS=n" >> mailcow.conf
+            ;;
+        SOGO_EXPIRE_SESSION)
+            echo '# SOGo session timeout in minutes' >> mailcow.conf
+            echo "SOGO_EXPIRE_SESSION=480" >> mailcow.conf
+            ;;
+        REDIS_PORT)
+            echo "REDIS_PORT=127.0.0.1:7654" >> mailcow.conf
+            ;;
+        DOVECOT_MASTER_USER)
+            echo '# DOVECOT_MASTER_USER and _PASS must _both_ be provided. No special chars.' >> mailcow.conf
+            echo '# Empty by default to auto-generate master user and password on start.' >> mailcow.conf
+            echo '# User expands to DOVECOT_MASTER_USER@mailcow.local' >> mailcow.conf
+            echo '# LEAVE EMPTY IF UNSURE' >> mailcow.conf
+            echo "DOVECOT_MASTER_USER=" >> mailcow.conf
+            ;;
+        DOVECOT_MASTER_PASS)
+            echo '# LEAVE EMPTY IF UNSURE' >> mailcow.conf
+            echo "DOVECOT_MASTER_PASS=" >> mailcow.conf
+            ;;
+        MAILCOW_PASS_SCHEME)
+            echo '# Password hash algorithm' >> mailcow.conf
+            echo '# Only certain password hash algorithm are supported. For a fully list of supported schemes,' >> mailcow.conf
+            echo '# see https://docs.mailcow.email/models/model-passwd/' >> mailcow.conf
+            echo "MAILCOW_PASS_SCHEME=BLF-CRYPT" >> mailcow.conf
+            ;;
+        ADDITIONAL_SERVER_NAMES)
+            echo '# Additional server names for mailcow UI' >> mailcow.conf
+            echo '#' >> mailcow.conf
+            echo '# Specify alternative addresses for the mailcow UI to respond to' >> mailcow.conf
+            echo '# This is useful when you set mail.* as ADDITIONAL_SAN and want to make sure mail.maildomain.com will always point to the mailcow UI.' >> mailcow.conf
+            echo '# If the server name does not match a known site, Nginx decides by best-guess and may redirect users to the wrong web root.' >> mailcow.conf
+            echo '# You can understand this as server_name directive in Nginx.' >> mailcow.conf
+            echo '# Comma separated list without spaces! Example: ADDITIONAL_SERVER_NAMES=a.b.c,d.e.f' >> mailcow.conf
+            echo 'ADDITIONAL_SERVER_NAMES=' >> mailcow.conf
+            ;;
+        WEBAUTHN_ONLY_TRUSTED_VENDORS)
+            echo "# WebAuthn device manufacturer verification" >> mailcow.conf
+            echo '# After setting WEBAUTHN_ONLY_TRUSTED_VENDORS=y only devices from trusted manufacturers are allowed' >> mailcow.conf
+            echo '# root certificates can be placed for validation under mailcow-dockerized/data/web/inc/lib/WebAuthn/rootCertificates' >> mailcow.conf
+            echo 'WEBAUTHN_ONLY_TRUSTED_VENDORS=n' >> mailcow.conf
+            ;;
+        SPAMHAUS_DQS_KEY)
+            echo "# Spamhaus Data Query Service Key" >> mailcow.conf
+            echo '# Optional: Leave empty for none' >> mailcow.conf
+            echo '# Enter your key here if you are using a blocked ASN (OVH, AWS, Cloudflare e.g) for the unregistered Spamhaus Blocklist.' >> mailcow.conf
+            echo '# If empty, it will completely disable Spamhaus blocklists if it detects that you are running on a server using a blocked AS.' >> mailcow.conf
+            echo '# Otherwise it will work as usual.' >> mailcow.conf
+            echo 'SPAMHAUS_DQS_KEY=' >> mailcow.conf
+            ;;
+        WATCHDOG_VERBOSE)
+            echo '# Enable watchdog verbose logging' >> mailcow.conf
+            echo 'WATCHDOG_VERBOSE=n' >> mailcow.conf
+            ;;
+        SKIP_UNBOUND_HEALTHCHECK)
+            echo '# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n' >> mailcow.conf
+            echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
+            ;;
+        DISABLE_NETFILTER_ISOLATION_RULE)
+            echo '# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n' >> mailcow.conf
+            echo '# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost' >> mailcow.conf
+            echo 'DISABLE_NETFILTER_ISOLATION_RULE=n' >> mailcow.conf
+            ;;
+        HTTP_REDIRECT)
+            echo '# Redirect HTTP connections to HTTPS - y/n' >> mailcow.conf
+            echo 'HTTP_REDIRECT=n' >> mailcow.conf
+            ;;
+        ENABLE_IPV6)
+            echo '# IPv6 Controller Section' >> mailcow.conf
+            echo '# This variable controls the usage of IPv6 within mailcow.' >> mailcow.conf
+            echo '# Can either be true or false | Defaults to true' >> mailcow.conf
+            echo '# WARNING: MAKE SURE TO PROPERLY CONFIGURE IPv6 ON YOUR HOST FIRST BEFORE ENABLING THIS AS FAULTY CONFIGURATIONS CAN LEAD TO OPEN RELAYS!' >> mailcow.conf
+            echo '# A COMPLETE DOCKER STACK REBUILD (compose down && compose up -d) IS NEEDED TO APPLY THIS.' >> mailcow.conf
+            echo ENABLE_IPV6=${IPV6_BOOL} >> mailcow.conf
+            ;;
+        SKIP_CLAMD)
+            echo '# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n' >> mailcow.conf
+            echo 'SKIP_CLAMD=n' >> mailcow.conf
+            ;;
+        SKIP_OLEFY)
+            echo '# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n' >> mailcow.conf
+            echo 'SKIP_OLEFY=n' >> mailcow.conf
+            ;;
+        REDISPASS)
+            echo "REDISPASS=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 28)" >> mailcow.conf
+            ;;
+        SOGO_URL_ENCRYPTION_KEY)
+            echo '# SOGo URL encryption key (exactly 16 characters, limited to A–Z, a–z, 0–9)' >> mailcow.conf
+            echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
+            echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
+            ;;
+        *)
+            echo "${option}=" >> mailcow.conf
+            ;;
+    esac
+  done
+}

+ 2 - 3
data/Dockerfiles/acme/Dockerfile

@@ -1,8 +1,7 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 
-
 RUN apk upgrade --no-cache \
   && apk add --update --no-cache \
   bash \
@@ -15,7 +14,7 @@ RUN apk upgrade --no-cache \
   tini \
   tzdata \
   python3 \
-  acme-tiny --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/
+  acme-tiny
 
 COPY acme.sh /srv/acme.sh
 COPY functions.sh /srv/functions.sh

+ 4 - 16
data/Dockerfiles/acme/acme.sh

@@ -138,7 +138,7 @@ log_f "Resolver OK"
 log_f "Waiting for domain table..."
 while [[ -z ${DOMAIN_TABLE} ]]; do
   curl --silent http://nginx.${COMPOSE_PROJECT_NAME}_mailcow-network/ >/dev/null 2>&1
-  DOMAIN_TABLE=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'domain'" -Bs)
+  DOMAIN_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'domain'" -Bs)
   [[ -z ${DOMAIN_TABLE} ]] && sleep 10
 done
 log_f "OK" no_date
@@ -159,18 +159,6 @@ while true; do
   fi
   if [[ ! -f ${ACME_BASE}/acme/account.pem ]]; then
     log_f "Generating missing Lets Encrypt account key..."
-    if [[ ! -z ${ACME_CONTACT} ]]; then
-      if ! verify_email "${ACME_CONTACT}"; then
-        log_f "Invalid email address, will not start registration!"
-        sleep 365d
-        exec $(readlink -f "$0")
-      else
-        ACME_CONTACT_PARAMETER="--contact mailto:${ACME_CONTACT}"
-        log_f "Valid email address, using ${ACME_CONTACT} for registration"
-      fi
-    else
-      ACME_CONTACT_PARAMETER=""
-    fi
     openssl genrsa 4096 > ${ACME_BASE}/acme/account.pem
   else
     log_f "Using existing Lets Encrypt account key ${ACME_BASE}/acme/account.pem"
@@ -218,7 +206,7 @@ while true; do
 
   if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
   # Fetch certs for autoconfig and autodiscover subdomains
-  ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig')
+  ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig' 'mta-sts')
   fi
 
   if [[ ${SKIP_IP_CHECK} != "y" ]]; then
@@ -231,7 +219,7 @@ while true; do
 
   #########################################
   # IP and webroot challenge verification #
-  SQL_DOMAINS=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain WHERE backupmx=0 and active=1" -Bs)
+  SQL_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain WHERE backupmx=0 and active=1" -Bs)
   if [[ ! $? -eq 0 ]]; then
     log_f "Failed to read SQL domains, retrying in 1 minute..."
     sleep 1m
@@ -299,7 +287,7 @@ while true; do
     VALIDATED_CERTIFICATES+=("${CERT_NAME}")
 
     # obtain server certificate if required
-    ACME_CONTACT_PARAMETER=${ACME_CONTACT_PARAMETER} DOMAINS=${SERVER_SAN_VALIDATED[@]} /srv/obtain-certificate.sh rsa
+    DOMAINS=${SERVER_SAN_VALIDATED[@]} /srv/obtain-certificate.sh rsa
     RETURN="$?"
     if [[ "$RETURN" == "0" ]]; then # 0 = cert created successfully
       CERT_AMOUNT_CHANGED=1

+ 2 - 2
data/Dockerfiles/acme/obtain-certificate.sh

@@ -93,8 +93,8 @@ until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
   sleep 2
 done
 log_f "Resolver OK"
-log_f "Using command acme-tiny ${DIRECTORY_URL} ${ACME_CONTACT_PARAMETER} --account-key ${ACME_BASE}/acme/account.pem --disable-check --csr ${CSR} --acme-dir /var/www/acme/"
-ACME_RESPONSE=$(acme-tiny ${DIRECTORY_URL} ${ACME_CONTACT_PARAMETER} \
+log_f "Using command acme-tiny ${DIRECTORY_URL} --account-key ${ACME_BASE}/acme/account.pem --disable-check --csr ${CSR} --acme-dir /var/www/acme/"
+ACME_RESPONSE=$(acme-tiny ${DIRECTORY_URL} \
   --account-key ${ACME_BASE}/acme/account.pem \
   --disable-check \
   --csr ${CSR} \

+ 1 - 1
data/Dockerfiles/backup/Dockerfile

@@ -1,3 +1,3 @@
 FROM debian:bookworm-slim
 
-RUN apt update && apt install pigz
+RUN apt update && apt install pigz -y --no-install-recommends

+ 1 - 1
data/Dockerfiles/clamd/clamd.sh

@@ -8,7 +8,7 @@ fi
 
 # Cleaning up garbage
 echo "Cleaning up tmp files..."
-rm -rf /var/lib/clamav/clamav-*.tmp
+rm -rf /var/lib/clamav/tmp.*
 
 # Prepare whitelist
 

+ 1 - 1
data/Dockerfiles/clamd/clamdcheck.sh

@@ -11,4 +11,4 @@ if [ "${CLAMAV_NO_CLAMD:-}" != "false" ]; then
 	echo "Clamd is up"
 fi
 
-exit 0
+exit 0

+ 1 - 1
data/Dockerfiles/dockerapi/Dockerfile

@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 

+ 2 - 2
data/Dockerfiles/dockerapi/main.py

@@ -241,9 +241,9 @@ async def handle_pubsub_messages(channel: aioredis.client.PubSub):
               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))
+              dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
           else:
-            dockerapi.logger.error("Unknwon PubSub recieved - %s" % json.dumps(data_json))
+            dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
 
         await asyncio.sleep(0.0)
     except asyncio.TimeoutError:

+ 7 - 3
data/Dockerfiles/dovecot/Dockerfile

@@ -1,9 +1,9 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
 
 # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
-ARG GOSU_VERSION=1.16
+ARG GOSU_VERSION=1.17
 
 ENV LANG=C.UTF-8
 ENV LC_ALL=C.UTF-8
@@ -34,9 +34,13 @@ RUN addgroup -g 5000 vmail \
   lua5.3-sql-mysql \
   icu-data-full \
   mariadb-connector-c \
+  lua-sec \
+  mariadb-dev \
+  glib-dev \
   gcompat \
   mariadb-client \
   perl \
+  perl-dev \
   perl-ntlm \
   perl-cgi \
   perl-crypt-openssl-rsa \
@@ -65,7 +69,7 @@ RUN addgroup -g 5000 vmail \
   perl-par-packer \
   perl-parse-recdescent \
   perl-lockfile-simple \
-  libproc \
+  libproc2 \
   perl-readonly \
   perl-regexp-common \
   perl-sys-meminfo \

+ 2 - 2
data/Dockerfiles/dovecot/clean_q_aged.sh

@@ -15,6 +15,6 @@ if ! [[ ${MAX_AGE} =~ ${NUM_REGEXP} ]] ; then
   exit 1
 fi
 
-TO_DELETE=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT COUNT(id) FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY" -BN)
-mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DELETE FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY"
+TO_DELETE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT COUNT(id) FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY" -BN)
+mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DELETE FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY"
 echo "Deleted ${TO_DELETE} items from quarantine table (max age is ${MAX_AGE//[!0-9]/} days)"

+ 9 - 126
data/Dockerfiles/dovecot/docker-entrypoint.sh

@@ -28,7 +28,7 @@ ${VALKEY_CMDLINE} SET DOVECOT_REPL_HEALTH 1 > /dev/null
 
 # Create missing directories
 [[ ! -d /etc/dovecot/sql/ ]] && mkdir -p /etc/dovecot/sql/
-[[ ! -d /etc/dovecot/lua/ ]] && mkdir -p /etc/dovecot/lua/
+[[ ! -d /etc/dovecot/auth/ ]] && mkdir -p /etc/dovecot/auth/
 [[ ! -d /etc/dovecot/conf.d/ ]] && mkdir -p /etc/dovecot/conf.d/
 [[ ! -d /var/vmail/_garbage ]] && mkdir -p /var/vmail/_garbage
 [[ ! -d /var/vmail/sieve ]] && mkdir -p /var/vmail/sieve
@@ -131,123 +131,6 @@ user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format
 iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
 EOF
 
-cat <<EOF > /etc/dovecot/lua/passwd-verify.lua
-function auth_password_verify(req, pass)
-
-  if req.domain == nil then
-    return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
-  end
-
-  if cur == nil then
-    script_init()
-  end
-
-  if req.user == nil then
-    req.user = ''
-  end
-
-  respbody = {}
-
-  -- check against mailbox passwds
-  local cur,errorString = con:execute(string.format([[SELECT password FROM mailbox
-    WHERE username = '%s'
-      AND active = '1'
-      AND domain IN (SELECT domain FROM domain WHERE domain='%s' AND active='1')
-      AND IFNULL(JSON_UNQUOTE(JSON_VALUE(mailbox.attributes, '$.force_pw_update')), 0) != '1'
-      AND IFNULL(JSON_UNQUOTE(JSON_VALUE(attributes, '$.%s_access')), 1) = '1']], con:escape(req.user), con:escape(req.domain), con:escape(req.service)))
-  local row = cur:fetch ({}, "a")
-  while row do
-    if req.password_verify(req, row.password, pass) == 1 then
-      con:execute(string.format([[REPLACE INTO sasl_log (service, app_password, username, real_rip)
-        VALUES ("%s", 0, "%s", "%s")]], con:escape(req.service), con:escape(req.user), con:escape(req.real_rip)))
-      cur:close()
-      con:close()
-      return dovecot.auth.PASSDB_RESULT_OK, ""
-    end
-    row = cur:fetch (row, "a")
-  end
-
-  -- check against app passwds for imap and smtp
-  -- app passwords are only available for imap, smtp, sieve and pop3 when using sasl
-  if req.service == "smtp" or req.service == "imap" or req.service == "sieve" or req.service == "pop3" then
-    local cur,errorString = con:execute(string.format([[SELECT app_passwd.id, %s_access AS has_prot_access, app_passwd.password FROM app_passwd
-      INNER JOIN mailbox ON mailbox.username = app_passwd.mailbox
-      WHERE mailbox = '%s'
-        AND app_passwd.active = '1'
-        AND mailbox.active = '1'
-        AND app_passwd.domain IN (SELECT domain FROM domain WHERE domain='%s' AND active='1')]], con:escape(req.service), con:escape(req.user), con:escape(req.domain)))
-    local row = cur:fetch ({}, "a")
-    while row do
-      if req.password_verify(req, row.password, pass) == 1 then
-        -- if password is valid and protocol access is 1 OR real_rip matches SOGo, proceed
-        if tostring(req.real_rip) == "__IPV4_SOGO__" then
-          cur:close()
-          con:close()
-          return dovecot.auth.PASSDB_RESULT_OK, ""
-        elseif row.has_prot_access == "1" then
-          con:execute(string.format([[REPLACE INTO sasl_log (service, app_password, username, real_rip)
-            VALUES ("%s", %d, "%s", "%s")]], con:escape(req.service), row.id, con:escape(req.user), con:escape(req.real_rip)))
-          cur:close()
-          con:close()
-          return dovecot.auth.PASSDB_RESULT_OK, ""
-        end
-      end
-      row = cur:fetch (row, "a")
-    end
-  end
-
-  cur:close()
-  con:close()
-
-  return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Failed to authenticate"
-
-  -- PoC
-  -- local reqbody = string.format([[{
-  --   "success":0,
-  --   "service":"%s",
-  --   "app_password":false,
-  --   "username":"%s",
-  --   "real_rip":"%s"
-  -- }]], con:escape(req.service), con:escape(req.user), con:escape(req.real_rip))
-  -- http.request {
-  --   method = "POST",
-  --   url = "http://nginx:8081/sasl_log.php",
-  --   source = ltn12.source.string(reqbody),
-  --   headers = {
-  --     ["content-type"] = "application/json",
-  --     ["content-length"] = tostring(#reqbody)
-  --   },
-  --   sink = ltn12.sink.table(respbody)
-  -- }
-
-end
-
-function auth_passdb_lookup(req)
-   return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, ""
-end
-
-function script_init()
-  mysql = require "luasql.mysql"
-  http = require "socket.http"
-  http.TIMEOUT = 5
-  ltn12 = require "ltn12"
-  env  = mysql.mysql()
-  con = env:connect("__DBNAME__","__DBUSER__","__DBPASS__","localhost")
-  return 0
-end
-
-function script_deinit()
-  con:close()
-  env:close()
-end
-EOF
-
-# Replace patterns in app-passdb.lua
-sed -i "s/__DBUSER__/${DBUSER}/g" /etc/dovecot/lua/passwd-verify.lua
-sed -i "s/__DBPASS__/${DBPASS}/g" /etc/dovecot/lua/passwd-verify.lua
-sed -i "s/__DBNAME__/${DBNAME}/g" /etc/dovecot/lua/passwd-verify.lua
-sed -i "s/__IPV4_SOGO__/${IPV4_NETWORK}.248/g" /etc/dovecot/lua/passwd-verify.lua
-
 
 # Migrate old sieve_after file
 [[ -f /etc/dovecot/sieve_after ]] && mv /etc/dovecot/sieve_after /etc/dovecot/global_sieve_after
@@ -385,8 +268,8 @@ sievec /usr/lib/dovecot/sieve/report-ham.sieve
 
 # Fix permissions
 chown root:root /etc/dovecot/sql/*.conf
-chown root:dovecot /etc/dovecot/sql/dovecot-dict-sql-sieve* /etc/dovecot/sql/dovecot-dict-sql-quota* /etc/dovecot/lua/passwd-verify.lua
-chmod 640 /etc/dovecot/sql/*.conf /etc/dovecot/lua/passwd-verify.lua
+chown root:dovecot /etc/dovecot/sql/dovecot-dict-sql-sieve* /etc/dovecot/sql/dovecot-dict-sql-quota* /etc/dovecot/auth/passwd-verify.lua
+chmod 640 /etc/dovecot/sql/*.conf /etc/dovecot/auth/passwd-verify.lua
 chown -R vmail:vmail /var/vmail/sieve
 chown -R vmail:vmail /var/volatile
 chown -R vmail:vmail /var/vmail_index
@@ -414,15 +297,15 @@ printenv | sed 's/^\(.*\)$/export \1/g' > /source_env.sh
 
 # Clean stopped imapsync jobs
 rm -f /tmp/imapsync_busy.lock
-IMAPSYNC_TABLE=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'imapsync'" -Bs)
-[[ ! -z ${IMAPSYNC_TABLE} ]] && mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "UPDATE imapsync SET is_running='0'"
+IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'imapsync'" -Bs)
+[[ ! -z ${IMAPSYNC_TABLE} ]] && mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "UPDATE imapsync SET is_running='0'"
 
 # Envsubst maildir_gc
 echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
 
 # GUID generation
 while [[ ${VERSIONS_OK} != 'OK' ]]; do
-  if [[ ! -z $(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = \"${DBNAME}\" AND TABLE_NAME = 'versions'") ]]; then
+  if [[ ! -z $(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = \"${DBNAME}\" AND TABLE_NAME = 'versions'") ]]; then
     VERSIONS_OK=OK
   else
     echo "Waiting for versions table to be created..."
@@ -433,11 +316,11 @@ PUBKEY_MCRYPT=$(doveconf -P 2> /dev/null | grep -i mail_crypt_global_public_key
 if [ -f ${PUBKEY_MCRYPT} ]; then
   GUID=$(cat <(echo ${MAILCOW_HOSTNAME}) /mail_crypt/ecpubkey.pem | sha256sum | cut -d ' ' -f1 | tr -cd "[a-fA-F0-9.:/] ")
   if [ ${#GUID} -eq 64 ]; then
-    mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+    mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
 REPLACE INTO versions (application, version) VALUES ("GUID", "${GUID}");
 EOF
   else
-    mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+    mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
 REPLACE INTO versions (application, version) VALUES ("GUID", "INVALID");
 EOF
   fi
@@ -456,7 +339,7 @@ done
 
 # For some strange, unknown and stupid reason, Dovecot may run into a race condition, when this file is not touched before it is read by dovecot/auth
 # May be related to something inside Docker, I seriously don't know
-touch /etc/dovecot/lua/passwd-verify.lua
+touch /etc/dovecot/auth/passwd-verify.lua
 
 if [[ ! -z ${VALKEY_SLAVEOF_IP} ]]; then
   cp /etc/syslog-ng/syslog-ng-valkey_slave.conf /etc/syslog-ng/syslog-ng.conf

+ 2 - 2
data/Dockerfiles/dovecot/imapsync_runner.pl

@@ -132,8 +132,8 @@ while ($row = $sth->fetchrow_arrayref()) {
   "--tmpdir", "/tmp",
   "--nofoldersizes",
   "--addheader",
-  ($timeout1 gt "0" ? () : ('--timeout1', $timeout1)),
-  ($timeout2 gt "0" ? () : ('--timeout2', $timeout2)),
+  ($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
+  ($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
   ($exclude eq "" ? () : ("--exclude", $exclude)),
   ($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
   ($maxage eq "0" ? () : ('--maxage', $maxage)),

+ 17 - 11
data/Dockerfiles/dovecot/quarantine_notify.py

@@ -8,7 +8,8 @@ from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from email.utils import COMMASPACE, formatdate
 import jinja2
-from jinja2 import Template
+from jinja2 import TemplateError
+from jinja2.sandbox import SandboxedEnvironment
 import json
 import redis
 import time
@@ -75,22 +76,27 @@ try:
 
   def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
     if category == "add_header": category = "add header"
-    meta_query = query_mysql('SELECT SHA2(CONCAT(id, qid), 256) AS qhash, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
+    meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
     print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
     if len(meta_query) == 0:
       return
     msg_count = len(meta_query)
+    env = SandboxedEnvironment()
     if r.get('Q_HTML'):
-      try:
-        template = Template(r.get('Q_HTML'))
-      except:
-        print("Error: Cannot parse quarantine template, falling back to default template.")
-        with open('/templates/quarantine.tpl') as file_:
-          template = Template(file_.read())
+        try:
+            template = env.from_string(r.get('Q_HTML'))
+        except Exception:
+            print("Error: Cannot parse quarantine template, falling back to default template.")
+            with open('/templates/quarantine.tpl') as file_:
+                template = env.from_string(file_.read())
     else:
-      with open('/templates/quarantine.tpl') as file_:
-        template = Template(file_.read())
-    html = template.render(meta=meta_query, username=rcpt, counter=msg_count, hostname=mailcow_hostname, quarantine_acl=quarantine_acl)
+        with open('/templates/quarantine.tpl') as file_:
+            template = env.from_string(file_.read())
+    try:
+        html = template.render(meta=meta_query, username=rcpt, counter=msg_count, hostname=mailcow_hostname, quarantine_acl=quarantine_acl)
+    except (jinja2.exceptions.SecurityError, TemplateError) as ex:
+        print(f"SecurityError or TemplateError in template rendering: {ex}")
+        return
     text = html2text.html2text(html)
     count = 0
     while count < 15:

+ 15 - 7
data/Dockerfiles/dovecot/quota_notify.py

@@ -6,7 +6,7 @@ from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from email.utils import COMMASPACE, formatdate
 import jinja2
-from jinja2 import Template
+from jinja2.sandbox import SandboxedEnvironment
 import redis
 import time
 import json
@@ -33,16 +33,24 @@ while True:
 
 if r.get('QW_HTML'):
   try:
-    template = Template(r.get('QW_HTML'))
-  except:
-    print("Error: Cannot parse quarantine template, falling back to default template.")
+    env = SandboxedEnvironment()
+    template = env.from_string(r.get('QW_HTML'))
+  except Exception:
+    print("Error: Cannot parse quota template, falling back to default template.")
     with open('/templates/quota.tpl') as file_:
-      template = Template(file_.read())
+      env = SandboxedEnvironment()
+      template = env.from_string(file_.read())
 else:
   with open('/templates/quota.tpl') as file_:
-    template = Template(file_.read())
+    env = SandboxedEnvironment()
+    template = env.from_string(file_.read())
+
+try:
+  html = template.render(username=username, percent=percent)
+except (jinja2.exceptions.SecurityError, jinja2.TemplateError) as ex:
+  print(f"SecurityError or TemplateError in template rendering: {ex}")
+  sys.exit(1)
 
-html = template.render(username=username, percent=percent)
 text = html2text.html2text(html)
 
 try:

+ 1 - 1
data/Dockerfiles/netfilter/Dockerfile

@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 

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

@@ -1,6 +1,6 @@
 #!/bin/sh
 
-backend=iptables
+backend=nftables
 
 nft list table ip filter &>/dev/null
 nftables_found=$?

+ 106 - 61
data/Dockerfiles/netfilter/main.py

@@ -1,5 +1,7 @@
 #!/usr/bin/env python3
 
+DEBUG = False
+
 import re
 import os
 import sys
@@ -20,10 +22,13 @@ from modules.Logger import Logger
 from modules.IPTables import IPTables
 from modules.NFTables import NFTables
 
+def logdebug(msg):
+  if DEBUG:
+    logger.logInfo("DEBUG: %s" % msg)
 
-# globals
+# Globals
 WHITELIST = []
-BLACKLIST= []
+BLACKLIST = []
 bans = {}
 quit_now = False
 exit_code = 0
@@ -33,12 +38,10 @@ r = None
 pubsub = None
 clear_before_quit = False
 
-
 def refreshF2boptions():
   global f2boptions
   global quit_now
   global exit_code
-
   f2boptions = {}
 
   if not valkey.get('F2B_OPTIONS'):
@@ -61,15 +64,15 @@ def refreshF2boptions():
   valkey.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
 
 def verifyF2boptions(f2boptions):
-  verifyF2boption(f2boptions,'ban_time', 1800)
-  verifyF2boption(f2boptions,'max_ban_time', 10000)
-  verifyF2boption(f2boptions,'ban_time_increment', True)
-  verifyF2boption(f2boptions,'max_attempts', 10)
-  verifyF2boption(f2boptions,'retry_window', 600)
-  verifyF2boption(f2boptions,'netban_ipv4', 32)
-  verifyF2boption(f2boptions,'netban_ipv6', 128)
-  verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4()))
-  verifyF2boption(f2boptions,'manage_external', 0)
+  verifyF2boption(f2boptions, 'ban_time', 1800)
+  verifyF2boption(f2boptions, 'max_ban_time', 10000)
+  verifyF2boption(f2boptions, 'ban_time_increment', True)
+  verifyF2boption(f2boptions, 'max_attempts', 10)
+  verifyF2boption(f2boptions, 'retry_window', 600)
+  verifyF2boption(f2boptions, 'netban_ipv4', 32)
+  verifyF2boption(f2boptions, 'netban_ipv6', 128)
+  verifyF2boption(f2boptions, 'banlist_id', str(uuid.uuid4()))
+  verifyF2boption(f2boptions, 'manage_external', 0)
 
 def verifyF2boption(f2boptions, f2boption, f2bdefault):
   f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
@@ -111,7 +114,7 @@ def get_ip(address):
 def ban(address):
   global f2boptions
   global lock
-
+  logdebug("ban() called with address=%s" % address)
   refreshF2boptions()
   MAX_ATTEMPTS = int(f2boptions['max_attempts'])
   RETRY_WINDOW = int(f2boptions['retry_window'])
@@ -119,31 +122,43 @@ def ban(address):
   NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
 
   ip = get_ip(address)
-  if not ip: return
+  if not ip:
+    logdebug("No valid IP -- skipping ban()")
+    return
   address = str(ip)
   self_network = ipaddress.ip_network(address)
 
   with lock:
     temp_whitelist = set(WHITELIST)
-  if temp_whitelist:
-    for wl_key in temp_whitelist:
-      wl_net = ipaddress.ip_network(wl_key, False)
-      if wl_net.overlaps(self_network):
-        logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
-        return
-
-  net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
+    logdebug("Checking if %s overlaps with any WHITELIST entries" % self_network)
+    if temp_whitelist:
+      for wl_key in temp_whitelist:
+        wl_net = ipaddress.ip_network(wl_key, False)
+        logdebug("Checking overlap between %s and %s" % (self_network, wl_net))
+        if wl_net.overlaps(self_network):
+          logger.logInfo(
+            'Address %s is allowlisted by rule %s' % (self_network, wl_net))
+          return
+
+  net = ipaddress.ip_network(
+    (address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
   net = str(net)
+  logdebug("Ban net: %s" % net)
 
   if not net in bans:
     bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
+    logdebug("Initing new ban counter for %s" % net)
 
   current_attempt = time.time()
+  logdebug("Current attempt ts=%s, previous: %s, retry_window: %s" %
+           (current_attempt, bans[net]['last_attempt'], RETRY_WINDOW))
   if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
     bans[net]['attempts'] = 0
+    logdebug("Ban counter for %s reset as window expired" % net)
 
   bans[net]['attempts'] += 1
   bans[net]['last_attempt'] = current_attempt
+  logdebug("%s attempts now %d" % (net, bans[net]['attempts']))
 
   if bans[net]['attempts'] >= MAX_ATTEMPTS:
     cur_time = int(round(time.time()))
@@ -151,34 +166,41 @@ def ban(address):
     logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
     if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1:
       with lock:
+        logdebug("Calling tables.banIPv4(%s)" % net)
         tables.banIPv4(net)
     elif int(f2boptions['manage_external']) != 1:
       with lock:
+        logdebug("Calling tables.banIPv6(%s)" % net)
         tables.banIPv6(net)
 
+    logdebug("Updating F2B_ACTIVE_BANS[%s]=%d" %
+              (net, cur_time + NET_BAN_TIME))
     valkey.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
   else:
-    logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
+    logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (
+      MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
 
 def unban(net):
   global lock
-
+  logdebug("Calling unban() with net=%s" % net)
   if not net in bans:
-   logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
-   valkey.hdel('F2B_QUEUE_UNBAN', '%s' % net)
-   return
-
+    logger.logInfo(
+      '%s is not banned, skipping unban and deleting from queue (if any)' % net)
+    valkey.hdel('F2B_QUEUE_UNBAN', '%s' % net)
+    return
   logger.logInfo('Unbanning %s' % net)
   if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
     with lock:
+      logdebug("Calling tables.unbanIPv4(%s)" % net)
       tables.unbanIPv4(net)
   else:
     with lock:
+      logdebug("Calling tables.unbanIPv6(%s)" % net)
       tables.unbanIPv6(net)
-
   valkey.hdel('F2B_ACTIVE_BANS', '%s' % net)
   valkey.hdel('F2B_QUEUE_UNBAN', '%s' % net)
   if net in bans:
+    logdebug("Unban for %s, setting attempts=0, ban_counter+=1" % net)
     bans[net]['attempts'] = 0
     bans[net]['ban_counter'] += 1
 
@@ -204,17 +226,19 @@ def permBan(net, unban=False):
 
   if is_unbanned:
     valkey.hdel('F2B_PERM_BANS', '%s' % net)
-    logger.logCrit('Removed host/network %s from blacklist' % net)
+    logger.logCrit('Removed host/network %s from denylist' % net)
   elif is_banned:
     valkey.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
-    logger.logCrit('Added host/network %s to blacklist' % net)
+    logger.logCrit('Added host/network %s to denylist' % net)
 
 def clear():
   global lock
   logger.logInfo('Clearing all bans')
   for net in bans.copy():
+    logdebug("Unbanning net: %s" % net)
     unban(net)
   with lock:
+    logdebug("Clearing IPv4/IPv6 table")
     tables.clearIPv4Table()
     tables.clearIPv6Table()
     try:
@@ -275,21 +299,35 @@ def snat6(snat_target):
 
 def autopurge():
   global f2boptions
-
+  logdebug("autopurge thread started")
   while not quit_now:
+    logdebug("autopurge tick")
     time.sleep(10)
     refreshF2boptions()
     MAX_ATTEMPTS = int(f2boptions['max_attempts'])
     QUEUE_UNBAN = valkey.hgetall('F2B_QUEUE_UNBAN')
+    logdebug("QUEUE_UNBAN: %s" % QUEUE_UNBAN)
     if QUEUE_UNBAN:
       for net in QUEUE_UNBAN:
+        logdebug("Autopurge: unbanning queued net: %s" % net)
         unban(str(net))
-    for net in bans.copy():
-      if bans[net]['attempts'] >= MAX_ATTEMPTS:
-        NET_BAN_TIME = calcNetBanTime(bans[net]['ban_counter'])
-        TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt']
-        if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME:
-          unban(net)
+    # Only check expiry for actively banned IPs:
+    active_bans = r.hgetall('F2B_ACTIVE_BANS')
+    now = time.time()
+    for net_str, expire_str in active_bans.items():
+      logdebug("Checking ban expiry for (actively banned): %s" % net_str)
+      # Defensive: always process if timer missing or expired
+      try:
+        expire = float(expire_str)
+      except Exception:
+        logdebug("Invalid expire time for %s; unbanning" % net_str)
+        unban(net_str)
+        continue
+      time_left = expire - now
+      logdebug("Time left for %s: %.1f seconds" % (net_str, time_left))
+      if time_left <= 0:
+        logdebug("Ban expired for %s" % net_str)
+        unban(net_str)
 
 def mailcowChainOrder():
   global lock
@@ -359,7 +397,7 @@ def whitelistUpdate():
     with lock:
       if Counter(new_whitelist) != Counter(WHITELIST):
         WHITELIST = new_whitelist
-        logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
+        logger.logInfo('Allowlist was changed, it has %s entries' % len(WHITELIST))
     time.sleep(60.0 - ((time.time() - start_time) % 60.0))
 
 def blacklistUpdate():
@@ -375,7 +413,7 @@ def blacklistUpdate():
       addban = set(new_blacklist).difference(BLACKLIST)
       delban = set(BLACKLIST).difference(new_blacklist)
       BLACKLIST = new_blacklist
-      logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
+      logger.logInfo('Denylist was changed, it has %s entries' % len(BLACKLIST))
       if addban:
         for net in addban:
           permBan(net=net)
@@ -386,42 +424,43 @@ def blacklistUpdate():
 
 def sigterm_quit(signum, frame):
   global clear_before_quit
+  logdebug("SIGTERM received, setting clear_before_quit to True and exiting")
   clear_before_quit = True
   sys.exit(exit_code)
 
-def berfore_quit():
+def before_quit():
+  logdebug("before_quit called, clear_before_quit=%s" % clear_before_quit)
   if clear_before_quit:
     clear()
   if pubsub is not None:
     pubsub.unsubscribe()
 
-
 if __name__ == '__main__':
-  atexit.register(berfore_quit)
-  signal.signal(signal.SIGTERM, sigterm_quit)
-
-  # init Logger
   logger = Logger()
+  logdebug("Sys.argv: %s" % sys.argv)
+  atexit.register(before_quit)
+  signal.signal(signal.SIGTERM, sigterm_quit)
 
-  # init backend
   backend = sys.argv[1]
+  logdebug("Backend: %s" % backend)
   if backend == "nftables":
     logger.logInfo('Using NFTables backend')
     tables = NFTables(chain_name, logger)
   else:
     logger.logInfo('Using IPTables backend')
+    logger.logWarn(
+        "DEPRECATION: iptables-legacy is deprecated and will be removed in future releases. "
+        "Please switch to nftables on your host to ensure complete compatibility."
+    )
+    time.sleep(5)
     tables = IPTables(chain_name, logger)
 
-  # In case a previous session was killed without cleanup
   clear()
-
-  # Reinit MAILCOW chain
-  # Is called before threads start, no locking
   logger.logInfo("Initializing mailcow netfilter chain")
   tables.initChainIPv4()
   tables.initChainIPv6()
 
-  if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE").lower() in ("y", "yes"):
+  if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE", "").lower() in ("y", "yes"):
     logger.logInfo(f"Skipping {chain_name} isolation")
   else:
     logger.logInfo(f"Setting {chain_name} isolation")
@@ -432,23 +471,28 @@ if __name__ == '__main__':
     try:
       valkey_slaveof_ip = os.getenv('VALKEY_SLAVEOF_IP', '')
       valkey_slaveof_port = os.getenv('VALKEY_SLAVEOF_PORT', '')
+      logdebug(
+        "Connecting valkey (SLAVEOF_IP:%s, PORT:%s)" % (valkey_slaveof_ip, valkey_slaveof_port))
       if "".__eq__(valkey_slaveof_ip):
-        valkey = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0, password=os.environ['VALKEYPASS'])
+        valkey = redis.StrictRedis(
+          host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0, password=os.environ['VALKEYPASS'])
       else:
-        valkey = redis.StrictRedis(host=valkey_slaveof_ip, decode_responses=True, port=valkey_slaveof_port, db=0, password=os.environ['VALKEYPASS'])
+        valkey = redis.StrictRedis(
+          host=valkey_slaveof_ip, decode_responses=True, port=valkey_slaveof_port, db=0, password=os.environ['VALKEYPASS'])
       valkey.ping()
       pubsub = valkey.pubsub()
     except Exception as ex:
-      print('%s - trying again in 3 seconds'  % (ex))
+      logdebug(
+        'Redis connection failed: %s - trying again in 3 seconds' % (ex))
       time.sleep(3)
     else:
       break
-  logger.set_valkey(r)
+  logger.set_valkey(valkey)
+  logdebug("Valkey connection established, setting up F2B keys")
 
-  # rename fail2ban to netfilter
   if valkey.exists('F2B_LOG'):
+    logdebug("Renaming F2B_LOG to NETFILTER_LOG")
     valkey.rename('F2B_LOG', 'NETFILTER_LOG')
-  # clear bans in valkey
   valkey.delete('F2B_ACTIVE_BANS')
   valkey.delete('F2B_PERM_BANS')
 
@@ -463,7 +507,7 @@ if __name__ == '__main__':
       snat_ip = os.getenv('SNAT_TO_SOURCE')
       snat_ipo = ipaddress.ip_address(snat_ip)
       if type(snat_ipo) is ipaddress.IPv4Address:
-        snat4_thread = Thread(target=snat4,args=(snat_ip,))
+        snat4_thread = Thread(target=snat4, args=(snat_ip,))
         snat4_thread.daemon = True
         snat4_thread.start()
     except ValueError:
@@ -499,4 +543,5 @@ if __name__ == '__main__':
   while not quit_now:
     time.sleep(0.5)
 
-  sys.exit(exit_code)
+  logdebug("Exiting with code %s" % exit_code)
+  sys.exit(exit_code)

+ 19 - 7
data/Dockerfiles/netfilter/modules/Logger.py

@@ -1,5 +1,6 @@
 import time
 import json
+import datetime
 
 class Logger:
   def __init__(self):
@@ -8,17 +9,28 @@ class Logger:
   def set_valkey(self, valkey):
     self.valkey = valkey
 
+  def _format_timestamp(self):
+    # Local time with milliseconds
+    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
   def log(self, priority, message):
-    tolog = {}
-    tolog['time'] = int(round(time.time()))
-    tolog['priority'] = priority
-    tolog['message'] = message
-    print(message)
+    # build valkey-friendly dict
+    tolog = {
+      'time': int(round(time.time())),  # keep raw timestamp for Valkey
+      'priority': priority,
+      'message': message
+    }
+
+    # print human-readable message with timestamp
+    ts = self._format_timestamp()
+    print(f"{ts} {priority.upper()}: {message}", flush=True)
+
+    # also push JSON to Redis if connected
     if self.valkey is not None:
       try:
         self.valkey.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
       except Exception as ex:
-        print('Failed logging to valkey: %s'  % (ex))
+        print(f'{ts} WARN: Failed logging to valkey: {ex}', flush=True)
 
   def logWarn(self, message):
     self.log('warn', message)
@@ -27,4 +39,4 @@ class Logger:
     self.log('crit', message)
 
   def logInfo(self, message):
-    self.log('info', message)
+    self.log('info', message)

+ 2 - 2
data/Dockerfiles/nginx/bootstrap.py

@@ -10,7 +10,7 @@ def includes_conf(env, template_vars):
   server_name_config = f"server_name {template_vars['MAILCOW_HOSTNAME']} autodiscover.* autoconfig.* {' '.join(template_vars['ADDITIONAL_SERVER_NAMES'])};"
   listen_plain_config = f"listen {template_vars['HTTP_PORT']};"
   listen_ssl_config = f"listen {template_vars['HTTPS_PORT']};"
-  if not template_vars['DISABLE_IPv6']:
+  if template_vars['ENABLE_IPV6']:
     listen_plain_config += f"\nlisten [::]:{template_vars['HTTP_PORT']};"
     listen_ssl_config += f"\nlisten [::]:{template_vars['HTTPS_PORT']} ssl;"
   listen_ssl_config += "\nhttp2 on;"
@@ -58,7 +58,7 @@ def prepare_template_vars():
     'SOGOHOST': os.getenv("SOGOHOST", ipv4_network + ".248"),
     'RSPAMDHOST': os.getenv("RSPAMDHOST", "rspamd-mailcow"),
     'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
-    'DISABLE_IPv6': os.getenv("DISABLE_IPv6", "n").lower() in ("y", "yes"),
+    'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
     'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
   }
 

+ 1 - 1
data/Dockerfiles/olefy/Dockerfile

@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 

+ 8 - 1
data/Dockerfiles/olefy/olefy.py

@@ -32,6 +32,13 @@ import time
 import magic
 import re
 
+skip_olefy = os.getenv('SKIP_OLEFY', '')
+
+if skip_olefy.lower() in ['yes', 'y']:
+    print("SKIP_OLEFY=y, skipping Olefy...")
+    time.sleep(365 * 24 * 60 * 60)
+    sys.exit(0)
+
 # merge variables from /etc/olefy.conf and the defaults
 olefy_listen_addr_string = os.getenv('OLEFY_BINDADDRESS', '127.0.0.1,::1')
 olefy_listen_port = int(os.getenv('OLEFY_BINDPORT', '10050'))
@@ -113,7 +120,7 @@ def oletools( stream, tmp_file_name, lid ):
         out = bytes(out.decode('utf-8', 'ignore').replace('  ', ' ').replace('\t', '').replace('\n', '').replace('XLMMacroDeobfuscator: pywin32 is not installed (only is required if you want to use MS Excel)', ''), encoding="utf-8")
         failed = False
         if out.__len__() < 30:
-            logger.error('{} olevba returned <30 chars - rc: {!r}, response: {!r}, error: {!r}'.format(lid,cmd_tmp.returncode, 
+            logger.error('{} olevba returned <30 chars - rc: {!r}, response: {!r}, error: {!r}'.format(lid,cmd_tmp.returncode,
                 out.decode('utf-8', 'ignore'), err.decode('utf-8', 'ignore')))
             out = b'[ { "error": "Unhandled error - too short olevba response" } ]'
             failed = True

+ 6 - 6
data/Dockerfiles/phpfpm/Dockerfile

@@ -1,19 +1,19 @@
-FROM php:8.2-fpm-alpine3.20
+FROM php:8.2-fpm-alpine3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 
 # renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
-ARG APCU_PECL_VERSION=5.1.24
+ARG APCU_PECL_VERSION=5.1.26
 # renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
-ARG IMAGICK_PECL_VERSION=3.7.0
+ARG IMAGICK_PECL_VERSION=3.8.0
 # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
 ARG MAILPARSE_PECL_VERSION=3.1.8
 # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
-ARG MEMCACHED_PECL_VERSION=3.2.0
+ARG MEMCACHED_PECL_VERSION=3.3.0
 # renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
-ARG REDIS_PECL_VERSION=6.1.0
+ARG REDIS_PECL_VERSION=6.2.0
 # renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
-ARG COMPOSER_VERSION=2.6.6
+ARG COMPOSER_VERSION=2.8.6
 
 RUN apk add -U --no-cache autoconf \
   aspell-dev \

+ 6 - 6
data/Dockerfiles/phpfpm/docker-entrypoint.sh

@@ -81,7 +81,7 @@ if [ ${SQL_CHANGED} -eq 1 ]; then
 fi
 
 # Check mysql tz import (master and slave)
-TZ_CHECK=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
+TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
 if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
   SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
   echo "MySQL mysql_tzinfo_to_sql - debug output:"
@@ -120,11 +120,11 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
   while read line
   do
     DOMAIN_ARR+=("$line")
-  done < <(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain" -Bs)
+  done < <(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain" -Bs)
   while read line
   do
     DOMAIN_ARR+=("$line")
-  done < <(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT alias_domain FROM alias_domain" -Bs)
+  done < <(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT alias_domain FROM alias_domain" -Bs)
 
   if [[ ! -z ${DOMAIN_ARR} ]]; then
   for domain in "${DOMAIN_ARR[@]}"; do
@@ -146,13 +146,13 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
     VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]})
     if [[ ! -z ${VALIDATED_IPS} ]]; then
       if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then
-        mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+        mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
 DELETE FROM api WHERE access = 'rw';
 INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY}", "1", "${VALIDATED_IPS}", "rw");
 EOF
       fi
       if [[ ${API_KEY_READ_ONLY} != "invalid" ]] && [[ ! -z ${API_KEY_READ_ONLY} ]]; then
-        mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+        mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
 DELETE FROM api WHERE access = 'ro';
 INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY_READ_ONLY}", "1", "${VALIDATED_IPS}", "ro");
 EOF
@@ -161,7 +161,7 @@ EOF
   fi
 
   # Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED)
-  mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+  mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
 DROP EVENT IF EXISTS clean_spamalias;
 DELIMITER //
 CREATE EVENT clean_spamalias

+ 50 - 0
data/Dockerfiles/postfix-tlspol/Dockerfile

@@ -0,0 +1,50 @@
+FROM golang:1.25-bookworm AS builder
+WORKDIR /src
+
+ENV CGO_ENABLED=0 \
+    GO111MODULE=on \
+		NOOPT=1 \
+    VERSION=1.8.14
+
+RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
+    cd /src/postfix-tlspol && \
+    scripts/build.sh build-only
+
+
+FROM debian:bookworm-slim
+LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
+
+ARG DEBIAN_FRONTEND=noninteractive
+ENV LC_ALL=C
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+	ca-certificates \
+	dirmngr \
+  	dnsutils \
+	iputils-ping \
+	sudo \
+	supervisor \
+	redis-tools \
+	syslog-ng \
+	syslog-ng-core \
+	syslog-ng-mod-redis \
+  	tzdata \
+	&& rm -rf /var/lib/apt/lists/* \
+	&& touch /etc/default/locale
+
+COPY supervisord.conf /etc/supervisor/supervisord.conf
+COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
+COPY syslog-ng-valkey_slave.conf /etc/syslog-ng/syslog-ng-valkey_slave.conf
+COPY postfix-tlspol.sh /opt/postfix-tlspol.sh
+COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
+COPY docker-entrypoint.sh /docker-entrypoint.sh
+COPY --from=builder /src/postfix-tlspol/build/postfix-tlspol /usr/local/bin/postfix-tlspol
+
+RUN chmod +x /opt/postfix-tlspol.sh \
+  /usr/local/sbin/stop-supervisor.sh \
+  /docker-entrypoint.sh
+RUN rm -rf /tmp/* /var/tmp/*
+
+ENTRYPOINT ["/docker-entrypoint.sh"]
+
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

+ 7 - 0
data/Dockerfiles/postfix-tlspol/docker-entrypoint.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+
+if [[ ! -z ${VALKEY_SLAVEOF_IP} ]]; then
+  cp /etc/syslog-ng/syslog-ng-valkey_slave.conf /etc/syslog-ng/syslog-ng.conf
+fi
+
+exec "$@"

+ 52 - 0
data/Dockerfiles/postfix-tlspol/postfix-tlspol.sh

@@ -0,0 +1,52 @@
+#!/bin/bash
+
+LOGLVL=info
+
+if [ ${DEV_MODE} != "n" ]; then
+  echo -e "\e[31mEnabling debug mode\e[0m"
+  set -x
+  LOGLVL=debug
+fi
+
+[[ ! -d /etc/postfix-tlspol ]] && mkdir -p /etc/postfix-tlspol
+[[ ! -d /var/lib/postfix-tlspol ]] && mkdir -p /var/lib/postfix-tlspol
+
+until dig +short mailcow.email > /dev/null; do
+  echo "Waiting for DNS..."
+  sleep 1
+done
+
+# Do not attempt to write to slave
+if [[ ! -z ${VALKEY_SLAVEOF_IP} ]]; then
+  export VALKEY_CMDLINE="redis-cli -h ${VALKEY_SLAVEOF_IP} -p ${VALKEY_SLAVEOF_PORT} -a ${VALKEYPASS} --no-auth-warning"
+else
+  export VALKEY_CMDLINE="redis-cli -h valkey -p 6379 -a ${VALKEYPASS} --no-auth-warning"
+fi
+
+until [[ $(${VALKEY_CMDLINE} PING) == "PONG" ]]; do
+  echo "Waiting for Valkey..."
+  sleep 2
+done
+
+echo "Waiting for Postfix..."
+until ping postfix -c1 > /dev/null; do
+  sleep 1
+done
+echo "Postfix OK"
+
+cat <<EOF > /etc/postfix-tlspol/config.yaml
+server:
+  address: 0.0.0.0:8642
+
+  log-level: ${LOGLVL}
+
+  prefetch: true
+
+  cache-file: /var/lib/postfix-tlspol/cache.db
+
+dns:
+  # must support DNSSEC
+  address: 127.0.0.11:53
+EOF
+
+/usr/local/bin/postfix-tlspol -config /etc/postfix-tlspol/config.yaml

+ 8 - 0
data/Dockerfiles/postfix-tlspol/stop-supervisor.sh

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+printf "READY\n";
+
+while read line; do
+  echo "Processing Event: $line" >&2;
+  kill -3 $(cat "/var/run/supervisord.pid")
+done < /dev/stdin

+ 25 - 0
data/Dockerfiles/postfix-tlspol/supervisord.conf

@@ -0,0 +1,25 @@
+[supervisord]
+pidfile=/var/run/supervisord.pid
+nodaemon=true
+user=root
+
+[program:syslog-ng]
+command=/usr/sbin/syslog-ng --foreground  --no-caps
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+autostart=true
+
+[program:postfix-tlspol]
+startsecs=10
+autorestart=true
+command=/opt/postfix-tlspol.sh
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[eventlistener:processes]
+command=/usr/local/sbin/stop-supervisor.sh
+events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL

+ 45 - 0
data/Dockerfiles/postfix-tlspol/syslog-ng-valkey_slave.conf

@@ -0,0 +1,45 @@
+@version: 3.38
+@include "scl.conf"
+options {
+  chain_hostnames(off);
+  flush_lines(0);
+  use_dns(no);
+  dns_cache(no);
+  use_fqdn(no);
+  owner("root"); group("adm"); perm(0640);
+  stats_freq(0);
+  bad_hostname("^gconfd$");
+};
+source s_src {
+  unix-stream("/dev/log");
+  internal();
+};
+destination d_stdout { pipe("/dev/stdout"); };
+destination d_valkey_ui_log {
+  redis(
+    host("`VALKEY_SLAVEOF_IP`")
+    persist-name("valkey1")
+    port(`VALKEY_SLAVEOF_PORT`)
+    auth("`VALKEYPASS`")
+    command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
+  );
+};
+filter f_mail { facility(mail); };
+# start
+# overriding warnings are still displayed when the entrypoint runs its initial check
+# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
+# Some other warnings are ignored
+filter f_ignore {
+  not match("overriding earlier entry" value("MESSAGE"));
+  not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
+  not match("no SASL support" value("MESSAGE"));
+  not facility (local0, local1, local2, local3, local4, local5, local6, local7);
+};
+# end
+log {
+  source(s_src);
+  filter(f_ignore);
+  destination(d_stdout);
+  filter(f_mail);
+  destination(d_valkey_ui_log);
+};

+ 45 - 0
data/Dockerfiles/postfix-tlspol/syslog-ng.conf

@@ -0,0 +1,45 @@
+@version: 3.38
+@include "scl.conf"
+options {
+  chain_hostnames(off);
+  flush_lines(0);
+  use_dns(no);
+  dns_cache(no);
+  use_fqdn(no);
+  owner("root"); group("adm"); perm(0640);
+  stats_freq(0);
+  bad_hostname("^gconfd$");
+};
+source s_src {
+  unix-stream("/dev/log");
+  internal();
+};
+destination d_stdout { pipe("/dev/stdout"); };
+destination d_valkey_ui_log {
+  redis(
+    host("valkey-mailcow")
+    persist-name("valkey1")
+    port(6379)
+    auth("`VALKEYPASS`")
+    command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
+  );
+};
+filter f_mail { facility(mail); };
+# start
+# overriding warnings are still displayed when the entrypoint runs its initial check
+# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
+# Some other warnings are ignored
+filter f_ignore {
+  not match("overriding earlier entry" value("MESSAGE"));
+  not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
+  not match("no SASL support" value("MESSAGE"));
+  not facility (local0, local1, local2, local3, local4, local5, local6, local7);
+};
+# end
+log {
+  source(s_src);
+  filter(f_ignore);
+  destination(d_stdout);
+  filter(f_mail);
+  destination(d_valkey_ui_log);
+};

+ 2 - 2
data/Dockerfiles/postfix/Dockerfile

@@ -1,9 +1,9 @@
 FROM debian:bookworm-slim
 
-LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
+LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
-ENV LC_ALL C
+ENV LC_ALL=C
 
 RUN dpkg-divert --local --rename --add /sbin/initctl \
 	&& ln -sf /bin/true /sbin/initctl \

+ 1 - 1
data/Dockerfiles/postfix/postfix.sh

@@ -524,4 +524,4 @@ if [[ $? != 0 ]]; then
 else
   postfix -c /opt/postfix/conf start
   sleep 126144000
-fi
+fi

+ 2 - 2
data/Dockerfiles/rspamd/Dockerfile

@@ -2,11 +2,11 @@ FROM debian:bookworm-slim
 LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
-ARG RSPAMD_VER=rspamd_3.11.0-2~90a175b45
+ARG RSPAMD_VER=rspamd_3.12.1-1~6dbfca2fa
 ARG CODENAME=bookworm
 ENV LC_ALL=C
 
-RUN apt-get update && apt-get install -y \
+RUN apt-get update && apt-get install -y --no-install-recommends \
   tzdata \
   ca-certificates \
   gnupg2 \

+ 23 - 0
data/Dockerfiles/rspamd/docker-entrypoint.sh

@@ -81,6 +81,29 @@ EOF
   redis-cli -h valkey-mailcow -a ${VALKEYPASS} --no-auth-warning SLAVEOF NO ONE
 fi
 
+if [[ "${SKIP_OLEFY}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+  if [[ -f /etc/rspamd/local.d/external_services.conf ]]; then
+    rm /etc/rspamd/local.d/external_services.conf
+  fi
+else
+  if [[ ! -f /etc/rspamd/local.d/external_services.conf ]]; then
+    cat <<EOF > /etc/rspamd/local.d/external_services.conf
+oletools {
+  # default olefy settings
+  servers = "olefy:10055";
+  # needs to be set explicitly for Rspamd < 1.9.5
+  scan_mime_parts = true;
+  # mime-part regex matching in content-type or filename
+  # block all macros
+  extended = true;
+  max_size = 3145728;
+  timeout = 20.0;
+  retransmits = 1;
+}
+EOF
+  fi
+fi
+
 # Provide additional lua modules
 ln -s /usr/lib/$(uname -m)-linux-gnu/liblua5.1-cjson.so.0.0.0 /usr/lib/rspamd/cjson.so
 

+ 1 - 0
data/Dockerfiles/sogo/Dockerfile

@@ -47,6 +47,7 @@ COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
 COPY syslog-ng-valkey_slave.conf /etc/syslog-ng/syslog-ng-valkey_slave.conf
 COPY supervisord.conf /etc/supervisor/supervisord.conf
 COPY acl.diff /acl.diff
+COPY navMailcowBtns.diff /navMailcowBtns.diff
 COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
 COPY docker-entrypoint.sh /
 

+ 13 - 105
data/Dockerfiles/sogo/bootstrap-sogo.sh

@@ -14,118 +14,18 @@ do
 done
 
 # Wait for updated schema
-DBV_NOW=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
+DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
 DBV_NEW=$(grep -oE '\$db_version = .*;' init_db.inc.php | sed 's/$db_version = //g;s/;//g' | cut -d \" -f2)
 while [[ "${DBV_NOW}" != "${DBV_NEW}" ]]; do
   echo "Waiting for schema update..."
-  DBV_NOW=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
+  DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
   DBV_NEW=$(grep -oE '\$db_version = .*;' init_db.inc.php | sed 's/$db_version = //g;s/;//g' | cut -d \" -f2)
   sleep 5
 done
 echo "DB schema is ${DBV_NOW}"
 
-# Recreate view
 if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
-  echo "We are master, preparing sogo_view..."
-  mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DROP VIEW IF EXISTS sogo_view"
-  while [[ ${VIEW_OK} != 'OK' ]]; do
-    mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
-CREATE VIEW sogo_view (c_uid, domain, c_name, c_password, c_cn, mail, aliases, ad_aliases, ext_acl, kind, multiple_bookings) AS
-SELECT
-   mailbox.username,
-   mailbox.domain,
-   mailbox.username,
-   IF(JSON_UNQUOTE(JSON_VALUE(attributes, '$.force_pw_update')) = '0', IF(JSON_UNQUOTE(JSON_VALUE(attributes, '$.sogo_access')) = 1, password, '{SSHA256}A123A123A321A321A321B321B321B123B123B321B432F123E321123123321321'), '{SSHA256}A123A123A321A321A321B321B321B123B123B321B432F123E321123123321321'),
-   mailbox.name,
-   mailbox.username,
-   IFNULL(GROUP_CONCAT(ga.aliases ORDER BY ga.aliases SEPARATOR ' '), ''),
-   IFNULL(gda.ad_alias, ''),
-   IFNULL(external_acl.send_as_acl, ''),
-   mailbox.kind,
-   mailbox.multiple_bookings
-FROM
-   mailbox
-   LEFT OUTER JOIN
-      grouped_mail_aliases ga
-      ON ga.username REGEXP CONCAT('(^|,)', mailbox.username, '($|,)')
-   LEFT OUTER JOIN
-      grouped_domain_alias_address gda
-      ON gda.username = mailbox.username
-   LEFT OUTER JOIN
-      grouped_sender_acl_external external_acl
-      ON external_acl.username = mailbox.username
-WHERE
-   mailbox.active = '1'
-GROUP BY
-   mailbox.username;
-EOF
-    if [[ ! -z $(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sogo_view'") ]]; then
-      VIEW_OK=OK
-    else
-      echo "Will retry to setup SOGo view in 3s..."
-      sleep 3
-    fi
-  done
-else
-  while [[ ${VIEW_OK} != 'OK' ]]; do
-    if [[ ! -z $(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sogo_view'") ]]; then
-      VIEW_OK=OK
-    else
-      echo "Waiting for SOGo view to be created by master..."
-      sleep 3
-    fi
-  done
-fi
-
-# Wait for static view table if missing after update and update content
-if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
-  echo "We are master, preparing _sogo_static_view..."
-  while [[ ${STATIC_VIEW_OK} != 'OK' ]]; do
-    if [[ ! -z $(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '_sogo_static_view'") ]]; then
-      STATIC_VIEW_OK=OK
-      echo "Updating _sogo_static_view content..."
-      # If changed, also update init_db.inc.php
-      mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "REPLACE INTO _sogo_static_view (c_uid, domain, c_name, c_password, c_cn, mail, aliases, ad_aliases, ext_acl, kind, multiple_bookings) SELECT c_uid, domain, c_name, c_password, c_cn, mail, aliases, ad_aliases, ext_acl, kind, multiple_bookings from sogo_view;"
-      mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "DELETE FROM _sogo_static_view WHERE c_uid NOT IN (SELECT username FROM mailbox WHERE active = '1')"
-    else
-      echo "Waiting for database initialization..."
-      sleep 3
-    fi
-  done
-else
-  while [[ ${STATIC_VIEW_OK} != 'OK' ]]; do
-    if [[ ! -z $(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '_sogo_static_view'") ]]; then
-      STATIC_VIEW_OK=OK
-    else
-      echo "Waiting for database initialization by master..."
-      sleep 3
-    fi
-  done
-fi
-
-
-# Recreate password update trigger
-if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
-  echo "We are master, preparing update trigger..."
-  mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DROP TRIGGER IF EXISTS sogo_update_password"
-  while [[ ${TRIGGER_OK} != 'OK' ]]; do
-  mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
-DELIMITER -
-CREATE TRIGGER sogo_update_password AFTER UPDATE ON _sogo_static_view
-FOR EACH ROW
-BEGIN
-UPDATE mailbox SET password = NEW.c_password WHERE NEW.c_uid = username;
-END;
--
-DELIMITER ;
-EOF
-    if [[ ! -z $(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TRIGGERS WHERE TRIGGER_NAME = 'sogo_update_password'") ]]; then
-      TRIGGER_OK=OK
-    else
-      echo "Will retry to setup SOGo password update trigger in 3s"
-      sleep 3
-    fi
-  done
+  mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DROP TRIGGER IF EXISTS sogo_update_password"
 fi
 
 # cat /dev/urandom seems to hang here occasionally and is not recommended anyway, better use openssl
@@ -150,6 +50,10 @@ cat <<EOF > /var/lib/sogo/GNUstep/Defaults/sogod.plist
     <string>YES</string>
     <key>SOGoEncryptionKey</key>
     <string>${RAND_PASS}</string>
+    <key>SOGoURLEncryptionEnabled</key>
+    <string>YES</string>
+    <key>SOGoURLEncryptionPassphrase</key>
+    <string>${SOGO_URL_ENCRYPTION_KEY}</string>
     <key>OCSAdminURL</key>
     <string>mysql://${DBUSER}:${DBPASS}@%2Fvar%2Frun%2Fmysqld%2Fmysqld.sock/${DBNAME}/sogo_admin</string>
     <key>OCSCacheFolderURL</key>
@@ -213,10 +117,10 @@ while read -r line gal
                 </dict>" >> /var/lib/sogo/GNUstep/Defaults/sogod.plist
   # Generate alternative LDAP authentication dict, when SQL authentication fails
   # This will nevertheless read attributes from LDAP
-  line=${line} envsubst < /etc/sogo/plist_ldap >> /var/lib/sogo/GNUstep/Defaults/sogod.plist
+  /etc/sogo/plist_ldap.sh ${line} ${gal} >> /var/lib/sogo/GNUstep/Defaults/sogod.plist
   echo "            </array>
         </dict>" >> /var/lib/sogo/GNUstep/Defaults/sogod.plist
-done < <(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain, CASE gal WHEN '1' THEN 'YES' ELSE 'NO' END AS gal FROM domain;" -B -N)
+done < <(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain, CASE gal WHEN '1' THEN 'YES' ELSE 'NO' END AS gal FROM domain;" -B -N)
 
 # Generate footer
 echo '    </dict>
@@ -240,6 +144,10 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
 #  fi
 #fi
 
+if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
+  patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
+fi
+
 # Rename custom logo, if any
 [[ -f /etc/sogo/sogo-full.svg ]] && mv /etc/sogo/sogo-full.svg /etc/sogo/custom-fulllogo.svg
 

+ 15 - 0
data/Dockerfiles/sogo/navMailcowBtns.diff

@@ -0,0 +1,15 @@
+60,65d58
+<                var:ng-click="navButtonClick"
+<                ng-href="/user">
+<       <md-icon>build</md-icon>
+<       <md-tooltip>mailcow <var:string label:value="Preferences"/></md-tooltip>
+<     </md-button>
+<     <md-button class="md-icon-button"
+83c76
+<                onclick="mc_logout();"
+---
+>                ng-show="::activeUser.path.logoff.length"
+85c78
+<                ng-href="#">
+---
+>                ng-href="{{::activeUser.path.logoff}}">

+ 1 - 1
data/Dockerfiles/unbound/Dockerfile

@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 

+ 4 - 3
data/Dockerfiles/watchdog/Dockerfile

@@ -1,4 +1,4 @@
-FROM alpine:3.20
+FROM alpine:3.21
 
 LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 
@@ -16,7 +16,6 @@ RUN apk add --update \
   fcgi \
   openssl \
   nagios-plugins-mysql \
-  nagios-plugins-dns \
   nagios-plugins-disk \
   bind-tools \
   redis \
@@ -32,9 +31,11 @@ RUN apk add --update \
   tzdata \
   whois \
   && curl https://raw.githubusercontent.com/mludvig/smtp-cli/v3.10/smtp-cli -o /smtp-cli \
-  && chmod +x smtp-cli
+  && chmod +x smtp-cli \
+  && mkdir /usr/lib/mailcow
 
 COPY watchdog.sh /watchdog.sh
 COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
+COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
 
 CMD ["/watchdog.sh"]

+ 39 - 0
data/Dockerfiles/watchdog/check_dns.sh

@@ -0,0 +1,39 @@
+#!/bin/sh
+
+while getopts "H:s:" opt; do
+  case "$opt" in
+    H) HOST="$OPTARG" ;;
+    s) SERVER="$OPTARG" ;;
+    *) echo "Usage: $0 -H host -s server"; exit 3 ;;
+  esac
+done
+
+if [ -z "$SERVER" ]; then
+  echo "No DNS Server provided"
+  exit 3
+fi
+
+if [ -z "$HOST" ]; then
+  echo "No host to test provided"
+  exit 3
+fi
+
+# run dig and measure the time it takes to run
+START_TIME=$(date +%s%3N)
+dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null)
+dig_rc=$?
+dig_output_ips=$(echo "$dig_output" | grep -E '^[0-9.]+$' | sort | paste -sd ',' -)
+END_TIME=$(date +%s%3N)
+ELAPSED_TIME=$((END_TIME - START_TIME))
+
+# validate and perform nagios like output and exit codes
+if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then
+  echo "Domain $HOST was not found by the server"
+  exit 2
+elif [ $dig_rc -eq 0 ]; then
+  echo "DNS OK: $ELAPSED_TIME ms response time. $HOST returns $dig_output_ips"
+  exit 0
+else
+  echo "Unknown error"
+  exit 3
+fi

+ 13 - 13
data/Dockerfiles/watchdog/check_mysql_slavestatus.sh

@@ -49,7 +49,7 @@
 # 2013101601 Optical clean up                                           #
 # 2013101602 Rewrite help output                                        #
 # 2013101700 Handle Slave IO in 'Connecting' state                      #
-# 2013101701 Minor changes in output, handling UNKWNON situations now   #
+# 2013101701 Minor changes in output, handling UNKNOWN situations now   #
 # 2013101702 Exit CRITICAL when Slave IO in Connecting state            #
 # 2013123000 Slave_SQL_Running also matched Slave_SQL_Running_State     #
 # 2015011600 Added 'moving' check to catch possible connection issues   #
@@ -131,10 +131,10 @@ elif [[ -n "${socket}" && (-z "${user}" || -z "${password}") ]]; then
 fi
 
 # Connect to the DB server and store output in vars
-if [[ -n $socket ]]; then 
-  ConnectionResult=$(mysql ${optfile} ${socket} ${user} -e "show slave ${connection} status\G" 2>&1)
+if [[ -n $socket ]]; then
+  ConnectionResult=$(mariadb --skip-ssl ${optfile} ${socket} ${user} -e "show slave ${connection} status\G" 2>&1)
 else
-  ConnectionResult=$(mysql ${optfile} ${host} ${port} ${user} -e "show slave ${connection} status\G" 2>&1)
+  ConnectionResult=$(mariadb --skip-ssl ${optfile} ${host} ${port} ${user} -e "show slave ${connection} status\G" 2>&1)
 fi
 
 if [ -z "`echo "${ConnectionResult}" |grep Slave_IO_State`" ]; then
@@ -178,33 +178,33 @@ if [ ${check} = ${ok} ] && [ ${checkio} = ${ok} ]; then
   then echo "CRITICAL: Slave is ${delayinfo} seconds behind Master | delay=${delayinfo}s"; exit ${STATE_CRITICAL}
   elif [[ ${delayinfo} -ge ${warn_delay} ]]
   then echo "WARNING: Slave is ${delayinfo} seconds behind Master | delay=${delayinfo}s"; exit ${STATE_WARNING}
-  else 
+  else
     # Everything looks OK here but now let us check if the replication is moving
     if [[ -n ${moving} ]] && [[ -n ${tmpfile} ]] && [[ $readpos -eq $execpos ]]
-    then  
-      #echo "Debug: Read pos is $readpos - Exec pos is $execpos" 
+    then
+      #echo "Debug: Read pos is $readpos - Exec pos is $execpos"
       # Check if tmp file exists
       curtime=`date +%s`
-      if [[ -w $tmpfile ]] 
-      then 
+      if [[ -w $tmpfile ]]
+      then
         tmpfiletime=`date +%s -r $tmpfile`
         if [[ `expr $curtime - $tmpfiletime` -gt ${moving} ]]
         then
           exectmp=`cat $tmpfile`
           #echo "Debug: Exec pos in tmpfile is $exectmp"
           if [[ $exectmp -eq $execpos ]]
-          then 
+          then
             # The value read from the tmp file and from db are the same. Replication hasnt moved!
             echo "WARNING: Slave replication has not moved in ${moving} seconds. Manual check required."; exit ${STATE_WARNING}
-          else 
+          else
             # Replication has moved since the tmp file was written. Delete tmp file and output OK.
             rm $tmpfile
             echo "OK: Slave SQL running: ${check} Slave IO running: ${checkio} / master: ${masterinfo} / slave is ${delayinfo} seconds behind master | delay=${delayinfo}s"; exit ${STATE_OK};
           fi
-        else 
+        else
           echo "OK: Slave SQL running: ${check} Slave IO running: ${checkio} / master: ${masterinfo} / slave is ${delayinfo} seconds behind master | delay=${delayinfo}s"; exit ${STATE_OK};
         fi
-      else 
+      else
         echo "$execpos" > $tmpfile
         echo "OK: Slave SQL running: ${check} Slave IO running: ${checkio} / master: ${masterinfo} / slave is ${delayinfo} seconds behind master | delay=${delayinfo}s"; exit ${STATE_OK};
       fi

+ 46 - 2
data/Dockerfiles/watchdog/watchdog.sh

@@ -1,5 +1,10 @@
 #!/bin/bash
 
+if [ "${DEV_MODE}" != "n" ]; then
+  echo -e "\e[31mEnabled Debug Mode\e[0m"
+  set -x
+fi
+
 trap "exit" INT TERM
 trap "kill 0" EXIT
 
@@ -234,7 +239,7 @@ external_checks() {
   diff_c=0
   THRESHOLD=${EXTERNAL_CHECKS_THRESHOLD}
   # Reduce error count by 2 after restarting an unhealthy container
-  GUID=$(mysql -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'GUID'" -BN)
+  GUID=$(mariadb --skip-ssl -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'GUID'" -BN)
   trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
   while [ ${err_count} -lt ${THRESHOLD} ]; do
     err_c_cur=${err_count}
@@ -297,7 +302,7 @@ unbound_checks() {
     touch /tmp/unbound-mailcow; echo "$(tail -50 /tmp/unbound-mailcow)" > /tmp/unbound-mailcow
     host_ip=$(get_container_ip unbound-mailcow)
     err_c_cur=${err_count}
-    /usr/lib/nagios/plugins/check_dns -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
+    /usr/lib/mailcow/check_dns.sh -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
     DNSSEC=$(dig com +dnssec | egrep 'flags:.+ad')
     if [[ -z ${DNSSEC} ]]; then
       echo "DNSSEC failure" 2>> /tmp/unbound-mailcow 1>&2
@@ -445,6 +450,31 @@ postfix_checks() {
   return 1
 }
 
+postfix-tlspol_checks() {
+  err_count=0
+  diff_c=0
+  THRESHOLD=${POSTFIX_TLSPOL_THRESHOLD}
+  # Reduce error count by 2 after restarting an unhealthy container
+  trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
+  while [ ${err_count} -lt ${THRESHOLD} ]; do
+    touch /tmp/postfix-tlspol-mailcow; echo "$(tail -50 /tmp/postfix-tlspol-mailcow)" > /tmp/postfix-tlspol-mailcow
+    host_ip=$(get_container_ip postfix-tlspol-mailcow)
+    err_c_cur=${err_count}
+    /usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 8642 2>> /tmp/postfix-tlspol-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
+    [ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
+    [ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
+    progress "Postfix TLS Policy companion" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
+    if [[ $? == 10 ]]; then
+      diff_c=0
+      sleep 1
+    else
+      diff_c=0
+      sleep $(( ( RANDOM % 60 ) + 20 ))
+    fi
+  done
+  return 1
+}
+
 clamd_checks() {
   err_count=0
   diff_c=0
@@ -922,6 +952,18 @@ PID=$!
 echo "Spawned mailq_checks with PID ${PID}"
 BACKGROUND_TASKS+=(${PID})
 
+(
+while true; do
+  if ! postfix-tlspol_checks; then
+    log_msg "Postfix TLS Policy hit error limit"
+    echo postfix-tlspol-mailcow > /tmp/com_pipe
+  fi
+done
+) &
+PID=$!
+echo "Spawned postfix-tlspol_checks with PID ${PID}"
+BACKGROUND_TASKS+=(${PID})
+
 (
 while true; do
   if ! dovecot_checks; then
@@ -994,6 +1036,7 @@ PID=$!
 echo "Spawned cert_checks with PID ${PID}"
 BACKGROUND_TASKS+=(${PID})
 
+if [[ "${SKIP_OLEFY}" =~ ^([nN][oO]|[nN])+$ ]]; then
 (
 while true; do
   if ! olefy_checks; then
@@ -1005,6 +1048,7 @@ done
 PID=$!
 echo "Spawned olefy_checks with PID ${PID}"
 BACKGROUND_TASKS+=(${PID})
+fi
 
 (
 while true; do

+ 115 - 0
data/conf/dovecot/auth/mailcowauth.php

@@ -0,0 +1,115 @@
+<?php
+ini_set('error_reporting', 0);
+header('Content-Type: application/json');
+
+$post = trim(file_get_contents('php://input'));
+if ($post) {
+  $post = json_decode($post, true);
+}
+
+
+$return = array("success" => false);
+if(!isset($post['username']) || !isset($post['password']) || !isset($post['real_rip'])){
+  error_log("MAILCOWAUTH: Bad Request");
+  http_response_code(400); // Bad Request
+  echo json_encode($return);
+  exit();
+}
+
+require_once('../../../web/inc/vars.inc.php');
+if (file_exists('../../../web/inc/vars.local.inc.php')) {
+  include_once('../../../web/inc/vars.local.inc.php');
+}
+require_once '../../../web/inc/lib/vendor/autoload.php';
+
+
+// Init Valkey
+$valkey = new Redis();
+try {
+  if (!empty(getenv('VALKEY_SLAVEOF_IP'))) {
+    $valkey->connect(getenv('VALKEY_SLAVEOF_IP'), getenv('VALKEY_SLAVEOF_PORT'));
+  }
+  else {
+    $valkey->connect('valkey-mailcow', 6379);
+  }
+  $valkey->auth(getenv("VALKEYPASS"));
+}
+catch (Exception $e) {
+  error_log("MAILCOWAUTH: " . $e . PHP_EOL);
+  http_response_code(500); // Internal Server Error
+  echo json_encode($return);
+  exit;
+}
+
+// Init database
+$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
+$opt = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+    PDO::ATTR_EMULATE_PREPARES   => false,
+];
+try {
+  $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
+}
+catch (PDOException $e) {
+  error_log("MAILCOWAUTH: " . $e . PHP_EOL);
+  http_response_code(500); // Internal Server Error
+  echo json_encode($return);
+  exit;
+}
+
+// Load core functions first
+require_once 'functions.inc.php';
+require_once 'functions.auth.inc.php';
+require_once 'sessions.inc.php';
+require_once 'functions.mailbox.inc.php';
+require_once 'functions.ratelimit.inc.php';
+require_once 'functions.acl.inc.php';
+
+
+$isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
+$result = false;
+if ($isSOGoRequest) {
+  // This is a SOGo Auth request. First check for SSO password.
+  $sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
+  if ($sogo_sso_pass === $post['password']){
+    error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']);
+    set_sasl_log($post['username'], $post['real_rip'], "SOGO");
+    $result = true;
+  }
+}
+if ($result === false){
+  // If it's a SOGo Request, don't check for protocol access
+  $service = ($isSOGoRequest) ? false : array($post['service'] => true);
+  $result = apppass_login($post['username'], $post['password'], $service, array(
+    'is_internal' => true,
+    'remote_addr' => $post['real_rip']
+  ));
+  if ($result) {
+    error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
+    set_sasl_log($post['username'], $post['real_rip'], $post['service']);
+  }
+}
+if ($result === false){
+  // Init Identity Provider
+  $iam_provider = identity_provider('init');
+  $iam_settings = identity_provider('get');
+  $result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
+  if ($result) {
+    error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
+    set_sasl_log($post['username'], $post['real_rip'], $post['service']);
+  }
+}
+
+if ($result) {
+  http_response_code(200); // OK
+  $return['success'] = true;
+} else {
+  error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
+  http_response_code(401); // Unauthorized
+}
+
+
+echo json_encode($return);
+session_destroy();
+exit;

+ 57 - 0
data/conf/dovecot/auth/passwd-verify.lua

@@ -0,0 +1,57 @@
+function auth_password_verify(request, password)
+  if request.domain == nil then
+    return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
+  end
+
+  local json = require "cjson"
+  local ltn12 = require "ltn12"
+  local https = require "ssl.https"
+  https.TIMEOUT = 30
+
+  local req = {
+    username = request.user,
+    password = password,
+    real_rip = request.real_rip,
+    service = request.service
+  }
+  local req_json = json.encode(req)
+  local res = {}
+
+  local b, c = https.request {
+    method = "POST",
+    url = "https://nginx:9082",
+    source = ltn12.source.string(req_json),
+    headers = {
+      ["content-type"] = "application/json",
+      ["content-length"] = tostring(#req_json)
+    },
+    sink = ltn12.sink.table(res),
+    insecure = true
+  }
+
+  -- Returning PASSDB_RESULT_PASSWORD_MISMATCH will reset the user's auth cache entry.
+  -- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
+  -- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
+  if c ~= 200 and c ~= 401 then
+    dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user)
+    return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
+  end
+
+  local response_str = table.concat(res)
+  local is_response_valid, response_json = pcall(json.decode, response_str)
+
+  if not is_response_valid then
+    dovecot.i_info("Invalid JSON received: " .. response_str)
+    return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Invalid response format"
+  end
+
+  if response_json.success == true then
+    return dovecot.auth.PASSDB_RESULT_OK, ""
+  end
+
+  return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Failed to authenticate"
+end
+
+function auth_passdb_lookup(req)
+   return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, ""
+end

+ 9 - 8
data/conf/dovecot/dovecot.conf

@@ -53,7 +53,7 @@ mail_shared_explicit_inbox = yes
 mail_prefetch_count = 30
 passdb {
   driver = lua
-  args = file=/etc/dovecot/lua/passwd-verify.lua blocking=yes
+  args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
   result_success = return-ok
   result_failure = continue
   result_internalfail = continue
@@ -69,7 +69,7 @@ passdb {
 # a return of the following passdb is mandatory
 passdb {
   driver = lua
-  args = file=/etc/dovecot/lua/passwd-verify.lua blocking=yes
+  args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes
 }
 # Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing)
 service doveadm {
@@ -125,6 +125,7 @@ service managesieve-login {
 }
 service imap-login {
   service_count = 1
+  process_min_avail = 2
   process_limit = 10000
   vsz_limit = 1G
   user = dovenull
@@ -140,6 +141,7 @@ service imap-login {
 }
 service pop3-login {
   service_count = 1
+  process_min_avail = 1
   vsz_limit = 1G
   inet_listener pop3_haproxy {
     port = 10110
@@ -239,7 +241,7 @@ plugin {
   mail_crypt_global_public_key = </mail_crypt/ecpubkey.pem
   mail_crypt_save_version = 2
 
-  # Enable compression while saving, lz4 Dovecot v2.2.11+
+  # Enable compression while saving, lz4 Dovecot v2.3.17+
   zlib_save = lz4
 
   mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
@@ -274,10 +276,10 @@ service stats {
   }
 }
 imap_max_line_length = 2 M
-#auth_cache_verify_password_with_worker = yes
-#auth_cache_negative_ttl = 0
-#auth_cache_ttl = 30 s
-#auth_cache_size = 2 M
+auth_cache_verify_password_with_worker = yes
+auth_cache_negative_ttl = 60s
+auth_cache_ttl = 300s
+auth_cache_size = 10M
 auth_verbose_passwords = sha1:6
 service replicator {
   process_min_avail = 1
@@ -302,7 +304,6 @@ replication_dsync_parameters = -d -l 30 -U -n INBOX
 !include_try /etc/dovecot/sni.conf
 !include_try /etc/dovecot/sogo_trusted_ip.conf
 !include_try /etc/dovecot/extra.conf
-!include_try /etc/dovecot/sogo-sso.conf
 !include_try /etc/dovecot/shared_namespace.conf
 !include_try /etc/dovecot/conf.d/fts.conf
 # </Includes>

+ 39 - 8
data/conf/nginx/templates/nginx.conf.j2

@@ -48,13 +48,21 @@ http {
         listen {{ HTTP_PORT }} default_server;
         listen [::]:{{ HTTP_PORT }} default_server;
 
-        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
+        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
 
         if ( $request_uri ~* "%0A|%0D" ) { return 403; }
         location ^~ /.well-known/acme-challenge/ {
             allow all;
             default_type "text/plain";
         }
+        location ^~ /.well-known/mta-sts.txt {
+            allow all;
+            fastcgi_split_path_info ^(.+\.php)(/.+)$;
+            fastcgi_pass {{ PHPFPMHOST }}:9002;
+            include /etc/nginx/fastcgi_params;
+            fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php;
+            fastcgi_param PATH_INFO $fastcgi_path_info;
+        }
         location / {
             return 301 https://$host$uri$is_args$args;
         }
@@ -70,7 +78,7 @@ http {
         {%endif%}
         listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
 
-        {% if not DISABLE_IPv6 %}
+        {% if ENABLE_IPV6 %}
         {% if not HTTP_REDIRECT %}
         listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
         {%endif%}
@@ -82,7 +90,7 @@ http {
         ssl_certificate /etc/ssl/mail/cert.pem;
         ssl_certificate_key /etc/ssl/mail/key.pem;
 
-        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.*;
+        server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.*;
 
         include /etc/nginx/includes/sites-default.conf;
     }
@@ -97,7 +105,7 @@ http {
         {%endif%}
         listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
 
-        {% if not DISABLE_IPv6 %}
+        {% if ENABLE_IPV6 %}
         {% if not HTTP_REDIRECT %}
         listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
         {%endif%}
@@ -118,7 +126,7 @@ http {
     # rspamd dynmaps:
     server {
         listen 8081;
-        {% if not DISABLE_IPv6 %}
+        {% if ENABLE_IPV6 %}
         listen [::]:8081;
         {%endif%}
         index index.php index.html;
@@ -159,6 +167,31 @@ http {
         }
     }
 
+    server {
+        listen 9082 ssl http2;
+
+        ssl_certificate /etc/ssl/mail/cert.pem;
+        ssl_certificate_key /etc/ssl/mail/key.pem;
+
+        index mailcowauth.php;
+        server_name _;
+        error_log  /var/log/nginx/error.log;
+        access_log /var/log/nginx/access.log;
+        root /mailcowauth;
+        client_max_body_size 10M;
+        location ~ \.php$ {
+            client_max_body_size 10M;
+            try_files $uri =404;
+            fastcgi_split_path_info ^(.+\.php)(/.+)$;
+            fastcgi_pass phpfpm:9001;
+            include fastcgi_params;
+            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+            fastcgi_param PATH_INFO $fastcgi_path_info;
+        }
+    }
+
+    include /etc/nginx/conf.d/*.conf;
+
     {% for cert in valid_cert_dirs %}
     server {
         {% if not HTTP_REDIRECT %}
@@ -166,7 +199,7 @@ http {
         {%endif%}
         listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
 
-        {% if not DISABLE_IPv6 %}
+        {% if ENABLE_IPV6 %}
         {% if not HTTP_REDIRECT %}
         listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
         {%endif%}
@@ -183,6 +216,4 @@ http {
         include /etc/nginx/includes/sites-default.conf;
     }
     {% endfor %}
-
-    include /etc/nginx/conf.d/*.conf;
 }

+ 8 - 0
data/conf/nginx/templates/sites-default.conf.j2

@@ -76,6 +76,14 @@ location ^~ /.well-known/acme-challenge/ {
     allow all;
     default_type "text/plain";
 }
+location ^~ /.well-known/mta-sts.txt {
+    allow all;
+    fastcgi_split_path_info ^(.+\.php)(/.+)$;
+    fastcgi_pass {{ PHPFPMHOST }}:9002;
+    include /etc/nginx/fastcgi_params;
+    fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php;
+    fastcgi_param PATH_INFO $fastcgi_path_info;
+}
 
 rewrite ^/.well-known/caldav$ /SOGo/dav/ permanent;
 rewrite ^/.well-known/carddav$ /SOGo/dav/ permanent;

+ 231 - 0
data/conf/phpfpm/crons/keycloak-sync.php

@@ -0,0 +1,231 @@
+<?php
+
+require_once(__DIR__ . '/../web/inc/vars.inc.php');
+if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
+  include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
+}
+require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
+
+// Init database
+//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
+$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
+$opt = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+    PDO::ATTR_EMULATE_PREPARES   => false,
+];
+try {
+  $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
+}
+catch (PDOException $e) {
+  logMsg("err", $e->getMessage());
+  session_destroy();
+  exit;
+}
+
+// Init Valkey
+$valkey = new Redis();
+try {
+  if (!empty(getenv('VALKEY_SLAVEOF_IP'))) {
+    $valkey->connect(getenv('VALKEY_SLAVEOF_IP'), getenv('VALKEY_SLAVEOF_PORT'));
+  }
+  else {
+    $valkey->connect('valkey-mailcow', 6379);
+  }
+  $valkey->auth(getenv("VALKEYPASS"));
+}
+catch (Exception $e) {
+  echo "Exiting: " . $e->getMessage();
+  session_destroy();
+  exit;
+}
+
+function logMsg($priority, $message, $task = "Keycloak Sync") {
+  global $valkey;
+
+  $finalMsg = array(
+    "time" => time(),
+    "priority" => $priority,
+    "task" => $task,
+    "message" => $message
+  );
+  $valkey->lPush('CRON_LOG', json_encode($finalMsg));
+}
+
+// Load core functions first
+require_once __DIR__ . '/../web/inc/functions.inc.php';
+require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
+require_once __DIR__ . '/../web/inc/sessions.inc.php';
+require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
+require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
+require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
+
+$_SESSION['mailcow_cc_username'] = "admin";
+$_SESSION['mailcow_cc_role'] = "admin";
+$_SESSION['acl']['tls_policy'] = "1";
+$_SESSION['acl']['quarantine_notification'] = "1";
+$_SESSION['acl']['quarantine_category'] = "1";
+$_SESSION['acl']['ratelimit'] = "1";
+$_SESSION['acl']['sogo_access'] = "1";
+$_SESSION['acl']['protocol_access'] = "1";
+$_SESSION['acl']['mailbox_relayhost'] = "1";
+$_SESSION['acl']['unlimited_quota'] = "1";
+
+$iam_settings = identity_provider('get');
+if ($iam_settings['authsource'] != "keycloak" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) {
+  session_destroy();
+  exit;
+}
+
+// Set pagination variables
+$start = 0;
+$max = 100;
+
+// lock sync if already running
+$lock_file = '/tmp/iam-sync.lock';
+if (file_exists($lock_file)) {
+  $lock_file_parts = explode("\n", file_get_contents($lock_file));
+  $pid = $lock_file_parts[0];
+  if (count($lock_file_parts) > 1){
+    $last_execution = $lock_file_parts[1];
+    $elapsed_time = (time() - $last_execution) / 60;
+    if ($elapsed_time < intval($iam_settings['sync_interval'])) {
+      logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)");
+      session_destroy();
+      exit;
+    }
+  }
+
+  if (posix_kill($pid, 0)) {
+    logMsg("warning", "Sync is already running");
+    session_destroy();
+    exit;
+  } else {
+    unlink($lock_file);
+  }
+}
+$lock_file_handle = fopen($lock_file, 'w');
+fwrite($lock_file_handle, getmypid());
+fclose($lock_file_handle);
+
+// Init Keycloak Provider
+$iam_provider = identity_provider('init');
+
+// Loop until all users have been retrieved
+while (true) {
+  // Get admin access token
+  $admin_token = identity_provider("get-keycloak-admin-token");
+
+  // Make the API request to retrieve the users
+  $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users?first=$start&max=$max";
+  $ch = curl_init();
+  curl_setopt($ch, CURLOPT_URL, $url);
+  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+  curl_setopt($ch, CURLOPT_HTTPHEADER, [
+    "Content-Type: application/json",
+    "Authorization: Bearer " . $admin_token
+  ]);
+  $response = curl_exec($ch);
+  $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+  curl_close($ch);
+
+  if ($code != 200){
+    logMsg("err", "Received HTTP {$code}");
+    session_destroy();
+    exit;
+  }
+  try {
+    $response = json_decode($response, true);
+  } catch (Exception $e) {
+    logMsg("err", $e->getMessage());
+    break;
+  }
+  if (!is_array($response)){
+    logMsg("err", "Received malformed response from keycloak api");
+    break;
+  }
+  if (count($response) == 0) {
+    break;
+  }
+
+  // Process the batch of users
+  foreach ($response as $user) {
+    if (empty($user['email'])){
+      logMsg("warning", "No email address in keycloak found for user " . $user['name']);
+      continue;
+    }
+
+    // try get mailbox user
+    $stmt = $pdo->prepare("SELECT
+      mailbox.*,
+      domain.active AS d_active
+      FROM `mailbox`
+      INNER JOIN domain on mailbox.domain = domain.domain
+      WHERE `kind` NOT REGEXP 'location|thing|group'
+        AND `username` = :user");
+    $stmt->execute(array(':user' => $user['email']));
+    $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+    // check if matching attribute mapping exists
+    $user_template = $user['attributes']['mailcow_template'][0];
+    $mapper_key = array_search($user_template, $iam_settings['mappers']);
+
+    $_SESSION['access_all_exception'] = '1';
+    if (!$row && intval($iam_settings['import_users']) == 1){
+      if ($mapper_key === false){
+        if (!empty($iam_settings['default_template'])) {
+          $mbox_template = $iam_settings['default_template'];
+          logMsg("warning", "Using default template for user " . $user['email']);
+        } else {
+          logMsg("warning", "No matching attribute mapping found for user " . $user['email']);
+          continue;
+        }
+      } else {
+        $mbox_template = $iam_settings['templates'][$mapper_key];
+      }
+      // mailbox user does not exist, create...
+      logMsg("info", "Creating user " . $user['email']);
+      $create_res = mailbox('add', 'mailbox_from_template', array(
+        'domain' => explode('@', $user['email'])[1],
+        'local_part' => explode('@', $user['email'])[0],
+        'name' => $user['firstName'] . " " . $user['lastName'],
+        'authsource' => 'keycloak',
+        'template' => $mbox_template
+      ));
+      if (!$create_res){
+        logMsg("err", "Could not create user " . $user['email']);
+        continue;
+      }
+    } else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "keycloak") {
+      if ($mapper_key === false){
+        logMsg("warning", "No matching attribute mapping found for user " . $user['email']);
+        continue;
+      }
+      $mbox_template = $iam_settings['templates'][$mapper_key];
+      // mailbox user does exist, sync attribtues...
+      logMsg("info", "Syncing attributes for user " . $user['email']);
+      mailbox('edit', 'mailbox_from_template', array(
+        'username' => $user['email'],
+        'name' => $user['firstName'] . " " . $user['lastName'],
+        'template' => $mbox_template
+      ));
+    } else {
+      // skip mailbox user
+      logMsg("info", "Skipping user " . $user['email']);
+    }
+    $_SESSION['access_all_exception'] = '0';
+
+    sleep(0.025);
+  }
+
+  // Update the pagination variables for the next batch
+  $start += $max;
+  sleep(1);
+}
+
+logMsg("info", "DONE!");
+// add last execution time to lock file
+$lock_file_handle = fopen($lock_file, 'w');
+fwrite($lock_file_handle, getmypid() . "\n" . time());
+fclose($lock_file_handle);
+session_destroy();

+ 198 - 0
data/conf/phpfpm/crons/ldap-sync.php

@@ -0,0 +1,198 @@
+<?php
+
+require_once(__DIR__ . '/../web/inc/vars.inc.php');
+if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
+  include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
+}
+require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
+
+// Init database
+//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
+$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
+$opt = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+    PDO::ATTR_EMULATE_PREPARES   => false,
+];
+try {
+  $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
+}
+catch (PDOException $e) {
+  logMsg("err", $e->getMessage());
+  session_destroy();
+  exit;
+}
+
+// Init Valkey
+$valkey = new Redis();
+try {
+  if (!empty(getenv('VALKEY_SLAVEOF_IP'))) {
+    $valkey->connect(getenv('VALKEY_SLAVEOF_IP'), getenv('VALKEY_SLAVEOF_PORT'));
+  }
+  else {
+    $valkey->connect('valkey-mailcow', 6379);
+  }
+  $valkey->auth(getenv("VALKEYPASS"));
+}
+catch (Exception $e) {
+  echo "Exiting: " . $e->getMessage();
+  session_destroy();
+  exit;
+}
+
+function logMsg($priority, $message, $task = "LDAP Sync") {
+  global $valkey;
+
+  $finalMsg = array(
+    "time" => time(),
+    "priority" => $priority,
+    "task" => $task,
+    "message" => $message
+  );
+  $valkey->lPush('CRON_LOG', json_encode($finalMsg));
+}
+
+// Load core functions first
+require_once __DIR__ . '/../web/inc/functions.inc.php';
+require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
+require_once __DIR__ . '/../web/inc/sessions.inc.php';
+require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
+require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
+require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
+
+$_SESSION['mailcow_cc_username'] = "admin";
+$_SESSION['mailcow_cc_role'] = "admin";
+$_SESSION['acl']['tls_policy'] = "1";
+$_SESSION['acl']['quarantine_notification'] = "1";
+$_SESSION['acl']['quarantine_category'] = "1";
+$_SESSION['acl']['ratelimit'] = "1";
+$_SESSION['acl']['sogo_access'] = "1";
+$_SESSION['acl']['protocol_access'] = "1";
+$_SESSION['acl']['mailbox_relayhost'] = "1";
+$_SESSION['acl']['unlimited_quota'] = "1";
+
+$iam_settings = identity_provider('get');
+if ($iam_settings['authsource'] != "ldap" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) {
+  session_destroy();
+  exit;
+}
+
+// Set pagination variables
+$start = 0;
+$max = 100;
+
+// lock sync if already running
+$lock_file = '/tmp/iam-sync.lock';
+if (file_exists($lock_file)) {
+  $lock_file_parts = explode("\n", file_get_contents($lock_file));
+  $pid = $lock_file_parts[0];
+  if (count($lock_file_parts) > 1){
+    $last_execution = $lock_file_parts[1];
+    $elapsed_time = (time() - $last_execution) / 60;
+    if ($elapsed_time < intval($iam_settings['sync_interval'])) {
+      logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)");
+      session_destroy();
+      exit;
+    }
+  }
+
+  if (posix_kill($pid, 0)) {
+    logMsg("warning", "Sync is already running");
+    session_destroy();
+    exit;
+  } else {
+    unlink($lock_file);
+  }
+}
+$lock_file_handle = fopen($lock_file, 'w');
+fwrite($lock_file_handle, getmypid());
+fclose($lock_file_handle);
+
+// Init Provider
+$iam_provider = identity_provider('init');
+
+// Get ldap users
+$ldap_query = $iam_provider->query();
+if (!empty($iam_settings['filter'])) {
+  $ldap_query = $ldap_query->rawFilter($iam_settings['filter']);
+}
+$response = $ldap_query->where($iam_settings['username_field'], "*")
+  ->where($iam_settings['attribute_field'], "*")
+  ->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname'])
+  ->paginate($max);
+
+// Process the users
+foreach ($response as $user) {
+  // try get mailbox user
+  $stmt = $pdo->prepare("SELECT
+    mailbox.*,
+    domain.active AS d_active
+    FROM `mailbox`
+    INNER JOIN domain on mailbox.domain = domain.domain
+    WHERE `kind` NOT REGEXP 'location|thing|group'
+      AND `username` = :user");
+  $stmt->execute(array(':user' => $user[$iam_settings['username_field']][0]));
+  $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+  // check if matching attribute mapping exists
+  $user_template = $user[$iam_settings['attribute_field']][0];
+  $mapper_key = array_search($user_template, $iam_settings['mappers']);
+
+  if (empty($user[$iam_settings['username_field']][0])){
+    logMsg("warning", "Skipping user " . $user['displayname'][0] . " due to empty LDAP ". $iam_settings['username_field'] . " property.");
+    continue;
+  }
+
+  $_SESSION['access_all_exception'] = '1';
+  if (!$row && intval($iam_settings['import_users']) == 1){
+    if ($mapper_key === false){
+      if (!empty($iam_settings['default_template'])) {
+        $mbox_template = $iam_settings['default_template'];
+      } else {
+        logMsg("warning", "No matching attribute mapping found for user " . $user[$iam_settings['username_field']][0]);
+        continue;
+      }
+    } else {
+      $mbox_template = $iam_settings['templates'][$mapper_key];
+    }
+    // mailbox user does not exist, create...
+    logMsg("info", "Creating user " .  $user[$iam_settings['username_field']][0]);
+    $create_res = mailbox('add', 'mailbox_from_template', array(
+      'domain' => explode('@',  $user[$iam_settings['username_field']][0])[1],
+      'local_part' => explode('@',  $user[$iam_settings['username_field']][0])[0],
+      'name' => $user['displayname'][0],
+      'authsource' => 'ldap',
+      'template' => $mbox_template
+    ));
+    if (!$create_res){
+      logMsg("err", "Could not create user " . $user[$iam_settings['username_field']][0]);
+      continue;
+    }
+  } else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "ldap") {
+    if ($mapper_key === false){
+      logMsg("warning", "No matching attribute mapping found for user " . $user[$iam_settings['username_field']][0]);
+      continue;
+    }
+    $mbox_template = $iam_settings['templates'][$mapper_key];
+    // mailbox user does exist, sync attribtues...
+    logMsg("info", "Syncing attributes for user " . $user[$iam_settings['username_field']][0]);
+    mailbox('edit', 'mailbox_from_template', array(
+      'username' =>  $user[$iam_settings['username_field']][0],
+      'name' => $user['displayname'][0],
+      'template' => $mbox_template
+    ));
+  } else {
+    // skip mailbox user
+    logMsg("info", "Skipping user " .  $user[$iam_settings['username_field']][0]);
+  }
+  $_SESSION['access_all_exception'] = '0';
+
+  sleep(0.025);
+}
+
+logMsg("info", "DONE!");
+// add last execution time to lock file
+$lock_file_handle = fopen($lock_file, 'w');
+fwrite($lock_file_handle, getmypid() . "\n" . time());
+fclose($lock_file_handle);
+session_destroy();

+ 1 - 1
data/conf/postfix/main.cf

@@ -152,7 +152,7 @@ smtp_sasl_auth_enable = yes
 smtp_sasl_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_sender_dependent.cf
 smtp_sasl_security_options =
 smtp_sasl_mechanism_filter = plain, login
-smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf
+smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY
 smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre
 mail_name = Postcow
 # local_transport map catches local destinations and prevents routing local dests when the next map would route "*"

+ 275 - 42
data/conf/postfix/postscreen_access.cidr

@@ -1,32 +1,71 @@
-# Whitelist generated by Postwhite v3.4 on Sat Feb  1 00:18:03 UTC 2025
+# Whitelist generated by Postwhite v3.4 on Wed Oct  1 00:21:33 UTC 2025
 # https://github.com/stevejenkins/postwhite/
-# 1984 total rules
+# 2216 total rules
 2a00:1450:4000::/36	permit
 2a01:111:f400::/48	permit
-2a01:111:f403:8000::/50	permit
+2a01:111:f403:2800::/53	permit
 2a01:111:f403:8000::/51	permit
 2a01:111:f403::/49	permit
 2a01:111:f403:c000::/51	permit
+2a01:111:f403:d000::/53	permit
 2a01:111:f403:f000::/52	permit
+2a01:238:20a:202:5370::1	permit
+2a01:238:20a:202:5372::1	permit
+2a01:238:20a:202:5373::1	permit
+2a01:238:400:101:53::1	permit
+2a01:238:400:102:53::1	permit
+2a01:238:400:103:53::1	permit
+2a01:238:400:301:53::1	permit
+2a01:238:400:302:53::1	permit
+2a01:238:400:303:53::1	permit
+2a01:238:400:470:53::1	permit
+2a01:238:400:471:53::1	permit
+2a01:238:400:472:53::1	permit
+2a01:b747:3000:200::/56	permit
+2a01:b747:3001:200::/56	permit
+2a01:b747:3002:200::/56	permit
+2a01:b747:3003:200::/56	permit
+2a01:b747:3004:200::/56	permit
+2a01:b747:3005:200::/56	permit
+2a01:b747:3006:200::/56	permit
 2a02:a60:0:5::/64	permit
 2c0f:fb50:4000::/36	permit
-2.207.151.53	permit
+2.207.217.30	permit
+3.64.237.68	permit
+3.65.3.180	permit
 3.70.123.177	permit
+3.72.182.33	permit
+3.74.81.189	permit
+3.74.125.228	permit
+3.75.33.185	permit
 3.93.157.0/24	permit
 3.94.40.108	permit
+3.121.107.214	permit
 3.129.120.190	permit
 3.210.190.0/24	permit
+3.211.80.218	permit
+3.216.221.67	permit
+3.221.209.22	permit
 8.20.114.31	permit
 8.25.194.0/23	permit
 8.25.196.0/23	permit
-10.162.0.0/16	permit
+8.36.116.0/24	permit
+8.39.54.0/23	permit
+8.39.54.250/31	permit
+8.39.144.0/24	permit
+8.40.222.0/23	permit
+8.40.222.250/31	permit
 12.130.86.238	permit
+13.107.213.41	permit
+13.107.246.41	permit
 13.110.208.0/21	permit
 13.110.209.0/24	permit
 13.110.216.0/22	permit
 13.110.224.0/20	permit
 13.111.0.0/16	permit
 13.111.191.0/24	permit
+13.216.7.111	permit
+13.216.54.180	permit
 15.200.21.50	permit
 15.200.44.248	permit
 15.200.201.185	permit
@@ -35,25 +74,30 @@
 17.57.156.0/24	permit
 17.58.0.0/16	permit
 17.142.0.0/15	permit
-17.143.234.140/30	permit
+18.97.0.8/30	permit
+18.97.1.184/29	permit
+18.97.2.64/26	permit
 18.156.89.250	permit
+18.156.205.64	permit
+18.157.70.148	permit
+18.157.114.255	permit
 18.157.243.190	permit
+18.158.153.154	permit
 18.194.95.56	permit
+18.197.217.180	permit
 18.198.96.88	permit
+18.199.210.3	permit
+18.207.52.234	permit
 18.208.124.128/25	permit
 18.216.232.154	permit
 18.235.27.253	permit
 18.236.40.242	permit
-18.236.56.161	permit
 20.51.6.32/30	permit
-20.51.98.61	permit
 20.52.52.2	permit
 20.52.128.133	permit
 20.59.80.4/30	permit
 20.63.210.192/28	permit
 20.69.8.108/30	permit
-20.70.246.20	permit
-20.76.201.171	permit
 20.83.222.104/30	permit
 20.88.157.184/30	permit
 20.94.180.64/28	permit
@@ -62,14 +106,11 @@
 20.98.194.68/30	permit
 20.105.209.76/30	permit
 20.107.239.64/30	permit
-20.112.250.133	permit
 20.118.139.208/30	permit
 20.141.10.196	permit
 20.185.214.0/27	permit
 20.185.214.32/27	permit
 20.185.214.64/27	permit
-20.231.239.246	permit
-20.236.44.162	permit
 23.103.224.0/19	permit
 23.249.208.0/20	permit
 23.251.224.0/19	permit
@@ -81,6 +122,7 @@
 23.253.183.147	permit
 23.253.183.148	permit
 23.253.183.150	permit
+24.110.64.0/18	permit
 27.123.204.128/30	permit
 27.123.204.132/31	permit
 27.123.204.148/30	permit
@@ -93,19 +135,51 @@
 27.123.206.56/29	permit
 27.123.206.76/30	permit
 27.123.206.80/28	permit
-31.25.48.222	permit
 31.47.251.17	permit
+31.186.239.0/24	permit
+34.2.64.0/22	permit
+34.2.68.0/23	permit
+34.2.70.0/23	permit
+34.2.71.64/26	permit
+34.2.72.0/22	permit
+34.2.75.0/26	permit
+34.2.78.0/23	permit
+34.2.80.0/23	permit
+34.2.82.0/23	permit
+34.2.84.0/24	permit
+34.2.84.64/26	permit
+34.2.85.0/24	permit
+34.2.85.64/26	permit
+34.2.86.0/23	permit
+34.2.88.0/23	permit
+34.2.90.0/23	permit
+34.2.92.0/23	permit
+34.2.94.0/23	permit
+34.70.158.162	permit
+34.74.74.140	permit
+34.83.159.189	permit
+34.141.160.224	permit
+34.193.58.168	permit
 34.195.217.107	permit
+34.197.10.50	permit
+34.197.254.9	permit
+34.198.94.229	permit
+34.198.218.121	permit
 34.212.163.75	permit
 34.215.104.144	permit
-34.218.116.3	permit
+34.218.115.239	permit
 34.225.212.172	permit
+34.241.242.183	permit
+35.83.148.184	permit
+35.155.198.111	permit
+35.158.23.94	permit
 35.161.32.253	permit
+35.162.73.231	permit
 35.167.93.243	permit
+35.174.145.124	permit
 35.176.132.251	permit
-35.190.247.0/24	permit
-35.191.0.0/16	permit
 35.205.92.9	permit
+35.228.216.85	permit
 35.242.169.159	permit
 37.188.97.188	permit
 37.218.248.47	permit
@@ -119,12 +193,21 @@
 40.233.64.216	permit
 40.233.83.78	permit
 40.233.88.28	permit
+43.239.212.33	permit
 44.206.138.57	permit
+44.210.169.44	permit
 44.217.45.156	permit
 44.236.56.93	permit
 44.238.220.251	permit
+44.245.243.92	permit
+44.246.1.125	permit
+44.246.68.102	permit
+44.246.77.92	permit
 45.14.148.0/22	permit
-46.19.170.16	permit
+45.143.132.0/24	permit
+45.143.133.0/24	permit
+45.143.134.0/24	permit
+45.143.135.0/24	permit
 46.226.48.0/21	permit
 46.228.36.37	permit
 46.228.36.38/31	permit
@@ -175,6 +258,7 @@
 46.243.88.177	permit
 46.243.95.179	permit
 46.243.95.180	permit
+50.16.246.183	permit
 50.18.45.249	permit
 50.18.121.236	permit
 50.18.121.248	permit
@@ -188,14 +272,24 @@
 50.56.130.220	permit
 50.56.130.221	permit
 50.56.130.222	permit
+50.112.246.219	permit
 52.1.14.157	permit
 52.5.230.59	permit
+52.6.74.205	permit
+52.12.53.23	permit
+52.13.214.179	permit
+52.26.1.71	permit
 52.27.5.72	permit
 52.27.28.47	permit
 52.28.63.81	permit
+52.28.197.132	permit
+52.34.181.151	permit
+52.35.192.45	permit
 52.36.138.31	permit
 52.37.142.146	permit
+52.42.203.116	permit
 52.50.24.208	permit
+52.57.120.243	permit
 52.58.216.183	permit
 52.59.143.3	permit
 52.60.41.5	permit
@@ -238,23 +332,24 @@
 54.174.63.0/24	permit
 54.186.193.102	permit
 54.191.223.56	permit
+54.211.126.101	permit
 54.213.20.246	permit
 54.214.39.184	permit
 54.240.0.0/18	permit
-54.240.64.0/19	permit
-54.240.96.0/19	permit
+54.240.64.0/18	permit
 54.241.16.209	permit
 54.244.54.130	permit
 54.244.242.0/24	permit
 54.255.61.23	permit
+56.124.6.228	permit
 57.103.64.0/18	permit
+57.129.93.249	permit
 62.13.128.0/24	permit
 62.13.129.128/25	permit
 62.13.136.0/21	permit
 62.13.144.0/21	permit
 62.13.152.0/21	permit
 62.17.146.128/26	permit
-62.179.121.0/24	permit
 62.201.172.0/27	permit
 62.201.172.32/27	permit
 62.253.227.114	permit
@@ -262,6 +357,9 @@
 63.128.21.0/24	permit
 63.143.57.128/25	permit
 63.143.59.128/25	permit
+63.176.194.123	permit
+63.178.132.221	permit
+63.178.143.178	permit
 64.18.0.0/20	permit
 64.20.241.45	permit
 64.69.212.0/24	permit
@@ -274,6 +372,7 @@
 64.127.115.252	permit
 64.132.88.0/23	permit
 64.132.92.0/24	permit
+64.181.194.190	permit
 64.207.219.7	permit
 64.207.219.8	permit
 64.207.219.9	permit
@@ -327,10 +426,10 @@
 65.110.161.77	permit
 65.123.29.213	permit
 65.123.29.220	permit
+65.154.166.0/24	permit
 65.212.180.36	permit
 66.102.0.0/20	permit
 66.119.150.192/26	permit
-66.162.193.226/31	permit
 66.163.184.0/24	permit
 66.163.185.0/24	permit
 66.163.186.0/24	permit
@@ -536,7 +635,6 @@
 74.86.241.250/31	permit
 74.112.67.243	permit
 74.125.0.0/16	permit
-74.202.227.40	permit
 74.208.4.200	permit
 74.208.4.201	permit
 74.208.4.220	permit
@@ -565,6 +663,11 @@
 77.238.189.142	permit
 77.238.189.146/31	permit
 77.238.189.148/30	permit
+79.135.106.0/24	permit
+79.135.107.0/24	permit
+81.169.146.243	permit
+81.169.146.245	permit
+81.169.146.246	permit
 81.223.46.0/27	permit
 82.165.159.2	permit
 82.165.159.3	permit
@@ -580,10 +683,17 @@
 82.165.159.45	permit
 82.165.159.130	permit
 82.165.159.131	permit
-84.116.6.0/23	permit
-84.116.36.0/24	permit
-84.116.50.0/23	permit
+85.9.206.169	permit
+85.9.210.45	permit
 85.158.136.0/21	permit
+85.215.255.39	permit
+85.215.255.40	permit
+85.215.255.41	permit
+85.215.255.45	permit
+85.215.255.46	permit
+85.215.255.47	permit
+85.215.255.48	permit
+85.215.255.49	permit
 86.61.88.25	permit
 87.238.80.0/21	permit
 87.248.103.12	permit
@@ -623,12 +733,13 @@
 87.248.117.205	permit
 87.253.232.0/21	permit
 89.22.108.0/24	permit
+91.198.2.0/24	permit
 91.211.240.0/22	permit
-94.169.2.0/23	permit
 94.236.119.0/26	permit
 94.245.112.0/27	permit
 94.245.112.10/31	permit
 95.131.104.0/21	permit
+95.217.114.154	permit
 96.43.144.0/20	permit
 96.43.144.64/28	permit
 96.43.144.64/31	permit
@@ -1122,15 +1233,14 @@
 99.83.190.102	permit
 103.9.96.0/22	permit
 103.28.42.0/24	permit
+103.84.217.238	permit
+103.89.75.238	permit
 103.151.192.0/23	permit
 103.168.172.128/27	permit
 103.237.104.0/22	permit
 104.43.243.237	permit
 104.44.112.128/25	permit
 104.47.0.0/17	permit
-104.47.20.0/23	permit
-104.47.75.0/24	permit
-104.47.108.0/23	permit
 104.130.96.0/28	permit
 104.130.122.0/23	permit
 106.10.144.64/27	permit
@@ -1256,6 +1366,7 @@
 106.50.16.0/28	permit
 107.20.18.111	permit
 107.20.210.250	permit
+107.22.191.150	permit
 108.174.0.0/24	permit
 108.174.0.215	permit
 108.174.3.0/24	permit
@@ -1264,9 +1375,8 @@
 108.174.6.215	permit
 108.175.18.45	permit
 108.175.30.45	permit
-108.177.8.0/21	permit
-108.177.96.0/19	permit
 108.179.144.0/20	permit
+109.224.244.0/24	permit
 109.237.142.0/24	permit
 111.221.23.128/25	permit
 111.221.26.0/27	permit
@@ -1290,6 +1400,9 @@
 117.120.16.0/21	permit
 119.42.242.52/31	permit
 119.42.242.156	permit
+121.244.91.48	permit
+121.244.91.52	permit
+122.15.156.182	permit
 123.126.78.64/29	permit
 124.108.96.24/31	permit
 124.108.96.28/31	permit
@@ -1338,7 +1451,6 @@
 129.213.195.191	permit
 130.61.9.72	permit
 130.162.39.83	permit
-130.211.0.0/22	permit
 130.248.172.0/24	permit
 130.248.173.0/24	permit
 131.253.30.0/24	permit
@@ -1347,12 +1459,28 @@
 132.226.26.225	permit
 132.226.49.32	permit
 132.226.56.24	permit
+134.128.64.0/19	permit
+134.128.96.0/19	permit
 134.170.27.8	permit
 134.170.113.0/26	permit
 134.170.141.64/26	permit
 134.170.143.0/24	permit
 134.170.174.0/24	permit
+135.84.80.0/24	permit
+135.84.81.0/24	permit
+135.84.82.0/24	permit
+135.84.83.0/24	permit
 135.84.216.0/22	permit
+136.143.160.0/24	permit
+136.143.161.0/24	permit
+136.143.162.0/24	permit
+136.143.176.0/24	permit
+136.143.177.0/24	permit
+136.143.178.49	permit
+136.143.182.0/23	permit
+136.143.184.0/24	permit
+136.143.188.0/24	permit
+136.143.190.0/23	permit
 136.147.128.0/20	permit
 136.147.135.0/24	permit
 136.147.176.0/20	permit
@@ -1367,6 +1495,7 @@
 139.138.46.219	permit
 139.138.57.55	permit
 139.138.58.119	permit
+139.167.79.86	permit
 139.180.17.0/24	permit
 140.238.148.191	permit
 141.148.159.229	permit
@@ -1401,6 +1530,7 @@
 146.20.215.0/24	permit
 146.20.215.182	permit
 146.88.28.0/24	permit
+146.148.116.76	permit
 147.154.32.0/25	permit
 147.243.1.47	permit
 147.243.1.48	permit
@@ -1410,7 +1540,7 @@
 148.105.0.0/16	permit
 148.105.8.0/21	permit
 149.72.0.0/16	permit
-149.72.223.204	permit
+149.72.234.184	permit
 149.72.248.236	permit
 149.97.173.180	permit
 150.230.98.160	permit
@@ -1464,6 +1594,9 @@
 159.135.224.0/20	permit
 159.135.228.10	permit
 159.183.0.0/16	permit
+159.183.68.71	permit
+159.183.79.38	permit
+159.183.129.172	permit
 160.1.62.192	permit
 161.38.192.0/20	permit
 161.38.204.0/22	permit
@@ -1480,8 +1613,14 @@
 163.114.132.120	permit
 163.114.134.16	permit
 163.114.135.16	permit
+163.116.128.0/17	permit
+163.192.116.87	permit
 164.152.23.32	permit
+164.152.25.241	permit
 164.177.132.168/30	permit
+165.173.128.0/24	permit
+165.173.180.250/31	permit
+165.173.182.250/31	permit
 166.78.68.0/22	permit
 166.78.68.221	permit
 166.78.69.169	permit
@@ -1506,20 +1645,28 @@
 168.138.5.36	permit
 168.138.73.51	permit
 168.138.77.31	permit
+168.138.237.153	permit
 168.245.0.0/17	permit
 168.245.12.252	permit
 168.245.46.9	permit
 168.245.127.231	permit
+169.148.129.0/24	permit
+169.148.131.0/24	permit
+169.148.138.0/24	permit
+169.148.142.10	permit
+169.148.142.33	permit
+169.148.144.0/25	permit
+169.148.144.10	permit
+169.148.146.0/23	permit
+169.148.175.3	permit
+169.148.188.0/24	permit
+169.148.188.182	permit
 170.10.128.0/24	permit
 170.10.129.0/24	permit
 170.10.132.56/29	permit
 170.10.132.64/29	permit
 170.10.133.0/24	permit
-172.217.0.0/19	permit
 172.217.32.0/20	permit
-172.217.128.0/19	permit
-172.217.160.0/20	permit
-172.217.192.0/19	permit
 172.253.56.0/21	permit
 172.253.112.0/20	permit
 173.0.84.0/29	permit
@@ -1549,9 +1696,14 @@
 182.50.78.64/28	permit
 183.240.219.64/29	permit
 185.4.120.0/22	permit
+185.11.253.128/27	permit
+185.11.255.0/24	permit
 185.12.80.0/22	permit
 185.28.196.0/22	permit
 185.58.84.93	permit
+185.70.40.0/24	permit
+185.70.41.0/24	permit
+185.70.43.0/24	permit
 185.80.93.204	permit
 185.80.93.227	permit
 185.80.95.31	permit
@@ -1559,6 +1711,8 @@
 185.138.56.128/25	permit
 185.189.236.0/22	permit
 185.211.120.0/22	permit
+185.233.188.0/23	permit
+185.233.190.0/23	permit
 185.250.236.0/22	permit
 185.250.239.148	permit
 185.250.239.168	permit
@@ -1611,6 +1765,7 @@
 188.125.85.234/31	permit
 188.125.85.236/31	permit
 188.125.85.238	permit
+188.165.51.139	permit
 188.172.128.0/20	permit
 192.0.64.0/18	permit
 192.18.139.154	permit
@@ -1633,8 +1788,28 @@
 193.109.254.0/23	permit
 193.122.128.100	permit
 193.123.56.63	permit
+193.142.157.0/24	permit
+193.142.157.191	permit
+193.142.157.198	permit
 194.19.134.0/25	permit
+194.25.134.16/28	permit
+194.25.134.80/28	permit
 194.64.234.129	permit
+194.97.196.0/24	permit
+194.97.196.3	permit
+194.97.196.4	permit
+194.97.196.11	permit
+194.97.196.12	permit
+194.97.204.0/24	permit
+194.97.204.3	permit
+194.97.204.4	permit
+194.97.204.11	permit
+194.97.204.12	permit
+194.97.212.0/24	permit
+194.97.212.3	permit
+194.97.212.4	permit
+194.97.212.11	permit
+194.97.212.12	permit
 194.106.220.0/23	permit
 194.113.24.0/22	permit
 194.154.193.192/27	permit
@@ -1651,6 +1826,14 @@
 198.61.254.231	permit
 198.178.234.57	permit
 198.244.48.0/20	permit
+198.244.56.107	permit
+198.244.56.108	permit
+198.244.56.109	permit
+198.244.56.111	permit
+198.244.56.112	permit
+198.244.56.113	permit
+198.244.56.114	permit
+198.244.56.115	permit
 198.244.59.30	permit
 198.244.59.33	permit
 198.244.59.35	permit
@@ -1663,7 +1846,16 @@
 199.16.156.0/22	permit
 199.33.145.1	permit
 199.33.145.32	permit
+199.34.22.36	permit
 199.59.148.0/22	permit
+199.67.80.2	permit
+199.67.80.20	permit
+199.67.82.2	permit
+199.67.82.20	permit
+199.67.84.0/24	permit
+199.67.86.0/24	permit
+199.67.88.0/24	permit
+199.67.90.0/24	permit
 199.101.161.130	permit
 199.101.162.0/25	permit
 199.122.120.0/21	permit
@@ -1720,9 +1912,13 @@
 204.92.114.187	permit
 204.92.114.203	permit
 204.92.114.204/31	permit
+204.141.32.0/23	permit
+204.141.42.0/23	permit
+204.216.164.202	permit
 204.220.160.0/21	permit
 204.220.168.0/21	permit
 204.220.176.0/20	permit
+204.220.181.105	permit
 204.232.168.0/24	permit
 205.139.110.0/24	permit
 205.201.128.0/20	permit
@@ -1797,8 +1993,6 @@
 208.71.42.212/31	permit
 208.71.42.214	permit
 208.72.249.240/29	permit
-208.74.204.5	permit
-208.74.204.9	permit
 208.75.120.0/22	permit
 208.76.62.0/24	permit
 208.76.63.0/24	permit
@@ -1862,6 +2056,8 @@
 212.227.15.4	permit
 212.227.15.5	permit
 212.227.15.6	permit
+212.227.15.7	permit
+212.227.15.8	permit
 212.227.15.14	permit
 212.227.15.15	permit
 212.227.15.18	permit
@@ -1878,21 +2074,36 @@
 212.227.15.53	permit
 212.227.15.54	permit
 212.227.15.55	permit
+212.227.17.1	permit
+212.227.17.2	permit
+212.227.17.7	permit
 212.227.17.11	permit
 212.227.17.12	permit
+212.227.17.16	permit
+212.227.17.17	permit
 212.227.17.18	permit
 212.227.17.19	permit
 212.227.17.20	permit
 212.227.17.21	permit
 212.227.17.22	permit
 212.227.17.26	permit
+212.227.17.27	permit
 212.227.17.28	permit
 212.227.17.29	permit
+212.227.126.206	permit
+212.227.126.207	permit
+212.227.126.208	permit
+212.227.126.209	permit
+212.227.126.220	permit
+212.227.126.221	permit
+212.227.126.222	permit
+212.227.126.223	permit
 212.227.126.224	permit
 212.227.126.225	permit
 212.227.126.226	permit
 212.227.126.227	permit
-213.46.255.0/24	permit
+213.95.19.64/27	permit
+213.95.135.4	permit
 213.199.128.139	permit
 213.199.128.145	permit
 213.199.138.181	permit
@@ -1902,6 +2113,7 @@
 216.17.150.242	permit
 216.17.150.251	permit
 216.24.224.0/20	permit
+216.27.86.152/31	permit
 216.39.60.154/31	permit
 216.39.60.156/30	permit
 216.39.60.160/30	permit
@@ -1939,6 +2151,8 @@
 216.99.5.68	permit
 216.109.114.32/27	permit
 216.109.114.64/29	permit
+216.113.162.65	permit
+216.113.163.65	permit
 216.128.126.97	permit
 216.136.162.65	permit
 216.136.162.120/29	permit
@@ -1966,12 +2180,30 @@
 2001:0868:0100:0600::/64	permit
 2001:4860:4000::/36	permit
 2001:748:100:40::2:0/112	permit
+2001:748:400:1300::3	permit
+2001:748:400:1300::4	permit
+2001:748:400:1301::0/64	permit
+2001:748:400:1301::3	permit
+2001:748:400:1301::4	permit
+2001:748:400:2300::3	permit
+2001:748:400:2300::4	permit
+2001:748:400:2301::0/64	permit
+2001:748:400:2301::3	permit
+2001:748:400:2301::4	permit
+2001:748:400:3300::3	permit
+2001:748:400:3300::4	permit
+2001:748:400:3301::0/64	permit
+2001:748:400:3301::3	permit
+2001:748:400:3301::4	permit
 2404:6800:4000::/36	permit
 2603:1010:3:3::5b	permit
 2603:1020:201:10::10f	permit
 2603:1030:20e:3::23c	permit
 2603:1030:b:3::152	permit
 2603:1030:c02:8::14	permit
+2607:13c0:0001:0000:0000:0000:0000:7000/116	permit
+2607:13c0:0002:0000:0000:0000:0000:1000/116	permit
+2607:13c0:0004:0000:0000:0000:0000:0000/116	permit
 2607:f8b0:4000::/36	permit
 2620:109:c003:104::/64	permit
 2620:109:c003:104::215	permit
@@ -1985,4 +2217,5 @@
 2620:119:50c0:207::/64	permit
 2620:119:50c0:207::215	permit
 2800:3f0:4000::/36	permit
-194.25.134.0/24 permit # t-online.de
+49.12.4.251 permit # checks.mailcow.email
+2a01:4f8:c17:7906::10 permit # checks.mailcow.email

+ 0 - 1
data/conf/rspamd/custom/fishy_tlds.map

@@ -24,7 +24,6 @@
 /.+\.guru$/i
 /.+\.icu$/i
 /.+\.id$/i
-/.+\.info$/i
 /.+\.in.net$/i
 /.+\.ir$/i
 /.+\.jetzt$/i

+ 1 - 1
data/conf/rspamd/dynmaps/aliasexp.php

@@ -133,7 +133,7 @@ try {
             error_log("ALIAS EXPANDER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
             $goto_branch_array = explode(',', $goto_branch);
           } else {
-            $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` AND '1'");
+            $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
             $stmt->execute(array(':domain' => $parsed_goto['domain']));
             $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
             if ($goto_branch) {

+ 35 - 3
data/conf/rspamd/dynmaps/settings.php

@@ -56,7 +56,7 @@ function normalize_email($email) {
     $email = explode('@', $email);
     $email[0] = str_replace('.', '', $email[0]);
     $email = implode('@', $email);
-  } 
+  }
   $gm_alt = "@googlemail.com";
   if (substr_compare($email, $gm_alt, -strlen($gm_alt)) == 0) {
     $email = explode('@', $email);
@@ -114,7 +114,7 @@ function ucl_rcpts($object, $type) {
       $rcpt[] = str_replace('/', '\/', $row['address']);
     }
     // Aliases by alias domains
-    $stmt = $pdo->prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `alias` FROM `mailbox` 
+    $stmt = $pdo->prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `alias` FROM `mailbox`
       LEFT OUTER JOIN `alias_domain` ON `mailbox`.`domain` = `alias_domain`.`target_domain`
       WHERE `mailbox`.`username` = :object");
     $stmt->execute(array(
@@ -184,7 +184,7 @@ while ($row = array_shift($rows)) {
     rcpt = <?=json_encode($rcpt, JSON_UNESCAPED_SLASHES);?>;
 <?php
   }
-  $stmt = $pdo->prepare("SELECT `option`, `value` FROM `filterconf` 
+  $stmt = $pdo->prepare("SELECT `option`, `value` FROM `filterconf`
     WHERE (`option` = 'highspamlevel' OR `option` = 'lowspamlevel')
       AND `object`= :object");
   $stmt->execute(array(':object' => $row['object']));
@@ -468,4 +468,36 @@ while ($row = array_shift($rows)) {
 <?php
 }
 ?>
+
+<?php
+// Start internal aliases
+
+$stmt = $pdo->query("SELECT `id`, `address`, `domain` FROM `alias` WHERE `active` = '1' AND `internal` = '1'");
+$aliases = $stmt->fetchAll(PDO::FETCH_ASSOC);
+while ($alias = array_shift($aliases)) {
+  // build allowed_domains regex and add target domain and alias domains
+  $stmt = $pdo->prepare("SELECT `alias_domain` FROM `alias_domain` WHERE `active` = '1' AND `target_domain` = :target_domain");
+  $stmt->execute(array(':target_domain' => $alias['domain']));
+  $allowed_domains = $stmt->fetchAll(PDO::FETCH_ASSOC);
+  $allowed_domains = array_map(function($item) {
+    return str_replace('.', '\.', $item['alias_domain']);
+  }, $allowed_domains);
+  $allowed_domains[] = str_replace('.', '\.', $alias['domain']);
+  $allowed_domains = implode('|', $allowed_domains);
+?>
+  internal_alias_<?=$alias['id'];?> {
+    priority = 10;
+    rcpt = "<?=$alias['address'];?>";
+    from = "/^((?!.*@(<?=$allowed_domains;?>)).)*$/";
+    apply "default" {
+      MAILCOW_INTERNAL_ALIAS = 9999.0;
+    }
+    symbols [
+      "MAILCOW_INTERNAL_ALIAS"
+    ]
+  }
+<?php
+}
+?>
+
 }

+ 1 - 1
data/conf/rspamd/local.d/composites.conf

@@ -8,7 +8,7 @@ VIRUS_FOUND {
 }
 # Bad policy from free mail providers
 FREEMAIL_POLICY_FAILURE {
-  expression = "FREEMAIL_FROM & !DMARC_POLICY_ALLOW & !MAILLIST& !WHITELISTED_FWD_HOST & -g+:policies";
+  expression = "FREEMAIL_FROM & !DMARC_POLICY_ALLOW & !MAILLIST & !WHITELISTED_FWD_HOST & -g+:policies";
   score = 16.0;
 }
 # Applies to freemail with undisclosed recipients

+ 0 - 12
data/conf/rspamd/local.d/external_services.conf

@@ -1,12 +0,0 @@
-oletools {
-  # default olefy settings
-  servers = "olefy:10055";
-  # needs to be set explicitly for Rspamd < 1.9.5
-  scan_mime_parts = true;
-  # mime-part regex matching in content-type or filename
-  # block all macros
-  extended = true;
-  max_size = 3145728;
-  timeout = 20.0;
-  retransmits = 1;
-}

+ 0 - 2
data/conf/rspamd/local.d/redis.conf

@@ -1,2 +0,0 @@
-servers = "redis:6379";
-timeout = 10;

+ 26 - 16
data/conf/rspamd/lua/rspamd.local.lua

@@ -102,7 +102,7 @@ rspamd_config:register_symbol({
       local rcpt_split = rspamd_str_split(rcpt['addr'], '@')
       if #rcpt_split == 2 then
         if rcpt_split[1] == 'postmaster' then
-          task:set_pre_result('accept', 'whitelisting postmaster smtp rcpt')
+          task:set_pre_result('accept', 'whitelisting postmaster smtp rcpt', 'postmaster')
           return
         end
       end
@@ -167,7 +167,7 @@ rspamd_config:register_symbol({
         for k,v in pairs(data) do
           if (v and v ~= userdata and v == '1') then
             rspamd_logger.infox(rspamd_config, "found ip in keep_spam map, setting pre-result")
-            task:set_pre_result('accept', 'ip matched with forward hosts')
+            task:set_pre_result('accept', 'ip matched with forward hosts', 'keep_spam')
           end
         end
       end
@@ -454,12 +454,18 @@ rspamd_config:register_symbol({
     local redis_params = rspamd_parse_redis_server('dyn_rl')
     local rspamd_logger = require "rspamd_logger"
     local envfrom = task:get_from(1)
+    local envrcpt = task:get_recipients(1) or {}
     local uname = task:get_user()
     if not envfrom or not uname then
       return false
     end
+
     local uname = uname:lower()
 
+    if #envrcpt == 1 and envrcpt[1].addr:lower() == uname then
+      return false
+    end
+
     local env_from_domain = envfrom[1].domain:lower() -- get smtp from domain in lower case
 
     local function redis_cb_user(err, data)
@@ -544,13 +550,13 @@ rspamd_config:register_symbol({
     -- determine newline type
     local function newline(task)
       local t = task:get_newlines_type()
-    
+
       if t == 'cr' then
         return '\r'
       elseif t == 'lf' then
         return '\n'
       end
-    
+
       return '\r\n'
     end
     -- retrieve footer
@@ -558,7 +564,7 @@ rspamd_config:register_symbol({
       if err or type(data) ~= 'string' then
         rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
       else
-        
+
         -- parse json string
         local footer = cjson.decode(data)
         if not footer then
@@ -607,26 +613,30 @@ rspamd_config:register_symbol({
             if footer.plain and footer.plain ~= "" then
               footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
             end
-  
+
             -- add footer
             local out = {}
             local rewrite = lua_mime.add_text_footer(task, footer.html, footer.plain) or {}
-        
+
             local seen_cte
             local newline_s = newline(task)
-        
+
             local function rewrite_ct_cb(name, hdr)
               if rewrite.need_rewrite_ct then
                 if name:lower() == 'content-type' then
-                  local nct = string.format('%s: %s/%s; charset=utf-8',
-                      'Content-Type', rewrite.new_ct.type, rewrite.new_ct.subtype)
+                  -- include boundary if present
+                  local boundary_part = rewrite.new_ct.boundary and
+                    string.format('; boundary="%s"', rewrite.new_ct.boundary) or ''
+                  local nct = string.format('%s: %s/%s; charset=utf-8%s',
+                      'Content-Type', rewrite.new_ct.type, rewrite.new_ct.subtype, boundary_part)
                   out[#out + 1] = nct
-                  -- update Content-Type header
+                  -- update Content-Type header (include boundary if present)
                   task:set_milter_reply({
                     remove_headers = {['Content-Type'] = 0},
                   })
                   task:set_milter_reply({
-                    add_headers = {['Content-Type'] = string.format('%s/%s; charset=utf-8', rewrite.new_ct.type, rewrite.new_ct.subtype)}
+                    add_headers = {['Content-Type'] = string.format('%s/%s; charset=utf-8%s',
+                      rewrite.new_ct.type, rewrite.new_ct.subtype, boundary_part)}
                   })
                   return
                 elseif name:lower() == 'content-transfer-encoding' then
@@ -645,16 +655,16 @@ rspamd_config:register_symbol({
               end
               out[#out + 1] = hdr.raw:gsub('\r?\n?$', '')
             end
-        
+
             task:headers_foreach(rewrite_ct_cb, {full = true})
-        
+
             if not seen_cte and rewrite.need_rewrite_ct then
               out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable')
             end
-        
+
             -- End of headers
             out[#out + 1] = newline_s
-        
+
             if rewrite.out then
               for _,o in ipairs(rewrite.out) do
                 out[#out + 1] = o

+ 4 - 1
data/conf/rspamd/meta_exporter/pipe.php

@@ -182,7 +182,7 @@ foreach (json_decode($rcpts, true) as $rcpt) {
               error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
               $goto_branch_array = explode(',', $goto_branch);
             } else {
-              $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` AND '1'");
+              $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
               $stmt->execute(array(':domain' => $parsed_goto['domain']));
               $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
               if ($goto_branch) {
@@ -236,6 +236,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
       ':action' => $action,
       ':fuzzy_hashes' => $fuzzy
     ));
+    $lastId = $pdo->lastInsertId();
+    $stmt_update = $pdo->prepare("UPDATE `quarantine` SET `qhash` = SHA2(CONCAT(`id`, `qid`), 256) WHERE `id` = :id");
+    $stmt_update->execute(array(':id' => $lastId));
     $stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
       SELECT `id`
       FROM (

+ 1 - 1
data/conf/rspamd/meta_exporter/pushover.php

@@ -167,7 +167,7 @@ foreach (json_decode($rcpts, true) as $rcpt) {
               error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
               $goto_branch_array = explode(',', $goto_branch);
             } else {
-              $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` AND '1'");
+              $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
               $stmt->execute(array(':domain' => $parsed_goto['domain']));
               $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
               if ($goto_branch) {

+ 19 - 0
data/conf/sogo/custom-sogo.js

@@ -1,3 +1,21 @@
+// redirect to mailcow login form
+document.addEventListener('DOMContentLoaded', function () {
+    var loginForm = document.forms.namedItem("loginForm");
+    if (loginForm) {
+        window.location.href = '/user';
+    }
+});
+// logout function
+function mc_logout() {
+    fetch("/", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/x-www-form-urlencoded"
+        },
+        body: "logout=1"
+    }).then(() => window.location.href = '/');
+}
+
 // Custom SOGo JS
 
 // Change the visible font-size in the editor, this does not change the font of a html message by default
@@ -5,3 +23,4 @@ CKEDITOR.addCss("body {font-size: 16px !important}");
 
 // Enable scayt by default
 //CKEDITOR.config.scayt_autoStartup = true;
+

+ 34 - 28
data/conf/sogo/plist_ldap → data/conf/sogo/plist_ldap.sh

@@ -1,28 +1,34 @@
-                <!--
-                <example>
-                    <key>canAuthenticate</key>
-                    <string>YES</string>
-                    <key>id</key>
-                    <string>${line}_ldap</string>
-                    <key>isAddressBook</key>
-                    <string>NO</string>
-                    <key>IDFieldName</key>
-                    <string>mail</string>
-                    <key>UIDFieldName</key>
-                    <string>uid</string>
-                    <key>bindFields</key>
-                    <array>
-                        <string>mail</string>
-                    </array>
-                    <key>type</key>
-                    <string>ldap</string>
-                    <key>bindDN</key>
-                    <string>cn=admin,dc=example,dc=local</string>
-                    <key>bindPassword</key>
-                    <string>password</string>
-                    <key>baseDN</key>
-                    <string>ou=People,dc=example,dc=local</string>
-                    <key>hostname</key>
-                    <string>ldap://1.2.3.4:389</string>
-                </example>
-                -->
+#!/bin/bash
+
+domain="$1"
+gal_status="$2"
+
+echo "
+                <!--
+                <example>
+                    <key>canAuthenticate</key>
+                    <string>YES</string>
+                    <key>id</key>
+                    <string>"${domain}"_ldap</string>
+                    <key>isAddressBook</key>
+                    <string>"${gal_status}"</string>
+                    <key>IDFieldName</key>
+                    <string>mail</string>
+                    <key>UIDFieldName</key>
+                    <string>uid</string>
+                    <key>bindFields</key>
+                    <array>
+                        <string>mail</string>
+                    </array>
+                    <key>type</key>
+                    <string>ldap</string>
+                    <key>bindDN</key>
+                    <string>cn=admin,dc=example,dc=local</string>
+                    <key>bindPassword</key>
+                    <string>password</string>
+                    <key>baseDN</key>
+                    <string>ou=People,dc=example,dc=local</string>
+                    <key>hostname</key>
+                    <string>ldap://1.2.3.4:389</string>
+                </example>
+                -->"

+ 4 - 1
data/conf/sogo/sogo.conf

@@ -16,6 +16,9 @@
     SOGoFoldersSendEMailNotifications = YES;
     SOGoForwardEnabled = YES;
 
+    // Added with SOGo 5.12 - Allows users to cleanup there maildirectories by deleting mails oder than X
+    SOGoEnableMailCleaning = YES;
+
     // Fixes "MODIFICATION_FAILED" error (HTTP 412) in Clients when accepting invitations from external services
     SOGoDisableOrganizerEventCheck = YES;
 
@@ -91,7 +94,7 @@
   //SoDebugBaseURL = YES;
   //ImapDebugEnabled = YES;
   //SOGoEASDebugEnabled = YES;
-  SOGoEASSearchInBody = YES; // Experimental. Enabled since 2023-10
+  SOGoEASSearchInBody = YES;
   //LDAPDebugEnabled = YES;
   //PGDebugEnabled = YES;
   //MySQL4DebugEnabled = YES;

+ 1 - 1
data/conf/valkey/valkey-conf.sh

@@ -9,4 +9,4 @@ if [ -n "$VALKEYMASTERPASS" ]; then
   echo "masterauth $VALKEYMASTERPASS" >> /valkey.conf
 fi
 
-exec valkey-server /valkey.conf
+exec "$@"

+ 16 - 4
data/web/debug.php → data/web/admin/dashboard.php

@@ -1,21 +1,31 @@
 <?php
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
 
-if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
-  header('Location: /');
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /domainadmin/mailbox');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
+  header('Location: /admin');
   exit();
 }
 
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
 $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
 $clamd_status = (preg_match("/^([yY][eE][sS]|[yY])+$/", $_ENV["SKIP_CLAMD"])) ? false : true;
+$olefy_status = (preg_match("/^([yY][eE][sS]|[yY])+$/", $_ENV["SKIP_OLEFY"])) ? false : true;
 
 
 if (!isset($_SESSION['gal']) && $license_cache = $valkey->Get('LICENSE_STATUS_CACHE')) {
   $_SESSION['gal'] = json_decode($license_cache, true);
 }
 
-$js_minifier->add('/web/js/site/debug.js');
+$js_minifier->add('/web/js/site/dashboard.js');
 
 // vmail df
 $exec_fields = array('cmd' => 'system', 'task' => 'df', 'dir' => '/var/vmail');
@@ -24,6 +34,7 @@ $vmail_df = explode(',', (string)json_decode(docker('post', 'dovecot-mailcow', '
 // containers
 $containers_info = (array) docker('info');
 if ($clamd_status === false) unset($containers_info['clamd-mailcow']);
+if ($olefy_status === false) unset($containers_info['olefy-mailcow']);
 ksort($containers_info);
 $containers = array();
 foreach ($containers_info as $container => $container_info) {
@@ -59,7 +70,7 @@ foreach ($containers_info as $container => $container_info) {
 $hostname = getenv('MAILCOW_HOSTNAME');
 $timezone = getenv('TZ');
 
-$template = 'debug.twig';
+$template = 'dashboard.twig';
 $template_data = [
   'log_lines' => getenv('LOG_LINES'),
   'vmail_df' => $vmail_df,
@@ -68,6 +79,7 @@ $template_data = [
   'gal' => @$_SESSION['gal'],
   'license_guid' => license('guid'),
   'clamd_status' => $clamd_status,
+  'olefy_status' => $olefy_status,
   'containers' => $containers,
   'ip_check' => customize('get', 'ip_check'),
   'lang_admin' => json_encode($lang['admin']),

+ 30 - 0
data/web/admin/index.php

@@ -0,0 +1,30 @@
+<?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
+
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+  header('Location: /admin/dashboard');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /domainadmin/mailbox');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
+$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+$_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
+
+
+$template = 'admin_index.twig';
+$template_data = [
+  'login_delay' => @$_SESSION['ldelay'],
+  'custom_login' => customize('get', 'custom_login'),
+];
+
+$js_minifier->add('/web/js/site/index.js');
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 13 - 3
data/web/mailbox.php → data/web/admin/mailbox.php

@@ -1,10 +1,20 @@
 <?php
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
 
-if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin" && $_SESSION['mailcow_cc_role'] != "domainadmin") {
-  header('Location: /');
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /domainadmin/mailbox');
   exit();
 }
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
+  header('Location: /admin');
+  exit();
+}
+
 require_once $_SERVER['DOCUMENT_ROOT'] .  '/inc/header.inc.php';
 $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
 
@@ -14,7 +24,7 @@ $js_minifier->add('/web/js/site/mailbox.js');
 $js_minifier->add('/web/js/presets/sieveMailbox.js');
 $js_minifier->add('/web/js/site/pwgen.js');
 
-$role = ($_SESSION['mailcow_cc_role'] == "admin") ? 'admin' : 'domainadmin';
+$role = "admin";
 $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? 'true' : 'false';
 $allow_admin_email_login = (preg_match("/^([yY][eE][sS]|[yY])+$/", $_ENV["ALLOW_ADMIN_EMAIL_LOGIN"])) ? 'true' : 'false';
 

+ 12 - 3
data/web/queue.php → data/web/admin/queue.php

@@ -1,8 +1,17 @@
 <?php
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
 
-if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
-  header('Location: /');
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /domainadmin/mailbox');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
+  header('Location: /admin');
   exit();
 }
 
@@ -11,7 +20,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
 $js_minifier->add('/web/js/site/queue.js');
 $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
 
-$role = ($_SESSION['mailcow_cc_role'] == "admin") ? 'admin' : 'domainadmin';
+$role = "admin";
 
 $template = 'queue.twig';
 $template_data = [

+ 16 - 2
data/web/admin.php → data/web/admin/system.php

@@ -1,8 +1,17 @@
 <?php
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
 
-if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
-  header('Location: /');
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /domainadmin/mailbox');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
+  header('Location: /admin');
   exit();
 }
 
@@ -86,6 +95,8 @@ $cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allo
 $cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']);
 
 $f2b_data = fail2ban('get');
+// mbox templates
+$mbox_templates = mailbox('get', 'mailbox_templates');
 
 $template = 'admin.twig';
 $template_data = [
@@ -114,10 +125,13 @@ $template_data = [
   'logo_specs' => customize('get', 'main_logo_specs'),
   'logo_dark_specs' => customize('get', 'main_logo_dark_specs'),
   'ip_check' => customize('get', 'ip_check'),
+  'custom_login' => customize('get', 'custom_login'),
   'password_complexity' => password_complexity('get'),
   'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
   'cors_settings' => $cors_settings,
   'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
+  'iam_settings' => $iam_settings,
+  'mbox_templates' => $mbox_templates,
   'lang_admin' => json_encode($lang['admin']),
   'lang_datatables' => json_encode($lang['datatables'])
 ];

+ 239 - 5
data/web/api/openapi.yaml

@@ -346,7 +346,8 @@ paths:
                   description: the domain which emails should be forwarded
                   type: string
                 type:
-                  description: the type of bcc map can be `sender` or `recipient`
+                  description: the type of bcc map can be `sender` or `rcpt`
+                  enum: [sender, rcpt]
                   type: string
               type: object
       summary: Create BCC Map
@@ -409,7 +410,7 @@ paths:
                   description: a list of domains for which a dkim key should be generated
                   type: string
                 key_size:
-                  description: the key size (1024 or 2048)
+                  description: the key size (1024, 2048, 3072 or 4096)
                   type: number
               type: object
       summary: Generate DKIM Key
@@ -1112,6 +1113,7 @@ paths:
                 domain: domain.tld
                 local_part: info
                 name: Full name
+                authsource: mailcow
                 password: atedismonsin
                 password2: atedismonsin
                 quota: "3072"
@@ -1132,11 +1134,16 @@ paths:
                 name:
                   description: Full name of the mailbox user
                   type: string
+                authsource:
+                  description: Specifies the authentication source for the mailbox.
+                  type: string
+                  enum: [mailcow, ldap, keycloak, generic-oidc]
+                  default: mailcow
                 password2:
                   description: mailbox password for confirmation
                   type: string
                 password:
-                  description: mailbox password
+                  description: mailbox password when using `mailcow` as the authentication source.
                   type: string
                 quota:
                   description: mailbox quota
@@ -3374,6 +3381,7 @@ paths:
                   active: "1"
                   force_pw_update: "0"
                   name: Full name
+                  authsource: mailcow
                   password: ""
                   password2: ""
                   quota: "3072"
@@ -3398,11 +3406,15 @@ paths:
                     name:
                       description: Full name of the mailbox user
                       type: string
+                    authsource:
+                      description: Specifies the authentication source for the mailbox.
+                      type: string
+                      enum: [mailcow, ldap, keycloak, generic-oidc]
                     password2:
                       description: new mailbox password for confirmation
                       type: string
                     password:
-                      description: new mailbox password
+                      description: new mailbox password when using `mailcow` as the authentication source.
                       type: string
                     quota:
                       description: mailbox quota
@@ -5687,7 +5699,7 @@ paths:
         - description: name of domain
           in: path
           name: domain
-          required: false
+          required: true
           schema:
             type: string
         - description: e.g. api-key-string
@@ -5814,6 +5826,226 @@ paths:
         Using this endpoint you can get the global spam filter score or the spam filter score of a certain mailbox.
       operationId: Get mailbox or global spam filter score
       summary: Get mailbox or global spam filter score
+  /api/v1/edit/identity-provider:
+    post:
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                    - type: "success"
+                      log:
+                        - "identity_provider"
+                        - "edit"
+                        - authsource: "keycloak"
+                          server_url: "https://auth.mailcow.tld"
+                          realm: "mailcow"
+                          client_id: "mailcow_client"
+                          client_secret: "*"
+                          redirect_url: "https://mail.mailcow.tld"
+                          redirect_url_extra: ["https://extramail.mailcow.tld"]
+                          version: "26.1.3"
+                          default_template: "Default"
+                          mappers:
+                            - "small_mbox"
+                            - "medium_mbox"
+                          templates:
+                            - "small"
+                            - "medium"
+                          ignore_ssl_error: true
+                          mailpassword_flow: true
+                          periodic_sync: true
+                          import_users: true
+                          sync_interval: 30
+                      msg:
+                        - "object_modified"
+                        - ""
+          description: OK
+          headers: { }
+      tags:
+        - Identity Provider
+      description: >-
+        Configure an external Identity Provider to use as user authentication
+      operationId: Edit external Identity Provider settings
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                items:
+                  type: array
+                  default: ["identity-provider"]
+                attr:
+                  type: object
+                  properties:
+                    authsource:
+                      description: Specifies the type of the Identity Provider
+                      type: string
+                      enum: [ldap, keycloak, generic-oidc]
+                    server_url:
+                      description: The base URL of your Keycloak server. Required if `authsource` is keycloak.
+                      type: string
+                    realm:
+                      description: The Keycloak realm where the mailcow client is configured. Required if `authsource` is keycloak.
+                      type: string
+                    client_id:
+                      description: The Client ID assigned to mailcow Client in OIDC Provider. Required if `authsource` is keycloak or generic-oidc.
+                      type: string
+                    client_secret:
+                      description: The Client Secret assigned to mailcow Client in OIDC Provider. Required if `authsource` is keycloak or generic-oidc.
+                      type: string
+                    redirect_url:
+                      description: The redirect URL that OIDC Provider will use after authentication. Required if `authsource` is keycloak or generic-oidc.
+                      type: string
+                    redirect_url_extra:
+                      description: Additional redirect URLs that OIDC Provider can use after authentication if valid.
+                      type: array
+                    version:
+                      description: Specifies the Keycloak version. Required if `authsource` is keycloak.
+                      type: string
+                    default_template:
+                      description: (Optional) If no matching Attribute Mapping exists for a User, the default template will be used for creating the mailbox, but not for updating the mailbox.
+                      type: string
+                    mappers:
+                      description: (Optional) Attribute values used to match a mailbox template. Each element corresponds to the respective index in the templates array (i.e., the first element matches the first element of templates, the second matches the second, and so on).
+                      type: array
+                    templates:
+                      description: (Optional) Defines the mailbox templates to be assigned. Each element corresponds to the respective index in the `mappers` array.
+                      type: array
+                    ignore_ssl_error:
+                      description: If enabled, SSL certificate validation is bypassed
+                      type: boolean
+                      default: false
+                    mailpassword_flow:
+                      description: If enabled, mailcow will attempt to validate user credentials using the Keycloak Admin REST API instead of relying solely on the Authorization Code Flow.
+                      type: boolean
+                      default: false
+                    periodic_sync:
+                      description: If enabled, mailcow periodically performs a full sync of all users from Keycloak or LDAP.
+                      type: boolean
+                      default: false
+                    import_users:
+                      description: If enabled, new users are automatically imported from Keycloak or LDAP into mailcow.
+                      type: boolean
+                      default: false
+                    sync_interval:
+                      description: Defines the time interval (in minutes) for periodic synchronization and user imports.
+                      type: number
+                      default: 15
+                    host:
+                      description: The address of your LDAP server. You can provide a single hostname or a comma-separated list of hosts for fallback in case the primary server is unreachable. Required if `authsource` is ldap.
+                      type: string
+                    port:
+                      description: The port used to connect to the LDAP server. Required if `authsource` is ldap.
+                      type: string
+                    use_ssl:
+                      description: enable LDAPS connection. If Port is set to 389 it will be overriden to 636.
+                      type: boolean
+                      default: false
+                    use_tls:
+                      description: enable TLS connection. TLS is recommended over SSL. SSL Ports cannot be used.
+                      type: boolean
+                      default: false
+                    basedn:
+                      description: The Distinguished Name (DN) from which searches will be performed. Required if `authsource` is ldap.
+                      type: string
+                    username_field:
+                      description: The LDAP attribute used to identify users during authentication. Required if `authsource` is ldap.
+                      type: string
+                      default: mail
+                    filter:
+                      description: An optional LDAP search filter to refine which users can authenticate.
+                      type: string
+                    attribute_field:
+                      description: Specifies an LDAP attribute that holds a specific value which can be mapped to a mailbox template using the Attribute Mapping section. Required if `authsource` is ldap.
+                      type: string
+                    binddn:
+                      description: The Distinguished Name (DN) of the LDAP user that will be used to authenticate and perform LDAP searches. This account should have sufficient permissions to read the required attributes. Required if `authsource` is ldap.
+                      type: string
+                    bindpass:
+                      description: The password for the Bind DN user. It is required for authentication when connecting to the LDAP server. Required if `authsource` is ldap.
+                      type: string
+                    authorize_url:
+                      description: The OIDC provider's authorization server URL. Required if `authsource` is generic-oidc.
+                      type: string
+                    token_url:
+                      description: The OIDC provider's token server URL. Required if `authsource` is generic-oidc.
+                      type: string
+                    userinfo_url:
+                      description: The OIDC provider's user info server URL. Required if `authsource` is generic-oidc.
+                      type: string
+                    client_scopes:
+                      description: Specifies the OIDC scopes requested during authentication.
+                      type: string
+                      default: "openid profile email mailcow_template"
+            examples:
+              keycloak:
+                value:
+                  items:
+                    - "identity-provider"
+                  attr:
+                    authsource: "keycloak"
+                    server_url: "https://auth.mailcow.tld"
+                    realm: "mailcow"
+                    client_id: "mailcow_client"
+                    client_secret: "Xy7GdPqvJ9m3R8sT2LkVZ5W1oNbCaYQf"
+                    redirect_url: "https://mail.mailcow.tld"
+                    redirect_url_extra: ["https://extramail.mailcow.tld"]
+                    version: "26.1.3"
+                    default_template: "Default"
+                    mappers: ["small_mbox", "medium_mbox"]
+                    templates: ["small", "medium"]
+                    ignore_ssl_error: true
+                    mailpassword_flow: true
+                    periodic_sync: true
+                    import_users: true
+                    sync_interval: 30
+              ldap:
+                value:
+                  items:
+                    - "identity-provider"
+                  attr:
+                    authsource: "ldap"
+                    host: "127.0.0.1"
+                    port: "389"
+                    use_ssl: false
+                    use_tls: false
+                    ignore_ssl_error: false
+                    basedn: "DC=mailcow,DC=local"
+                    username_field: "mail"
+                    filter: "(memberOf:1.2.840.113556.1.4.1941:=DC=mailcow,DC=local)"
+                    attribute_field: "othermailbox"
+                    binddn: "CN=LDAP Read Only,CN=Users,DC=mailcow,DC=local"
+                    bindpass: "moohoo"
+                    default_template: "Default"
+                    mappers: ["small_mbox", "medium_mbox"]
+                    templates: ["small", "medium"]
+                    periodic_sync: true
+                    import_users: true
+                    sync_interval: 30
+              generic-oidc:
+                value:
+                  items:
+                    - "identity-provider"
+                  attr:
+                    authsource: "generic-oidc"
+                    authorize_url: "https://auth.mailcow.tld/application/o/authorize/"
+                    token_url: "https://auth.mailcow.tld/application/o/token/"
+                    userinfo_url: "https://auth.mailcow.tld/application/o/userinfo/"
+                    client_id: "mailcow_client"
+                    client_secret: "Xy7GdPqvJ9m3R8sT2LkVZ5W1oNbCaYQf"
+                    redirect_url: "https://mail.mailcow.tld"
+                    redirect_url_extra: ["https://extramail.mailcow.tld"]
+                    client_scopes: "openid profile email mailcow_template"
+                    default_template: "Default"
+                    mappers: ["small_mbox", "medium_mbox"]
+                    templates: ["small", "medium"]
+                    ignore_ssl_error: true
+      summary: Edit external Identity Provider
 
 tags:
   - name: Domains
@@ -5860,3 +6092,5 @@ tags:
     description: Edit domain ratelimits
   - name: Cross-Origin Resource Sharing (CORS)
     description: Manage Cross-Origin Resource Sharing (CORS) settings
+  - name: Identity Provider
+    description: Manage external Identity Provider settings

+ 1 - 1
data/web/autoconfig.php

@@ -85,7 +85,7 @@ if (count($records) == 0 || $records[0]['target'] != '') { ?>
          <authentication>password-cleartext</authentication>
       </outgoingServer>
 
-      <enable visiturl="https://<?=$mailcow_hostname; ?><?php if ($port != 443) echo ':'.$port; ?>/admin.php">
+      <enable visiturl="https://<?=$mailcow_hostname; ?><?php if ($port != 443) echo ':'.$port; ?>/admin">
          <instruction>If you didn't change the password given to you by the administrator or if you didn't change it in a long time, please consider doing that now.</instruction>
          <instruction lang="de">Sollten Sie das Ihnen durch den Administrator vergebene Passwort noch nicht geändert haben, empfehlen wir dies nun zu tun. Auch ein altes Passwort sollte aus Sicherheitsgründen geändert werden.</instruction>
       </enable>

+ 10 - 2
data/web/autodiscover.php

@@ -1,10 +1,13 @@
 <?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/lib/vendor/autoload.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/vars.inc.php';
-require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.inc.php';
-$default_autodiscover_config = $autodiscover_config;
 if(file_exists('inc/vars.local.inc.php')) {
   include_once 'inc/vars.local.inc.php';
 }
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.auth.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/sessions.inc.php';
+$default_autodiscover_config = $autodiscover_config;
 $autodiscover_config = array_merge($default_autodiscover_config, $autodiscover_config);
 
 // Valkey
@@ -50,6 +53,11 @@ $opt = [
   PDO::ATTR_EMULATE_PREPARES   => false,
 ];
 $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
+
+// Init Identity Provider
+$iam_provider = identity_provider('init');
+$iam_settings = identity_provider('get');
+
 $login_user = strtolower(trim($_SERVER['PHP_AUTH_USER']));
 $login_pass = trim(htmlspecialchars_decode($_SERVER['PHP_AUTH_PW']));
 

File diff suppressed because it is too large
+ 0 - 0
data/web/css/build/007-languages.min.css


+ 0 - 3
data/web/css/build/013-datatables.css

@@ -8,9 +8,6 @@
 .dtr-details {
     width: 100%;
 }
-.table-striped>tbody>tr:nth-of-type(odd) {
-    background-color: #F2F2F2;
-}
 td.child>ul>li {
     display: flex;
 }

+ 60 - 17
data/web/css/build/014-mailcow.css

@@ -33,6 +33,13 @@
        url('/fonts/noto-sans-v12-latin_greek_cyrillic-700italic.woff2') format('woff2'),
        url('/fonts/noto-sans-v12-latin_greek_cyrillic-700italic.woff') format('woff');
 }
+
+body {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  background-color: #fbfbfb;
+}
 #maxmsgsize { min-width: 80px; }
 #slider1 .slider-selection {
 	background: #FFD700;
@@ -74,10 +81,23 @@
   align-items: center;
   padding: 0 10px !important;
 }
-.navbar-fixed-bottom .navbar-collapse, 
+.navbar-fixed-bottom .navbar-collapse,
 .navbar-fixed-top .navbar-collapse {
   max-height: 1000px
 }
+.nav-tabs .nav-link, .nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus {
+  border-color: #dfdfdf;
+}
+.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
+  border-color: #dfdfdf;
+  border-bottom: 1px solid #ffffff;
+}
+.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {
+  border-color: #dfdfdf;
+}
+.nav-tabs {
+  border-bottom: 1px solid #dfdfdf;
+}
 .bi {
   display: inline-block;
   font-size: 12pt;
@@ -123,18 +143,18 @@
   }
 }
 @keyframes blink {
-  50% { 
-    color: transparent 
+  50% {
+    color: transparent
   }
 }
-.loader-dot { 
-  animation: 1s blink infinite 
+.loader-dot {
+  animation: 1s blink infinite
 }
-.loader-dot:nth-child(2) { 
-  animation-delay: 250ms 
+.loader-dot:nth-child(2) {
+  animation-delay: 250ms
 }
-.loader-dot:nth-child(3) { 
-  animation-delay: 500ms 
+.loader-dot:nth-child(3) {
+  animation-delay: 500ms
 }
 
 pre{white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word;}
@@ -200,13 +220,13 @@ legend {
 }
 .haveibeenpwned {
   cursor: pointer;
-  -webkit-user-select: none;  
-  -moz-user-select: none;    
-  -ms-user-select: none;      
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
   user-select: none;
 }
 .full-width-select {
-  width: 100%!important;  
+  width: 100%!important;
 }
 .tooltip {
   font-family: inherit;
@@ -330,7 +350,7 @@ code {
 .caret {
   transform: rotate(0deg);
 }
-a[aria-expanded='true'] > .caret, 
+a[aria-expanded='true'] > .caret,
 button[aria-expanded='true'] > .caret {
   transform: rotate(-180deg);
 }
@@ -340,7 +360,7 @@ button[aria-expanded='true'] > .caret {
 }
 .list-group-header {
   background: #f7f7f7;
-} 
+}
 
 
 .bg-primary, .alert-primary, .btn-primary {
@@ -366,12 +386,13 @@ button[aria-expanded='true'] > .caret {
     background-color: #f0f0f0;
 }
 .btn.btn-outline-secondary {
-  border-color: #cfcfcf !important;  
+  color: #000000 !important;
+  border-color: #cfcfcf !important;
 }
 .btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
     background-color: #f0f0f0 !important;
 }
-.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {    
+.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
     color: #fff;
     background-color: #555;
     background-image: none;
@@ -389,4 +410,26 @@ button[aria-expanded='true'] > .caret {
 .badge.bg-danger > a {
     color: #fff !important;
     text-decoration: none;
+}
+
+.hr-title {
+  display: flex;
+  align-items: center;
+  text-align: center;
+  margin: 20px 0;
+}
+
+.hr-title::before,
+.hr-title::after {
+  content: "";
+  flex: 1;
+  border-bottom: 1px solid #ccc;
+}
+
+.hr-title:not(:empty)::before {
+  margin-right: 10px;
+}
+
+.hr-title:not(:empty)::after {
+  margin-left: 10px;
 }

+ 5 - 27
data/web/css/build/015-responsive.css

@@ -6,15 +6,9 @@
   max-width: 350px;
 }
 
-.card-login .apps .btn {
-  width: auto;
-  float: left;
-  margin-right: 10px;
-  margin-top: auto;
-}
-.card-login .apps .btn:hover {
-  margin-top: 1px !important;
-  border-bottom-width: 3px;
+.card .apps {
+  display: flex;
+  flex-wrap: wrap;
 }
 
 .responsive-tabs .nav-tabs {
@@ -43,16 +37,6 @@
       opacity: 1;
   }
 
-  .card-login .apps .btn {
-    width: 100%;
-    float: none;
-    margin-bottom: 10px;
-  }
-
-  .card-login .apps .btn {
-    border-bottom-width: 4px;
-  }
-
   .xs-show {
     display: block !important;
   }
@@ -113,9 +97,6 @@
   .btn-group.nowrap .dropdown-menu {
     width: 100%;
   }
-  .card-login .btn-group {
-    display: block;
-  }
   .mass-actions-user .btn-group {
     float: none;
   }
@@ -191,9 +172,6 @@
   .btn-group .btn i {
     margin-right: 5px;
   }
-  .card-login .btn-group .btn {
-    display: block !important;
-  }
 
   .dt-sm-head-hidden .dtr-title {
     display: none !important;
@@ -206,7 +184,7 @@
   .senders-mw220 {
     max-width: 100% !important;
   }
-  
+
   table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
   table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before,
   table.dataTable td.dt-control:before {
@@ -215,7 +193,7 @@
       line-height: 2rem;
       margin-top: -15px;
   }
-  
+
   li .dtr-data {
       padding: 0;
   }

+ 0 - 3
data/web/css/site/admin.css

@@ -59,9 +59,6 @@ body.modal-open {
 .table-condensed > thead > tr > th, .table-condensed > tbody > tr > th, .table-condensed > tfoot > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > td {
   padding: 3px;
 }
-table tbody tr {
-  cursor: pointer;
-}
 table tbody tr td input[type="checkbox"] {
   cursor: pointer;
 }

+ 29 - 0
data/web/domainadmin/index.php

@@ -0,0 +1,29 @@
+<?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
+
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /domainadmin/mailbox');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+  header('Location: /admin/dashboard');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
+$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+$_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
+
+$template = 'domainadmin_index.twig';
+$template_data = [
+  'login_delay' => @$_SESSION['ldelay'],
+  'custom_login' => customize('get', 'custom_login'),
+];
+
+$js_minifier->add('/web/js/site/index.js');
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 58 - 0
data/web/domainadmin/mailbox.php

@@ -0,0 +1,58 @@
+<?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
+
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+  header('Location: /admin/dashboard');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "domainadmin") {
+  header('Location: /domainadmin');
+  exit();
+}
+
+require_once $_SERVER['DOCUMENT_ROOT'] .  '/inc/header.inc.php';
+$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+
+
+
+$js_minifier->add('/web/js/site/mailbox.js');
+$js_minifier->add('/web/js/presets/sieveMailbox.js');
+$js_minifier->add('/web/js/site/pwgen.js');
+
+$role = "domainadmin";
+$is_dual = (!empty($_SESSION["dual-login"]["username"])) ? 'true' : 'false';
+$allow_admin_email_login = (preg_match("/^([yY][eE][sS]|[yY])+$/", $_ENV["ALLOW_ADMIN_EMAIL_LOGIN"])) ? 'true' : 'false';
+
+// domains
+$domains = mailbox('get', 'domains');
+
+// mailboxes
+$mailboxes = [];
+foreach ($domains as $domain) {
+  foreach (mailbox('get', 'mailboxes', $domain) as $mailbox) {
+    $mailboxes[] = $mailbox;
+  }
+}
+
+$template = 'mailbox.twig';
+$template_data = [
+  'acl' => $_SESSION['acl'],
+  'acl_json' => json_encode($_SESSION['acl']),
+  'role' => $role,
+  'is_dual' => $is_dual,
+  'allow_admin_email_login' => $allow_admin_email_login,
+  'global_filters' => mailbox('get', 'global_filter_details'),
+  'domains' => $domains,
+  'mailboxes' => $mailboxes,
+  'lang_mailbox' => json_encode($lang['mailbox']),
+  'lang_rl' => json_encode($lang['ratelimit']),
+  'lang_edit' => json_encode($lang['edit']),
+  'lang_datatables' => json_encode($lang['datatables']),
+];
+
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 44 - 0
data/web/domainadmin/user.php

@@ -0,0 +1,44 @@
+<?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
+
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+
+  /*
+  / DOMAIN ADMIN
+  */
+
+  require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
+  $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+  $tfa_data = get_tfa();
+  $fido2_data = fido2(array("action" => "get_friendly_names"));
+  $username = $_SESSION['mailcow_cc_username'];
+
+  $template = 'domainadmin.twig';
+  $template_data = [
+    'acl' => $_SESSION['acl'],
+    'acl_json' => json_encode($_SESSION['acl']),
+    'user_spam_score' => mailbox('get', 'spam_score', $username),
+    'tfa_data' => $tfa_data,
+    'fido2_data' => $fido2_data,
+    'lang_user' => json_encode($lang['user']),
+    'lang_datatables' => json_encode($lang['datatables']),
+  ];
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+  header('Location: /admin/dashboard');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+else {
+  header('Location: /domainadmin');
+  exit();
+}
+
+$js_minifier->add('/web/js/site/user.js');
+$js_minifier->add('/web/js/site/pwgen.js');
+
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 10 - 1
data/web/edit.php

@@ -48,6 +48,12 @@ if (isset($_SESSION['mailcow_cc_role'])) {
           $rl = ratelimit('get', 'domain', $domain);
           $rlyhosts = relayhost('get');
           $domain_footer = mailbox('get', 'domain_wide_footer', $domain);
+          $mta_sts = mailbox('get', 'mta_sts', $domain);
+          if (count($mta_sts) == 0) {
+            $mta_sts = false;
+          } elseif (isset($mta_sts['mx'])) {
+            $mta_sts['mx'] = implode(',', $mta_sts['mx']);
+          }
           $template = 'edit/domain.twig';
           $template_data = [
             'acl' => $_SESSION['acl'],
@@ -58,6 +64,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
             'dkim' => dkim('details', $domain),
             'domain_details' => $result,
             'domain_footer' => $domain_footer,
+            'mta_sts' => $mta_sts,
             'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
             'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
             'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"])
@@ -125,13 +132,15 @@ if (isset($_SESSION['mailcow_cc_role'])) {
           'mailbox' => $mailbox,
           'rl' => $rl,
           'pushover_data' => $pushover_data,
+          'get_tagging_options' => mailbox('get', 'delimiter_action', $mailbox),
           'quarantine_notification' => $quarantine_notification,
           'quarantine_category' => $quarantine_category,
           'get_tls_policy' => $get_tls_policy,
           'rlyhosts' => $rlyhosts,
           'sender_acl_handles' => mailbox('get', 'sender_acl_handles', $mailbox),
           'user_acls' => acl('get', 'user', $mailbox),
-          'mailbox_details' => $result
+          'mailbox_details' => $result,
+          'iam_settings' => $iam_settings,
         ];
       }
     }

BIN
data/web/favicon.png


+ 39 - 7
data/web/inc/ajax/dns_diagnostics.php

@@ -71,6 +71,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
   // Init records array
   $spf_link = '<a href="http://www.open-spf.org/SPF_Record_Syntax/" target="_blank">SPF Record Syntax</a><br />';
   $dmarc_link = '<a href="https://www.kitterman.com/dmarc/assistant.html" target="_blank">DMARC Assistant</a>';
+  $mtasts_report_link = '<a href="https://mxtoolbox.com/dmarc/smtp-tls/how-to-setup-smtp-tls-reports" target="_blank">TLS Report Record Syntax</a>';
 
   $records = array();
 
@@ -128,6 +129,27 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
     );
   }
 
+  $mta_sts = mailbox('get', 'mta_sts', $domain);
+  if (count($mta_sts) > 0 && $mta_sts['active'] == 1) {
+    if (!in_array($domain, $alias_domains)) {
+      $records[] = array(
+        'mta-sts.' . $domain,
+        'CNAME',
+        $mailcow_hostname
+      );
+    }
+    $records[] = array(
+        '_mta-sts.' . $domain,
+        'TXT',
+        "v={$mta_sts['version']};id={$mta_sts['id']};",
+    );
+    $records[] = array(
+        '_smtp._tls.' . $domain,
+        'TXT',
+        $mtasts_report_link,
+    );
+  }
+
   $records[] = array(
     $domain,
     'TXT',
@@ -341,15 +363,25 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
         }
 
         foreach ($currents as &$current) {
+          if ($current['type'] == "TXT" &&
+              stripos(strtolower($current['txt']), 'v=sts') === 0) {
+            if (strtolower($current[$data_field[$current['type']]]) == strtolower($record[2])) {
+              $state = state_good;
+            }
+            else {
+              $state = state_nomatch;
+            }
+            $state .= '<br />' . $current[$data_field[$current['type']]];
+          }
           if ($current['type'] == 'TXT' &&
-          stripos($current['txt'], 'v=dmarc') === 0 &&
-          $record[2] == $dmarc_link) {
+              stripos($current['txt'], 'v=dmarc') === 0 &&
+              $record[2] == $dmarc_link) {
             $current['txt'] = str_replace(' ', '', $current['txt']);
             $state = $current[$data_field[$current['type']]] . state_optional;
           }
           elseif ($current['type'] == 'TXT' &&
-          stripos($current['txt'], 'v=spf') === 0 &&
-          $record[2] == $spf_link) {
+                  stripos($current['txt'], 'v=spf') === 0 &&
+                  $record[2] == $spf_link) {
             $state = state_nomatch;
             $rslt = get_spf_allowed_hosts($record[0], true);
             if (in_array($ip, $rslt) && in_array(expand_ipv6($ip6), $rslt)) {
@@ -358,8 +390,8 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
             $state .= '<br />' . $current[$data_field[$current['type']]] . state_optional;
           }
           elseif ($current['type'] == 'TXT' &&
-          stripos($current['txt'], 'v=dkim') === 0 &&
-          stripos($record[2], 'v=dkim') === 0) {
+                  stripos($current['txt'], 'v=dkim') === 0 &&
+                  stripos($record[2], 'v=dkim') === 0) {
             preg_match('/v=DKIM1;.*k=rsa;.*p=([^;]*).*/i', $current[$data_field[$current['type']]], $dkim_matches_current);
             preg_match('/v=DKIM1;.*k=rsa;.*p=([^;]*).*/i', $record[2], $dkim_matches_good);
             if ($dkim_matches_current[1] == $dkim_matches_good[1]) {
@@ -367,7 +399,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
             }
           }
           elseif ($current['type'] != 'TXT' &&
-          isset($data_field[$current['type']]) && $state != state_good) {
+                  isset($data_field[$current['type']]) && $state != state_good) {
             $state = state_nomatch;
             if ($current[$data_field[$current['type']]] == $record[2]) {
               $state = state_good;

+ 21 - 17
data/web/inc/footer.inc.php

@@ -26,23 +26,25 @@ if (is_array($alertbox_log_parser)) {
 
 // map tfa details for twig
 $pending_tfa_authmechs = [];
-foreach($_SESSION['pending_tfa_methods'] as $authdata){
-  $pending_tfa_authmechs[$authdata['authmech']] = false;
-}
-if (isset($pending_tfa_authmechs['webauthn'])) {
-  $pending_tfa_authmechs['webauthn'] = true;
-}
-if (!isset($pending_tfa_authmechs['webauthn']) 
-    && isset($pending_tfa_authmechs['yubi_otp'])) {
-  $pending_tfa_authmechs['yubi_otp'] = true;
-}
-if (!isset($pending_tfa_authmechs['webauthn']) 
-    && !isset($pending_tfa_authmechs['yubi_otp'])
-    && isset($pending_tfa_authmechs['totp'])) {
-  $pending_tfa_authmechs['totp'] = true;
-}
-if (isset($pending_tfa_authmechs['u2f'])) {
-  $pending_tfa_authmechs['u2f'] = true;
+if (array_key_exists('pending_tfa_methods', $_SESSION)) {
+  foreach($_SESSION['pending_tfa_methods'] as $authdata){
+    $pending_tfa_authmechs[$authdata['authmech']] = false;
+  }
+  if (isset($pending_tfa_authmechs['webauthn'])) {
+    $pending_tfa_authmechs['webauthn'] = true;
+  }
+  if (!isset($pending_tfa_authmechs['webauthn']) 
+      && isset($pending_tfa_authmechs['yubi_otp'])) {
+    $pending_tfa_authmechs['yubi_otp'] = true;
+  }
+  if (!isset($pending_tfa_authmechs['webauthn']) 
+      && !isset($pending_tfa_authmechs['yubi_otp'])
+      && isset($pending_tfa_authmechs['totp'])) {
+    $pending_tfa_authmechs['totp'] = true;
+  }
+  if (isset($pending_tfa_authmechs['u2f'])) {
+    $pending_tfa_authmechs['u2f'] = true;
+  }
 }
 
 // globals
@@ -66,6 +68,8 @@ $globalVariables = [
   'lang_acl' => json_encode($lang['acl']),
   'lang_tfa' => json_encode($lang['tfa']),
   'lang_fido2' => json_encode($lang['fido2']),
+  'lang_success' => json_encode($lang['success']),
+  'lang_danger' => json_encode($lang['danger']),
   'docker_timeout' => $DOCKER_TIMEOUT,
   'session_lifetime' => (int)$SESSION_LIFETIME,
   'csrf_token' => $_SESSION['CSRF']['TOKEN'],

+ 3 - 3
data/web/inc/functions.acl.inc.php

@@ -1,5 +1,5 @@
 <?php
-function acl($_action, $_scope = null, $_data = null) {
+function acl($_action, $_scope = null, $_data = null, $_extra = null) {
   global $pdo;
   global $lang;
   $_data_log = $_data;
@@ -24,7 +24,7 @@ function acl($_action, $_scope = null, $_data = null) {
             }
             // Users cannot change their own ACL
             if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $username)
-              || ($_SESSION['mailcow_cc_role'] != 'admin' && $_SESSION['mailcow_cc_role'] != 'domainadmin')) {
+              || ($_SESSION['mailcow_cc_role'] != 'admin' && $_SESSION['mailcow_cc_role'] != 'domainadmin' && $_SESSION['access_all_exception'] != '1')) {
               $_SESSION['return'][] = array(
                 'type' => 'danger',
                 'log' => array(__FUNCTION__, $_action, $_scope, $_data_log),
@@ -34,7 +34,7 @@ function acl($_action, $_scope = null, $_data = null) {
             }
             // Read all available acl options by calling acl(get)
             // Set all available acl options we cannot find in the post data to 0, else 1
-            $is_now = acl('get', 'user', $username);
+            $is_now = acl('get', 'user', $username, $_extra);
             if (!empty($is_now)) {
               foreach ($is_now as $acl_now_name => $acl_now_val) {
                 $set_acls[$acl_now_name] = (isset($acl_post[$acl_now_name])) ? 1 : 0;

+ 12 - 40
data/web/inc/functions.app_passwd.inc.php

@@ -1,7 +1,7 @@
 <?php
 function app_passwd($_action, $_data = null) {
-	global $pdo;
-	global $lang;
+  global $pdo;
+  global $lang;
   $_data_log = $_data;
   !isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
   !isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
@@ -43,20 +43,7 @@ function app_passwd($_action, $_data = null) {
         );
         return false;
       }
-      if (!preg_match('/' . $GLOBALS['PASSWD_REGEP'] . '/', $password)) {
-        $_SESSION['return'][] = array(
-          'type' => 'danger',
-          'log' => array(__FUNCTION__, $_action, $_data_log),
-          'msg' => 'password_complexity'
-        );
-        return false;
-      }
-      if ($password != $password2) {
-        $_SESSION['return'][] = array(
-          'type' => 'danger',
-          'log' => array(__FUNCTION__, $_action, $_data_log),
-          'msg' => 'password_mismatch'
-        );
+      if (password_check($password, $password2) !== true) {
         return false;
       }
       $password_hashed = hash_password($password);
@@ -88,15 +75,15 @@ function app_passwd($_action, $_data = null) {
         'log' => array(__FUNCTION__, $_action, $_data_log),
         'msg' => 'app_passwd_added'
       );
-    break;
+      break;
     case 'edit':
       $ids = (array)$_data['id'];
       foreach ($ids as $id) {
         $is_now = app_passwd('details', $id);
         if (!empty($is_now)) {
           $app_name = (!empty($_data['app_name'])) ? $_data['app_name'] : $is_now['name'];
-          $password = (!empty($_data['password'])) ? $_data['password'] : null;
-          $password2 = (!empty($_data['password2'])) ? $_data['password2'] : null;
+          $password = (!empty($_data['app_passwd'])) ? $_data['app_passwd'] : null;
+          $password2 = (!empty($_data['app_passwd2'])) ? $_data['app_passwd2'] : null;
           if (isset($_data['protocols'])) {
             $protocols = (array)$_data['protocols'];
             $imap_access = (in_array('imap_access', $protocols)) ? 1 : 0;
@@ -126,20 +113,7 @@ function app_passwd($_action, $_data = null) {
         }
         $app_name = htmlspecialchars(trim($app_name));
         if (!empty($password) && !empty($password2)) {
-          if (!preg_match('/' . $GLOBALS['PASSWD_REGEP'] . '/', $password)) {
-            $_SESSION['return'][] = array(
-              'type' => 'danger',
-              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => 'password_complexity'
-            );
-            continue;
-          }
-          if ($password != $password2) {
-            $_SESSION['return'][] = array(
-              'type' => 'danger',
-              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => 'password_mismatch'
-            );
+          if (password_check($password, $password2) !== true) {
             continue;
           }
           $password_hashed = hash_password($password);
@@ -182,7 +156,7 @@ function app_passwd($_action, $_data = null) {
           'msg' => array('object_modified', htmlspecialchars(implode(', ', $ids)))
         );
       }
-    break;
+      break;
     case 'delete':
       $ids = (array)$_data['id'];
       foreach ($ids as $id) {
@@ -213,19 +187,17 @@ function app_passwd($_action, $_data = null) {
           'msg' => array('app_passwd_removed', htmlspecialchars($id))
         );
       }
-    break;
+      break;
     case 'get':
       $app_passwds = array();
       $stmt = $pdo->prepare("SELECT `id`, `name` FROM `app_passwd` WHERE `mailbox` = :username");
       $stmt->execute(array(':username' => $username));
       $app_passwds = $stmt->fetchAll(PDO::FETCH_ASSOC);
       return $app_passwds;
-    break;
+      break;
     case 'details':
       $app_passwd_data = array();
-      $stmt = $pdo->prepare("SELECT *
-          FROM `app_passwd`
-            WHERE `id` = :id");
+      $stmt = $pdo->prepare("SELECT * FROM `app_passwd` WHERE `id` = :id");
       $stmt->execute(array(':id' => $_data));
       $app_passwd_data = $stmt->fetch(PDO::FETCH_ASSOC);
       if (empty($app_passwd_data)) {
@@ -237,6 +209,6 @@ function app_passwd($_action, $_data = null) {
       }
       $app_passwd_data['name'] = htmlspecialchars(trim($app_passwd_data['name']));
       return $app_passwd_data;
-    break;
+      break;
   }
 }

+ 740 - 0
data/web/inc/functions.auth.inc.php

@@ -0,0 +1,740 @@
+<?php
+function check_login($user, $pass, $app_passwd_data = false, $extra = null) {
+  global $pdo;
+  global $valkey;
+
+  $is_internal = $extra['is_internal'];
+  $role = $extra['role'];
+
+  // Try validate admin
+  if (!isset($role) || $role == "admin") {
+    $result = admin_login($user, $pass);
+    if ($result !== false){
+      return $result;
+    }
+  }
+
+  // Try validate domain admin
+  if (!isset($role) || $role == "domain_admin") {
+    $result = domainadmin_login($user, $pass);
+    if ($result !== false) {
+      return $result;
+    }
+  }
+
+
+  // Try validate app password
+  if (!isset($role) || $role == "app") {
+    $result = apppass_login($user, $pass, $app_passwd_data);
+    if ($result !== false) {
+      if ($app_passwd_data['eas'] === true) {
+        $service = 'EAS';
+      } elseif ($app_passwd_data['dav'] === true) {
+        $service = 'DAV';
+      } else {
+        $service = 'NONE';
+      }
+      $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
+      set_sasl_log($user, $real_rip, $service, $pass);
+      return $result;
+    }
+  }
+
+  // Try validate user
+  if (!isset($role) || $role == "user") {
+    $result = user_login($user, $pass);
+    if ($result !== false) {
+      if ($app_passwd_data['eas'] === true) {
+        $service = 'EAS';
+      } elseif ($app_passwd_data['dav'] === true) {
+        $service = 'DAV';
+      } else {
+        $service = 'MAILCOWUI';
+      }
+      $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
+      set_sasl_log($user, $real_rip, $service);
+      return $result;
+    }
+  }
+
+  // skip log and only return false if it's an internal request
+  if ($is_internal == true) return false;
+
+  if (!isset($_SESSION['ldelay'])) {
+    $_SESSION['ldelay'] = "0";
+    $valkey->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
+    error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
+  }
+  elseif (!isset($_SESSION['mailcow_cc_username'])) {
+    $_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
+    $valkey->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
+    error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
+  }
+  $_SESSION['return'][] =  array(
+    'type' => 'danger',
+    'log' => array(__FUNCTION__, $user, '*'),
+    'msg' => 'login_failed'
+  );
+
+  sleep($_SESSION['ldelay']);
+  return false;
+}
+
+function admin_login($user, $pass){
+  global $pdo;
+
+  if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'malformed_username'
+      );
+    }
+    return false;
+  }
+
+  $user = strtolower(trim($user));
+  $stmt = $pdo->prepare("SELECT `password` FROM `admin`
+      WHERE `superadmin` = '1'
+      AND `active` = '1'
+      AND `username` = :user");
+  $stmt->execute(array(':user' => $user));
+  $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+  // verify password
+  if (verify_hash($row['password'], $pass)) {
+    // check for tfa authenticators
+    $authenticators = get_tfa($user);
+    if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
+      // active tfa authenticators found, set pending user login
+      $_SESSION['pending_mailcow_cc_username'] = $user;
+      $_SESSION['pending_mailcow_cc_role'] = "admin";
+      $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
+      unset($_SESSION['ldelay']);
+      $_SESSION['return'][] =  array(
+        'type' => 'info',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'awaiting_tfa_confirmation'
+      );
+      return "pending";
+    } else {
+      unset($_SESSION['ldelay']);
+      // Reactivate TFA if it was set to "deactivate TFA for next login"
+      $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
+      $stmt->execute(array(':user' => $user));
+      $_SESSION['return'][] =  array(
+        'type' => 'success',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => array('logged_in_as', $user)
+      );
+      return "admin";
+    }
+  }
+
+  return false;
+}
+function domainadmin_login($user, $pass){
+  global $pdo;
+
+  if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'malformed_username'
+      );
+    }
+    return false;
+  }
+
+  $stmt = $pdo->prepare("SELECT `password` FROM `admin`
+      WHERE `superadmin` = '0'
+      AND `active`='1'
+      AND `username` = :user");
+  $stmt->execute(array(':user' => $user));
+  $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+  // verify password
+  if (verify_hash($row['password'], $pass) !== false) {
+    // check for tfa authenticators
+    $authenticators = get_tfa($user);
+    if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
+      $_SESSION['pending_mailcow_cc_username'] = $user;
+      $_SESSION['pending_mailcow_cc_role'] = "domainadmin";
+      $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
+      unset($_SESSION['ldelay']);
+      $_SESSION['return'][] =  array(
+        'type' => 'info',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'awaiting_tfa_confirmation'
+      );
+      return "pending";
+    }
+    else {
+      unset($_SESSION['ldelay']);
+      // Reactivate TFA if it was set to "deactivate TFA for next login"
+      $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
+      $stmt->execute(array(':user' => $user));
+      $_SESSION['return'][] =  array(
+        'type' => 'success',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => array('logged_in_as', $user)
+      );
+      return "domainadmin";
+    }
+  }
+
+  return false;
+}
+function user_login($user, $pass, $extra = null){
+  global $pdo;
+  global $iam_provider;
+  global $iam_settings;
+
+  $is_internal = $extra['is_internal'];
+  $service = $extra['service'];
+
+  if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'malformed_username'
+      );
+    }
+    return false;
+  }
+
+  $stmt = $pdo->prepare("SELECT
+      mailbox.*,
+      domain.active AS d_active
+      FROM `mailbox`
+      INNER JOIN domain on mailbox.domain = domain.domain
+      WHERE `kind` NOT REGEXP 'location|thing|group'
+        AND `username` = :user");
+  $stmt->execute(array(':user' => $user));
+  $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+  // user does not exist, try call idp login and create user if possible via rest flow
+  if (!$row){
+    $result = false;
+    if ($iam_settings['authsource'] == 'keycloak' && intval($iam_settings['mailpassword_flow']) == 1){
+      $result = keycloak_mbox_login_rest($user, $pass, array('is_internal' => $is_internal, 'create' => true));
+    } else if ($iam_settings['authsource'] == 'ldap') {
+      $result = ldap_mbox_login($user, $pass, array('is_internal' => $is_internal, 'create' => true));
+    }
+    if ($result !== false){
+      // double check if mailbox is active
+      $stmt = $pdo->prepare("SELECT * FROM `mailbox`
+      INNER JOIN domain on mailbox.domain = domain.domain
+      WHERE `kind` NOT REGEXP 'location|thing|group'
+        AND `mailbox`.`active`='1'
+        AND `domain`.`active`='1'
+        AND `username` = :user");
+      $stmt->execute(array(':user' => $user));
+      $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+      if (!empty($row)) {
+        // check if user has access to service (imap, smtp, pop3, sieve) if service is set
+        $row['attributes'] = json_decode($row['attributes'], true);
+        if (isset($service)) {
+          $key = strtolower($service) . "_access";
+          if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
+            return false;
+          }
+        }
+        return true;
+      }
+    }
+    clear_session();
+    return false;
+  }
+
+  // check if user has access to service (imap, smtp, pop3, sieve) if service is set
+  $row['attributes'] = json_decode($row['attributes'], true);
+  if (isset($service)) {
+    $key = strtolower($service) . "_access";
+    if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
+      return false;
+    }
+  }
+  switch ($row['authsource']) {
+    case 'keycloak':
+      // user authsource is keycloak, try using via rest flow
+      if (intval($iam_settings['mailpassword_flow']) == 1){
+        $result = keycloak_mbox_login_rest($user, $pass, array('is_internal' => $is_internal));
+        if ($result !== false) {
+          // double check if mailbox and domain is active
+          $stmt = $pdo->prepare("SELECT * FROM `mailbox`
+          INNER JOIN domain on mailbox.domain = domain.domain
+          WHERE `kind` NOT REGEXP 'location|thing|group'
+            AND `mailbox`.`active`='1'
+            AND `domain`.`active`='1'
+            AND `username` = :user");
+          $stmt->execute(array(':user' => $user));
+          $row = $stmt->fetch(PDO::FETCH_ASSOC);
+          if (empty($row)) {
+            return false;
+          }
+
+          // check for tfa authenticators
+          $authenticators = get_tfa($user);
+          if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
+            // authenticators found, init TFA flow
+            $_SESSION['pending_mailcow_cc_username'] = $user;
+            $_SESSION['pending_mailcow_cc_role'] = "user";
+            $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
+            unset($_SESSION['ldelay']);
+            $_SESSION['return'][] =  array(
+              'type' => 'success',
+              'log' => array(__FUNCTION__, $user, '*', 'Provider: Keycloak'),
+              'msg' => array('logged_in_as', $user)
+            );
+            return "pending";
+          } else if (!isset($authenticators['additional']) || !is_array($authenticators['additional']) || count($authenticators['additional']) == 0) {
+            // no authenticators found, login successfull
+            if (!$is_internal){
+              unset($_SESSION['ldelay']);
+              // Reactivate TFA if it was set to "deactivate TFA for next login"
+              $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
+              $stmt->execute(array(':user' => $user));
+              $_SESSION['return'][] =  array(
+                'type' => 'success',
+                'log' => array(__FUNCTION__, $user, '*', 'Provider: Keycloak'),
+                'msg' => array('logged_in_as', $user)
+              );
+            }
+            return "user";
+          }
+        }
+        return $result;
+      } else {
+        return false;
+      }
+    break;
+    case 'ldap':
+      // user authsource is ldap
+      $result = ldap_mbox_login($user, $pass, array('is_internal' => $is_internal));
+      if ($result !== false) {
+        // double check if mailbox and domain is active
+        $stmt = $pdo->prepare("SELECT * FROM `mailbox`
+        INNER JOIN domain on mailbox.domain = domain.domain
+        WHERE `kind` NOT REGEXP 'location|thing|group'
+          AND `mailbox`.`active`='1'
+          AND `domain`.`active`='1'
+          AND `username` = :user");
+        $stmt->execute(array(':user' => $user));
+        $row = $stmt->fetch(PDO::FETCH_ASSOC);
+        if (empty($row)) {
+          return false;
+        }
+
+        // check for tfa authenticators
+        $authenticators = get_tfa($user);
+        if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
+          // authenticators found, init TFA flow
+          $_SESSION['pending_mailcow_cc_username'] = $user;
+          $_SESSION['pending_mailcow_cc_role'] = "user";
+          $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
+          unset($_SESSION['ldelay']);
+          $_SESSION['return'][] =  array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP'),
+            'msg' => array('logged_in_as', $user)
+          );
+          return "pending";
+        } else if (!isset($authenticators['additional']) || !is_array($authenticators['additional']) || count($authenticators['additional']) == 0) {
+          // no authenticators found, login successfull
+          if (!$is_internal){
+            unset($_SESSION['ldelay']);
+            // Reactivate TFA if it was set to "deactivate TFA for next login"
+            $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
+            $stmt->execute(array(':user' => $user));
+            $_SESSION['return'][] =  array(
+              'type' => 'success',
+              'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP'),
+              'msg' => array('logged_in_as', $user)
+            );
+          }
+          return "user";
+        }
+      }
+      return $result;
+    break;
+    case 'mailcow':
+      if ($row['active'] != 1 || $row['d_active'] != 1) {
+        return false;
+      }
+      // verify password
+      if (verify_hash($row['password'], $pass) !== false) {
+
+        if (intval($row['attributes']['force_pw_update']) == 1) {
+          $_SESSION['pending_pw_update'] = true;
+        }
+
+        // check for tfa authenticators
+        $authenticators = get_tfa($user);
+        if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
+          // authenticators found, init TFA flow
+          $_SESSION['pending_mailcow_cc_username'] = $user;
+          $_SESSION['pending_mailcow_cc_role'] = "user";
+          $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
+          unset($_SESSION['ldelay']);
+          $_SESSION['return'][] =  array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $user, '*', 'Provider: mailcow'),
+            'msg' => array('logged_in_as', $user)
+          );
+          return "pending";
+        } else if (!isset($authenticators['additional']) || !is_array($authenticators['additional']) || count($authenticators['additional']) == 0) {
+          // no authenticators found, login successfull
+          if (!$is_internal){
+            unset($_SESSION['ldelay']);
+            // Reactivate TFA if it was set to "deactivate TFA for next login"
+            $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
+            $stmt->execute(array(':user' => $user));
+            $_SESSION['return'][] =  array(
+              'type' => 'success',
+              'log' => array(__FUNCTION__, $user, '*', 'Provider: mailcow'),
+              'msg' => array('logged_in_as', $user)
+            );
+          }
+          return "user";
+        }
+      }
+    break;
+  }
+
+  return false;
+}
+function apppass_login($user, $pass, $app_passwd_data, $extra = null){
+  global $pdo;
+
+  $is_internal = $extra['is_internal'];
+
+  if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'malformed_username'
+      );
+    }
+    return false;
+  }
+
+  $protocol = false;
+  if ($app_passwd_data['eas']){
+    $protocol = 'eas';
+  } else if ($app_passwd_data['dav']){
+    $protocol = 'dav';
+  } else if ($app_passwd_data['smtp']){
+    $protocol = 'smtp';
+  } else if ($app_passwd_data['imap']){
+    $protocol = 'imap';
+  } else if ($app_passwd_data['sieve']){
+    $protocol = 'sieve';
+  } else if ($app_passwd_data['pop3']){
+    $protocol = 'pop3';
+  } else if (!$is_internal) {
+    return false;
+  }
+
+  // fetch app password data
+  $stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd`
+    INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox`
+    INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
+    WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group'
+      AND `mailbox`.`active` = '1'
+      AND `domain`.`active` = '1'
+      AND `app_passwd`.`active` = '1'
+      AND `app_passwd`.`mailbox` = :user"
+  );
+  // fetch password data
+  $stmt->execute(array(
+    ':user' => $user,
+  ));
+  $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+  foreach ($rows as $row) {
+    if ($protocol && $row[$protocol . '_access'] != '1'){
+      continue;
+    }
+
+    // verify password
+    if (verify_hash($row['password'], $pass) !== false) {
+      $_SESSION['app_passwd_id'] = $row['app_passwd_id'];
+      unset($_SESSION['ldelay']);
+      return "user";
+    }
+  }
+
+  return false;
+}
+// Keycloak REST Api Flow - auth user by mailcow_password attribute
+// This password will be used for direct UI, IMAP and SMTP Auth
+// To use direct user credentials, only Authorization Code Flow is valid
+function keycloak_mbox_login_rest($user, $pass, $extra = null){
+  global $pdo;
+  global $iam_provider;
+  global $iam_settings;
+
+  $is_internal = $extra['is_internal'];
+  $create = $extra['create'];
+
+  if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'malformed_username'
+      );
+    }
+    return false;
+  }
+  if (!$iam_provider) {
+    return false;
+  }
+
+  // get access_token for service account of mailcow client
+  $admin_token = identity_provider("get-keycloak-admin-token");
+
+  // get the mailcow_password attribute from keycloak user
+  $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
+  $queryParams = array('email' => $user, 'exact' => true);
+  $queryString = http_build_query($queryParams);
+  $curl = curl_init();
+  curl_setopt($curl, CURLOPT_TIMEOUT, 7);
+  curl_setopt($curl, CURLOPT_URL, $url . '?' . $queryString);
+  curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
+  curl_setopt($curl, CURLOPT_HTTPHEADER, array(
+      'Authorization: Bearer ' . $admin_token,
+      'Content-Type: application/json'
+  ));
+  $user_res = json_decode(curl_exec($curl), true)[0];
+  $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+  curl_close($curl);
+  if ($code != 200) {
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', 'Identity Provider returned HTTP ' . $code),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+  if (!isset($user_res['attributes']['mailcow_password']) || !is_array($user_res['attributes']['mailcow_password'])){
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', 'User has no mailcow_password attribute'),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+  if (empty($user_res['attributes']['mailcow_password'][0])){
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', "User's mailcow_password attribute is empty"),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+
+  // validate mailcow_password
+  $mailcow_password = $user_res['attributes']['mailcow_password'][0];
+  if (!verify_hash($mailcow_password, $pass)) {
+    return false;
+  }
+
+  // get mapped template
+  $user_template = $user_res['attributes']['mailcow_template'][0];
+  $mapper_key = array_search($user_template, $iam_settings['mappers']);
+
+  if (!$create) {
+    // login success
+    if ($mapper_key !== false) {
+      // update user
+      $_SESSION['access_all_exception'] = '1';
+      mailbox('edit', 'mailbox_from_template', array(
+        'username' => $user,
+        'name' => $user_res['name'],
+        'template' => $iam_settings['templates'][$mapper_key]
+      ));
+      $_SESSION['access_all_exception'] = '0';
+    }
+    return 'user';
+  }
+
+  // check if login provisioning is enabled before creating user
+  if (!$iam_settings['login_provisioning']){
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, "Auto-create users on login is deactivated"),
+        'msg' => 'login_failed'
+      );
+    }
+    return false;
+  }
+  // check if matching attribute exist
+  if (empty($iam_settings['mappers']) || !$user_template || $mapper_key === false) {
+    if (!empty($iam_settings['default_template'])) {
+      $mbox_template = $iam_settings['default_template'];
+    } else {
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*', 'No matching attribute mapping was found'),
+        'msg' => 'generic_server_error'
+      );
+      return false;
+    }
+  } else {
+    $mbox_template = $iam_settings['templates'][$mapper_key];
+  }
+
+  // create mailbox
+  $_SESSION['access_all_exception'] = '1';
+  $create_res = mailbox('add', 'mailbox_from_template', array(
+    'domain' => explode('@', $user)[1],
+    'local_part' => explode('@', $user)[0],
+    'name' => $user_res['name'],
+    'authsource' => 'keycloak',
+    'template' => $mbox_template
+  ));
+  $_SESSION['access_all_exception'] = '0';
+  if (!$create_res){
+    clear_session();
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', 'Could not create mailbox on login'),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+
+  return 'user';
+}
+function ldap_mbox_login($user, $pass, $extra = null){
+  global $pdo;
+  global $iam_provider;
+  global $iam_settings;
+
+  $is_internal = $extra['is_internal'];
+  $create = $extra['create'];
+
+  if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*'),
+        'msg' => 'malformed_username'
+      );
+    }
+    return false;
+  }
+  if (!$iam_provider) {
+    return false;
+  }
+
+  try {
+    $ldap_query = $iam_provider->query();
+    if (!empty($iam_settings['filter'])) {
+      $ldap_query = $ldap_query->rawFilter($iam_settings['filter']);
+    }
+    $ldap_query = $ldap_query->where($iam_settings['username_field'], '=', $user)
+      ->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn']);
+
+    $user_res = $ldap_query->firstOrFail();
+  } catch (Exception $e) {
+    // clear $_SESSION['return'] to not leak data
+    $_SESSION['return'] = array();
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+  try {
+    if (!$iam_provider->auth()->attempt($user_res['dn'], $pass)) {
+      return false;
+    }
+  } catch (Exception $e) {
+    // clear $_SESSION['return'] to not leak data
+    $_SESSION['return'] = array();
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+
+  // get mapped template
+  $user_template = $user_res[$iam_settings['attribute_field']][0];
+  $mapper_key = array_search($user_template, $iam_settings['mappers']);
+
+  if (!$create) {
+    // login success
+    if ($mapper_key !== false) {
+      // update user
+      $_SESSION['access_all_exception'] = '1';
+      mailbox('edit', 'mailbox_from_template', array(
+        'username' => $user,
+        'name' => $user_res['displayname'][0],
+        'template' => $iam_settings['templates'][$mapper_key]
+      ));
+      $_SESSION['access_all_exception'] = '0';
+    }
+    return 'user';
+  }
+
+  // check if login provisioning is enabled before creating user
+  if (!$iam_settings['login_provisioning']){
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, "Auto-create users on login is deactivated"),
+        'msg' => 'login_failed'
+      );
+    }
+    return false;
+  }
+  // check if matching attribute exist
+  if (empty($iam_settings['mappers']) || !$user_template || $mapper_key === false) {
+    if (!empty($iam_settings['default_template'])) {
+      $mbox_template = $iam_settings['default_template'];
+    } else {
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, $user, '*', 'No matching attribute mapping was found'),
+        'msg' => 'generic_server_error'
+      );
+      return false;
+    }
+  } else {
+    $mbox_template = $iam_settings['templates'][$mapper_key];
+  }
+
+  // create mailbox
+  $_SESSION['access_all_exception'] = '1';
+  $create_res = mailbox('add', 'mailbox_from_template', array(
+    'domain' => explode('@', $user)[1],
+    'local_part' => explode('@', $user)[0],
+    'name' => $user_res['displayname'][0],
+    'authsource' => 'ldap',
+    'template' => $mbox_template
+  ));
+  $_SESSION['access_all_exception'] = '0';
+  if (!$create_res){
+    clear_session();
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $user, '*', 'Could not create mailbox on login'),
+      'msg' => 'generic_server_error'
+    );
+    return false;
+  }
+
+  return 'user';
+}

+ 72 - 5
data/web/inc/functions.customize.inc.php

@@ -122,10 +122,16 @@ function customize($_action, $_item, $_data = null) {
         case 'app_links':
           $apps = (array)$_data['app'];
           $links = (array)$_data['href'];
+          $user_links = (array)$_data['user_href'];
+          $hide = (array)$_data['hide'];
           $out = array();
-          if (count($apps) == count($links)) {
+          if (count($apps) == count($links) && count($apps) == count($user_links) && count($apps) == count($hide)) {
             for ($i = 0; $i < count($apps); $i++) {
-              $out[] = array($apps[$i] => $links[$i]);
+              $out[] = array($apps[$i] => array(
+                'link' => $links[$i],
+                'user_link' => $user_links[$i],
+                'hide' => ($hide[$i] === '0' || $hide[$i] === 0) ? false : true
+              ));
             }
             try {
               $valkey->set('APP_LINKS', json_encode($out));
@@ -198,6 +204,35 @@ function customize($_action, $_item, $_data = null) {
             'msg' => 'ip_check_opt_in_modified'
           );
         break;
+        case 'custom_login':
+          $hide_user_quicklink        = ($_data['hide_user_quicklink'] == "1") ? 1 : 0;
+          $hide_domainadmin_quicklink = ($_data['hide_domainadmin_quicklink'] == "1") ? 1 : 0;
+          $hide_admin_quicklink       = ($_data['hide_admin_quicklink'] == "1") ? 1 : 0;
+          $force_sso                  = ($_data['force_sso'] == "1") ? 1 : 0;
+
+          $custom_login = array(
+            "hide_user_quicklink" => $hide_user_quicklink,
+            "hide_domainadmin_quicklink" => $hide_domainadmin_quicklink,
+            "hide_admin_quicklink" => $hide_admin_quicklink,
+            "force_sso" => $force_sso,
+          );
+          try {
+            $valkey->set('CUSTOM_LOGIN', json_encode($custom_login));
+          }
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_item, $_data),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+          $_SESSION['return'][] = array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $_action, $_item, $_data),
+            'msg' => 'custom_login_modified'
+          );
+        break;
       }
     break;
     case 'delete':
@@ -256,7 +291,23 @@ function customize($_action, $_item, $_data = null) {
             );
             return false;
           }
-          return ($app_links) ? $app_links : false;
+
+          if (empty($app_links)){
+            return [];
+          }
+
+          // convert from old style
+          foreach($app_links as $i => $entry){
+            foreach($entry as $app => $link){
+              if (empty($link['link']) && empty($link['user_link'])){
+                $app_links[$i][$app] = array();
+                $app_links[$i][$app]['link'] = $link;
+                $app_links[$i][$app]['user_link'] = $link;
+              }
+            }
+          }
+
+          return $app_links;
         break;
         case 'main_logo':
         case 'main_logo_dark':
@@ -274,8 +325,10 @@ function customize($_action, $_item, $_data = null) {
         break;
         case 'ui_texts':
           try {
-            $data['title_name'] = ($title_name = $valkey->get('TITLE_NAME')) ? $title_name : 'mailcow UI';
-            $data['main_name'] = ($main_name = $valkey->get('MAIN_NAME')) ? $main_name : 'mailcow UI';
+            $mailcow_hostname = strtolower(getenv("MAILCOW_HOSTNAME"));
+
+            $data['title_name'] = ($title_name = $valkey->get('TITLE_NAME')) ? $title_name : "$mailcow_hostname - mail UI";
+            $data['main_name'] = ($main_name = $valkey->get('MAIN_NAME')) ? $main_name : "$mailcow_hostname - mail UI";
             $data['apps_name'] = ($apps_name = $valkey->get('APPS_NAME')) ? $apps_name : $lang['header']['apps'];
             $data['help_text'] = ($help_text = $valkey->get('HELP_TEXT')) ? $help_text : false;
             if (!empty($valkey->get('UI_IMPRESS'))) {
@@ -335,6 +388,20 @@ function customize($_action, $_item, $_data = null) {
             return false;
           }
         break;
+        case 'custom_login':
+          try {
+            $custom_login = $valkey->get('CUSTOM_LOGIN');
+            return $custom_login ? json_decode($custom_login, true) : array();
+          }
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_item, $_data),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+        break;
       }
     break;
   }

Some files were not shown because too many files changed in this diff