Browse Source

Merge from upstream branch 'staging'

# Conflicts:
#	data/web/inc/vars.inc.php
Tomy Hsieh 2 years ago
parent
commit
7d46de33d8
64 changed files with 1703 additions and 871 deletions
  1. 0 120
      .drone.yml
  2. 2 1
      .github/workflows/close_old_issues_and_prs.yml
  3. 45 0
      .github/workflows/image_builds.yml
  4. 63 0
      .github/workflows/integration_tests.yml
  5. 17 0
      .github/workflows/tweet-trigger-publish-release.yml
  6. 0 16
      .travis.yml
  7. 2 1
      README.md
  8. 42 0
      SECURITY.md
  9. 8 2
      create_cold_standby.sh
  10. 9 0
      data/Dockerfiles/clamd/healthcheck.sh
  11. 1 1
      data/Dockerfiles/dovecot/Dockerfile
  12. 9 0
      data/Dockerfiles/dovecot/docker-entrypoint.sh
  13. 2 2
      data/Dockerfiles/dovecot/quarantine_notify.py
  14. 14 2
      data/Dockerfiles/postfix/postfix.sh
  15. 3 3
      data/Dockerfiles/sogo/Dockerfile
  16. 4 0
      data/Dockerfiles/sogo/bootstrap-sogo.sh
  17. 3 0
      data/conf/rspamd/local.d/groups.conf
  18. 0 2
      data/conf/sogo/sogo.conf
  19. 16 0
      data/web/api/index.css
  20. 2 43
      data/web/api/index.html
  21. 10 6
      data/web/api/oauth2-redirect.html
  22. 95 51
      data/web/api/openapi.yaml
  23. 19 0
      data/web/api/swagger-initializer.js
  24. 0 0
      data/web/api/swagger-ui-bundle.js
  25. 0 0
      data/web/api/swagger-ui-bundle.js.map
  26. 0 0
      data/web/api/swagger-ui-es-bundle-core.js
  27. 0 0
      data/web/api/swagger-ui-es-bundle-core.js.map
  28. 0 0
      data/web/api/swagger-ui-es-bundle.js
  29. 0 0
      data/web/api/swagger-ui-es-bundle.js.map
  30. 0 0
      data/web/api/swagger-ui-standalone-preset.js
  31. 0 0
      data/web/api/swagger-ui-standalone-preset.js.map
  32. 0 1
      data/web/api/swagger-ui.css
  33. 0 0
      data/web/api/swagger-ui.css.map
  34. 0 1
      data/web/api/swagger-ui.js
  35. 0 0
      data/web/api/swagger-ui.js.map
  36. 12 1
      data/web/css/build/008-mailcow.css
  37. 1 1
      data/web/inc/ajax/destroy_tfa_auth.php
  38. 31 2
      data/web/inc/footer.inc.php
  39. 246 176
      data/web/inc/functions.inc.php
  40. 14 12
      data/web/inc/functions.mailbox.inc.php
  41. 5 6
      data/web/inc/init_db.inc.php
  42. 2 1
      data/web/inc/prerequisites.inc.php
  43. 16 15
      data/web/inc/triggers.inc.php
  44. 121 124
      data/web/inc/vars.inc.php
  45. 28 19
      data/web/json_api.php
  46. 1 1
      data/web/lang/lang.cs-cz.json
  47. 5 2
      data/web/lang/lang.fr-fr.json
  48. 2 1
      data/web/lang/lang.it-it.json
  49. 3 2
      data/web/lang/lang.ro-ro.json
  50. 3 2
      data/web/lang/lang.ru-ru.json
  51. 86 0
      data/web/lang/lang.tr.json
  52. 2 1
      data/web/lang/lang.uk-ua.json
  53. 101 27
      data/web/templates/base.twig
  54. 1 1
      data/web/templates/domainadmin.twig
  55. 148 58
      data/web/templates/modals/footer.twig
  56. 2 2
      data/web/templates/modals/mailbox.twig
  57. 21 0
      data/web/templates/user/tab-user-auth.twig
  58. 2 2
      data/web/user.php
  59. 4 4
      docker-compose.yml
  60. 102 12
      generate_config.sh
  61. 35 8
      helper-scripts/_cold-standby.sh
  62. 27 10
      helper-scripts/backup_and_restore.sh
  63. 70 0
      helper-scripts/update_compose.sh
  64. 246 129
      update.sh

+ 0 - 120
.drone.yml

@@ -1,120 +0,0 @@
----
-kind: pipeline
-name: integration-testing
-
-platform:
-  os: linux
-  arch: amd64
-
-clone:
-  disable: true
-
-steps:
-- name: prepare-tests
-  pull: default
-  image: timovibritannia/ansible
-  commands:
-  - git clone https://github.com/mailcow/mailcow-integration-tests.git --branch $(curl -sL https://api.github.com/repos/mailcow/mailcow-integration-tests/releases/latest | jq -r '.tag_name') --single-branch .
-  - chmod +x ci.sh
-  - chmod +x ci-ssh.sh
-  - chmod +x ci-piprequierments.sh
-  - ./ci.sh
-  - wget -O group_vars/all/secrets.yml $SECRETS_DOWNLOAD_URL --quiet
-  environment:
-    SECRETS_DOWNLOAD_URL:
-      from_secret: SECRETS_DOWNLOAD_URL
-    VAULT_PW:
-      from_secret: VAULT_PW
-  when:
-    branch:
-    - master
-    - staging
-    event:
-    - push
-
-- name: lint
-  pull: default
-  image: timovibritannia/ansible
-  commands:
-  - ansible-lint ./
-  when:
-    branch:
-    - master
-    - staging
-    event:
-    - push
-
-- name: create-server
-  pull: default
-  image: timovibritannia/ansible
-  commands:
-  - ./ci-piprequierments.sh
-  - ansible-playbook mailcow-start-server.yml --diff
-  - ./ci-ssh.sh
-  environment:
-    ANSIBLE_HOST_KEY_CHECKING: false
-    ANSIBLE_FORCE_COLOR: true
-  when:
-    branch:
-    - master
-    - staging
-    event:
-    - push
-
-- name: setup-server
-  pull: default
-  image: timovibritannia/ansible
-  commands:
-  - sleep 120
-  - ./ci-piprequierments.sh
-  - ansible-playbook mailcow-setup-server.yml --private-key /drone/src/id_ssh_rsa --diff
-  environment:
-    ANSIBLE_HOST_KEY_CHECKING: false
-    ANSIBLE_FORCE_COLOR: true
-  when:
-    branch:
-    - master
-    - staging
-    event:
-    - push
-
-- name: run-tests
-  pull: default
-  image: timovibritannia/ansible
-  commands:
-  - ./ci-piprequierments.sh
-  - ansible-playbook mailcow-integration-tests.yml --private-key /drone/src/id_ssh_rsa --diff
-  environment:
-    ANSIBLE_HOST_KEY_CHECKING: false
-    ANSIBLE_FORCE_COLOR: true
-  when:
-    branch:
-    - master
-    - staging
-    event:
-    - push
-
-- name: delete-server
-  pull: default
-  image: timovibritannia/ansible
-  commands:
-  - ./ci-piprequierments.sh
-  - ansible-playbook mailcow-delete-server.yml --diff
-  environment:
-    ANSIBLE_HOST_KEY_CHECKING: false
-    ANSIBLE_FORCE_COLOR: true
-  when:
-    branch:
-    - master
-    - staging
-    event:
-    - push
-    status:
-    - failure
-    - success
-
----
-kind: signature
-hmac: f6619243fe2a27563291c9f2a46d93ffbc3b6dced9a05f23e64b555ce03a31e5
-
-...

+ 2 - 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@v5.0.0
+        uses: actions/stale@v6.0.0
         with:
           repo-token: ${{ secrets.STALE_ACTION_PAT }}
           days-before-stale: 60
@@ -30,6 +30,7 @@ jobs:
           stale-issue-label: "stale"
           stale-pr-label: "stale"
           exempt-draft-pr: "true"
+          close-issue-reason: "not_planned"
           operations-per-run: "250"
           ascending: "true"
           #DRY-RUN

+ 45 - 0
.github/workflows/image_builds.yml

@@ -0,0 +1,45 @@
+name: Build mailcow Docker Images
+
+on:
+  push:
+    branches: [ "master", "staging" ]
+  workflow_dispatch:
+
+permissions:
+  contents: read # to fetch code (actions/checkout)
+
+jobs:
+  docker_image_builds:
+    strategy:
+      matrix:
+        images:
+          - "acme-mailcow"
+          - "clamd-mailcow"
+          - "dockerapi-mailcow"
+          - "dovecot-mailcow"
+          - "netfilter-mailcow"
+          - "olefy-mailcow"
+          - "php-fpm-mailcow"
+          - "postfix-mailcow"
+          - "rspamd-mailcow"
+          - "sogo-mailcow"
+          - "solr-mailcow"
+          - "unbound-mailcow"
+          - "watchdog-mailcow"
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Setup Docker
+        run: |
+          curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh
+          sudo service docker start
+          sudo curl -L https://github.com/docker/compose/releases/download/v$(curl -Ls https://www.servercow.de/docker-compose/latest.php)/docker-compose-$(uname -s)-$(uname -m) > /usr/local/bin/docker-compose
+          sudo chmod +x /usr/local/bin/docker-compose
+      - name: Prepair Image Builds
+        run: |
+          cp helper-scripts/docker-compose.override.yml.d/BUILD_FLAGS/docker-compose.override.yml docker-compose.override.yml
+      - name: Build Docker Images
+        run: |
+          docker-compose build ${image}
+        env:
+          image: ${{ matrix.images }}

+ 63 - 0
.github/workflows/integration_tests.yml

@@ -0,0 +1,63 @@
+name: mailcow Integration Tests
+
+on:
+  push:
+    branches: [ "master", "staging" ]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  integration_tests:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Setup Ansible
+        run: |
+          export DEBIAN_FRONTEND=noninteractive
+          sudo apt-get update
+          sudo apt-get install python3 python3-pip git
+          sudo pip3 install ansible
+      - name: Prepair Test Environment
+        run: |
+          git clone https://github.com/mailcow/mailcow-integration-tests.git --branch $(curl -sL https://api.github.com/repos/mailcow/mailcow-integration-tests/releases/latest | jq -r '.tag_name') --single-branch .
+          ./fork_check.sh
+          ./ci.sh
+          ./ci-pip-requirements.sh
+        env:
+          VAULT_PW: ${{ secrets.MAILCOW_TESTS_VAULT_PW }}
+          VAULT_FILE: ${{ secrets.MAILCOW_TESTS_VAULT_FILE }}
+      - name: Start Integration Test Server
+        run: |
+          ./fork_check.sh
+          ansible-playbook mailcow-start-server.yml --diff
+        env:
+          PY_COLORS: '1'
+          ANSIBLE_FORCE_COLOR: '1'
+          ANSIBLE_HOST_KEY_CHECKING: 'false'
+      - name: Setup Integration Test Server
+        run: |
+          ./fork_check.sh
+          sleep 30
+          ansible-playbook mailcow-setup-server.yml --private-key id_ssh_rsa --diff
+        env:
+          PY_COLORS: '1'
+          ANSIBLE_FORCE_COLOR: '1'
+          ANSIBLE_HOST_KEY_CHECKING: 'false'
+      - name: Run Integration Tests
+        run: |
+          ./fork_check.sh
+          ansible-playbook mailcow-integration-tests.yml --private-key id_ssh_rsa --diff
+        env:
+          PY_COLORS: '1'
+          ANSIBLE_FORCE_COLOR: '1'
+          ANSIBLE_HOST_KEY_CHECKING: 'false'
+      - name: Delete Integration Test Server
+        if: always()
+        run: |
+          ./fork_check.sh
+          ansible-playbook mailcow-delete-server.yml --diff
+        env:
+          PY_COLORS: '1'
+          ANSIBLE_FORCE_COLOR: '1'
+          ANSIBLE_HOST_KEY_CHECKING: 'false'

+ 17 - 0
.github/workflows/tweet-trigger-publish-release.yml

@@ -0,0 +1,17 @@
+name: "Tweet trigger release"
+on:
+  release:
+    types: [published]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Tweet-trigger-publish-release
+        uses: mugi111/tweet-trigger-release@v1.1
+        with:
+          consumer_key: ${{ secrets.CONSUMER_KEY }}
+          consumer_secret: ${{ secrets.CONSUMER_SECRET }}
+          access_token_key: ${{ secrets.ACCESS_TOKEN_KEY }}
+          access_token_secret: ${{ secrets.ACCESS_TOKEN_SECRET }}
+          tweet_body: 'A new mailcow-dockerized Release has been Released on GitHub! Checkout our GitHub Page for the latest Release: github.com/mailcow/mailcow-dockerized/releases/latest'

+ 0 - 16
.travis.yml

@@ -1,16 +0,0 @@
-sudo: required
-services:
-- docker
-script:
-- echo 'Europe/Berlin' | MAILCOW_HOSTNAME=build.mailcow ./generate_config.sh
-- docker-compose pull --ignore-pull-failures --parallel
-- docker-compose build
-- docker login --username=$DOCKER_HUB_USERNAME --password=$DOCKER_HUB_PASSWORD
-- docker-compose push
-branches:
-  only:
-  - master_disabled
-env:
-  global:
-  - secure: MpxpTwD7f0CNEVLitSpVmocK7O9r+BwFE1deEHK4AlQo/oc9cOlhGe1EL3mx9zbglPmjlDg/8kMUGv6vSirIabfBo9Szjps76bHckFr9lr2Ykkg0e29oC8pgPpSXD1eY/1ZIN/FvIkxpUFLETo1okS/j9q/A0DCGFmti0n3EoMORsgRz9CpNAiEh0zpSd6+euPAGHuczuCrDuO84my9bIOCjA/+aPunHNeXiuM8yIM2SxCSyGtIKT0+jvquIvLF58VxivysXBlRfhDn8fhB09nXA2Ru/derYQACfcmNSn9Pd4bDpebPJW5B9H/XA8xjb58uKinUlncbAMB/QnxoT75j9YRWJZRSQ+34XNYP6ZgK9soZ2TC6djQyEKTUu45Kp/1s+poSn42m9jytJJTmmK0KxsZTRcC8JD5nrjIMZWPUNNTwC5L4+I7ZRWg2WooK3LNyq1Ng8Hn6W77wSgsvAJw2HD3Lx58AprGUhHuBeaIZRuSN9aKwZrl9vKQJLqPnOp/nF2EC6kot5HYYtcotGtETXPUDih21gWD5ZM2BqVqYfQQnJnNMgeYmMdj6QQuTFqhuNJf7hXRIRkTnD3j1gDOLKQZazW0+N2JE8XWDFwi6fKScDsxT85lJti9HmzHa7+k4RVHmUYuDgRoPuzUgjWHvPsiz3/Z8WQ9JYpH84S8w=
-  - secure: fWzZisT6nGDNL4lf6tXB07eFG2drgBakHxzdF/NFVvzuP861RFR6omuL+ED0PgXrEHDJBxaBLv52je8irmUXrAH1CNr7T8DWiZo/h5h609Uzr+38T1NnIu4krL0Wo6/CDwlLKnzqTq9yBIZLQSHVJmo8AOpo1JPIi2ajodqj9ZfmAxDQTQl+G6zvQjtqIkYHsHY7A44Rto0f14ykn7w2S82Jn6Ry89VNI5V1WEO3sMpM/XekNP/HokNcRIuntL/0+kuLvTJ5akGoTjBQxSnSW95opzPeGky74HRU2obExJYqKvF0VfVJRNAqejwjIiFIbbjqV0Sk5391kFuhuBErQQDM1bOHGdxZ41HsJH29qNWIl7C33Yl10qERoqecgsJ1N/bS2ZEmWqm/zQh5GClCXPvYmzEqMYsMGM3vjbKdjDlc1Wh2w/eFclsXN9LSXh1mc35rtj46frcT6e5Kof87AIfC9hTgDvk9kAsyjaHMkSHSZthbZXCIcsD8qriNm5UqfFBYD79mPIP1S2YMQ2jscCsjHOZgYVrcm0kzDF21J1w6H0Lo7d1jw37LYlegBdtLQ9gYgqY2D5m+nxWuVoD5FZmpR+5JGtK+ootyLFF8aiFoHXd4op1JCxRLjgkmnZKXzw3kTQSpE7oa7CgzchtQmK2nqcqla1b5Qk7ilVcjooo=

+ 2 - 1
README.md

@@ -2,7 +2,8 @@
 
 ## We stand with 🇺🇦
 
-[![master build status](https://img.shields.io/drone/build/mailcow/mailcow-dockerized/master?label=master%20build&server=https%3A%2F%2Fdrone.mailcow.email)](https://drone.mailcow.email/mailcow/mailcow-dockerized) [![staging build status](https://img.shields.io/drone/build/mailcow/mailcow-dockerized/staging?label=staging%20build&server=https%3A%2F%2Fdrone.mailcow.email)](https://drone.mailcow.email/mailcow/mailcow-dockerized) [![Translation status](https://translate.mailcow.email/widgets/mailcow-dockerized/-/translation/svg-badge.svg)](https://translate.mailcow.email/engage/mailcow-dockerized/)
+[![Mailcow Integration Tests](https://github.com/mailcow/mailcow-dockerized/actions/workflows/integration_tests.yml/badge.svg?branch=master)](https://github.com/mailcow/mailcow-dockerized/actions/workflows/integration_tests.yml)
+[![Translation status](https://translate.mailcow.email/widgets/mailcow-dockerized/-/translation/svg-badge.svg)](https://translate.mailcow.email/engage/mailcow-dockerized/)
 [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/mailcow_email.svg?style=social&label=Follow%20%40mailcow_email)](https://twitter.com/mailcow_email)
 
 ## Want to support mailcow?

+ 42 - 0
SECURITY.md

@@ -0,0 +1,42 @@
+# Security Policies and Procedures
+
+This document outlines security procedures and general policies for the _mailcow: dockerized_ project as found on [mailcow-dockerized](https://github.com/mailcow/mailcow-dockerized).
+
+  * [Reporting a Vulnerability](#reporting-a-vulnerability)
+  * [Disclosure Policy](#disclosure-policy)
+  * [Comments on this Policy](#comments-on-this-policy)
+
+## Reporting a Vulnerability 
+
+The mailcow team and community take all security vulnerabilities
+seriously. Thank you for improving the security of our open source 
+software. We appreciate your efforts and responsible disclosure and will
+make every effort to acknowledge your contributions.
+
+Report security vulnerabilities by emailing the mailcow team at:
+    
+    info at servercow.de
+
+mailcow team will acknowledge your email as soon as possible, and will
+send a more detailed response afterwards indicating the next steps in 
+handling your report. After the initial reply to your report, the mailcow
+team will endeavor to keep you informed of the progress towards a fix and
+full announcement, and may ask for additional information or guidance.
+
+Report security vulnerabilities in third-party modules to the person or 
+team maintaining the module.
+
+## Disclosure Policy
+
+When the mailcow team receives a security bug report, they will assign it
+to a primary handler. This person will coordinate the fix and release
+process, involving the following steps:
+
+  * Confirm the problem and determine the affected versions.
+  * Audit code to find any potential similar problems.
+  * Prepare fixes for all releases still under maintenance.
+
+## Comments on this Policy
+
+If you have suggestions on how this process could be improved please submit a
+pull request.

+ 8 - 2
create_cold_standby.sh

@@ -1,4 +1,4 @@
-FROM clamav/clamav:0.105.0_base
+FROM clamav/clamav:0.105.1_base
 
 LABEL maintainer "André Peters <andre.peters@servercow.de>"
 
@@ -8,8 +8,14 @@ RUN apk upgrade --no-cache \
   bind-tools \
   bash 
 
-COPY clamd.sh ./
+# init
+COPY clamd.sh /clamd.sh
 RUN chmod +x /sbin/tini
 
+# healthcheck
+COPY healthcheck.sh /healthcheck.sh
+RUN chmod +x /healthcheck.sh
+HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
+
 ENTRYPOINT []
 CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]

+ 9 - 0
data/Dockerfiles/clamd/healthcheck.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+
+if [[ "${SKIP_CLAMD}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+  echo "SKIP_CLAMD=y, skipping ClamAV..."
+  exit 0
+fi
+
+# run clamd healthcheck
+/usr/local/bin/clamdcheck.sh

+ 1 - 1
data/Dockerfiles/dovecot/Dockerfile

@@ -2,7 +2,7 @@ FROM debian:bullseye-slim
 LABEL maintainer "Andre Peters <andre.peters@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
-ARG DOVECOT=2.3.18
+ARG DOVECOT=2.3.19.1
 ENV LC_ALL C
 ENV GOSU_VERSION 1.14
 

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

@@ -307,6 +307,7 @@ namespace {
 }
 EOF
 
+
 cat <<EOF > /etc/dovecot/sogo_trusted_ip.conf
 # Autogenerated by mailcow
 remote ${IPV4_NETWORK}.248 {
@@ -349,6 +350,14 @@ sievec /var/vmail/sieve/global_sieve_after.sieve
 sievec /usr/lib/dovecot/sieve/report-spam.sieve
 sievec /usr/lib/dovecot/sieve/report-ham.sieve
 
+for file in /var/vmail/*/*/sieve/*.sieve ; do
+  if [[ "$file" == "/var/vmail/*/*/sieve/*.sieve" ]]; then
+    continue
+  fi
+  sievec "$file" "$(dirname "$file")/../.dovecot.svbin"
+  chown vmail:vmail "$(dirname "$file")/../.dovecot.svbin"
+done
+
 # 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

+ 2 - 2
data/Dockerfiles/dovecot/quarantine_notify.py

@@ -50,7 +50,7 @@ try:
   def query_mysql(query, headers = True, update = False):
     while True:
       try:
-        cnx = mysql.connector.connect(unix_socket = '/var/run/mysqld/mysqld.sock', user=os.environ.get('DBUSER'), passwd=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8")
+        cnx = mysql.connector.connect(unix_socket = '/var/run/mysqld/mysqld.sock', user=os.environ.get('DBUSER'), passwd=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
       except Exception as ex:
         print('%s - trying again...'  % (ex))
         time.sleep(3)
@@ -166,4 +166,4 @@ try:
       notify_rcpt(record['rcpt'], record['counter'], record['quarantine_acl'], attrs['quarantine_category'])
 
 finally:
-  os.unlink(pidfile)
+  os.unlink(pidfile)

+ 14 - 2
data/Dockerfiles/postfix/postfix.sh

@@ -323,7 +323,19 @@ hosts = unix:/var/run/mysqld/mysqld.sock
 dbname = ${DBNAME}
 # First select queries domain and alias_domain to determine if domains are active.
 query = SELECT goto FROM alias
-  WHERE address='%s'
+  WHERE id IN (
+      SELECT COALESCE (
+        (
+          SELECT id FROM alias
+            WHERE address='%s'
+            AND (active='1' OR active='2')
+        ), (
+          SELECT id FROM alias
+            WHERE address='@%d'
+            AND (active='1' OR active='2')
+        )
+      )
+    )
     AND active='1'
     AND (domain IN
       (SELECT domain FROM domain
@@ -354,7 +366,7 @@ query = SELECT goto FROM alias
     WHERE alias_domain.alias_domain = '%d'
       AND mailbox.username = CONCAT('%u','@',alias_domain.target_domain)
       AND (mailbox.active = '1' OR mailbox.active ='2')
-      AND alias_domain.active='1'
+      AND alias_domain.active='1';
 EOF
 
 # MX based routing

+ 3 - 3
data/Dockerfiles/sogo/Dockerfile

@@ -2,7 +2,7 @@ FROM debian:bullseye-slim
 LABEL maintainer "Andre Peters <andre.peters@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
-ARG SOGO_DEBIAN_REPOSITORY=http://packages.inverse.ca/SOGo/nightly/5/debian/
+ARG SOGO_DEBIAN_REPOSITORY=http://packages.sogo.nu/nightly/5/debian/
 ENV LC_ALL C
 ENV GOSU_VERSION 1.14
 
@@ -30,7 +30,7 @@ RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
   && gosu nobody true \
   && mkdir /usr/share/doc/sogo \
   && touch /usr/share/doc/sogo/empty.sh \
-  && apt-key adv --keyserver keyserver.ubuntu.com --recv-key 0x810273C4 \
+  && apt-key adv --keyserver keys.openpgp.org --recv-key 74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 \
   && echo "deb ${SOGO_DEBIAN_REPOSITORY} bullseye bullseye" > /etc/apt/sources.list.d/sogo.list \
   && apt-get update && apt-get install -y --no-install-recommends \
     sogo \
@@ -52,4 +52,4 @@ RUN chmod +x /bootstrap-sogo.sh \
 
 ENTRYPOINT ["/docker-entrypoint.sh"]
 
-CMD exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+CMD exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf

+ 4 - 0
data/Dockerfiles/sogo/bootstrap-sogo.sh

@@ -142,6 +142,10 @@ cat <<EOF > /var/lib/sogo/GNUstep/Defaults/sogod.plist
     <string>mysql://${DBUSER}:${DBPASS}@%2Fvar%2Frun%2Fmysqld%2Fmysqld.sock/${DBNAME}/sogo_acl</string>
     <key>SOGoIMAPServer</key>
     <string>imap://${IPV4_NETWORK}.250:143/?TLS=YES&amp;tlsVerifyMode=none</string>
+    <key>SOGoSieveServer</key>
+    <string>sieve://${IPV4_NETWORK}.250:4190/?TLS=YES&amp;tlsVerifyMode=none</string>
+    <key>SOGoSMTPServer</key>
+    <string>smtp://${IPV4_NETWORK}.253:588/?TLS=YES&amp;tlsVerifyMode=none</string>
     <key>SOGoTrustProxyAuthentication</key>
     <string>YES</string>
     <key>SOGoEncryptionKey</key>

+ 3 - 0
data/conf/rspamd/local.d/groups.conf

@@ -18,6 +18,9 @@ symbols {
   "ENCRYPTED_CHAT" {
     score = -20.0;
   }
+  "SOGO_CONTACT" {
+    score = -99.0;
+  }
 }
 
 group "MX" {

+ 0 - 2
data/conf/sogo/sogo.conf

@@ -32,8 +32,6 @@
     // );
 
     // self-signed is not trusted anymore
-    SOGoSieveServer = "sieve://dovecot:4190/?TLS=YES&tlsVerifyMode=none";
-    SOGoSMTPServer = "smtp://postfix:588/?TLS=YES&tlsVerifyMode=none";
     WOPort = "0.0.0.0:20000";
     SOGoMemcachedHost = "memcached";
 

+ 16 - 0
data/web/api/index.css

@@ -0,0 +1,16 @@
+html {
+    box-sizing: border-box;
+    overflow: -moz-scrollbars-vertical;
+    overflow-y: scroll;
+}
+
+*,
+*:before,
+*:after {
+    box-sizing: inherit;
+}
+
+body {
+    margin: 0;
+    background: #fafafa;
+}

+ 2 - 43
data/web/api/index.html

@@ -5,56 +5,15 @@
     <meta charset="UTF-8">
     <title>Swagger UI</title>
     <link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
+    <link rel="stylesheet" type="text/css" href="index.css" />
     <link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
     <link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
-    <style>
-      html
-      {
-        box-sizing: border-box;
-        overflow: -moz-scrollbars-vertical;
-        overflow-y: scroll;
-      }
-
-      *,
-      *:before,
-      *:after
-      {
-        box-sizing: inherit;
-      }
-
-      body
-      {
-        margin:0;
-        background: #fafafa;
-      }
-    </style>
   </head>
 
   <body>
     <div id="swagger-ui"></div>
-
     <script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
     <script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
-    <script>
-    window.onload = function() {
-      // Begin Swagger UI call region
-      const ui = SwaggerUIBundle({
-        urls: [{url: "/api/openapi.yaml", name: "mailcow API"}],
-        dom_id: '#swagger-ui',
-        deepLinking: true,
-        presets: [
-          SwaggerUIBundle.presets.apis,
-          SwaggerUIStandalonePreset
-        ],
-        plugins: [
-          SwaggerUIBundle.plugins.DownloadUrl
-        ],
-        layout: "StandaloneLayout"
-      });
-      // End Swagger UI call region
-
-      window.ui = ui;
-    };
-  </script>
+    <script src="./swagger-initializer.js" charset="UTF-8"> </script>
   </body>
 </html>

+ 10 - 6
data/web/api/oauth2-redirect.html

@@ -13,7 +13,7 @@
         var isValid, qp, arr;
 
         if (/code|token|error/.test(window.location.hash)) {
-            qp = window.location.hash.substring(1);
+            qp = window.location.hash.substring(1).replace('?', '&');
         } else {
             qp = location.search.substring(1);
         }
@@ -38,7 +38,7 @@
                     authId: oauth2.auth.name,
                     source: "auth",
                     level: "warning",
-                    message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
+                    message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
                 });
             }
 
@@ -58,7 +58,7 @@
                     authId: oauth2.auth.name,
                     source: "auth",
                     level: "error",
-                    message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
+                    message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
                 });
             }
         } else {
@@ -67,9 +67,13 @@
         window.close();
     }
 
-    window.addEventListener('DOMContentLoaded', function () {
-      run();
-    });
+    if (document.readyState !== 'loading') {
+        run();
+    } else {
+        document.addEventListener('DOMContentLoaded', function () {
+            run();
+        });
+    }
 </script>
 </body>
 </html>

+ 95 - 51
data/web/api/openapi.yaml

@@ -518,21 +518,23 @@ paths:
                         - domain.tld
                       type: success
               schema:
-                properties:
-                  log:
-                    description: contains request object
-                    items: {}
-                    type: array
-                  msg:
-                    items: {}
-                    type: array
-                  type:
-                    enum:
-                      - success
-                      - danger
-                      - error
-                    type: string
-                type: object
+                type: array
+                items:
+                  type: object
+                  properties:
+                    log:
+                      description: contains request object
+                      items: {}
+                      type: array
+                    msg:
+                      items: {}
+                      type: array
+                    type:
+                      enum:
+                        - success
+                        - danger
+                        - error
+                      type: string
           description: OK
           headers: {}
       tags:
@@ -579,6 +581,11 @@ paths:
                 domain:
                   description: Fully qualified domain name
                   type: string
+                gal:
+                  description: >-
+                    is domain global address list active or not, it enables
+                    shared contacts accross domain in SOGo webmail
+                  type: boolean
                 mailboxes:
                   description: limit count of mailboxes associated with this domain
                   type: number
@@ -596,6 +603,9 @@ paths:
                     if not, them you have to create "dummy" mailbox for each
                     address to relay
                   type: boolean
+                relay_unknown_only:
+                  description: Relay non-existing mailboxes only. Existing mailboxes will be delivered locally.
+                  type: boolean
                 rl_frame:
                   enum:
                     - s
@@ -606,6 +616,11 @@ paths:
                 rl_value:
                   description: rate limit value
                   type: number
+                tags:
+                  description: tags for this Domain
+                  type: array
+                  items:
+                    type: string
               type: object
       summary: Create domain
   /api/v1/add/domain-admin:
@@ -1952,21 +1967,23 @@ paths:
                         - domain2.tld
                       type: success
               schema:
-                properties:
-                  log:
-                    description: contains request object
-                    items: {}
-                    type: array
-                  msg:
-                    items: {}
-                    type: array
-                  type:
-                    enum:
-                      - success
-                      - danger
-                      - error
-                    type: string
-                type: object
+                type: array
+                items:
+                  type: object
+                  properties:
+                    log:
+                      description: contains request object
+                      items: {}
+                      type: array
+                    msg:
+                      items: {}
+                      type: array
+                    type:
+                      enum:
+                        - success
+                        - danger
+                        - error
+                      type: string
           description: OK
           headers: {}
       tags:
@@ -1977,14 +1994,15 @@ paths:
         content:
           application/json:
             schema:
+              type: object
               example:
                 - domain.tld
                 - domain2.tld
               properties:
-                items:
-                  description: contains list of domains you want to delete
-                  type: object
-              type: object
+                items: 
+                  type: array
+                  items:
+                    type: string
       summary: Delete domain
   /api/v1/delete/domain-admin:
     post:
@@ -2972,23 +2990,25 @@ paths:
           $ref: "#/components/responses/Unauthorized"
         "200":
           content:
-            "*/*":
+            application/json:
               schema:
-                properties:
-                  log:
-                    description: contains request object
-                    items: {}
-                    type: array
-                  msg:
-                    items: {}
-                    type: array
-                  type:
-                    enum:
-                      - success
-                      - danger
-                      - error
-                    type: string
-                type: object
+                type: array
+                items: 
+                  type: object
+                  properties:
+                    log:
+                      type: array
+                      description: contains request object
+                      items: {}
+                    msg:
+                      type: array
+                      items: {}
+                    type:
+                      enum:
+                        - success
+                        - danger
+                        - error
+                      type: string
           description: OK
           headers: {}
       tags:
@@ -3056,13 +3076,33 @@ paths:
                         if not, them you have to create "dummy" mailbox for each
                         address to relay
                       type: boolean
+                    relay_unknown_only:
+                      description: Relay non-existing mailboxes only. Existing mailboxes will be delivered locally.
+                      type: boolean
                     relayhost:
                       description: id of relayhost
                       type: number
+                    rl_frame:
+                      enum:
+                        - s
+                        - m
+                        - h
+                        - d
+                      type: string
+                    rl_value:
+                      description: rate limit value
+                      type: number
+                    tags:
+                      description: tags for this Domain
+                      type: array
+                      items:
+                        type: string
                   type: object
                 items:
                   description: contains list of domain names you want update
-                  type: object
+                  type: array
+                  items:
+                    type: string
               type: object
       summary: Update domain
   /api/v1/edit/fail2ban:
@@ -3953,6 +3993,8 @@ paths:
           in: query
           name: tags
           required: false
+          schema:
+            type: string
         - description: e.g. api-key-string
           example: api-key-string
           in: header
@@ -4512,6 +4554,8 @@ paths:
           in: query
           name: tags
           required: false
+          schema:
+            type: string
         - description: e.g. api-key-string
           example: api-key-string
           in: header

+ 19 - 0
data/web/api/swagger-initializer.js

@@ -0,0 +1,19 @@
+window.onload = function() {
+  // Begin Swagger UI call region
+  const ui = SwaggerUIBundle({
+    urls: [{url: "/api/openapi.yaml", name: "mailcow API"}],
+    dom_id: '#swagger-ui',
+    deepLinking: true,
+    presets: [
+      SwaggerUIBundle.presets.apis,
+      SwaggerUIStandalonePreset
+    ],
+    plugins: [
+      SwaggerUIBundle.plugins.DownloadUrl
+    ],
+    layout: "StandaloneLayout"
+  });
+  // End Swagger UI call region
+
+  window.ui = ui;
+};

File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-bundle.js


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-bundle.js.map


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-es-bundle-core.js


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-es-bundle-core.js.map


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-es-bundle.js


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-es-bundle.js.map


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-standalone-preset.js


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui-standalone-preset.js.map


File diff suppressed because it is too large
+ 0 - 1
data/web/api/swagger-ui.css


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui.css.map


File diff suppressed because it is too large
+ 0 - 1
data/web/api/swagger-ui.js


File diff suppressed because it is too large
+ 0 - 0
data/web/api/swagger-ui.js.map


+ 12 - 1
data/web/css/build/008-mailcow.css

@@ -260,6 +260,18 @@ code {
   margin-right: 5px;
 }
 
+.list-group-item.webauthn-authenticator-selection,
+.list-group-item.totp-authenticator-selection,
+.list-group-item.yubi_otp-authenticator-selection {
+  border-radius: 0px !important;
+}
+.pending-tfa-collapse {
+  padding: 10px;
+  background: #fbfbfb;
+  border: 1px solid #ededed;
+  min-height: 110px;
+}
+
 .tag-box {
   display: flex;
   flex-wrap: wrap;
@@ -296,4 +308,3 @@ code {
   align-items: center;
   display: inline-flex;
 }
-

+ 1 - 1
data/web/inc/ajax/destroy_tfa_auth.php

@@ -2,5 +2,5 @@
 session_start();
 unset($_SESSION['pending_mailcow_cc_username']);
 unset($_SESSION['pending_mailcow_cc_role']);
-unset($_SESSION['pending_tfa_method']);
+unset($_SESSION['pending_tfa_methods']);
 ?>

+ 31 - 2
data/web/inc/footer.inc.php

@@ -23,14 +23,43 @@ if (is_array($alertbox_log_parser)) {
   unset($_SESSION['return']);
 }
 
+// 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;
+}
+
 // globals
 $globalVariables = [
   'mailcow_info' => array(
     'version_tag' => $GLOBALS['MAILCOW_GIT_VERSION'],
-    'git_project_url' => $GLOBALS['MAILCOW_GIT_URL']
+    'last_version_tag' => $GLOBALS['MAILCOW_LAST_GIT_VERSION'],
+    'git_owner' => $GLOBALS['MAILCOW_GIT_OWNER'],
+    'git_repo' => $GLOBALS['MAILCOW_GIT_REPO'],
+    'git_project_url' => $GLOBALS['MAILCOW_GIT_URL'],
+    'git_commit' => $GLOBALS['MAILCOW_GIT_COMMIT'],
+    'git_commit_date' => $GLOBALS['MAILCOW_GIT_COMMIT_DATE'],
+    'mailcow_branch' => $GLOBALS['MAILCOW_BRANCH'],
+    'updated_at' => $GLOBALS['MAILCOW_UPDATEDAT']
   ),
   'js_path' => '/cache/'.basename($JSPath),
-  'pending_tfa_method' => @$_SESSION['pending_tfa_method'],
+  'pending_tfa_methods' => @$_SESSION['pending_tfa_methods'],
+  'pending_tfa_authmechs' => $pending_tfa_authmechs,
   'pending_mailcow_cc_username' => @$_SESSION['pending_mailcow_cc_username'],
   'lang_footer' => json_encode($lang['footer']),
   'lang_acl' => json_encode($lang['acl']),

+ 246 - 176
data/web/inc/functions.inc.php

@@ -830,11 +830,15 @@ function check_login($user, $pass, $app_passwd_data = false) {
   $stmt->execute(array(':user' => $user));
   $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
   foreach ($rows as $row) {
+    // verify password
     if (verify_hash($row['password'], $pass)) {
-      if (get_tfa($user)['name'] != "none") {
+      // 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_method'] = get_tfa($user)['name'];
+        $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
         unset($_SESSION['ldelay']);
         $_SESSION['return'][] =  array(
           'type' => 'info',
@@ -842,8 +846,7 @@ function check_login($user, $pass, $app_passwd_data = false) {
           'msg' => 'awaiting_tfa_confirmation'
         );
         return "pending";
-      }
-      else {
+      } 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");
@@ -866,11 +869,14 @@ function check_login($user, $pass, $app_passwd_data = false) {
   $stmt->execute(array(':user' => $user));
   $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
   foreach ($rows as $row) {
+    // verify password
     if (verify_hash($row['password'], $pass) !== false) {
-      if (get_tfa($user)['name'] != "none") {
+      // 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_method'] = get_tfa($user)['name'];
+        $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
         unset($_SESSION['ldelay']);
         $_SESSION['return'][] =  array(
           'type' => 'info',
@@ -929,15 +935,37 @@ function check_login($user, $pass, $app_passwd_data = false) {
     $stmt->execute(array(':user' => $user));
     $rows = array_merge($rows, $stmt->fetchAll(PDO::FETCH_ASSOC));
   }
-  foreach ($rows as $row) {
+  foreach ($rows as $row) { 
+    // verify password
     if (verify_hash($row['password'], $pass) !== false) {
-      unset($_SESSION['ldelay']);
-      $_SESSION['return'][] =  array(
-        'type' => 'success',
-        'log' => array(__FUNCTION__, $user, '*'),
-        'msg' => array('logged_in_as', $user)
-      );
-      if ($app_passwd_data['eas'] === true || $app_passwd_data['dav'] === true) {
+      if (!array_key_exists("app_passwd_id", $row)){ 
+        // password is not a app password
+        // check for tfa authenticators
+        $authenticators = get_tfa($user);
+        if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 &&
+            $app_passwd_data['eas'] !== true && $app_passwd_data['dav'] !== true) {
+          // 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, '*'),
+            '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
+          // 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));
+
+          unset($_SESSION['ldelay']);
+          return "user";
+        }
+      } elseif ($app_passwd_data['eas'] === true || $app_passwd_data['dav'] === true) {
+        // password is a app password
         $service = ($app_passwd_data['eas'] === true) ? 'EAS' : 'DAV';
         $stmt = $pdo->prepare("REPLACE INTO sasl_log (`service`, `app_password`, `username`, `real_rip`) VALUES (:service, :app_id, :username, :remote_addr)");
         $stmt->execute(array(
@@ -946,8 +974,10 @@ function check_login($user, $pass, $app_passwd_data = false) {
           ':username' => $user,
           ':remote_addr' => ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'])
         ));
+
+        unset($_SESSION['ldelay']);
+        return "user";
       }
-      return "user";
     }
   }
 
@@ -1142,47 +1172,46 @@ function set_tfa($_data) {
   global $yubi;
   global $tfa;
   $_data_log = $_data;
+  $access_denied = null;
   !isset($_data_log['confirm_password']) ?: $_data_log['confirm_password'] = '*';
   $username = $_SESSION['mailcow_cc_username'];
-  if (!isset($_SESSION['mailcow_cc_role']) || empty($username)) {
-      $_SESSION['return'][] =  array(
-        'type' => 'danger',
-        'log' => array(__FUNCTION__, $_data_log),
-        'msg' => 'access_denied'
-      );
-      return false;
-  }
-  $stmt = $pdo->prepare("SELECT `password` FROM `admin`
-      WHERE `username` = :username");
-  $stmt->execute(array(':username' => $username));
-  $row = $stmt->fetch(PDO::FETCH_ASSOC);
-  $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
-  if (!empty($num_results)) {
-    if (!verify_hash($row['password'], $_data["confirm_password"])) {
-      $_SESSION['return'][] =  array(
-        'type' => 'danger',
-        'log' => array(__FUNCTION__, $_data_log),
-        'msg' => 'access_denied'
-      );
-      return false;
+
+  // check for empty user and role
+  if (!isset($_SESSION['mailcow_cc_role']) || empty($username)) $access_denied = true;
+
+  // check admin confirm password
+  if ($access_denied === null) {
+    $stmt = $pdo->prepare("SELECT `password` FROM `admin`
+        WHERE `username` = :username");
+    $stmt->execute(array(':username' => $username));
+    $row = $stmt->fetch(PDO::FETCH_ASSOC);
+    if ($row) {
+      if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
+      else $access_denied = false;
     }
   }
-  $stmt = $pdo->prepare("SELECT `password` FROM `mailbox`
-      WHERE `username` = :username");
-  $stmt->execute(array(':username' => $username));
-  $row = $stmt->fetch(PDO::FETCH_ASSOC);
-  $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
-  if (!empty($num_results)) {
-    if (!verify_hash($row['password'], $_data["confirm_password"])) {
-      $_SESSION['return'][] =  array(
-        'type' => 'danger',
-        'log' => array(__FUNCTION__, $_data_log),
-        'msg' => 'access_denied'
-      );
-      return false;
+
+  // check mailbox confirm password
+  if ($access_denied === null) {
+    $stmt = $pdo->prepare("SELECT `password` FROM `mailbox`
+        WHERE `username` = :username");
+    $stmt->execute(array(':username' => $username));
+    $row = $stmt->fetch(PDO::FETCH_ASSOC);
+    if ($row) {
+      if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
+      else $access_denied = false;
     }
   }
 
+  // set access_denied error
+  if ($access_denied){
+    $_SESSION['return'][] =  array(
+      'type' => 'danger',
+      'log' => array(__FUNCTION__, $_data_log),
+      'msg' => 'access_denied'
+    );
+    return false;
+  }
 
   switch ($_data["tfa_method"]) {
     case "yubi_otp":
@@ -1220,8 +1249,7 @@ function set_tfa($_data) {
         $yubico_modhex_id = substr($_data["otp_token"], 0, 12);
         $stmt = $pdo->prepare("DELETE FROM `tfa`
           WHERE `username` = :username
-            AND (`authmech` != 'yubi_otp')
-            OR (`authmech` = 'yubi_otp' AND `secret` LIKE :modhex)");
+            AND (`authmech` = 'yubi_otp' AND `secret` LIKE :modhex)");
         $stmt->execute(array(':username' => $username, ':modhex' => '%' . $yubico_modhex_id));
         $stmt = $pdo->prepare("INSERT INTO `tfa` (`key_id`, `username`, `authmech`, `active`, `secret`) VALUES
           (:key_id, :username, 'yubi_otp', '1', :secret)");
@@ -1265,9 +1293,6 @@ function set_tfa($_data) {
     case "webauthn":
         $key_id = (!isset($_data["key_id"])) ? 'unidentified' : $_data["key_id"];
 
-        $stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `username` = :username AND `authmech` != 'webauthn'");
-        $stmt->execute(array(':username' => $username));
-
         $stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `keyHandle`, `publicKey`, `certificate`, `counter`, `active`)
         VALUES (?, ?, 'webauthn', ?, ?, ?, ?, '1')");
         $stmt->execute(array(
@@ -1439,25 +1464,27 @@ function unset_tfa_key($_data) {
   global $pdo;
   global $lang;
   $_data_log = $_data;
+  $access_denied = null;
   $id = intval($_data['unset_tfa_key']);
   $username = $_SESSION['mailcow_cc_username'];
-  if (!isset($_SESSION['mailcow_cc_role']) || empty($username)) {
-    $_SESSION['return'][] =  array(
-      'type' => 'danger',
-      'log' => array(__FUNCTION__, $_data_log),
-      'msg' => 'access_denied'
-    );
-    return false;
-  }
+
+  // check for empty user and role
+  if (!isset($_SESSION['mailcow_cc_role']) || empty($username)) $access_denied = true;
+
   try {
-    if (!is_numeric($id)) {
-      $_SESSION['return'][] =  array(
+    if (!is_numeric($id)) $access_denied = true;
+    
+    // set access_denied error
+    if ($access_denied){
+      $_SESSION['return'][] = array(
         'type' => 'danger',
         'log' => array(__FUNCTION__, $_data_log),
         'msg' => 'access_denied'
       );
       return false;
-    }
+    } 
+
+    // check if it's last key
     $stmt = $pdo->prepare("SELECT COUNT(*) AS `keys` FROM `tfa`
       WHERE `username` = :username AND `active` = '1'");
     $stmt->execute(array(':username' => $username));
@@ -1470,6 +1497,8 @@ function unset_tfa_key($_data) {
       );
       return false;
     }
+
+    // delete key
     $stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `username` = :username AND `id` = :id");
     $stmt->execute(array(':username' => $username, ':id' => $id));
     $_SESSION['return'][] =  array(
@@ -1487,7 +1516,7 @@ function unset_tfa_key($_data) {
     return false;
   }
 }
-function get_tfa($username = null) {
+function get_tfa($username = null, $id = null) {
   global $pdo;
   if (isset($_SESSION['mailcow_cc_username'])) {
     $username = $_SESSION['mailcow_cc_username'];
@@ -1495,95 +1524,119 @@ function get_tfa($username = null) {
   elseif (empty($username)) {
     return false;
   }
-  $stmt = $pdo->prepare("SELECT * FROM `tfa`
-      WHERE `username` = :username AND `active` = '1'");
-  $stmt->execute(array(':username' => $username));
-  $row = $stmt->fetch(PDO::FETCH_ASSOC);
 
-  if (isset($row["authmech"])) {
-    switch ($row["authmech"]) {
-      case "yubi_otp":
-        $data['name'] = "yubi_otp";
-        $data['pretty'] = "Yubico OTP";
-        $stmt = $pdo->prepare("SELECT `id`, `key_id`, RIGHT(`secret`, 12) AS 'modhex' FROM `tfa` WHERE `authmech` = 'yubi_otp' AND `username` = :username");
-        $stmt->execute(array(
-          ':username' => $username,
-        ));
-        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
-        while($row = array_shift($rows)) {
-          $data['additional'][] = $row;
-        }
-        return $data;
-      break;
-      // u2f - deprecated, should be removed
-      case "u2f":
-        $data['name'] = "u2f";
-        $data['pretty'] = "Fido U2F";
-        $stmt = $pdo->prepare("SELECT `id`, `key_id` FROM `tfa` WHERE `authmech` = 'u2f' AND `username` = :username");
-        $stmt->execute(array(
-          ':username' => $username,
-        ));
-        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
-        while($row = array_shift($rows)) {
-          $data['additional'][] = $row;
-        }
-        return $data;
-      break;
-      case "hotp":
-        $data['name'] = "hotp";
-        $data['pretty'] = "HMAC-based OTP";
-        return $data;
-      break;
-      case "totp":
-        $data['name'] = "totp";
-        $data['pretty'] = "Time-based OTP";
-        $stmt = $pdo->prepare("SELECT `id`, `key_id`, `secret` FROM `tfa` WHERE `authmech` = 'totp' AND `username` = :username");
-        $stmt->execute(array(
-          ':username' => $username,
-        ));
-        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
-        while($row = array_shift($rows)) {
-          $data['additional'][] = $row;
-        }
+  if (!isset($id)){
+    // fetch all tfa methods - just get information about possible authenticators
+    $stmt = $pdo->prepare("SELECT `id`, `key_id`, `authmech` FROM `tfa`
+        WHERE `username` = :username AND `active` = '1'");
+    $stmt->execute(array(':username' => $username));
+    $results = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ 
+    // no tfa methods found
+    if (count($results) == 0) {
+        $data['name'] = 'none';
+        $data['pretty'] = "-";
+        $data['additional'] = array();
         return $data;
-      break;
-      case "webauthn":
-        $data['name'] = "webauthn";
-        $data['pretty'] = "WebAuthn";
-        $stmt = $pdo->prepare("SELECT `id`, `key_id` FROM `tfa` WHERE `authmech` = 'webauthn' AND `username` = :username");
-        $stmt->execute(array(
-          ':username' => $username,
-        ));
-        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
-        while($row = array_shift($rows)) {
-          $data['additional'][] = $row;
+    }
+
+    $data['additional'] = $results;
+    return $data;
+  } else {
+    // fetch specific authenticator details by id
+    $stmt = $pdo->prepare("SELECT * FROM `tfa`
+    WHERE `username` = :username AND `id` = :id AND `active` = '1'");
+    $stmt->execute(array(':username' => $username, ':id' => $id));
+    $row = $stmt->fetch(PDO::FETCH_ASSOC);
+
+    if (isset($row["authmech"])) {
+        switch ($row["authmech"]) {
+          case "yubi_otp":
+            $data['name'] = "yubi_otp";
+            $data['pretty'] = "Yubico OTP";
+            $stmt = $pdo->prepare("SELECT `id`, `key_id`, RIGHT(`secret`, 12) AS 'modhex' FROM `tfa` WHERE `authmech` = 'yubi_otp' AND `username` = :username AND `id` = :id");
+            $stmt->execute(array(
+              ':username' => $username,
+              ':id' => $id
+            ));
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              $data['additional'][] = $row;
+            }
+            return $data;
+          break;
+          // u2f - deprecated, should be removed
+          case "u2f":
+            $data['name'] = "u2f";
+            $data['pretty'] = "Fido U2F";
+            $stmt = $pdo->prepare("SELECT `id`, `key_id` FROM `tfa` WHERE `authmech` = 'u2f' AND `username` = :username AND `id` = :id");
+            $stmt->execute(array(
+              ':username' => $username,
+              ':id' => $id
+            ));
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              $data['additional'][] = $row;
+            }
+            return $data;
+          break;
+          case "hotp":
+            $data['name'] = "hotp";
+            $data['pretty'] = "HMAC-based OTP";
+            return $data;
+          break;
+          case "totp":
+            $data['name'] = "totp";
+            $data['pretty'] = "Time-based OTP";
+            $stmt = $pdo->prepare("SELECT `id`, `key_id`, `secret` FROM `tfa` WHERE `authmech` = 'totp' AND `username` = :username AND `id` = :id");
+            $stmt->execute(array(
+              ':username' => $username,
+              ':id' => $id
+            ));
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              $data['additional'][] = $row;
+            }
+            return $data;
+          break;
+          case "webauthn":
+            $data['name'] = "webauthn";
+            $data['pretty'] = "WebAuthn";
+            $stmt = $pdo->prepare("SELECT `id`, `key_id` FROM `tfa` WHERE `authmech` = 'webauthn' AND `username` = :username AND `id` = :id");
+            $stmt->execute(array(
+              ':username' => $username,
+              ':id' => $id
+            ));
+            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+            while($row = array_shift($rows)) {
+              $data['additional'][] = $row;
+            }
+            return $data;
+          break;
+          default:
+            $data['name'] = 'none';
+            $data['pretty'] = "-";
+            return $data;
+          break;
         }
-        return $data;
-      break;
-      default:
+      }
+      else {
         $data['name'] = 'none';
         $data['pretty'] = "-";
         return $data;
-      break;
+      }
     }
-  }
-  else {
-    $data['name'] = 'none';
-    $data['pretty'] = "-";
-    return $data;
-  }
 }
-function verify_tfa_login($username, $_data, $WebAuthn) {
-    global $pdo;
-    global $yubi;
-    global $u2f;
-    global $tfa;
-    $stmt = $pdo->prepare("SELECT `authmech` FROM `tfa`
-        WHERE `username` = :username AND `active` = '1'");
-    $stmt->execute(array(':username' => $username));
-    $row = $stmt->fetch(PDO::FETCH_ASSOC);
+function verify_tfa_login($username, $_data) {
+  global $pdo;
+  global $yubi;
+  global $u2f;
+  global $tfa;
+  global $WebAuthn;
+
+  if ($_data['tfa_method'] != 'u2f'){
 
-    switch ($row["authmech"]) {
+    switch ($_data["tfa_method"]) {
         case "yubi_otp":
             if (!ctype_alnum($_data['token']) || strlen($_data['token']) != 44) {
                 $_SESSION['return'][] =  array(
@@ -1597,7 +1650,7 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
             $stmt = $pdo->prepare("SELECT `id`, `secret` FROM `tfa`
                 WHERE `username` = :username
                 AND `authmech` = 'yubi_otp'
-                AND `active`='1'
+                AND `active` = '1'
                 AND `secret` LIKE :modhex");
             $stmt->execute(array(':username' => $username, ':modhex' => '%' . $yubico_modhex_id));
             $row = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -1632,15 +1685,16 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
             return false;
         break;
         case "totp":
-            try {
+          try {
             $stmt = $pdo->prepare("SELECT `id`, `secret` FROM `tfa`
                 WHERE `username` = :username
                 AND `authmech` = 'totp'
+                AND `id` = :id
                 AND `active`='1'");
-            $stmt->execute(array(':username' => $username));
+            $stmt->execute(array(':username' => $username, ':id' => $_data['id']));
             $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
             foreach ($rows as $row) {
-                if ($tfa->verifyCode($row['secret'], $_data['token']) === true) {
+              if ($tfa->verifyCode($row['secret'], $_data['token']) === true) {
                 $_SESSION['tfa_id'] = $row['id'];
                 $_SESSION['return'][] =  array(
                     'type' => 'success',
@@ -1648,7 +1702,7 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
                     'msg' => 'verified_totp_login'
                 );
                 return true;
-                }
+              }
             }
             $_SESSION['return'][] =  array(
                 'type' => 'danger',
@@ -1656,23 +1710,16 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
                 'msg' => 'totp_verification_failed'
             );
             return false;
-            }
-            catch (PDOException $e) {
+          }
+          catch (PDOException $e) {
             $_SESSION['return'][] =  array(
                 'type' => 'danger',
                 'log' => array(__FUNCTION__, $username, '*'),
                 'msg' => array('mysql_error', $e)
             );
             return false;
-            }
+          }
         break;
-        // u2f - deprecated, should be removed
-        case "u2f":
-            // delete old keys that used u2f
-            $stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `authmech` = :authmech AND `username` = :username");
-            $stmt->execute(array(':authmech' => 'u2f', ':username' => $username));
-
-            return true;
         case "webauthn":
             $tokenData = json_decode($_data['token']);
             $clientDataJSON = base64_decode($tokenData->clientDataJSON);
@@ -1681,13 +1728,20 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
             $id = base64_decode($tokenData->id);
             $challenge = $_SESSION['challenge'];
 
-            $stmt = $pdo->prepare("SELECT `key_id`, `keyHandle`, `username`, `publicKey` FROM `tfa` WHERE `keyHandle` = :tokenId");
-            $stmt->execute(array(':tokenId' => $tokenData->id));
+            $stmt = $pdo->prepare("SELECT `id`, `key_id`, `keyHandle`, `username`, `publicKey` FROM `tfa` WHERE `id` = :id AND `active`='1'");
+            $stmt->execute(array(':id' => $_data['id']));
             $process_webauthn = $stmt->fetch(PDO::FETCH_ASSOC);
 
-            if (empty($process_webauthn) || empty($process_webauthn['publicKey']) || empty($process_webauthn['username'])) return false;
+            if (empty($process_webauthn)){
+              $_SESSION['return'][] =  array(
+                  'type' => 'danger',
+                  'log' => array(__FUNCTION__, $username, '*'),
+                  'msg' => array('webauthn_verification_failed', 'authenticator not found')
+              );
+              return false;
+            } 
             
-            if ($process_webauthn['publicKey'] === false) {
+            if (empty($process_webauthn['publicKey']) || $process_webauthn['publicKey'] === false) {
                 $_SESSION['return'][] =  array(
                     'type' => 'danger',
                     'log' => array(__FUNCTION__, $username, '*'),
@@ -1695,6 +1749,7 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
                 );
                 return false;
             }
+
             try {
                 $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $process_webauthn['publicKey'], $challenge, null, $GLOBALS['WEBAUTHN_UV_FLAG_LOGIN'], $GLOBALS['WEBAUTHN_USER_PRESENT_FLAG']);
             }
@@ -1707,26 +1762,31 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
                 return false;
             }
 
-            
             $stmt = $pdo->prepare("SELECT `superadmin` FROM `admin` WHERE `username` = :username");
             $stmt->execute(array(':username' => $process_webauthn['username']));
             $obj_props = $stmt->fetch(PDO::FETCH_ASSOC);
             if ($obj_props['superadmin'] === 1) {
-                $_SESSION["mailcow_cc_role"] = "admin";
+              $_SESSION["mailcow_cc_role"] = "admin";
             }
             elseif ($obj_props['superadmin'] === 0) {
-                $_SESSION["mailcow_cc_role"] = "domainadmin";
+              $_SESSION["mailcow_cc_role"] = "domainadmin";
             }
             else {
-                $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :username");
-                $stmt->execute(array(':username' => $process_webauthn['username']));
-                $row = $stmt->fetch(PDO::FETCH_ASSOC);
-                if ($row['username'] == $process_webauthn['username']) {
+              $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :username");
+              $stmt->execute(array(':username' => $process_webauthn['username']));
+              $row = $stmt->fetch(PDO::FETCH_ASSOC);
+              if (!empty($row['username'])) {
                 $_SESSION["mailcow_cc_role"] = "user";
-                }
+              } else {
+                $_SESSION['return'][] =  array(
+                  'type' => 'danger',
+                  'log' => array(__FUNCTION__, $username, '*'),
+                  'msg' => array('webauthn_verification_failed', 'could not determine user role')
+                );
+                return false;
+              }
             }
 
-        
             if ($process_webauthn['username'] != $_SESSION['pending_mailcow_cc_username']){
                 $_SESSION['return'][] =  array(
                     'type' => 'danger',
@@ -1736,9 +1796,8 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
                 return false;
             }
 
-
             $_SESSION["mailcow_cc_username"] = $process_webauthn['username'];
-            $_SESSION['tfa_id'] = $process_webauthn['key_id'];
+            $_SESSION['tfa_id'] = $process_webauthn['id'];
             $_SESSION['authReq'] = null;
             unset($_SESSION["challenge"]);
             $_SESSION['return'][] =  array(
@@ -1759,6 +1818,17 @@ function verify_tfa_login($username, $_data, $WebAuthn) {
     }
 
     return false;
+  } else {
+    // delete old keys that used u2f
+    $stmt = $pdo->prepare("SELECT * FROM `tfa` WHERE `authmech` = 'u2f' AND `username` = :username");
+    $stmt->execute(array(':username' => $username));
+    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+    if (count($rows) == 0) return false;
+
+    $stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `authmech` = 'u2f' AND `username` = :username");
+    $stmt->execute(array(':username' => $username));
+    return true;
+  }
 }
 function admin_api($access, $action, $data = null) {
   global $pdo;

+ 14 - 12
data/web/inc/functions.mailbox.inc.php

@@ -338,9 +338,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $custom_params        = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']);
 
           // validate custom params
-          foreach (explode(' -', $custom_params) as $param){
+          foreach (explode('-', $custom_params) as $param){
             if(empty($param)) continue;
 
+            // extract option
+            if (str_contains($param, '=')) $param = explode('=', $param)[0];
+            else $param = rtrim($param, ' ');
+            // remove first char if first char is -
+            if ($param[0] == '-') $param = ltrim($param, $param[0]);
+
             if (str_contains($param, ' ')) {
               // bad char
               $_SESSION['return'][] = array(
@@ -351,11 +357,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               return false;
             }
 
-            // extract option
-            if (str_contains($param, '=')) $param = explode('=', $param)[0];
-            // remove first char if first char is -
-            if ($param[0] == '-') $param = ltrim($param, $param[0]);
-            
             // check if param is whitelisted
             if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
               // bad option
@@ -1793,9 +1794,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             }
 
             // validate custom params
-            foreach (explode(' -', $custom_params) as $param){
+            foreach (explode('-', $custom_params) as $param){
               if(empty($param)) continue;
 
+              // extract option
+              if (str_contains($param, '=')) $param = explode('=', $param)[0];
+              else $param = rtrim($param, ' ');
+              // remove first char if first char is -
+              if ($param[0] == '-') $param = ltrim($param, $param[0]);
+
               if (str_contains($param, ' ')) {
                 // bad char
                 $_SESSION['return'][] = array(
@@ -1806,11 +1813,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
                 return false;
               }
   
-              // extract option
-              if (str_contains($param, '=')) $param = explode('=', $param)[0];
-              // remove first char if first char is -
-              if ($param[0] == '-') $param = ltrim($param, $param[0]);
-              
               // check if param is whitelisted
               if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
                 // bad option

+ 5 - 6
data/web/inc/init_db.inc.php

@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
     global $pdo;
 
-    $db_version = "18062022_1153";
+    $db_version = "25072022_2300";
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -738,8 +738,8 @@ function init_db_schema() {
           "username" => "VARCHAR(255) NOT NULL",
           "authmech" => "ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')",
           "secret" => "VARCHAR(255) DEFAULT NULL",
-          "keyHandle" => "VARCHAR(255) DEFAULT NULL",
-          "publicKey" => "VARCHAR(255) DEFAULT NULL",
+          "keyHandle" => "VARCHAR(1023) DEFAULT NULL",
+          "publicKey" => "VARCHAR(4096) DEFAULT NULL",
           "counter" => "INT NOT NULL DEFAULT '0'",
           "certificate" => "TEXT",
           "active" => "TINYINT(1) NOT NULL DEFAULT '0'"
@@ -1227,7 +1227,7 @@ function init_db_schema() {
       $pdo->query($create);
     }
     
-    // Mitigate imapsync pipemess issue
+    // Mitigate imapsync argument injection issue
     $pdo->query("UPDATE `imapsync` SET `custom_params` = '' 
       WHERE `custom_params` LIKE '%pipemess%' 
         OR custom_params LIKE '%skipmess%' 
@@ -1237,8 +1237,7 @@ function init_db_schema() {
         OR custom_params LIKE '%pipemess%' 
         OR custom_params LIKE '%regextrans2%' 
         OR custom_params LIKE '%maxlinelengthcmd%';");
-
-
+    
     // Migrate webauthn tfa
     $stmt = $pdo->query("ALTER TABLE `tfa` MODIFY COLUMN `authmech` ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')");
 

+ 2 - 1
data/web/inc/prerequisites.inc.php

@@ -66,8 +66,9 @@ $qrprovider = new RobThree\Auth\Providers\Qr\QRServerProvider();
 $tfa = new RobThree\Auth\TwoFactorAuth($OTP_LABEL, 6, 30, 'sha1', $qrprovider);
 
 // FIDO2
+$server_name = parse_url('https://' . $_SERVER['HTTP_HOST'], PHP_URL_HOST);
 $formats = $GLOBALS['FIDO2_FORMATS'];
-$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $_SERVER['HTTP_HOST'], $formats);
+$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $server_name, $formats);
 // only include root ca's when needed
 if (getenv('WEBAUTHN_ONLY_TRUSTED_VENDORS') == 'y') $WebAuthn->addRootCertificates($_SERVER['DOCUMENT_ROOT'] . '/inc/lib/WebAuthn/rootCertificates');
 

+ 16 - 15
data/web/inc/triggers.inc.php

@@ -1,24 +1,24 @@
 <?php
 if (isset($_POST["verify_tfa_login"])) {
-  if (verify_tfa_login($_SESSION['pending_mailcow_cc_username'], $_POST, $WebAuthn)) {
+  if (verify_tfa_login($_SESSION['pending_mailcow_cc_username'], $_POST)) {
     $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username'];
     $_SESSION['mailcow_cc_role'] = $_SESSION['pending_mailcow_cc_role'];
     unset($_SESSION['pending_mailcow_cc_username']);
     unset($_SESSION['pending_mailcow_cc_role']);
-    unset($_SESSION['pending_tfa_method']);
+    unset($_SESSION['pending_tfa_methods']);
 	
     header("Location: /user");
   } else {
     unset($_SESSION['pending_mailcow_cc_username']);
     unset($_SESSION['pending_mailcow_cc_role']);
-    unset($_SESSION['pending_tfa_method']);
+    unset($_SESSION['pending_tfa_methods']);
   }
 }
 
 if (isset($_GET["cancel_tfa_login"])) {
     unset($_SESSION['pending_mailcow_cc_username']);
     unset($_SESSION['pending_mailcow_cc_role']);
-    unset($_SESSION['pending_tfa_method']);
+    unset($_SESSION['pending_tfa_methods']);
 
     header("Location: /");
 }
@@ -34,6 +34,7 @@ if (isset($_POST["quick_delete"])) {
 if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
 	$login_user = strtolower(trim($_POST["login_user"]));
 	$as = check_login($login_user, $_POST["pass_user"]);
+  
 	if ($as == "admin") {
 		$_SESSION['mailcow_cc_username'] = $login_user;
 		$_SESSION['mailcow_cc_role'] = "admin";
@@ -47,22 +48,22 @@ if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
 	elseif ($as == "user") {
 		$_SESSION['mailcow_cc_username'] = $login_user;
 		$_SESSION['mailcow_cc_role'] = "user";
-    $http_parameters = explode('&', $_SESSION['index_query_string']);
-    unset($_SESSION['index_query_string']);
-    if (in_array('mobileconfig', $http_parameters)) {
-      if (in_array('only_email', $http_parameters)) {
-        header("Location: /mobileconfig.php?email_only");
-        die();
-      }
-      header("Location: /mobileconfig.php");
-      die();
-    }
+        $http_parameters = explode('&', $_SESSION['index_query_string']);
+        unset($_SESSION['index_query_string']);
+        if (in_array('mobileconfig', $http_parameters)) {
+            if (in_array('only_email', $http_parameters)) {
+                header("Location: /mobileconfig.php?email_only");
+                die();
+            }
+            header("Location: /mobileconfig.php");
+            die();
+        }
 		header("Location: /user");
 	}
 	elseif ($as != "pending") {
     unset($_SESSION['pending_mailcow_cc_username']);
     unset($_SESSION['pending_mailcow_cc_role']);
-    unset($_SESSION['pending_tfa_method']);
+    unset($_SESSION['pending_tfa_methods']);
 		unset($_SESSION['mailcow_cc_username']);
 		unset($_SESSION['mailcow_cc_role']);
 	}

+ 121 - 124
data/web/inc/vars.inc.php

@@ -101,6 +101,7 @@ $AVAILABLE_LANGUAGES = array(
   'ru-ru' => 'Pусский (Russian)',
   'sk-sk' => 'Slovenčina (Slovak)',
   'sv-se' => 'Svenska (Swedish)',
+  'tr-tr' => 'Türkçe (Turkish)',
   'uk-ua' => 'Українська (Ukrainian)',
   'zh-cn' => '简体中文 (Simplified Chinese)',
   'zh-tw' => '繁體中文 (Traditional Chinese)',
@@ -234,131 +235,127 @@ $RSPAMD_MAPS = array(
 
 $IMAPSYNC_OPTIONS = array(
   'whitelist' => array(
-      'log',
-      'showpasswords',   
-      'nossl1',            
-      'nossl2',            
-      'ssl2',              
-      'notls1',             
-      'notls2',            
-      'tls2',              
-      'debugssl', 
-      'sslargs1',
-      'sslargs2', 
-      'authmech1',
-      'authmech2',
-      'authuser1', 
-      'authuser2',  
-      'proxyauth1',        
-      'proxyauth2',        
-      'authmd51',          
-      'authmd52',         
-      'domain1',
-      'domain2',
-      'oauthaccesstoken1',
-      'oauthaccesstoken2',
-      'oauthdirect1',
-      'oauthdirect2',
-      'folder',
-      'folder', 
-      'folderrec',
-      'folderrec', 
-      'folderfirst',
-      'folderfirst', 
-      'folderlast',
-      'folderlast',
-      'nomixfolders',  
-      'skipemptyfolders',
-      'include',
-      'include',
-      'subfolder1',
-      'subscribed',
-      'subscribe',  
-      'prefix1',
-      'prefix2',
-      'sep1',
-      'sep2',
-      'nofoldersizesatend',
-      'justfoldersizes', 
-      'pidfile', 
-      'pidfilelocking',  
-      'nolog',        
-      'logfile', 
-      'logdir',
-      'debugcrossduplicates', 
-      'disarmreadreceipts', 
-      'truncmess', 
-      'synclabels',     
-      'resynclabels',     
-      'resyncflags',   
-      'noresyncflags',  
-      'filterbuggyflags',  
-      'expunge1',       
-      'noexpunge1',    
-      'delete1emptyfolders',
-      'delete2folders',   
-      'noexpunge2',   
-      'nouidexpunge2',   
-      'syncinternaldates',
-      'idatefromheader', 
-      'maxsize',
-      'minsize',
-      'minage',
-      'search', 
-      'search1',
-      'search2', 
-      'noabletosearch',  
-      'noabletosearch1',   
-      'noabletosearch2',  
-      'maxlinelength',
-      'useheader',
-      'useheader',   
-      'syncduplicates',
-      'usecache',      
-      'nousecache',   
-      'useuid',     
-      'syncacls',
-      'nosyncacls',   
-      'debug',           
-      'debugfolders', 
-      'debugcontent',    
-      'debugflags',     
-      'debugimap1',   
-      'debugimap2',    
-      'debugimap',       
-      'debugmemory',     
-      'errorsmax',
-      'tests',      
-      'testslive',    
-      'testslive6',     
-      'gmail1',    
-      'gmail2',    
-      'office1',      
-      'office2',   
-      'exchange1',   
-      'exchange2',   
-      'domino1',  
-      'domino2',   
-      'keepalive1',   
-      'keepalive2',     
-      'maxmessagespersecond',
-      'maxbytesafter',
-      'maxsleep',
-      'abort',       
-      'exitwhenover',
-      'noid',  
-      'justconnect',   
-      'justlogin',  
-      'justfolders'
+    'authmech1',
+    'authmech2',
+    'authuser1', 
+    'authuser2', 
+    'debugcontent', 
+    'disarmreadreceipts', 
+    'logdir',
+    'debugcrossduplicates', 
+    'maxsize',
+    'minsize',
+    'minage',
+    'search', 
+    'noabletosearch', 
+    'pidfile', 
+    'pidfilelocking', 
+    'search1',
+    'search2', 
+    'sslargs1',
+    'sslargs2', 
+    'syncduplicates',
+    'usecache', 
+    'synclabels', 
+    'truncmess',  
+    'domino2',  
+    'expunge1',  
+    'filterbuggyflags',  
+    'justconnect',  
+    'justfolders',  
+    'maxlinelength',
+    'useheader',  
+    'noabletosearch1',  
+    'nolog',  
+    'prefix1',
+    'prefix2',
+    'sep1',
+    'sep2',
+    'nofoldersizesatend',
+    'justfoldersizes',  
+    'proxyauth1',  
+    'skipemptyfolders',
+    'include',
+    'subfolder1',
+    'subscribed',
+    'subscribe',   
+    'debug',   
+    'debugimap2',   
+    'domino1',   
+    'exchange1',   
+    'exchange2',   
+    'justlogin',   
+    'keepalive1',   
+    'keepalive2',   
+    'noabletosearch2',   
+    'noexpunge2',   
+    'noresyncflags',   
+    'nossl1',   
+    'nouidexpunge2',   
+    'syncinternaldates',
+    'idatefromheader',   
+    'useuid',    
+    'debugflags',    
+    'debugimap',    
+    'delete1emptyfolders',
+    'delete2folders',    
+    'gmail2',    
+    'office1',    
+    'testslive6',     
+    'debugimap1',     
+    'errorsmax',
+    'tests',     
+    'gmail1',     
+    'maxmessagespersecond',
+    'maxbytesafter',
+    'maxsleep',
+    'abort',     
+    'resyncflags',     
+    'resynclabels',     
+    'syncacls',
+    'nosyncacls',      
+    'nousecache',      
+    'office2',      
+    'testslive',       
+    'debugmemory',       
+    'exitwhenover',
+    'noid',       
+    'noexpunge1',        
+    'authmd51',        
+    'logfile',        
+    'proxyauth2',         
+    'domain1',
+    'domain2',
+    'oauthaccesstoken1',
+    'oauthaccesstoken2',
+    'oauthdirect1',
+    'oauthdirect2',
+    'folder',
+    'folderrec',
+    'folderfirst',
+    'folderlast',
+    'nomixfolders',          
+    'authmd52',           
+    'debugfolders',            
+    'nossl2',            
+    'ssl2',            
+    'tls2',             
+    'notls2',              
+    'debugssl',              
+    'notls1', 
+    'inet4',
+    'inet6',
+    'log',
+    'showpasswords'
   ),
   'blacklist' => array(
-      'skipmess',
-      'delete2foldersonly',
-      'delete2foldersbutnot',
-      'regexflag',
-      'regexmess',
-      'pipemess',
-      'regextrans2',
-      'maxlinelengthcmd'
+    'skipmess',
+    'delete2foldersonly',
+    'delete2foldersbutnot',
+    'regexflag',
+    'regexmess',
+    'pipemess',
+    'regextrans2',
+    'maxlinelengthcmd'
   )
 );

+ 28 - 19
data/web/json_api.php

@@ -178,15 +178,22 @@ if (isset($_GET['query'])) {
               // parse post data
               $post = trim(file_get_contents('php://input'));
               if ($post) $post = json_decode($post);
-
-              // decode base64 strings
-              $clientDataJSON = base64_decode($post->clientDataJSON);
-              $attestationObject = base64_decode($post->attestationObject);             
               
               // process registration data from authenticator
               try {
+                // decode base64 strings
+                $clientDataJSON = base64_decode($post->clientDataJSON);
+                $attestationObject = base64_decode($post->attestationObject);   
+
                 // processCreate($clientDataJSON, $attestationObject, $challenge, $requireUserVerification=false, $requireUserPresent=true, $failIfRootMismatch=true)
                 $data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $_SESSION['challenge'], false, true);
+
+                // safe authenticator in mysql `tfa` table
+                $_data['tfa_method'] = $post->tfa_method;
+                $_data['key_id'] = $post->key_id;
+                $_data['confirm_password'] = $post->confirm_password;
+                $_data['registration'] = $data;
+                set_tfa($_data);
               }
               catch (Throwable $ex) {
                 // err
@@ -197,11 +204,6 @@ if (isset($_GET['query'])) {
                 exit;
               }
 
-              // safe authenticator in mysql `tfa` table
-              $_data['tfa_method'] = $post->tfa_method;
-              $_data['key_id'] = $post->key_id;
-              $_data['registration'] = $data;
-              set_tfa($_data);
 
               // send response
               $return = new stdClass();
@@ -419,7 +421,7 @@ if (isset($_GET['query'])) {
           // }
           $ids = NULL;
 
-          $getArgs = $WebAuthn->getGetArgs($ids, 30, true, true, true, true, $GLOBALS['FIDO2_UV_FLAG_LOGIN']);
+          $getArgs = $WebAuthn->getGetArgs($ids, 30, false, false, false, false, $GLOBALS['FIDO2_UV_FLAG_LOGIN']);
           print(json_encode($getArgs));
           $_SESSION['challenge'] = $WebAuthn->getChallenge();
           return;
@@ -428,8 +430,11 @@ if (isset($_GET['query'])) {
         case "webauthn-tfa-registration":
           if (isset($_SESSION["mailcow_cc_role"])) {
               // Exclude existing CredentialIds, if any
-              $stmt = $pdo->prepare("SELECT `keyHandle` FROM `tfa` WHERE username = :username");
-              $stmt->execute(array(':username' => $_SESSION['mailcow_cc_username']));
+              $stmt = $pdo->prepare("SELECT `keyHandle` FROM `tfa` WHERE username = :username AND authmech = :authmech");
+              $stmt->execute(array(
+                ':username' => $_SESSION['mailcow_cc_username'],
+                ':authmech' => 'webauthn'
+              ));
               $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
               while($row = array_shift($rows)) {
                 $excludeCredentialIds[] = base64_decode($row['keyHandle']);
@@ -450,20 +455,24 @@ if (isset($_GET['query'])) {
           }
         break;
         case "webauthn-tfa-get-args":
-          $stmt = $pdo->prepare("SELECT `keyHandle` FROM `tfa` WHERE username = :username");
-          $stmt->execute(array(':username' => $_SESSION['pending_mailcow_cc_username']));
+          $stmt = $pdo->prepare("SELECT `keyHandle` FROM `tfa` WHERE username = :username AND authmech = :authmech");
+          $stmt->execute(array(
+            ':username' => $_SESSION['pending_mailcow_cc_username'],
+            ':authmech' => 'webauthn'
+          ));
           $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
-          while($row = array_shift($rows)) {
-            $cids[] = base64_decode($row['keyHandle']);
-          }
-          if (count($cids) == 0) {
+          if (count($rows) == 0) {
             print(json_encode(array(
                 'type' => 'error',
                 'msg' => 'Cannot find matching credentialIds'
             )));
+            exit;
+          }
+          while($row = array_shift($rows)) {
+            $cids[] = base64_decode($row['keyHandle']);
           }
 
-          $getArgs = $WebAuthn->getGetArgs($cids, 30, true, true, true, true, $GLOBALS['WEBAUTHN_UV_FLAG_LOGIN']);
+          $getArgs = $WebAuthn->getGetArgs($cids, 30, false, false, false, false, $GLOBALS['WEBAUTHN_UV_FLAG_LOGIN']);
           $getArgs->publicKey->extensions = array('appid' => "https://".$getArgs->publicKey->rpId);
           print(json_encode($getArgs));
           $_SESSION['challenge'] = $WebAuthn->getChallenge();

+ 1 - 1
data/web/lang/lang.cs-cz.json

@@ -839,7 +839,7 @@
         "confirm_delete": "Potvrdit smazání prvku.",
         "danger": "Nebezpečí",
         "deliver_inbox": "Doručit do schránky",
-        "disabled_by_config": "Funkce karanténa je momentálně vypnuta v nastavení systému.",
+        "disabled_by_config": "Funkce karanténa je momentálně vypnuta v nastavení systému. Nastavte, prosím, prvkům karantény hodnoty \"počet zadržených zpráv\" a \"maximální velikost\".",
         "download_eml": "Stáhnout (.eml)",
         "empty": "Žádné výsledky",
         "high_danger": "Vysoké nebezpečí",

+ 5 - 2
data/web/lang/lang.fr-fr.json

@@ -102,7 +102,8 @@
         "timeout2": "Délai d'expiration pour la connexion à l'hôte local",
         "username": "Nom d'utilisateur",
         "validate": "Valider",
-        "validation_success": "Validation réussie"
+        "validation_success": "Validation réussie",
+        "bcc_dest_format": "La destination Cci doit être une seule adresse e-mail valide.<br>Si vous avez besoin d'envoyer une copie à plusieurs adresses, créez un alias et utilisez-le ici."
     },
     "admin": {
         "access": "Accès",
@@ -322,7 +323,9 @@
         "yes": "&#10003;",
         "api_read_write": "Accès Lecture-Écriture",
         "oauth2_add_client": "Ajouter un client OAuth2",
-        "password_policy": "Politique de mots de passe"
+        "password_policy": "Politique de mots de passe",
+        "admins": "Administrateurs",
+        "api_read_only": "Accès lecture-seule"
     },
     "danger": {
         "access_denied": "Accès refusé ou données de formulaire non valides",

+ 2 - 1
data/web/lang/lang.it-it.json

@@ -973,7 +973,8 @@
         "verified_fido2_login": "Verified FIDO2 login",
         "verified_totp_login": "Verified TOTP login",
         "verified_webauthn_login": "Verified WebAuthn login",
-        "verified_yotp_login": "Verified Yubico OTP login"
+        "verified_yotp_login": "Verified Yubico OTP login",
+        "domain_add_dkim_available": "Esisteva già una chiave DKIM"
     },
     "tfa": {
         "api_register": "%s usa le API Yubico Cloud. Richiedi una chiave API <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">qui</a>",

+ 3 - 2
data/web/lang/lang.ro-ro.json

@@ -979,7 +979,8 @@
         "verified_totp_login": "Autentificarea TOTP verificată",
         "verified_webauthn_login": "Autentificarea WebAuthn verificată",
         "verified_fido2_login": "Conectare FIDO2 verificată",
-        "verified_yotp_login": "Autentificarea Yubico OTP verificată"
+        "verified_yotp_login": "Autentificarea Yubico OTP verificată",
+        "domain_add_dkim_available": "O cheie DKIM deja a existat"
     },
     "tfa": {
         "api_register": "%s utilizează API-ul Yubico Cloud. Obțineți o cheie API pentru cheia dvs. de <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aici</a>",
@@ -990,7 +991,7 @@
         "enter_qr_code": "Codul tău TOTP dacă dispozitivul tău nu poate scana codurile QR",
         "error_code": "Cod de eroare",
         "init_webauthn": "Inițializare, vă rugăm așteptați...",
-        "key_id": "Un identificator pentru YubiKey",
+        "key_id": "Un identificator pentru dispozitiv",
         "key_id_totp": "Un identificator pentru cheia ta",
         "none": "Dezactivează",
         "reload_retry": "- (reîncărcați browserul dacă eroarea persistă)",

+ 3 - 2
data/web/lang/lang.ru-ru.json

@@ -988,7 +988,7 @@
         "enter_qr_code": "Ваш код TOTP, если устройство не может отсканировать QR-код",
         "error_code": "Код ошибки",
         "init_webauthn": "Инициализация, пожалуйста, подождите...",
-        "key_id": "Идентификатор YubiKey ключа",
+        "key_id": "Идентификатор вашего устройства",
         "key_id_totp": "Идентификатор TOTP ключа",
         "none": "Отключить",
         "reload_retry": "- (перезагрузить страницу браузера или почистите кеш/cookies, если ошибка повторяется)",
@@ -1002,7 +1002,8 @@
         "webauthn": "WebAuthn аутентификация",
         "waiting_usb_auth": "<i>Ожидание устройства USB...</i><br><br>Пожалуйста, нажмите кнопку на USB устройстве сейчас.",
         "waiting_usb_register": "<i>Ожидание устройства USB...</i><br><br>Пожалуйста, введите пароль выше и подтвердите регистрацию, нажав кнопку на USB устройстве.",
-        "yubi_otp": "Yubico OTP аутентификация"
+        "yubi_otp": "Yubico OTP аутентификация",
+        "u2f_deprecated": "Похоже, что ваш ключ был зарегистрирован с использованием устаревшего метода U2F. Мы деактивируем для вас двухфакторную аутентификацию и удалим ваш ключ."
     },
     "user": {
         "action": "Действия",

+ 86 - 0
data/web/lang/lang.tr.json

@@ -0,0 +1,86 @@
+{
+    "acl": {
+        "alias_domains": "Takma alan adı ekle",
+        "app_passwds": "Uygulama şifrelerini yönet",
+        "delimiter_action": "Sınırlama işlemi",
+        "domain_relayhost": "Bir alan adı için relayhost sunucusunu değiştir",
+        "eas_reset": "EAS cihazlarını sıfırla",
+        "mailbox_relayhost": "Bir posta kutusunun relayhost sunucularını değiştir",
+        "pushover": "Bildirim",
+        "quarantine": "Karantina işlemleri",
+        "quarantine_attachments": "Ekleri karantinaya al",
+        "quarantine_notification": "Karantina bildirimlerini değiştir",
+        "smtp_ip_access": "SMTP sunucularının değiştirilmesine izin ver",
+        "sogo_access": "SOGo erişiminin yönetilmesine izin ver",
+        "domain_desc": "Alan adı açıklamasını değiştir",
+        "extend_sender_acl": "Gönderenin acl'sini harici adreslere göre genişletmeye izin ver",
+        "spam_policy": "Engellenenler / İzin verilenler",
+        "filters": "Fitreler"
+    },
+    "add": {
+        "activate_filter_warn": "Aktif edilirse diğer tüm filtreler devre dışı bırakılacak.",
+        "add_domain_only": "Sadece alan adı ekle",
+        "alias_address": "Takma ad adres(leri)",
+        "alias_domain": "Takma alan adı",
+        "alias_domain_info": "<small>Sadece geçerli alan adları (virgülle ayırın).</small>",
+        "backup_mx_options": "İletme ayarları",
+        "delete2": "Kaynakta olmayan hedefteki mesajları sil",
+        "delete2duplicates": "Hedefteki kopyaları sil",
+        "disable_login": "Giriş yapmaya izin verme ( Gelen mailler yine de kabul edilir)",
+        "domain": "Alan adı",
+        "domain_matches_hostname": "Alan adı %s ana bilgisayar adıyla eşleşiyor",
+        "add_domain_restart": "Alan adı ekleyin ve SOGo'yu yeniden başlatın",
+        "alias_address_info": "<small>Bir alan adına ilişkin tüm iletileri yakalamak için tam e-posta adresi veya @example.com olacak şeklinde girin (virgülle ayırın).<b>sadece mailcow alan adları</b>.</small>",
+        "domain_quota_m": "Toplam alan adı kotası (MiB)",
+        "generate": "oluştur",
+        "goto_ham": "Ham olarak<span class=\"text-success\"><b>işaretle</b></span>",
+        "goto_null": "Postaları sessizce çöpe at",
+        "goto_spam": "Spam olarak<span class=\"text-danger\"><b>işaretle</b></span>",
+        "hostname": "Ana sunucu",
+        "kind": "Tür",
+        "mailbox_quota_m": "Posta kutusu başına maksimum kota (MiB)",
+        "max_aliases": "Maksimum olası takma adı",
+        "max_mailboxes": "Maksimum olası posta kutusu",
+        "nexthop": "Sonraki atlama",
+        "port": "Port",
+        "public_comment": "Genel yorum",
+        "relay_all": "Tüm alıcılara ilet",
+        "relay_all_info": "Eğer <b>hiçbir</b> alıcıya iletilmemesini seçerseniz, aktarılması gereken her alıcı için bir (\"kör\") posta kutusu eklemeniz gerekecektir.",
+        "relay_domain": "Bu alan adını ilet",
+        "relay_transport_info": "<div class=\"label label-info\">Bilgi</div> Bu etki alanı için özel bir hedef için aktarım eşlemeleri tanımlayabilirsiniz. Ayarlanmazsa, bir MX araması yapılacaktır.",
+        "relay_unknown_only": "Yalnızca mevcut olmayan posta kutularını ilet. Mevcut posta kutuları yerel olarak teslim edilecektir.",
+        "relayhost_wrapped_tls_info": "Lütfen TLS ile örtülmüş portları <b> kullanmayın</b> (çoğu 465 portunda çalışır).<br>\nÖrtülmemiş port kullan ve STARTTLS üzerinden yayınla. TLS'yi zorlamak için bir TLS ilkesi \"TLS ilke eşlemeleri\" sayfası içinde oluşturulabilir.",
+        "skipcrossduplicates": "Klasörler arasında yinelenen mesajları atlayın (ilk mesaj seçilir)",
+        "target_address": "Adreslere git",
+        "target_address_info": "<small>Tam e-posta adres(leri) girin ( virgülle ayırın).</small>",
+        "target_domain": "Hedef alan adı",
+        "timeout1": "Uzak ana bilgisayara bağlantısı zaman aşımına uğradı",
+        "timeout2": "Yerel ana bilgisayara bağlantı zaman aşımına uğradı"
+    },
+    "admin": {
+        "action": "İşlem",
+        "add_forwarding_host": "Yönlendirme sunucusu ekle",
+        "add_transport": "İletim ekle",
+        "admin_details": "Yönetici detaylarını düzenle",
+        "admin_domains": "Alan adı atamaları",
+        "add_domain_admin": "Alan adı yöneticisi ekle",
+        "api_info": "API üzerinde çalışmalar devam etmektedir. Belgeler <a href=\"/api\">/api</a>adresinde bulunabilir",
+        "apps_name": "\"mailcow Uygulamaları\" adı",
+        "authed_user": "Yetkili kullanıcı",
+        "ban_list_info": "Aşağıdaki yasaklı IP'lerin listesine bakın: <b>ağ (kalan yasak süresi) - [işlemler]</b>.<br />Yasağı kaldırılmak üzere sıraya alınan IP'ler birkaç saniye içinde aktif yasak listesinden kaldırılacaktır.<br />Kırmızı etiketler, kara listeye alınarak aktif kalıcı yasakları gösterir.",
+        "configuration": "Yapılandırma",
+        "dkim_from_title": "Verilerin kopyalanacağı kaynak alan adı",
+        "dkim_to": "Kime",
+        "dkim_to_title": "Hedef alan ad(ları) üzerinde yazılacak",
+        "dkim_domains_wo_keys": "Eksik anahtarları olan alan adlarını seçin",
+        "domain": "Alan adı",
+        "domain_admin": "Alan adı yöneticisi",
+        "domain_admins": "Alan adı yöneticileri",
+        "domain_s": "Alan ad(ları)",
+        "duplicate": "Çift",
+        "duplicate_dkim": "Çift DKIM kayıtları",
+        "f2b_ban_time": "Yasaklama süresi (saniye)",
+        "f2b_max_attempts": "Maksimum giriş denemesi",
+        "f2b_retry_window": "Maksimum girişim için deneme pencere(leri)"
+    }
+}

+ 2 - 1
data/web/lang/lang.uk-ua.json

@@ -980,7 +980,8 @@
         "resource_modified": "Зміни поштового акаунту %s збережено",
         "settings_map_added": "Правило додано",
         "tls_policy_map_entry_deleted": "Політику TLS ID %s видалено",
-        "verified_totp_login": "Авторизацію TOTP пройдено"
+        "verified_totp_login": "Авторизацію TOTP пройдено",
+        "domain_add_dkim_available": "Ключ DKIM вже існує"
     },
     "tfa": {
         "confirm": "Підтвердьте",

+ 101 - 27
data/web/templates/base.twig

@@ -176,15 +176,75 @@ function recursiveBase64StrToArrayBuffer(obj) {
     {% endfor %}
 
     // Confirm TFA modal
-  {% if pending_tfa_method %}
+  {% if pending_tfa_methods %}
     $('#ConfirmTFAModal').modal({
       backdrop: 'static',
       keyboard: false
     });
 
+
+    // validate Time based OTP tfa
+    $("#pending_tfa_tab_totp").click(function(){
+      $(".webauthn-authenticator-selection").removeClass("active");
+      $("#collapseWebAuthnTFA").collapse('hide');
+
+      // select default if only one authenticator exists
+      if ($('.totp-authenticator-selection').length == 1){
+        $('.totp-authenticator-selection').addClass("active");
+        var id = $('.totp-authenticator-selection').children('input').first().val();
+        $("#totp_selected_id").val(id);
+        $("#collapseTotpTFA").collapse('show');
+      }
+    });
+    $(".totp-authenticator-selection").click(function(){
+      $(".totp-authenticator-selection").removeClass("active");
+      $(this).addClass("active");
+      
+      var id = $(this).children('input').first().val();
+      $("#totp_selected_id").val(id);
+
+      $("#collapseTotpTFA").collapse('show');
+    });
+    if ($('.totp-authenticator-selection').length == 1 &&
+        $('#pending_tfa_tab_yubi_otp').length == 0 &&
+        $('.webauthn-authenticator-selection').length == 0){
+      
+      // select default if only one authenticator exists
+      $('.totp-authenticator-selection').addClass("active");
+
+      var id = $('.totp-authenticator-selection').children('input').first().val();
+      $("#totp_selected_id").val(id);
+
+      $("#collapseTotpTFA").collapse('show');
+      setTimeout(function() { $("#collapseTotpTFA").find('input[name="token"]').focus(); }, 1000);
+    }
+    $('#pending_tfa_tab_totp').on('shown.bs.tab', function() {
+      // autofocus
+      setTimeout(function() { $("#collapseTotpTFA").find('input[name="token"]').focus(); }, 200);
+    });    
+    // validate Yubi OTP tfa
+    if ($('.webauthn-authenticator-selection').length == 0){
+      // autofocus
+      setTimeout(function() { $("#collapseYubiTFA").find('input[name="token"]').focus(); }, 1000);
+    }
+    $('#pending_tfa_tab_yubi_otp').on('shown.bs.tab', function() {
+      // autofocus
+      $("#collapseYubiTFA").find('input[name="token"]').focus();
+    });
     // validate WebAuthn tfa
-    $('#start_webauthn_confirmation').click(function(){
-      $('#webauthn_status_auth').html('<p><i class="bi bi-arrow-repeat icon-spin"></i> ' + lang_tfa.init_webauthn + '</p>');
+    $("#pending_tfa_tab_webauthn").click(function(){
+      $(".totp-authenticator-selection").removeClass("active");
+
+      $("#collapseTotpTFA").collapse('hide');
+    });
+    $(".webauthn-authenticator-selection").click(function(){
+      $(".webauthn-authenticator-selection").removeClass("active");
+      $(this).addClass("active");
+      
+      var id = $(this).children('input').first().val();
+      $("#webauthn_selected_id").val(id);
+      
+      $("#collapseWebAuthnTFA").collapse('show');
 
       $(this).find('input[name=token]').focus();
       if(document.getElementById("webauthn_auth_data") !== null) {
@@ -198,30 +258,32 @@ function recursiveBase64StrToArrayBuffer(obj) {
         window.fetch("/api/v1/get/webauthn-tfa-get-args", {method:'GET',cache:'no-cache'}).then(response => {
             return response.json();
         }).then(json => {
-            if (json.success === false) throw new Error();
+          console.log(json);
+          if (json.success === false) throw new Error();
+          if (json.type === "error") throw new Error(json.msg);
       
-            recursiveBase64StrToArrayBuffer(json);
-            return json;
+          recursiveBase64StrToArrayBuffer(json);
+          return json;
         }).then(getCredentialArgs => {
-            // get credentials
-            return navigator.credentials.get(getCredentialArgs);
+          // get credentials
+          return navigator.credentials.get(getCredentialArgs);
         }).then(cred => {
-            return {
-                id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
-                clientDataJSON: cred.response.clientDataJSON  ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
-                authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
-                signature : cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null
-            };
+          return {
+            id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null,
+            clientDataJSON: cred.response.clientDataJSON  ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
+            authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null,
+            signature : cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null
+          };
         }).then(JSON.stringify).then(function(AuthenticatorAttestationResponse) {
-            // send request by submit
-            var form = document.getElementById('webauthn_auth_form');
-            var auth = document.getElementById('webauthn_auth_data');
-            auth.value = AuthenticatorAttestationResponse;
-            form.submit();
+          // send request by submit
+          var form = document.getElementById('webauthn_auth_form');
+          var auth = document.getElementById('webauthn_auth_data');
+          auth.value = AuthenticatorAttestationResponse;
+          form.submit();
         }).catch(function(err) {
-            var webauthn_return_code = document.getElementById('webauthn_return_code');
-            webauthn_return_code.style.display = webauthn_return_code.style.display === 'none' ? '' : null;
-            webauthn_return_code.innerHTML = lang_tfa.error_code + ': ' + err + ' ' + lang_tfa.reload_retry;
+          var webauthn_return_code = document.getElementById('webauthn_return_code');
+          webauthn_return_code.style.display = webauthn_return_code.style.display === 'none' ? '' : null;
+          webauthn_return_code.innerHTML = lang_tfa.error_code + ': ' + err + ' ' + lang_tfa.reload_retry;
         });
       } 
     });
@@ -237,7 +299,9 @@ function recursiveBase64StrToArrayBuffer(obj) {
         }
       });
     });
-    {% endif %}
+  {% endif %}
+
+
     // Validate FIDO2
   $("#fido2-login").click(function(){
     $('#fido2-alerts').html();
@@ -358,11 +422,13 @@ function recursiveBase64StrToArrayBuffer(obj) {
 
         $("#start_webauthn_register").click(() => {
             var key_id = document.getElementsByName('key_id')[1].value;
+            var confirm_password = document.getElementsByName('confirm_password')[1].value;
 
             // fetch WebAuthn create args
             window.fetch("/api/v1/get/webauthn-tfa-registration/{{ mailcow_cc_username|url_encode(true)|default('null') }}", {method:'GET',cache:'no-cache'}).then(response => {
                 return response.json();
             }).then(json => {
+                console.log(json);
                 if (json.success === false) throw new Error(json.msg);
                 recursiveBase64StrToArrayBuffer(json);
 
@@ -375,7 +441,8 @@ function recursiveBase64StrToArrayBuffer(obj) {
                     clientDataJSON: cred.response.clientDataJSON  ? arrayBufferToBase64(cred.response.clientDataJSON) : null,
                     attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null,
                     key_id: key_id,
-                    tfa_method: "webauthn"
+                    tfa_method: "webauthn",
+                    confirm_password: confirm_password
                 };
             }).then(JSON.stringify).then(AuthenticatorAttestationResponse => {
                 // send request
@@ -423,13 +490,20 @@ function recursiveBase64StrToArrayBuffer(obj) {
   {% if ui_texts.ui_footer %}
   <hr><span class="rot-enc">{{ ui_texts.ui_footer|rot13|raw }}</span>
   {% endif %}
-  {% if mailcow_cc_username and mailcow_info.version_tag|default %}
+  {% if mailcow_cc_username and mailcow_info.mailcow_branch|lower == "master" and mailcow_info.version_tag|default %}
   <span class="version">
     🐮 + 🐋 = 💕
-    <a href="{{ mailcow_info.git_project_url }}/releases/tag/{{ mailcow_info.version_tag }}" target="_blank">
-        Version: {{ mailcow_info.version_tag }}
+        Version: <a href="{{ mailcow_info.git_project_url }}/releases/tag/{{ mailcow_info.version_tag }}" target="_blank">{{ mailcow_info.version_tag }}
     </a>
   </span>
+  {% endif %}  
+  {% if mailcow_cc_username and mailcow_info.mailcow_branch|lower == "nightly" and mailcow_info.version_tag|default %}
+  <span class="version">
+    🛠️🐮 + 🐋 = 💕
+        Nightly: <a href="{{ mailcow_info.git_project_url }}/commit/{{ mailcow_info.git_commit }}" target="_blank">{{ mailcow_info.version_tag }}
+    </a><br>
+    <span style="text-align:right;display:block;">Build: {{ mailcow_info.git_commit_date }}</span>
+  </span>
   {% endif %}
 </div>
 </body>

+ 1 - 1
data/web/templates/domainadmin.twig

@@ -28,7 +28,7 @@
       <div class="col-sm-9 col-xs-7">
         <select id="selectTFA" class="selectpicker" title="{{ lang.tfa.select }}">
           <option value="yubi_otp">{{ lang.tfa.yubi_otp }}</option>
-          <option value="u2f">{{ lang.tfa.u2f }}</option>
+          <option value="webauthn">{{ lang.tfa.webauthn }}</option>
           <option value="totp">{{ lang.tfa.totp }}</option>
           <option value="none">{{ lang.tfa.none }}</option>
         </select>

+ 148 - 58
data/web/templates/modals/footer.twig

@@ -133,73 +133,163 @@
   </div>
 </div>
 {% endif %}
-{% if pending_tfa_method %}
+{% if pending_tfa_methods %}
 <div class="modal fade" id="ConfirmTFAModal" tabindex="-1" role="dialog" aria-labelledby="ConfirmTFAModalLabel">
   <div class="modal-dialog" role="document">
     <div class="modal-content">
       <div class="modal-header">
         <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span></button>
-        <h3 class="modal-title">{{ lang.tfa[pending_tfa_method] }}</h3>
+        <h3 class="modal-title">{{ lang.tfa.tfa }}</h3>
       </div>
-      <div class="modal-body">
-        {% if pending_tfa_method == 'yubi_otp' %}
-        <form role="form" method="post">
-          <div class="form-group">
-            <div class="input-group">
-              <span class="input-group-addon" id="yubi-addon"><img alt="Yubicon Icon" src="/img/yubi.ico"></span>
-              <input type="text" name="token" class="form-control" autocomplete="off" placeholder="Touch Yubikey" aria-describedby="yubi-addon">
-              <input type="hidden" name="tfa_method" value="yubi_otp">
+      
+        <ul class="nav nav-tabs" id="tabContent">
+            {% if pending_tfa_authmechs["webauthn"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
+              <li class="active"><a href="#tfa_tab_webauthn" data-toggle="tab" id="pending_tfa_tab_webauthn"><i class="bi bi-fingerprint"></i> WebAuthn</a></li>
+            {% endif %}
+
+            {% if pending_tfa_authmechs["yubi_otp"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
+              <li class="tab-pane {% if pending_tfa_authmechs["yubi_otp"] %}active{% endif %}">
+                <a href="#tfa_tab_yubi_otp" data-toggle="tab" id="pending_tfa_tab_yubi_otp"><i class="bi bi-usb-drive"></i> Yubi OTP</a>
+              </li>
+            {% endif %}
+
+            {% if pending_tfa_authmechs["totp"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
+              <li class="tab-pane {% if pending_tfa_authmechs["totp"] %}active{% endif %}">
+                <a href="#tfa_tab_totp" data-toggle="tab" id="pending_tfa_tab_totp"><i class="bi bi-clock-history"></i> Time based OTP</a>
+              </li>
+            {% endif %}
+
+            <!-- <li><a href="#tfa_tab_hotp" data-toggle="tab">HOTP</a></li> -->
+            {% if pending_tfa_authmechs["u2f"] is defined %}
+              <li class="active"><a href="#tfa_tab_u2f" data-toggle="tab"><i class="bi bi-x-octagon"></i> U2F</a></li>
+            {% endif %}
+        </ul>
+
+        <div class="tab-content">
+          {% if pending_tfa_authmechs["webauthn"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
+            <div role="tabpanel" class="tab-pane active" id="tfa_tab_webauthn">
+              <div class="panel panel-default" style="margin-bottom: 0px;">
+                  <div class="panel-body">
+                    <form role="form" method="post" id="webauthn_auth_form">
+                      <legend>
+                          <i class="bi bi-shield-fill-check"></i>
+                          Authenticators
+                      </legend>
+                      <div class="list-group">
+                        {% for authenticator in pending_tfa_methods %}
+                          {% if authenticator["authmech"] == "webauthn" %}
+                            <a href="#" class="list-group-item webauthn-authenticator-selection">
+                              <i class="bi bi-key-fill" style="margin-right: 5px"></i>
+                              <span>{{ authenticator["key_id"] }}</span>
+                              <input type="hidden" value="{{ authenticator["id"] }}" /><br/>
+                            </a>
+                          {% endif %}
+                        {% endfor %}
+                      </div>
+                      <div class="collapse pending-tfa-collapse" id="collapseWebAuthnTFA">
+                        <p id="webauthn_status_auth"><p><i class="bi bi-arrow-repeat icon-spin"></i> {{ lang.tfa.init_webauthn }}</p></p>
+                        <div class="alert alert-danger" style="display:none" id="webauthn_return_code"></div>
+                      </div>
+                      <input type="hidden" name="token" id="webauthn_auth_data"/>
+                      <input type="hidden" name="tfa_method" value="webauthn">
+                      <input type="hidden" name="verify_tfa_login"/><br/>
+                      <input type="hidden" name="id" id="webauthn_selected_id" /><br/>
+                    </form>
+                  </div>
+              </div>
             </div>
-          </div>
-          <button class="btn btn-sm visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-sm btn-default" type="submit" name="verify_tfa_login">{{ lang.login.login }}</button>
-        </form>
-        {% endif %}
-        {% if pending_tfa_method == 'totp' %}
-        <form role="form" method="post">
-          <div class="form-group">
-            <div class="input-group">
-              <span class="input-group-addon" id="tfa-addon"><i class="bi bi-shield-lock-fill"></i></span>
-              <input type="number" min="000000" max="999999" name="token" class="form-control" placeholder="123456" autocomplete="one-time-code" aria-describedby="tfa-addon">
-              <input type="hidden" name="tfa_method" value="totp">
+          {% endif %}
+          {% if pending_tfa_authmechs["yubi_otp"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
+            <div role="tabpanel" class="tab-pane {% if pending_tfa_authmechs["yubi_otp"] %}active{% endif %}" id="tfa_tab_yubi_otp">
+              <div class="panel panel-default" style="margin-bottom: 0px;">
+                  <div class="panel-body">
+                    <form role="form" method="post">
+                      <legend>
+                          <i class="bi bi-shield-fill-check"></i>
+                          Authenticate
+                      </legend>
+                      <div class="collapse in pending-tfa-collapse" id="collapseYubiTFA">
+                        <div class="form-group">
+                          <div class="input-group">
+                            <span class="input-group-addon" id="yubi-addon"><img alt="Yubicon Icon" src="/img/yubi.ico"></span>
+                            <input type="text" name="token" class="form-control" autocomplete="off" placeholder="Touch Yubikey" aria-describedby="yubi-addon">
+                            <input type="hidden" name="tfa_method" value="yubi_otp">
+                            <input type="hidden" name="id" id="yubi_selected_id" />
+                          </div>
+                        </div>
+                        <button class="btn btn-sm visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-sm btn-default" type="submit" name="verify_tfa_login">{{ lang.login.login }}</button>
+                      </div>
+                    </form>
+                  </div>
+              </div>
             </div>
-          </div>
-          <button class="btn btn-sm visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-default" type="submit" name="verify_tfa_login">{{ lang.login.login }}</button>
-        </form>
-        {% endif %}
-        {% if pending_tfa_method == 'hotp' %}
-        <div class="empty"></div>
-        {% endif %}
-
-        {% if pending_tfa_method == 'webauthn' %}
-        <form role="form" method="post" id="webauthn_auth_form">
-          <center>
-            <div style="cursor:pointer" id="start_webauthn_confirmation">
-              <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
-                <path d="M17.81 4.47c-.08 0-.16-.02-.23-.06C15.66 3.42 14 3 12.01 3c-1.98 0-3.86.47-5.57 1.41-.24.13-.54.04-.68-.2-.13-.24-.04-.55.2-.68C7.82 2.52 9.86 2 12.01 2c2.13 0 3.99.47 6.03 1.52.25.13.34.43.21.67-.09.18-.26.28-.44.28zM3.5 9.72c-.1 0-.2-.03-.29-.09-.23-.16-.28-.47-.12-.7.99-1.4 2.25-2.5 3.75-3.27C9.98 4.04 14 4.03 17.15 5.65c1.5.77 2.76 1.86 3.75 3.25.16.22.11.54-.12.7-.23.16-.54.11-.7-.12-.9-1.26-2.04-2.25-3.39-2.94-2.87-1.47-6.54-1.47-9.4.01-1.36.7-2.5 1.7-3.4 2.96-.08.14-.23.21-.39.21zm6.25 12.07c-.13 0-.26-.05-.35-.15-.87-.87-1.34-1.43-2.01-2.64-.69-1.23-1.05-2.73-1.05-4.34 0-2.97 2.54-5.39 5.66-5.39s5.66 2.42 5.66 5.39c0 .28-.22.5-.5.5s-.5-.22-.5-.5c0-2.42-2.09-4.39-4.66-4.39-2.57 0-4.66 1.97-4.66 4.39 0 1.44.32 2.77.93 3.85.64 1.15 1.08 1.64 1.85 2.42.19.2.19.51 0 .71-.11.1-.24.15-.37.15zm7.17-1.85c-1.19 0-2.24-.3-3.1-.89-1.49-1.01-2.38-2.65-2.38-4.39 0-.28.22-.5.5-.5s.5.22.5.5c0 1.41.72 2.74 1.94 3.56.71.48 1.54.71 2.54.71.24 0 .64-.03 1.04-.1.27-.05.53.13.58.41.05.27-.13.53-.41.58-.57.11-1.07.12-1.21.12zM14.91 22c-.04 0-.09-.01-.13-.02-1.59-.44-2.63-1.03-3.72-2.1-1.4-1.39-2.17-3.24-2.17-5.22 0-1.62 1.38-2.94 3.08-2.94 1.7 0 3.08 1.32 3.08 2.94 0 1.07.93 1.94 2.08 1.94s2.08-.87 2.08-1.94c0-3.77-3.25-6.83-7.25-6.83-2.84 0-5.44 1.58-6.61 4.03-.39.81-.59 1.76-.59 2.8 0 .78.07 2.01.67 3.61.1.26-.03.55-.29.64-.26.1-.55-.04-.64-.29-.49-1.31-.73-2.61-.73-3.96 0-1.2.23-2.29.68-3.24 1.33-2.79 4.28-4.6 7.51-4.6 4.55 0 8.25 3.51 8.25 7.83 0 1.62-1.38 2.94-3.08 2.94s-3.08-1.32-3.08-2.94c0-1.07-.93-1.94-2.08-1.94s-2.08.87-2.08 1.94c0 1.71.66 3.31 1.87 4.51.95.94 1.86 1.46 3.27 1.85.27.07.42.35.35.61-.05.23-.26.38-.47.38z"></path>
-              </svg>
-              <p>{{ lang.tfa.start_webauthn_validation }}</p>
-              <hr>
+          {% endif %}
+          {% if pending_tfa_authmechs["totp"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
+            <div role="tabpanel" class="tab-pane {% if pending_tfa_authmechs["totp"] %}active{% endif %}" id="tfa_tab_totp">
+              <div class="panel panel-default" style="margin-bottom: 0px;">
+                  <div class="panel-body">
+                    <form role="form" method="post">        
+                      <legend>
+                          <i class="bi bi-shield-fill-check"></i>
+                          Authenticators
+                      </legend>
+                      <div class="list-group">
+                        {% for authenticator in pending_tfa_methods %}
+                          {% if authenticator["authmech"] == "totp" %}
+                            <a href="#" class="list-group-item totp-authenticator-selection">
+                              <i class="bi bi-key-fill" style="margin-right: 5px"></i>
+                              <span>{{ authenticator["key_id"] }}</span>
+                              <input type="hidden" value="{{ authenticator["id"] }}" />
+                            </a>
+                          {% endif %}
+                        {% endfor %}
+                      </div>
+                      <div class="collapse pending-tfa-collapse" id="collapseTotpTFA">
+                        <div class="form-group">
+                          <div class="input-group">
+                            <span class="input-group-addon" id="tfa-addon"><i class="bi bi-shield-lock-fill"></i></span>
+                            <input type="number" min="000000" max="999999" name="token" class="form-control" placeholder="123456" autocomplete="one-time-code" aria-describedby="tfa-addon">
+                            <input type="hidden" name="tfa_method" value="totp">
+                            <input type="hidden" name="id" id="totp_selected_id" /><br/>
+                          </div>
+                        </div>
+                        <button class="btn btn-sm visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline btn-default" type="submit" name="verify_tfa_login">{{ lang.login.login }}</button>
+                      </div>
+                    </form>
+                  </div>
+              </div>
             </div>
-          </center>
-          <p id="webauthn_status_auth"></p>
-          <div class="alert alert-danger" style="display:none" id="webauthn_return_code"></div>
-          <input type="hidden" name="token" id="webauthn_auth_data"/>
-          <input type="hidden" name="tfa_method" value="webauthn">
-          <input type="hidden" name="verify_tfa_login"/><br/>
-        </form>
-        {% endif %}
-        {# leave this here to inform users that u2f is deprecated #}
-        {% if pending_tfa_method == 'u2f' %}
-        <form role="form" method="post" id="u2f_auth_form">
-          <p>{{ lang.tfa.u2f_deprecated }}</p>
-          <p><b>{{ lang.tfa.u2f_deprecated_important }}</b></p>
-          <input type="hidden" name="token" value="destroy" />
-          <input type="hidden" name="tfa_method" value="u2f">
-          <input type="hidden" name="verify_tfa_login"/><br/>
-          <button type="submit" class="btn btn-xs-lg btn-success" value="Login">{{ lang.login.login }}</button>
-        </form>
-        {% endif %}
-      </div>
+          {% endif %}
+            <!--
+            <div role="tabpanel" class="tab-pane" id="tfa_tab_hotp">
+              <div class="panel panel-default" style="margin-bottom: 0px;">
+                  <div class="panel-body">
+                      <div class="empty"></div>
+                  </div>
+              </div>
+            </div>
+            -->
+          {% if pending_tfa_authmechs["u2f"] is defined %}
+            <div role="tabpanel" class="tab-pane active" id="tfa_tab_u2f">
+              <div class="panel panel-default" style="margin-bottom: 0px;">
+                  <div class="panel-body">
+                    {# leave this here to inform users that u2f is deprecated #}
+                    <form role="form" method="post" id="u2f_auth_form">
+                      <div>
+                        <p>{{ lang.tfa.u2f_deprecated }}</p>
+                        <p><b>{{ lang.tfa.u2f_deprecated_important }}</b></p>
+                        <input type="hidden" name="token" value="destroy" />
+                        <input type="hidden" name="tfa_method" value="u2f">
+                        <input type="hidden" name="verify_tfa_login"/><br/>
+                        <button type="submit" class="btn btn-xs-lg btn-success" value="Login">{{ lang.login.login }}</button>
+                      </div>
+                    </form>
+                  </div>
+              </div>
+            </div>
+          {% endif %}
+        </div>
+
     </div>
   </div>
 </div>

+ 2 - 2
data/web/templates/modals/mailbox.twig

@@ -435,11 +435,11 @@
       </div>
       <div class="modal-body">
         <p class="help-block">{{ lang.add.syncjob_hint }}</p>
-        <form class="form-horizontal" data-cached-form="true" role="form" data-id="add_syncjob">
+        <form class="form-horizontal" data-cached-form="false" role="form" data-id="add_syncjob">
           <div class="form-group">
             <label class="control-label col-sm-2" for="username">{{ lang.add.username }}</label>
             <div class="col-sm-10">
-              <select data-live-search="true" name="username" required>
+              <select data-live-search="true" name="username" title="{{ lang.add.select }}" required>
                 {% for mailbox in mailboxes %}
                   <option>{{ mailbox }}</option>
                 {% endfor %}

+ 21 - 0
data/web/templates/user/tab-user-auth.twig

@@ -48,6 +48,27 @@
         </div>
       </div>
       <hr>
+      {# TFA #}
+      <div class="row">
+        <div class="col-sm-3 col-xs-5 text-right">{{ lang.tfa.tfa }}:</div>
+        <div class="col-sm-9 col-xs-7">
+          <p id="tfa_pretty">{{ tfa_data.pretty }}</p>
+          {% include 'tfa_keys.twig' %}
+          <br>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-sm-3 col-xs-5 text-right">{{ lang.tfa.set_tfa }}:</div>
+        <div class="col-sm-9 col-xs-7">
+          <select data-style="btn btn-sm dropdown-toggle bs-placeholder btn-default" data-width="fit" id="selectTFA" class="selectpicker" title="{{ lang.tfa.select }}">
+            <option value="yubi_otp">{{ lang.tfa.yubi_otp }}</option>
+            <option value="webauthn">{{ lang.tfa.webauthn }}</option>
+            <option value="totp">{{ lang.tfa.totp }}</option>
+            <option value="none">{{ lang.tfa.none }}</option>
+          </select>
+        </div>
+      </div>
+      <hr>
       {# FIDO2 #}
       <div class="row">
         <div class="col-sm-3 col-xs-12 text-right text-xs-left">

+ 2 - 2
data/web/user.php

@@ -76,6 +76,7 @@ elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == '
     'acl_json' => json_encode($_SESSION['acl']),
     'user_spam_score' => mailbox('get', 'spam_score', $username),
     'tfa_data' => $tfa_data,
+    'tfa_id' => @$_SESSION['tfa_id'],
     'fido2_data' => $fido2_data,
     'mailboxdata' => $mailboxdata,
     'clientconfigstr' => $clientconfigstr,
@@ -90,8 +91,7 @@ elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == '
     'number_of_app_passwords' => $number_of_app_passwords,
   ];
 }
-
-if (!isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+else {
   header('Location: /');
   exit();
 }

+ 4 - 4
docker-compose.yml

@@ -58,7 +58,7 @@ services:
             - redis
 
     clamd-mailcow:
-      image: mailcow/clamd:1.52
+      image: mailcow/clamd:1.54
       restart: always
       depends_on:
         - unbound-mailcow
@@ -168,7 +168,7 @@ services:
             - phpfpm
 
     sogo-mailcow:
-      image: mailcow/sogo:1.108
+      image: mailcow/sogo:1.111
       environment:
         - DBNAME=${DBNAME}
         - DBUSER=${DBUSER}
@@ -215,7 +215,7 @@ services:
             - sogo
 
     dovecot-mailcow:
-      image: mailcow/dovecot:1.162
+      image: mailcow/dovecot:1.20
       depends_on:
         - mysql-mailcow
       dns:
@@ -295,7 +295,7 @@ services:
             - dovecot
 
     postfix-mailcow:
-      image: mailcow/postfix:1.67
+      image: mailcow/postfix:1.68
       depends_on:
         - mysql-mailcow
       volumes:

+ 102 - 12
generate_config.sh

@@ -16,19 +16,49 @@ if [[ "$(uname -r)" =~ ^4\.4\. ]]; then
   fi
 fi
 
-if grep --help 2>&1 | grep -q -i "busybox"; then
-  echo "BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\""
-  exit 1
-fi
-if cp --help 2>&1 | grep -q -i "busybox"; then
-  echo "BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\""
-  exit 1
-fi
+if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo "BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\""; exit 1; fi
+# This will also cover sort
+if cp --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo "BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\""; exit 1; fi
+if sed --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo "BusyBox sed detected, please install gnu sed, \"apk add --no-cache --upgrade sed\""; exit 1; fi
 
-for bin in openssl curl docker docker-compose git awk sha1sum; do
+for bin in openssl curl docker git awk sha1sum; do
   if [[ -z $(which ${bin}) ]]; then echo "Cannot find ${bin}, exiting..."; exit 1; fi
 done
 
+if docker compose > /dev/null 2>&1; then
+    if docker compose version --short | grep "^2." > /dev/null 2>&1; then
+      COMPOSE_VERSION=native
+      echo -e "\e[31mFound Docker Compose Plugin (native).\e[0m"
+      echo -e "\e[31mSetting 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://mailcow.github.io/mailcow-dockerized-docs/i_u_m/i_u_m_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
+      echo -e "\e[31mFound Docker Compose Standalone.\e[0m"
+      echo -e "\e[31mSetting 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://mailcow.github.io/mailcow-dockerized-docs/i_u_m/i_u_m_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://mailcow.github.io/mailcow-dockerized-docs/i_u_m/i_u_m_install/\e[0m"
+  exit 1
+fi
+    
+
 if [ -f mailcow.conf ]; then
   read -r -p "A config file exists and will be overwritten, are you sure you want to continue? [y/N] " response
   case $response in
@@ -105,6 +135,32 @@ else
   SKIP_SOLR=n
 fi
 
+echo "Which branch of mailcow do you want to use?"
+echo ""
+echo "Available Branches:"
+echo "- master branch (stable updates) | default, recommended [1]"
+echo "- nightly branch (unstable updates, testing) | not-production ready [2]"
+sleep 1
+
+while [ -z "${MAILCOW_BRANCH}" ]; do
+  read -r -p  "Choose the Branch with it´s number [1/2] " branch
+  case $branch in
+    [2])
+      MAILCOW_BRANCH="nightly"
+      ;;
+    *)
+      MAILCOW_BRANCH="master"
+    ;;
+  esac
+done
+
+if [ ! -z "${MAILCOW_BRANCH}" ]; then
+  git_branch=${MAILCOW_BRANCH}
+fi
+
+git fetch --all
+git checkout -f $git_branch
+
 [ ! -f ./data/conf/rspamd/override.d/worker-controller-password.inc ] && echo '# Placeholder' > ./data/conf/rspamd/override.d/worker-controller-password.inc
 
 cat << EOF > mailcow.conf
@@ -183,6 +239,14 @@ TZ=${MAILCOW_TZ}
 
 COMPOSE_PROJECT_NAME=mailcowdockerized
 
+# Used Docker Compose version
+# Switch here between native (compose plugin) and standalone
+# For more informations take a look at the mailcow docs regarding the configuration options.
+# Normally this should be untouched but if you decided to use either of those you can switch it manually here.
+# Please be aware that at least one of those variants should be installed on your maschine or mailcow will fail.
+
+DOCKER_COMPOSE_VERSION=${COMPOSE_VERSION}
+
 # Set this to "allow" to enable the anyone pseudo user. Disabled by default.
 # When enabled, ACL can be created, that apply to "All authenticated users"
 # This should probably only be activated on mail hosts, that are used exclusivly by one organisation.
@@ -363,16 +427,42 @@ echo "Copying snake-oil certificate..."
 cp -n -d data/assets/ssl-example/*.pem data/assets/ssl/
 
 # Set app_info.inc.php
-mailcow_git_version=$(git describe --tags `git rev-list --tags --max-count=1`)
+if [ ${git_branch} == "master" ]; then
+  mailcow_git_version=$(git describe --tags `git rev-list --tags --max-count=1`)
+elif [ ${git_branch} == "nightly" ]; then
+  mailcow_git_version=$(git rev-parse --short $(git rev-parse @{upstream}))
+  mailcow_last_git_version=""
+else
+  mailcow_git_version=$(git rev-parse --short HEAD)
+  mailcow_last_git_version=""
+fi
+
+mailcow_git_commit=$(git rev-parse origin/${git_branch})
+mailcow_git_commit_date=$(git log -1 --format=%ci @{upstream} )
+
 if [ $? -eq 0 ]; then
   echo '<?php' > data/web/inc/app_info.inc.php
   echo '  $MAILCOW_GIT_VERSION="'$mailcow_git_version'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_LAST_GIT_VERSION="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_OWNER="mailcow";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_REPO="mailcow-dockerized";' >> data/web/inc/app_info.inc.php
   echo '  $MAILCOW_GIT_URL="https://github.com/mailcow/mailcow-dockerized";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT="'$mailcow_git_commit'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT_DATE="'$mailcow_git_commit_date'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_BRANCH="'$git_branch'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_UPDATEDAT='$(date +%s)';' >> data/web/inc/app_info.inc.php
   echo '?>' >> data/web/inc/app_info.inc.php
 else
   echo '<?php' > data/web/inc/app_info.inc.php
-  echo '  $MAILCOW_GIT_VERSION="";' >> data/web/inc/app_info.inc.php
-  echo '  $MAILCOW_GIT_URL="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_VERSION="'$mailcow_git_version'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_LAST_GIT_VERSION="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_OWNER="mailcow";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_REPO="mailcow-dockerized";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_URL="https://github.com/mailcow/mailcow-dockerized";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT_DATE="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_BRANCH="'$git_branch'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_UPDATEDAT='$(date +%s)';' >> data/web/inc/app_info.inc.php
   echo '?>' >> data/web/inc/app_info.inc.php
   echo -e "\e[33mCannot determine current git repository version...\e[0m"
 fi

+ 35 - 8
helper-scripts/_cold-standby.sh

@@ -77,7 +77,7 @@ function preflight_local_checks() {
     exit 1
   fi
 
-  for bin in rsync docker docker-compose grep cut; do
+  for bin in rsync docker grep cut; do
     if [[ -z $(which ${bin}) ]]; then
       >&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"
       exit 1
@@ -111,7 +111,7 @@ function preflight_remote_checks() {
       exit 1
   fi
 
-  for bin in rsync docker docker-compose; do
+  for bin in rsync docker; do
     if ! ssh -o StrictHostKeyChecking=no \
       -i "${REMOTE_SSH_KEY}" \
       ${REMOTE_SSH_HOST} \
@@ -121,17 +121,44 @@ function preflight_remote_checks() {
         exit 1
     fi
   done
-}
 
-preflight_local_checks
-preflight_remote_checks
+  ssh -o StrictHostKeyChecking=no \
+      -i "${REMOTE_SSH_KEY}" \
+      ${REMOTE_SSH_HOST} \
+      -p ${REMOTE_SSH_PORT} \
+      "bash -s" << "EOF"
+if docker compose > /dev/null 2>&1; then
+	exit 0
+elif docker-compose version --short | grep "^2." > /dev/null 2>&1; then
+	exit 1
+else
+exit 2
+fi
+EOF
+
+if [ $? = 0 ]; then
+  COMPOSE_COMMAND="docker compose"
+  echo "DEBUG: Using native docker compose on remote"
+
+elif [ $? = 1 ]; then
+  COMPOSE_COMMAND="docker-compose"
+  echo "DEBUG: Using standalone docker compose on remote"
+
+else
+  echo -e "\e[31mCannot find any Docker Compose on remote, exiting...\e[0m"
+  exit 1
+fi
+}
 
 SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
-COMPOSE_FILE="${SCRIPT_DIR}/../docker-compose.yml"
 source "${SCRIPT_DIR}/../mailcow.conf"
+COMPOSE_FILE="${SCRIPT_DIR}/../docker-compose.yml"
 CMPS_PRJ=$(echo ${COMPOSE_PROJECT_NAME} | tr -cd 'A-Za-z-_')
 SQLIMAGE=$(grep -iEo '(mysql|mariadb)\:.+' "${COMPOSE_FILE}")
 
+preflight_local_checks
+preflight_remote_checks
+
 echo
 echo -e "\033[1mFound compose project name ${CMPS_PRJ} for ${MAILCOW_HOSTNAME}\033[0m"
 echo -e "\033[1mFound SQL ${SQLIMAGE}\033[0m"
@@ -258,7 +285,7 @@ echo "OK"
     -i "${REMOTE_SSH_KEY}" \
     ${REMOTE_SSH_HOST} \
     -p ${REMOTE_SSH_PORT} \
-    docker-compose -f "${SCRIPT_DIR}/../docker-compose.yml" pull --no-parallel --quiet 2>&1 ; then
+    ${COMPOSE_COMMAND} -f "${SCRIPT_DIR}/../docker-compose.yml" pull --no-parallel --quiet 2>&1 ; then
       >&2 echo -e "\e[31m[ERR]\e[0m - Could not pull images on remote"
   fi
 
@@ -271,4 +298,4 @@ if ! ssh -o StrictHostKeyChecking=no \
     >&2 echo -e "\e[31m[ERR]\e[0m - Could not cleanup old images on remote"
 fi
 
-echo -e "\e[32mDone\e[0m"
+echo -e "\e[32mDone\e[0m"

+ 27 - 10
helper-scripts/backup_and_restore.sh

@@ -76,13 +76,6 @@ else
   CMPS_PRJ=$(echo ${COMPOSE_PROJECT_NAME} | tr -cd "[0-9A-Za-z-_]")
 fi
 
-for bin in docker docker-compose; do
-  if [[ -z $(which ${bin}) ]]; then
-    >&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"
-    exit 1
-  fi
-done
-
 if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then
   >&2 echo -e "\e[31mBusyBox grep detected on local system, please install GNU grep\e[0m"
   exit 1
@@ -94,6 +87,12 @@ function backup() {
   mkdir -p "${BACKUP_LOCATION}/mailcow-${DATE}"
   chmod 755 "${BACKUP_LOCATION}/mailcow-${DATE}"
   cp "${SCRIPT_DIR}/../mailcow.conf" "${BACKUP_LOCATION}/mailcow-${DATE}"
+  for bin in docker; do
+  if [[ -z $(which ${bin}) ]]; then
+    >&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"
+    exit 1
+  fi
+  done
   while (( "$#" )); do
     case "$1" in
     vmail|all)
@@ -161,6 +160,24 @@ function backup() {
 }
 
 function restore() {
+  for bin in docker; do
+  if [[ -z $(which ${bin}) ]]; then
+    >&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"
+    exit 1
+  fi
+  done
+
+  if [ "${DOCKER_COMPOSE_VERSION}" == "native" ]; then
+  COMPOSE_COMMAND="docker compose"
+
+  elif [ "${DOCKER_COMPOSE_VERSION}" == "standalone" ]; then
+    COMPOSE_COMMAND="docker-compose"
+  
+  else
+    echo -e "\e[31mCan not read DOCKER_COMPOSE_VERSION variable from mailcow.conf! Is your mailcow up to date? Exiting...\e[0m"
+    exit 1
+  fi
+
   echo
   echo "Stopping watchdog-mailcow..."
   docker stop $(docker ps -qf name=watchdog-mailcow)
@@ -239,7 +256,7 @@ function restore() {
           continue
         else
           echo "Stopping mailcow..."
-          docker-compose -f ${COMPOSE_FILE} --env-file ${ENV_FILE} down
+          ${COMPOSE_COMMAND} -f ${COMPOSE_FILE} --env-file ${ENV_FILE} down
         fi
         #docker stop $(docker ps -qf name=mysql-mailcow)
         if [[ -d "${RESTORE_LOCATION}/mysql" ]]; then
@@ -277,7 +294,7 @@ function restore() {
         sed -i --follow-symlinks "/DBROOT/c\DBROOT=${DBROOT}" ${SCRIPT_DIR}/../mailcow.conf
         source ${SCRIPT_DIR}/../mailcow.conf
         echo "Starting mailcow..."
-        docker-compose -f ${COMPOSE_FILE} --env-file ${ENV_FILE} up -d
+        ${COMPOSE_COMMAND} -f ${COMPOSE_FILE} --env-file ${ENV_FILE} up -d
         #docker start $(docker ps -aqf name=mysql-mailcow)
       fi
       ;;
@@ -354,4 +371,4 @@ elif [[ ${1} == "restore" ]]; then
   done
   echo "Restoring ${FILE_SELECTION[${input_sel}]} from ${RESTORE_POINT}..."
   restore "${RESTORE_POINT}" ${FILE_SELECTION[${input_sel}]}
-fi
+fi

+ 70 - 0
helper-scripts/update_compose.sh

@@ -0,0 +1,70 @@
+#!/bin/bash
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+source ${SCRIPT_DIR}/../mailcow.conf
+
+if [ "${DOCKER_COMPOSE_VERSION}" == "standalone" ]; then
+LATEST_COMPOSE=$(curl -#L https://www.servercow.de/docker-compose/latest.php)
+COMPOSE_VERSION=$(docker-compose version --short)
+if [[ "$LATEST_COMPOSE" != "$COMPOSE_VERSION" ]]; then
+  echo -e "\e[33mA new docker-compose Version is available: $LATEST_COMPOSE\e[0m"
+  echo -e "\e[33mYour Version is: $COMPOSE_VERSION\e[0m"
+else
+  echo -e "\e[32mYour docker-compose Version is up to date! Not updating it...\e[0m"
+  exit 0 
+fi
+read -r -p "Do you want to update your docker-compose Version? It will automatic upgrade your docker-compose installation (recommended)? [y/N] " updatecomposeresponse 
+    if [[ ! "${updatecomposeresponse}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+      echo "OK, not updating docker-compose."
+      exit 0
+    fi 
+echo -e "\e[32mFetching new docker-compose (standalone) version...\e[0m"
+echo -e "\e[32mTrying to determine GLIBC version...\e[0m"
+    if ldd --version > /dev/null; then
+        GLIBC_V=$(ldd --version | grep -E '(GLIBC|GNU libc)' | rev | cut -d ' ' -f1 | rev | cut -d '.' -f2)
+        if [ ! -z "${GLIBC_V}" ] && [ ${GLIBC_V} -gt 27 ]; then
+        DC_DL_SUFFIX=
+        else
+        DC_DL_SUFFIX=legacy
+        fi
+    else
+        DC_DL_SUFFIX=legacy
+    fi
+    sleep 1
+    if [[ $(command -v pip 2>&1) && $(pip list --local 2>&1 | grep -v DEPRECATION | grep -c docker-compose) == 1 || $(command -v pip3 2>&1) && $(pip3 list --local 2>&1 | grep -v DEPRECATION | grep -c docker-compose) == 1 ]]; then
+        echo -e "\e[33mFound a docker-compose Version installed with pip!\e[0m"
+        echo -e "\e[31mPlease uninstall the pip Version of docker-compose since it doesn´t support Versions higher than 1.29.2.\e[0m"
+        sleep 2
+        echo -e "\e[33mExiting...\e[0m"
+        exit 1
+        #prevent breaking a working docker-compose installed with pip
+    elif [[ $(curl -sL -w "%{http_code}" https://www.servercow.de/docker-compose/latest.php?vers=${DC_DL_SUFFIX} -o /dev/null) == "200" ]]; then
+        LATEST_COMPOSE=$(curl -#L https://www.servercow.de/docker-compose/latest.php)
+        COMPOSE_VERSION=$(docker-compose version --short)
+        if [[ "$LATEST_COMPOSE" != "$COMPOSE_VERSION" ]]; then
+        COMPOSE_PATH=$(command -v docker-compose)
+        if [[ -w ${COMPOSE_PATH} ]]; then
+            curl -#L https://github.com/docker/compose/releases/download/v${LATEST_COMPOSE}/docker-compose-$(uname -s)-$(uname -m) > $COMPOSE_PATH
+            chmod +x $COMPOSE_PATH
+            echo -e "\e[32mYour Docker Compose (standalone) has been updated to: $LATEST_COMPOSE\e[0m"
+            exit 0
+        else
+            echo -e "\e[33mWARNING: $COMPOSE_PATH is not writable, but new version $LATEST_COMPOSE is available (installed: $COMPOSE_VERSION)\e[0m"
+            return 1
+        fi
+        fi
+    else
+        echo -e "\e[33mCannot determine latest docker-compose version, skipping...\e[0m"
+        exit 1
+    fi
+
+elif [ "${DOCKER_COMPOSE_VERSION}" == "native" ]; then
+    echo -e "\e[31mYou are using the native Docker Compose Plugin. This Script is for the standalone Docker Compose Version only.\e[0m"
+    sleep 2
+    echo -e "\e[33mNotice: You´ll have to update this Compose Version via your Package Manager manually!\e[0m"
+    exit 1
+
+else
+    echo -e "\e[31mCan not read DOCKER_COMPOSE_VERSION variable from mailcow.conf! Is your mailcow up to date? Exiting...\e[0m"
+    exit 1
+fi

+ 246 - 129
update.sh

@@ -1,64 +1,6 @@
 #!/usr/bin/env bash
 
-# Check permissions
-if [ "$(id -u)" -ne "0" ]; then
-  echo "You need to be root"
-  exit 1
-fi
-
-SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-
-# Run pre-update-hook
-if [ -f "${SCRIPT_DIR}/pre_update_hook.sh" ]; then
-  bash "${SCRIPT_DIR}/pre_update_hook.sh"
-fi
-
-if [[ "$(uname -r)" =~ ^4\.15\.0-60 ]]; then
-  echo "DO NOT RUN mailcow ON THIS UBUNTU KERNEL!";
-  echo "Please update to 5.x or use another distribution."
-  exit 1
-fi
-
-if [[ "$(uname -r)" =~ ^4\.4\. ]]; then
-  if grep -q Ubuntu <<< $(uname -a); then
-    echo "DO NOT RUN mailcow ON THIS UBUNTU KERNEL!"
-    echo "Please update to linux-generic-hwe-16.04 by running \"apt-get install --install-recommends linux-generic-hwe-16.04\""
-    exit 1
-  fi
-  echo "mailcow on a 4.4.x kernel is not supported. It may or may not work, please upgrade your kernel or continue at your own risk."
-  read -p "Press any key to continue..." < /dev/tty
-fi
-
-# Exit on error and pipefail
-set -o pipefail
-
-# Setting high dc timeout
-export COMPOSE_HTTP_TIMEOUT=600
-
-# Add /opt/bin to PATH
-PATH=$PATH:/opt/bin
-
-umask 0022
-
-for bin in curl docker git awk sha1sum; do
-  if [[ -z $(which ${bin}) ]]; then 
-  echo "Cannot find ${bin}, exiting..." 
-  exit 1;
-  elif [[ -z $(which docker-compose) ]]; then
-  echo "Cannot find docker-compose Standalone. Installing..."
-  sleep 3
-   if [[ -e /etc/alpine-release ]]; then
-    echo -e "\e[33mNot installing latest docker-compose, because you are using Alpine Linux without glibc support. Install docker-compose via apk!\e[0m"
-    exit 1
-   fi 
-  curl -#L https://github.com/docker/compose/releases/download/v$(curl -Ls https://www.servercow.de/docker-compose/latest.php)/docker-compose-$(uname -s)-$(uname -m) > /usr/local/bin/docker-compose
-  chmod +x /usr/local/bin/docker-compose  
-  fi
-done
-
-export LC_ALL=C
-DATE=$(date +%Y-%m-%d_%H_%M_%S)
-BRANCH=$(cd ${SCRIPT_DIR}; git rev-parse --abbrev-ref HEAD)
+############## Begin Function Section ##############
 
 check_online_status() {
   CHECK_ONLINE_IPS=(1.1.1.1 9.9.9.9 8.8.8.8)
@@ -223,7 +165,7 @@ remove_obsolete_nginx_ports() {
               sed -i '/nginx-mailcow:$/,/^$/d' $override
               echo -e "\e[33mRemoved obsolete NGINX IPv6 Bind from original override File.\e[0m"
                 if [[ "$(cat $override | sed '/^\s*$/d' | wc -l)" == "2" ]]; then
-                  mv $override ${override}_backup
+                  mv $override ${override}_empty
                   echo -e "\e[31m${override} is empty. Renamed it to ensure mailcow is startable.\e[0m"
                 fi
             fi
@@ -233,50 +175,108 @@ remove_obsolete_nginx_ports() {
     done        
 }
 
-update_compose(){
-if [[ ${NO_UPDATE_COMPOSE} == "y" ]]; then
-  echo -e "\e[33mNot fetching latest docker-compose, please check for updates manually!\e[0m"
-  return 0
-elif [[ -e /etc/alpine-release ]]; then
-  echo -e "\e[33mNot fetching latest docker-compose, because you are using Alpine Linux without glibc support. Please update docker-compose via apk!\e[0m"
-  return 0
-else
-  echo -e "\e[32mFetching new docker-compose version...\e[0m"
-  echo -e "\e[32mTrying to determine GLIBC version...\e[0m"
-  if ldd --version > /dev/null; then
-    GLIBC_V=$(ldd --version | grep -E '(GLIBC|GNU libc)' | rev | cut -d ' ' -f1 | rev | cut -d '.' -f2)
-    if [ ! -z "${GLIBC_V}" ] && [ ${GLIBC_V} -gt 27 ]; then
-      DC_DL_SUFFIX=
-    else
-      DC_DL_SUFFIX=legacy
-    fi
-  else
-    DC_DL_SUFFIX=legacy
-  fi
-  sleep 1
-  if [[ ! -z $(which pip) && $(pip list --local 2>&1 | grep -v DEPRECATION | grep -c docker-compose) == 1 ]]; then
-    true
-    #prevent breaking a working docker-compose installed with pip
-  elif [[ $(curl -sL -w "%{http_code}" https://www.servercow.de/docker-compose/latest.php?vers=${DC_DL_SUFFIX} -o /dev/null) == "200" ]]; then
-    LATEST_COMPOSE=$(curl -#L https://www.servercow.de/docker-compose/latest.php)
-    COMPOSE_VERSION=$(docker-compose version --short)
-    if [[ "$LATEST_COMPOSE" != "$COMPOSE_VERSION" ]]; then
-      COMPOSE_PATH=$(which docker-compose)
-      if [[ -w ${COMPOSE_PATH} ]]; then
-        curl -#L https://github.com/docker/compose/releases/download/v${LATEST_COMPOSE}/docker-compose-$(uname -s)-$(uname -m) > $COMPOSE_PATH
-        chmod +x $COMPOSE_PATH
+detect_docker_compose_command(){
+if ! [ "${DOCKER_COMPOSE_VERSION}" == "native" ] && ! [ "${DOCKER_COMPOSE_VERSION}" == "standalone" ]; then
+  if docker compose > /dev/null 2>&1; then
+      if docker compose version --short | grep "2." > /dev/null 2>&1; then
+        DOCKER_COMPOSE_VERSION=native
+        COMPOSE_COMMAND="docker compose"
+        echo -e "\e[31mFound Docker Compose Plugin (native).\e[0m"
+        echo -e "\e[31mSetting 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[33mWARNING: $COMPOSE_PATH is not writable, but new version $LATEST_COMPOSE is available (installed: $COMPOSE_VERSION)\e[0m"
-        return 1
+        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://mailcow.github.io/mailcow-dockerized-docs/i_u_m/i_u_m_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
+        DOCKER_COMPOSE_VERSION=standalone
+        COMPOSE_COMMAND="docker-compose"
+        echo -e "\e[31mFound Docker Compose Standalone.\e[0m"
+        echo -e "\e[31mSetting 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 regarding to this doc site: https://mailcow.github.io/mailcow-dockerized-docs/i_u_m/i_u_m_install/\e[0m"
+        exit 1
       fi
     fi
+
   else
-    echo -e "\e[33mCannot determine latest docker-compose version, skipping...\e[0m"
-    return 1
+    echo -e "\e[31mCannot find Docker Compose.\e[0m" 
+    echo -e "\e[31mPlease install it regarding to this doc site: https://mailcow.github.io/mailcow-dockerized-docs/i_u_m/i_u_m_install/\e[0m"
+    exit 1
   fi
+
+elif [ "${DOCKER_COMPOSE_VERSION}" == "native" ]; then
+  COMPOSE_COMMAND="docker compose"
+
+elif [ "${DOCKER_COMPOSE_VERSION}" == "standalone" ]; then
+  COMPOSE_COMMAND="docker-compose"
 fi
 }
 
+############## End Function Section ##############
+
+# Check permissions
+if [ "$(id -u)" -ne "0" ]; then
+  echo "You need to be root"
+  exit 1
+fi
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+# Run pre-update-hook
+if [ -f "${SCRIPT_DIR}/pre_update_hook.sh" ]; then
+  bash "${SCRIPT_DIR}/pre_update_hook.sh"
+fi
+
+if [[ "$(uname -r)" =~ ^4\.15\.0-60 ]]; then
+  echo "DO NOT RUN mailcow ON THIS UBUNTU KERNEL!";
+  echo "Please update to 5.x or use another distribution."
+  exit 1
+fi
+
+if [[ "$(uname -r)" =~ ^4\.4\. ]]; then
+  if grep -q Ubuntu <<< $(uname -a); then
+    echo "DO NOT RUN mailcow ON THIS UBUNTU KERNEL!"
+    echo "Please update to linux-generic-hwe-16.04 by running \"apt-get install --install-recommends linux-generic-hwe-16.04\""
+    exit 1
+  fi
+  echo "mailcow on a 4.4.x kernel is not supported. It may or may not work, please upgrade your kernel or continue at your own risk."
+  read -p "Press any key to continue..." < /dev/tty
+fi
+
+# Exit on error and pipefail
+set -o pipefail
+
+# Setting high dc timeout
+export COMPOSE_HTTP_TIMEOUT=600
+
+# Add /opt/bin to PATH
+PATH=$PATH:/opt/bin
+
+umask 0022
+
+# Unset COMPOSE_COMMAND and DOCKER_COMPOSE_VERSION Variable to be on the newest state.
+unset COMPOSE_COMMAND
+unset DOCKER_COMPOSE_VERSION
+
+for bin in curl docker git awk sha1sum; do
+  if [[ -z $(command -v ${bin}) ]]; then 
+  echo "Cannot find ${bin}, exiting..." 
+  exit 1;
+  fi  
+done
+
+export LC_ALL=C
+DATE=$(date +%Y-%m-%d_%H_%M_%S)
+BRANCH=$(cd ${SCRIPT_DIR}; git rev-parse --abbrev-ref HEAD)
+
 while (($#)); do
   case "${1}" in
     --check|-c)
@@ -301,11 +301,22 @@ while (($#)); do
     --skip-start)
       SKIP_START=y
     ;;
+    --skip-ping-check)
+      SKIP_PING_CHECK=y
+    ;;
+    --stable)
+      CURRENT_BRANCH="$(cd ${SCRIPT_DIR}; git rev-parse --abbrev-ref HEAD)"
+      NEW_BRANCH="master"
+    ;;
     --gc)
       echo -e "\e[32mCollecting garbage...\e[0m"
       docker_garbage
       exit 0
     ;;
+    --nightly)
+      CURRENT_BRANCH="$(cd ${SCRIPT_DIR}; git rev-parse --abbrev-ref HEAD)"
+      NEW_BRANCH="nightly"
+    ;;
     --prefetch)
       echo -e "\e[32mPrefetching images...\e[0m"
       prefetch_images
@@ -315,22 +326,17 @@ while (($#)); do
       echo -e "\e[32mRunning in forced mode...\e[0m"
       FORCE=y
     ;;
-    --no-update-compose)
-      NO_UPDATE_COMPOSE=y
-    ;;
-    --skip-ping-check)
-      SKIP_PING_CHECK=y
-    ;;
     --help|-h)
-    echo './update.sh [-c|--check, --ours, --gc, --no-update-compose, --prefetch, --skip-start, --skip-ping-check, -f|--force, -h|--help]
+    echo './update.sh [-c|--check, --ours, --gc, --nightly, --prefetch, --skip-start, --skip-ping-check, --stable, -f|--force, -h|--help]
 
   -c|--check           -   Check for updates and exit (exit codes => 0: update available, 3: no updates)
   --ours               -   Use merge strategy option "ours" to solve conflicts in favor of non-mailcow code (local changes over remote changes), not recommended!
   --gc                 -   Run garbage collector to delete old image tags
-  --no-update-compose  -   Do not update docker-compose  
+  --nightly            -   Switch your mailcow updates to the unstable (nightly) branch. FOR TESTING PURPOSES ONLY!!!!
   --prefetch           -   Only prefetch new images and exit (useful to prepare updates)
   --skip-start         -   Do not start mailcow after update
-  --skip-ping-check    -   Skip ICMP Check to public DNS resolvers (Use it only if you´ve blocked any ICMP Connections to your mailcow machine).
+  --skip-ping-check    -   Skip ICMP Check to public DNS resolvers (Use it only if you´ve blocked any ICMP Connections to your mailcow machine)
+  --stable             -   Switch your mailcow updates to the stable (master) branch. Default unless you changed it with --nightly.
   -f|--force           -   Force update, do not ask questions
 '
     exit 1
@@ -338,13 +344,16 @@ while (($#)); do
   shift
 done
 
-[[ ! -f mailcow.conf ]] && { echo "mailcow.conf is missing"; exit 1;}
 chmod 600 mailcow.conf
 source mailcow.conf
+
+detect_docker_compose_command
+
+[[ ! -f mailcow.conf ]] && { echo "mailcow.conf is missing! Is mailcow installed?"; exit 1;}
 DOTS=${MAILCOW_HOSTNAME//[^.]};
 if [ ${#DOTS} -lt 2 ]; then
   echo "MAILCOW_HOSTNAME (${MAILCOW_HOSTNAME}) is not a FQDN!"
-  echo "Please change it to a FQDN and run docker-compose down followed by docker-compose up -d"
+  echo "Please change it to a FQDN and run $COMPOSE_COMMAND down followed by $COMPOSE_COMMAND up -d"
   exit 1
 fi
 
@@ -371,6 +380,7 @@ CONFIG_ARRAY=(
   "SNAT_TO_SOURCE"
   "SNAT6_TO_SOURCE"
   "COMPOSE_PROJECT_NAME"
+  "DOCKER_COMPOSE_VERSION"
   "SQL_PORT"
   "API_KEY"
   "API_KEY_READ_ONLY"
@@ -406,6 +416,17 @@ for option in ${CONFIG_ARRAY[@]}; do
       echo "Adding new option \"${option}\" to mailcow.conf"
       echo "COMPOSE_PROJECT_NAME=mailcowdockerized" >> mailcow.conf
     fi
+  elif [[ ${option} == "DOCKER_COMPOSE_VERSION" ]]; then
+    if ! grep -q ${option} mailcow.conf; then
+      echo "Adding new option \"${option}\" to mailcow.conf"
+      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 maschine or mailcow will fail." >> mailcow.conf
+      echo "" >> mailcow.conf
+      echo "DOCKER_COMPOSE_VERSION=${DOCKER_COMPOSE_VERSION}" >> mailcow.conf
+    fi
   elif [[ ${option} == "DOVEADM_PORT" ]]; then
     if ! grep -q ${option} mailcow.conf; then
       echo "Adding new option \"${option}\" to mailcow.conf"
@@ -630,6 +651,82 @@ else
    fi
 fi
 
+if ! [ $NEW_BRANCH ]; then
+  echo -e "\e[33mDetecting which build your mailcow runs on...\e[0m"
+  sleep 1
+  if [ ${BRANCH} == "master" ]; then
+    echo -e "\e[32mYou are receiving stable updates (master).\e[0m"
+    echo -e "\e[33mTo change that run the update.sh Script one time with the --nightly parameter to switch to nightly builds.\e[0m"
+
+  elif [ ${BRANCH} == "nightly" ]; then
+    echo -e "\e[31mYou are receiving unstable updates (nightly). These are for testing purposes only!!!\e[0m"
+    sleep 1
+    echo -e "\e[33mTo change that run the update.sh Script one time with the --stable parameter to switch to stable builds.\e[0m"
+
+  else
+    echo -e "\e[33mYou are receiving updates from a unsupported branch.\e[0m"
+    sleep 1
+    echo -e "\e[33mThe mailcow stack might still work but it is recommended to switch to the master branch (stable builds).\e[0m"
+    echo -e "\e[33mTo change that run the update.sh Script one time with the --stable parameter to switch to stable builds.\e[0m"
+  fi
+elif [ $FORCE ]; then
+  echo -e "\e[31mYou are running in forced mode!\e[0m"
+  echo -e "\e[31mA Branch Switch can only be performed manually (monitored).\e[0m"
+  echo -e "\e[31mPlease rerun the update.sh Script without the --force/-f parameter.\e[0m"
+  sleep 1
+elif [ $NEW_BRANCH == "master" ] && [ $CURRENT_BRANCH != "master" ]; then
+  echo -e "\e[33mYou are about to switch your mailcow Updates to the stable (master) branch.\e[0m"
+  sleep 1
+  echo -e "\e[33mBefore you do: Please take a backup of all components to ensure that no Data is lost...\e[0m"
+  sleep 1
+  echo -e "\e[31mWARNING: Please see on GitHub or ask in the communitys if a switch to master is stable or not.
+  In some rear cases a Update back to master can destroy your mailcow configuration in case of Database Upgrades etc.
+  Normally a upgrade back to master should be safe during each full release. 
+  Check GitHub for Database Changes and Update only if there similar to the full release!\e[0m"
+  read -r -p "Are you sure you that want to continue upgrading to the stable (master) branch? [y/N] " response
+  if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+    echo "OK. If you prepared yourself for that please run the update.sh Script with the --stable parameter again to trigger this process here."
+    exit 0
+  fi
+  BRANCH=$NEW_BRANCH
+  DIFF_DIRECTORY=update_diffs
+  DIFF_FILE=${DIFF_DIRECTORY}/diff_before_upgrade_to_master_$(date +"%Y-%m-%d-%H-%M-%S")
+  mv diff_before_upgrade* ${DIFF_DIRECTORY}/ 2> /dev/null
+  if ! git diff-index --quiet HEAD; then
+    echo -e "\e[32mSaving diff to ${DIFF_FILE}...\e[0m"
+    mkdir -p ${DIFF_DIRECTORY}
+    git diff ${BRANCH} --stat > ${DIFF_FILE}
+    git diff ${BRANCH} >> ${DIFF_FILE}
+  fi
+  echo -e "\e[32mSwitching Branch to ${BRANCH}...\e[0m"
+  git fetch origin
+  git checkout -f ${BRANCH}
+
+elif [ $NEW_BRANCH == "nightly" ] && [ $CURRENT_BRANCH != "nightly" ]; then
+  echo -e "\e[33mYou are about to switch your mailcow Updates to the unstable (nightly) branch.\e[0m"
+  sleep 1
+  echo -e "\e[33mBefore you do: Please take a backup of all components to ensure that no Data is lost...\e[0m"
+  sleep 1
+  echo -e "\e[31mWARNING: A switch to nightly is possible any time. But a switch back (to master) isn't.\e[0m"
+  read -r -p "Are you sure you that want to continue upgrading to the unstable (nightly) branch? [y/N] " response
+  if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+    echo "OK. If you prepared yourself for that please run the update.sh Script with the --nightly parameter again to trigger this process here."
+    exit 0
+  fi
+  BRANCH=$NEW_BRANCH
+  DIFF_DIRECTORY=update_diffs
+  DIFF_FILE=${DIFF_DIRECTORY}/diff_before_upgrade_to_nightly_$(date +"%Y-%m-%d-%H-%M-%S")
+  mv diff_before_upgrade* ${DIFF_DIRECTORY}/ 2> /dev/null
+  if ! git diff-index --quiet HEAD; then
+    echo -e "\e[32mSaving diff to ${DIFF_FILE}...\e[0m"
+    mkdir -p ${DIFF_DIRECTORY}
+    git diff ${BRANCH} --stat > ${DIFF_FILE}
+    git diff ${BRANCH} >> ${DIFF_FILE}
+  fi
+  git fetch origin
+  git checkout -f ${BRANCH}
+fi
+
 echo -e "\e[32mChecking for newer update script...\e[0m"
 SHA1_1=$(sha1sum update.sh)
 git fetch origin #${BRANCH}
@@ -641,13 +738,6 @@ if [[ ${SHA1_1} != ${SHA1_2} ]]; then
   exit 2
 fi
 
-if [[ -f mailcow.conf ]]; then
-  source mailcow.conf
-else
-  echo -e "\e[31mNo mailcow.conf - is mailcow installed?\e[0m"
-  exit 1
-fi
-
 if [ ! $FORCE ]; then
   read -r -p "Are you sure you want to update mailcow: dockerized? All containers will be stopped. [y/N] " response
   if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
@@ -657,20 +747,18 @@ if [ ! $FORCE ]; then
   migrate_docker_nat
 fi
 
-update_compose
-
 remove_obsolete_nginx_ports
 
 echo -e "\e[32mValidating docker-compose stack configuration...\e[0m"
 sed -i 's/HTTPS_BIND:-:/HTTPS_BIND:-/g' docker-compose.yml
 sed -i 's/HTTP_BIND:-:/HTTP_BIND:-/g' docker-compose.yml
-if ! docker-compose config -q; then
+if ! $COMPOSE_COMMAND config -q; then
   echo -e "\e[31m\nOh no, something went wrong. Please check the error message above.\e[0m"
   exit 1
 fi
 
 echo -e "\e[32mChecking for conflicting bridges...\e[0m"
-MAILCOW_BRIDGE=$(docker-compose config | grep -i com.docker.network.bridge.name | cut -d':' -f2)
+MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2)
 while read NAT_ID; do
   iptables -t nat -D POSTROUTING $NAT_ID
 done < <(iptables -L -vn -t nat --line-numbers | grep $IPV4_NETWORK | grep -E 'MASQUERADE.*all' | grep -v ${MAILCOW_BRIDGE} | cut -d' ' -f1)
@@ -690,8 +778,8 @@ prefetch_images
 
 echo -e "\e[32mStopping mailcow...\e[0m"
 sleep 2
-MAILCOW_CONTAINERS=($(docker-compose ps -q))
-docker-compose down
+MAILCOW_CONTAINERS=($($COMPOSE_COMMAND ps -q))
+$COMPOSE_COMMAND down
 echo -e "\e[32mChecking for remaining containers...\e[0m"
 sleep 2
 for container in "${MAILCOW_CONTAINERS[@]}"; do
@@ -728,13 +816,13 @@ elif [[ ${MERGE_RETURN} == 1 ]]; then
 elif [[ ${MERGE_RETURN} != 0 ]]; then
   echo -e "\e[31m\nOh no, something went wrong. Please check the error message above.\e[0m"
   echo
-  echo "Run docker-compose up -d to restart your stack without updates or try again after fixing the mentioned errors."
+  echo "Run $COMPOSE_COMMAND up -d to restart your stack without updates or try again after fixing the mentioned errors."
   exit 1
 fi
 
 echo -e "\e[32mFetching new images, if any...\e[0m"
 sleep 2
-docker-compose pull
+$COMPOSE_COMMAND pull
 
 # Fix missing SSL, does not overwrite existing files
 [[ ! -d data/assets/ssl ]] && mkdir -p data/assets/ssl
@@ -746,7 +834,7 @@ if grep -q 'SYSCTL_IPV6_DISABLED=1' mailcow.conf; then
   echo '!! IMPORTANT !!'
   echo
   echo 'SYSCTL_IPV6_DISABLED was removed due to complications. IPv6 can be disabled by editing "docker-compose.yml" and setting "enable_ipv6: true" to "enable_ipv6: false".'
-  echo 'This setting will only be active after a complete shutdown of mailcow by running "docker-compose down" followed by "docker-compose up -d".'
+  echo 'This setting will only be active after a complete shutdown of mailcow by running $COMPOSE_COMMAND down followed by $COMPOSE_COMMAND up -d".'
   echo
   echo '!! IMPORTANT !!'
   echo
@@ -774,26 +862,55 @@ if [ -f "data/conf/rspamd/local.d/metrics.conf" ]; then
 fi
 
 # Set app_info.inc.php
-mailcow_git_version=$(git describe --tags `git rev-list --tags --max-count=1`)
+if [ ${BRANCH} == "master" ]; then
+  mailcow_git_version=$(git describe --tags `git rev-list --tags --max-count=1`)
+elif [ ${BRANCH} == "nightly" ]; then
+  mailcow_git_version=$(git rev-parse --short $(git rev-parse @{upstream}))
+  mailcow_last_git_version=""
+else
+  mailcow_git_version=$(git rev-parse --short HEAD)
+  mailcow_last_git_version=""
+fi
+
+mailcow_git_commit=$(git rev-parse origin/${BRANCH})
+mailcow_git_commit_date=$(git log -1 --format=%ci @{upstream} )
+
 if [ $? -eq 0 ]; then
   echo '<?php' > data/web/inc/app_info.inc.php
   echo '  $MAILCOW_GIT_VERSION="'$mailcow_git_version'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_LAST_GIT_VERSION="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_OWNER="mailcow";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_REPO="mailcow-dockerized";' >> data/web/inc/app_info.inc.php
   echo '  $MAILCOW_GIT_URL="https://github.com/mailcow/mailcow-dockerized";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT="'$mailcow_git_commit'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT_DATE="'$mailcow_git_commit_date'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_BRANCH="'$BRANCH'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_UPDATEDAT='$(date +%s)';' >> data/web/inc/app_info.inc.php
   echo '?>' >> data/web/inc/app_info.inc.php
 else
   echo '<?php' > data/web/inc/app_info.inc.php
-  echo '  $MAILCOW_GIT_VERSION="";' >> data/web/inc/app_info.inc.php
-  echo '  $MAILCOW_GIT_URL="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_VERSION="'$mailcow_git_version'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_LAST_GIT_VERSION="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_OWNER="mailcow";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_REPO="mailcow-dockerized";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_URL="https://github.com/mailcow/mailcow-dockerized";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_GIT_COMMIT_DATE="";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_BRANCH="'$BRANCH'";' >> data/web/inc/app_info.inc.php
+  echo '  $MAILCOW_UPDATEDAT='$(date +%s)';' >> data/web/inc/app_info.inc.php
   echo '?>' >> data/web/inc/app_info.inc.php
   echo -e "\e[33mCannot determine current git repository version...\e[0m"
 fi
 
+# Set DOCKER_COMPOSE_VERSION
+sed -i 's/^DOCKER_COMPOSE_VERSION=$/DOCKER_COMPOSE_VERSION='$DOCKER_COMPOSE_VERSION'/g' mailcow.conf
+
 if [[ ${SKIP_START} == "y" ]]; then
-  echo -e "\e[33mNot starting mailcow, please run \"docker-compose up -d --remove-orphans\" to start mailcow.\e[0m"
+  echo -e "\e[33mNot starting mailcow, please run \"$COMPOSE_COMMAND up -d --remove-orphans\" to start mailcow.\e[0m"
 else
   echo -e "\e[32mStarting mailcow...\e[0m"
   sleep 2
-  docker-compose up -d --remove-orphans
+  $COMPOSE_COMMAND up -d --remove-orphans
 fi
 
 echo -e "\e[32mCollecting garbage...\e[0m"
@@ -808,4 +925,4 @@ fi
 # echo
 # git reflog --color=always | grep "Before update on "
 # echo
-# echo "Use \"git reset --hard hash-on-the-left\" and run docker-compose up -d afterwards."
+# echo "Use \"git reset --hard hash-on-the-left\" and run $COMPOSE_COMMAND up -d afterwards."

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