Browse Source

Merge pull request #6535 from mailcow/staging

Update 2025-05
FreddleSpl0it 3 months ago
parent
commit
798e6a4c00
45 changed files with 687 additions and 202 deletions
  1. 8 1
      data/Dockerfiles/olefy/olefy.py
  2. 1 1
      data/Dockerfiles/phpfpm/Dockerfile
  3. 21 0
      data/Dockerfiles/rspamd/docker-entrypoint.sh
  4. 2 0
      data/Dockerfiles/watchdog/watchdog.sh
  5. 3 1
      data/conf/dovecot/auth/mailcowauth.php
  6. 14 4
      data/conf/dovecot/auth/passwd-verify.lua
  7. 45 54
      data/conf/postfix/postscreen_access.cidr
  8. 0 1
      data/conf/rspamd/custom/fishy_tlds.map
  9. 1 1
      data/conf/rspamd/local.d/composites.conf
  10. 0 12
      data/conf/rspamd/local.d/external_services.conf
  11. 4 1
      data/conf/sogo/sogo.conf
  12. 3 0
      data/web/admin/dashboard.php
  13. 2 1
      data/web/admin/index.php
  14. 1 0
      data/web/admin/system.php
  15. 6 0
      data/web/api/openapi.yaml
  16. 1 1
      data/web/autoconfig.php
  17. 1 0
      data/web/domainadmin/index.php
  18. 33 2
      data/web/inc/functions.auth.inc.php
  19. 43 0
      data/web/inc/functions.customize.inc.php
  20. 47 5
      data/web/inc/functions.inc.php
  21. 2 2
      data/web/inc/functions.quarantine.inc.php
  22. 8 2
      data/web/inc/triggers.user.inc.php
  23. 6 4
      data/web/index.php
  24. 34 4
      data/web/js/site/admin.js
  25. 1 1
      data/web/js/site/user.js
  26. 3 0
      data/web/json_api.php
  27. 1 0
      data/web/lang/lang.bg-bg.json
  28. 20 5
      data/web/lang/lang.de-de.json
  29. 13 0
      data/web/lang/lang.en-gb.json
  30. 4 1
      data/web/lang/lang.fr-fr.json
  31. 1 1
      data/web/lang/lang.hu-hu.json
  32. 43 20
      data/web/lang/lang.lv-lv.json
  33. 1 1
      data/web/lang/lang.tr-tr.json
  34. 1 1
      data/web/lang/lang.uk-ua.json
  35. 59 9
      data/web/lang/lang.zh-cn.json
  36. 2 1
      data/web/sogo-auth.php
  37. 35 1
      data/web/templates/admin/tab-config-customize.twig
  38. 2 2
      data/web/templates/admin/tab-config-f2b.twig
  39. 99 5
      data/web/templates/admin/tab-config-identity-provider.twig
  40. 27 16
      data/web/templates/admin_index.twig
  41. 27 16
      data/web/templates/domainadmin_index.twig
  42. 38 22
      data/web/templates/user_index.twig
  43. 6 3
      docker-compose.yml
  44. 4 0
      generate_config.sh
  45. 14 0
      update.sh

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

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

+ 1 - 1
data/Dockerfiles/phpfpm/Dockerfile

@@ -5,7 +5,7 @@ LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
 # renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
 ARG APCU_PECL_VERSION=5.1.24
 # renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
-ARG IMAGICK_PECL_VERSION=3.7.0
+ARG IMAGICK_PECL_VERSION=3.8.0
 # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
 ARG MAILPARSE_PECL_VERSION=3.1.8
 # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$

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

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

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

@@ -994,6 +994,7 @@ PID=$!
 echo "Spawned cert_checks with PID ${PID}"
 BACKGROUND_TASKS+=(${PID})
 
+if [[ "${SKIP_OLEFY}" =~ ^([nN][oO]|[nN])+$ ]]; then
 (
 while true; do
   if ! olefy_checks; then
@@ -1005,6 +1006,7 @@ done
 PID=$!
 echo "Spawned olefy_checks with PID ${PID}"
 BACKGROUND_TASKS+=(${PID})
+fi
 
 (
 while true; do

+ 3 - 1
data/conf/dovecot/auth/mailcowauth.php

@@ -79,7 +79,9 @@ if ($isSOGoRequest) {
   }
 }
 if ($result === false){
-  $result = apppass_login($post['username'], $post['password'], array($post['service'] => true), array(
+  // If it's a SOGo Request, don't check for protocol access
+  $service = ($isSOGoRequest) ? false : array($post['service'] => true);
+  $result = apppass_login($post['username'], $post['password'], $service, array(
     'is_internal' => true,
     'remote_addr' => $post['real_rip']
   ));

+ 14 - 4
data/conf/dovecot/auth/passwd-verify.lua

@@ -29,13 +29,23 @@ function auth_password_verify(request, password)
     insecure = true
   }
 
-  if c ~= 200 then
+  -- Returning PASSDB_RESULT_PASSWORD_MISMATCH will reset the user's auth cache entry.
+  -- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
+  -- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
+  if c ~= 200 and c ~= 401 then
     dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user)
-    return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream error"
+    return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
   end
 
-  local api_response = json.decode(table.concat(res))
-  if api_response.success == true then
+  local response_str = table.concat(res)
+  local is_response_valid, response_json = pcall(json.decode, response_str)
+
+  if not is_response_valid then
+    dovecot.i_info("Invalid JSON received: " .. response_str)
+    return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Invalid response format"
+  end
+
+  if response_json.success == true then
     return dovecot.auth.PASSDB_RESULT_OK, ""
   end
 

+ 45 - 54
data/conf/postfix/postscreen_access.cidr

@@ -1,6 +1,6 @@
-# Whitelist generated by Postwhite v3.4 on Tue Apr  1 00:20:51 UTC 2025
+# Whitelist generated by Postwhite v3.4 on Thu May  1 00:21:10 UTC 2025
 # https://github.com/stevejenkins/postwhite/
-# 2067 total rules
+# 2058 total rules
 2a00:1450:4000::/36	permit
 2a01:111:f400::/48	permit
 2a01:111:f403:8000::/50	permit
@@ -18,6 +18,7 @@
 2a02:a60:0:5::/64	permit
 2c0f:fb50:4000::/36	permit
 2.207.151.53	permit
+2.207.217.30	permit
 3.70.123.177	permit
 3.93.157.0/24	permit
 3.94.40.108	permit
@@ -26,10 +27,8 @@
 8.20.114.31	permit
 8.25.194.0/23	permit
 8.25.196.0/23	permit
-8.39.54.0/23	permit
-8.39.54.250/31	permit
-8.40.222.0/23	permit
-8.40.222.250/31	permit
+8.36.116.0/24	permit
+8.39.144.0/24	permit
 12.130.86.238	permit
 13.107.246.59	permit
 13.110.208.0/21	permit
@@ -103,6 +102,7 @@
 27.123.206.80/28	permit
 31.25.48.222	permit
 31.47.251.17	permit
+31.186.239.0/24	permit
 34.2.64.0/22	permit
 34.2.68.0/23	permit
 34.2.70.0/23	permit
@@ -121,6 +121,10 @@
 34.2.90.0/23	permit
 34.2.92.0/23	permit
 34.2.94.0/23	permit
+34.70.158.162	permit
+34.74.74.140	permit
+34.83.159.189	permit
+34.141.160.224	permit
 34.195.217.107	permit
 34.212.163.75	permit
 34.215.104.144	permit
@@ -132,6 +136,7 @@
 35.190.247.0/24	permit
 35.191.0.0/16	permit
 35.205.92.9	permit
+35.228.216.85	permit
 35.242.169.159	permit
 37.188.97.188	permit
 37.218.248.47	permit
@@ -233,6 +238,7 @@
 52.95.49.88/29	permit
 52.96.91.34	permit
 52.96.111.82	permit
+52.96.172.98	permit
 52.96.214.50	permit
 52.96.222.194	permit
 52.96.222.226	permit
@@ -272,7 +278,6 @@
 54.244.54.130	permit
 54.244.242.0/24	permit
 54.255.61.23	permit
-56.124.6.228	permit
 57.103.64.0/18	permit
 62.13.128.0/24	permit
 62.13.129.128/25	permit
@@ -309,9 +314,6 @@
 64.207.219.13	permit
 64.207.219.14	permit
 64.207.219.15	permit
-64.207.219.24	permit
-64.207.219.25	permit
-64.207.219.26	permit
 64.207.219.71	permit
 64.207.219.72	permit
 64.207.219.73	permit
@@ -321,9 +323,6 @@
 64.207.219.77	permit
 64.207.219.78	permit
 64.207.219.79	permit
-64.207.219.88	permit
-64.207.219.89	permit
-64.207.219.90	permit
 64.207.219.135	permit
 64.207.219.136	permit
 64.207.219.137	permit
@@ -359,7 +358,6 @@
 65.110.161.77	permit
 65.123.29.213	permit
 65.123.29.220	permit
-65.154.166.0/24	permit
 65.212.180.36	permit
 66.102.0.0/20	permit
 66.119.150.192/26	permit
@@ -1323,9 +1321,6 @@
 117.120.16.0/21	permit
 119.42.242.52/31	permit
 119.42.242.156	permit
-121.244.91.48	permit
-121.244.91.52	permit
-122.15.156.182	permit
 123.126.78.64/29	permit
 124.108.96.24/31	permit
 124.108.96.28/31	permit
@@ -1388,21 +1383,7 @@
 134.170.141.64/26	permit
 134.170.143.0/24	permit
 134.170.174.0/24	permit
-135.84.80.0/24	permit
-135.84.81.0/24	permit
-135.84.82.0/24	permit
-135.84.83.0/24	permit
 135.84.216.0/22	permit
-136.143.160.0/24	permit
-136.143.161.0/24	permit
-136.143.162.0/24	permit
-136.143.176.0/24	permit
-136.143.177.0/24	permit
-136.143.178.49	permit
-136.143.182.0/23	permit
-136.143.184.0/24	permit
-136.143.188.0/24	permit
-136.143.190.0/23	permit
 136.147.128.0/20	permit
 136.147.135.0/24	permit
 136.147.176.0/20	permit
@@ -1417,7 +1398,6 @@
 139.138.46.219	permit
 139.138.57.55	permit
 139.138.58.119	permit
-139.167.79.86	permit
 139.180.17.0/24	permit
 140.238.148.191	permit
 141.148.159.229	permit
@@ -1452,6 +1432,7 @@
 146.20.215.0/24	permit
 146.20.215.182	permit
 146.88.28.0/24	permit
+146.148.116.76	permit
 147.154.32.0/25	permit
 147.243.1.47	permit
 147.243.1.48	permit
@@ -1533,11 +1514,10 @@
 163.114.132.120	permit
 163.114.134.16	permit
 163.114.135.16	permit
+163.116.128.0/17	permit
 164.152.23.32	permit
+164.152.25.241	permit
 164.177.132.168/30	permit
-165.173.128.0/24	permit
-165.173.180.250/31	permit
-165.173.182.250/31	permit
 166.78.68.0/22	permit
 166.78.68.221	permit
 166.78.69.169	permit
@@ -1566,12 +1546,6 @@
 168.245.12.252	permit
 168.245.46.9	permit
 168.245.127.231	permit
-169.148.129.0/24	permit
-169.148.131.0/24	permit
-169.148.142.10	permit
-169.148.144.0/25	permit
-169.148.144.10	permit
-169.148.146.0/23	permit
 170.10.128.0/24	permit
 170.10.129.0/24	permit
 170.10.132.56/29	permit
@@ -1697,6 +1671,21 @@
 193.123.56.63	permit
 194.19.134.0/25	permit
 194.64.234.129	permit
+194.97.196.0/24	permit
+194.97.196.3	permit
+194.97.196.4	permit
+194.97.196.11	permit
+194.97.196.12	permit
+194.97.204.0/24	permit
+194.97.204.3	permit
+194.97.204.4	permit
+194.97.204.11	permit
+194.97.204.12	permit
+194.97.212.0/24	permit
+194.97.212.3	permit
+194.97.212.4	permit
+194.97.212.11	permit
+194.97.212.12	permit
 194.106.220.0/23	permit
 194.113.24.0/22	permit
 194.154.193.192/27	permit
@@ -1733,15 +1722,7 @@
 199.16.156.0/22	permit
 199.33.145.1	permit
 199.33.145.32	permit
-199.34.22.36	permit
 199.59.148.0/22	permit
-199.67.80.2	permit
-199.67.80.20	permit
-199.67.82.2	permit
-199.67.82.20	permit
-199.67.84.0/24	permit
-199.67.86.0/24	permit
-199.67.88.0/24	permit
 199.101.161.130	permit
 199.101.162.0/25	permit
 199.122.120.0/21	permit
@@ -1798,8 +1779,6 @@
 204.92.114.187	permit
 204.92.114.203	permit
 204.92.114.204/31	permit
-204.141.32.0/23	permit
-204.141.42.0/23	permit
 204.220.160.0/21	permit
 204.220.168.0/21	permit
 204.220.176.0/20	permit
@@ -2046,15 +2025,27 @@
 2001:0868:0100:0600::/64	permit
 2001:4860:4000::/36	permit
 2001:748:100:40::2:0/112	permit
+2001:748:400:1300::3	permit
+2001:748:400:1300::4	permit
+2001:748:400:1301::0/64	permit
+2001:748:400:1301::3	permit
+2001:748:400:1301::4	permit
+2001:748:400:2300::3	permit
+2001:748:400:2300::4	permit
+2001:748:400:2301::0/64	permit
+2001:748:400:2301::3	permit
+2001:748:400:2301::4	permit
+2001:748:400:3300::3	permit
+2001:748:400:3300::4	permit
+2001:748:400:3301::0/64	permit
+2001:748:400:3301::3	permit
+2001:748:400:3301::4	permit
 2404:6800:4000::/36	permit
 2603:1010:3:3::5b	permit
 2603:1020:201:10::10f	permit
 2603:1030:20e:3::23c	permit
 2603:1030:b:3::152	permit
 2603:1030:c02:8::14	permit
-2607:13c0:0001:0000:0000:0000:0000:7000/116	permit
-2607:13c0:0002:0000:0000:0000:0000:1000/116	permit
-2607:13c0:0004:0000:0000:0000:0000:0000/116	permit
 2607:f8b0:4000::/36	permit
 2620:109:c003:104::/64	permit
 2620:109:c003:104::215	permit

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

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

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

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

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

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

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

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

+ 3 - 0
data/web/admin/dashboard.php

@@ -18,6 +18,7 @@ elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] !=
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
 $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
 $clamd_status = (preg_match("/^([yY][eE][sS]|[yY])+$/", $_ENV["SKIP_CLAMD"])) ? false : true;
+$olefy_status = (preg_match("/^([yY][eE][sS]|[yY])+$/", $_ENV["SKIP_OLEFY"])) ? false : true;
 
 
 if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CACHE')) {
@@ -33,6 +34,7 @@ $vmail_df = explode(',', (string)json_decode(docker('post', 'dovecot-mailcow', '
 // containers
 $containers_info = (array) docker('info');
 if ($clamd_status === false) unset($containers_info['clamd-mailcow']);
+if ($olefy_status === false) unset($containers_info['olefy-mailcow']);
 ksort($containers_info);
 $containers = array();
 foreach ($containers_info as $container => $container_info) {
@@ -77,6 +79,7 @@ $template_data = [
   'gal' => @$_SESSION['gal'],
   'license_guid' => license('guid'),
   'clamd_status' => $clamd_status,
+  'olefy_status' => $olefy_status,
   'containers' => $containers,
   'ip_check' => customize('get', 'ip_check'),
   'lang_admin' => json_encode($lang['admin']),

+ 2 - 1
data/web/admin/index.php

@@ -22,7 +22,8 @@ $_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
 
 $template = 'admin_index.twig';
 $template_data = [
-  'login_delay' => @$_SESSION['ldelay']
+  'login_delay' => @$_SESSION['ldelay'],
+  'custom_login' => customize('get', 'custom_login'),
 ];
 
 $js_minifier->add('/web/js/site/index.js');

+ 1 - 0
data/web/admin/system.php

@@ -125,6 +125,7 @@ $template_data = [
   'logo_specs' => customize('get', 'main_logo_specs'),
   'logo_dark_specs' => customize('get', 'main_logo_dark_specs'),
   'ip_check' => customize('get', 'ip_check'),
+  'custom_login' => customize('get', 'custom_login'),
   'password_complexity' => password_complexity('get'),
   'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
   'cors_settings' => $cors_settings,

+ 6 - 0
data/web/api/openapi.yaml

@@ -5847,6 +5847,7 @@ paths:
                           client_id: "mailcow_client"
                           client_secret: "*"
                           redirect_url: "https://mail.mailcow.tld"
+                          redirect_url_extra: ["https://extramail.mailcow.tld"]
                           version: "26.1.3"
                           default_template: "Default"
                           mappers:
@@ -5900,6 +5901,9 @@ paths:
                     redirect_url:
                       description: The redirect URL that OIDC Provider will use after authentication. Required if `authsource` is keycloak or generic-oidc.
                       type: string
+                    redirect_url_extra:
+                      description: Additional redirect URLs that OIDC Provider can use after authentication if valid.
+                      type: array
                     version:
                       description: Specifies the Keycloak version. Required if `authsource` is keycloak.
                       type: string
@@ -5990,6 +5994,7 @@ paths:
                     client_id: "mailcow_client"
                     client_secret: "Xy7GdPqvJ9m3R8sT2LkVZ5W1oNbCaYQf"
                     redirect_url: "https://mail.mailcow.tld"
+                    redirect_url_extra: ["https://extramail.mailcow.tld"]
                     version: "26.1.3"
                     default_template: "Default"
                     mappers: ["small_mbox", "medium_mbox"]
@@ -6034,6 +6039,7 @@ paths:
                     client_id: "mailcow_client"
                     client_secret: "Xy7GdPqvJ9m3R8sT2LkVZ5W1oNbCaYQf"
                     redirect_url: "https://mail.mailcow.tld"
+                    redirect_url_extra: ["https://extramail.mailcow.tld"]
                     client_scopes: "openid profile email mailcow_template"
                     default_template: "Default"
                     mappers: ["small_mbox", "medium_mbox"]

+ 1 - 1
data/web/autoconfig.php

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

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

@@ -22,6 +22,7 @@ $_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
 $template = 'domainadmin_index.twig';
 $template_data = [
   'login_delay' => @$_SESSION['ldelay'],
+  'custom_login' => customize('get', 'custom_login'),
 ];
 
 $js_minifier->add('/web/js/site/index.js');

+ 33 - 2
data/web/inc/functions.auth.inc.php

@@ -242,6 +242,7 @@ function user_login($user, $pass, $extra = null){
     return false;
   }
 
+  $row['attributes'] = json_decode($row['attributes'], true);
   switch ($row['authsource']) {
     case 'keycloak':
       // user authsource is keycloak, try using via rest flow
@@ -351,6 +352,11 @@ function user_login($user, $pass, $extra = null){
       }
       // verify password
       if (verify_hash($row['password'], $pass) !== false) {
+
+        if (intval($row['attributes']['force_pw_update']) == 1) {
+          $_SESSION['pending_pw_update'] = true;
+        }
+
         // check for tfa authenticators
         $authenticators = get_tfa($user);
         if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
@@ -471,6 +477,9 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
     }
     return false;
   }
+  if (!$iam_provider) {
+    return false;
+  }
 
   // get access_token for service account of mailcow client
   $admin_token = identity_provider("get-keycloak-admin-token");
@@ -540,6 +549,17 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
     return 'user';
   }
 
+  // check if login provisioning is enabled before creating user
+  if (!$iam_settings['login_provisioning']){
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, "Auto-create users on login is deactivated"),
+        'msg' => 'login_failed'
+      );
+    }
+    return false;
+  }
   // check if matching attribute exist
   if (empty($iam_settings['mappers']) || !$user_template || $mapper_key === false) {
     if (!empty($iam_settings['default_template'])) {
@@ -653,10 +673,21 @@ function ldap_mbox_login($user, $pass, $extra = null){
     return 'user';
   }
 
+  // check if login provisioning is enabled before creating user
+  if (!$iam_settings['login_provisioning']){
+    if (!$is_internal){
+      $_SESSION['return'][] =  array(
+        'type' => 'danger',
+        'log' => array(__FUNCTION__, "Auto-create users on login is deactivated"),
+        'msg' => 'login_failed'
+      );
+    }
+    return false;
+  }
   // check if matching attribute exist
   if (empty($iam_settings['mappers']) || !$user_template || $mapper_key === false) {
-    if (!empty($iam_settings['default_tempalte'])) {
-      $mbox_template = $iam_settings['default_tempalte'];
+    if (!empty($iam_settings['default_template'])) {
+      $mbox_template = $iam_settings['default_template'];
     } else {
       $_SESSION['return'][] =  array(
         'type' => 'danger',

+ 43 - 0
data/web/inc/functions.customize.inc.php

@@ -204,6 +204,35 @@ function customize($_action, $_item, $_data = null) {
             'msg' => 'ip_check_opt_in_modified'
           );
         break;
+        case 'custom_login':
+          $hide_user_quicklink        = ($_data['hide_user_quicklink'] == "1") ? 1 : 0;
+          $hide_domainadmin_quicklink = ($_data['hide_domainadmin_quicklink'] == "1") ? 1 : 0;
+          $hide_admin_quicklink       = ($_data['hide_admin_quicklink'] == "1") ? 1 : 0;
+          $force_sso                  = ($_data['force_sso'] == "1") ? 1 : 0;
+
+          $custom_login = array(
+            "hide_user_quicklink" => $hide_user_quicklink,
+            "hide_domainadmin_quicklink" => $hide_domainadmin_quicklink,
+            "hide_admin_quicklink" => $hide_admin_quicklink,
+            "force_sso" => $force_sso,
+          );
+          try {
+            $redis->set('CUSTOM_LOGIN', json_encode($custom_login));
+          }
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_item, $_data),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+          $_SESSION['return'][] = array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $_action, $_item, $_data),
+            'msg' => 'custom_login_modified'
+          );
+        break;
       }
     break;
     case 'delete':
@@ -357,6 +386,20 @@ function customize($_action, $_item, $_data = null) {
             return false;
           }
         break;
+        case 'custom_login':
+          try {
+            $custom_login = ($custom_login = $redis->get('CUSTOM_LOGIN')) ? $custom_login : array();
+            return json_decode($custom_login, true);
+          }
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_item, $_data),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+        break;
       }
     break;
   }

+ 47 - 5
data/web/inc/functions.inc.php

@@ -1001,6 +1001,7 @@ function edit_user_account($_data) {
       ':password_hashed' => $password_hashed,
       ':username' => $username
     ));
+    $_SESSION['pending_pw_update'] = false;
 
     update_sogo_static_view();
   }
@@ -2286,12 +2287,14 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
       $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
       foreach($rows as $row){
         switch ($row["key"]) {
+          case "redirect_url_extra":
           case "mappers":
           case "templates":
             $settings[$row["key"]] = json_decode($row["value"]);
           break;
           case "use_ssl":
           case "use_tls":
+          case "login_provisioning":
           case "ignore_ssl_errors":
             $settings[$row["key"]] = boolval($row["value"]);
           break;
@@ -2300,6 +2303,10 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
           break;
         }
       }
+      // set login_provisioning if not exists
+      if (!array_key_exists('login_provisioning', $settings)) {
+        $settings['login_provisioning'] = 1;
+      }
       // return default client_scopes for generic-oidc if none is set
       if ($settings["authsource"] == "generic-oidc" && empty($settings["client_scopes"])){
         $settings["client_scopes"] = "openid profile email mailcow_template";
@@ -2364,7 +2371,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
         return false;
       }
 
-      $_data['ignore_ssl_error']  = isset($_data['ignore_ssl_error']) ? boolval($_data['ignore_ssl_error']) : false;
+      $_data['ignore_ssl_error']    = isset($_data['ignore_ssl_error']) ? boolval($_data['ignore_ssl_error']) : false;
+      $_data['login_provisioning']  = isset($_data['login_provisioning']) ? boolval($_data['login_provisioning']) : false;
       switch ($_data['authsource']) {
         case "keycloak":
           $_data['server_url']        = (!empty($_data['server_url'])) ? rtrim($_data['server_url'], '/') : null;
@@ -2373,14 +2381,14 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
           $_data['import_users']      = isset($_data['import_users']) ? intval($_data['import_users']) : 0;
           $_data['sync_interval']     = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15;
           $_data['sync_interval']     = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval'];
-          $required_settings          = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval', 'ignore_ssl_error');
+          $required_settings          = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval', 'ignore_ssl_error', 'login_provisioning');
         break;
         case "generic-oidc":
           $_data['authorize_url']     = (!empty($_data['authorize_url'])) ? $_data['authorize_url'] : null;
           $_data['token_url']         = (!empty($_data['token_url'])) ? $_data['token_url'] : null;
           $_data['userinfo_url']      = (!empty($_data['userinfo_url'])) ? $_data['userinfo_url'] : null;
           $_data['client_scopes']     = (!empty($_data['client_scopes'])) ? $_data['client_scopes'] : "openid profile email mailcow_template";
-          $required_settings          = array('authsource', 'authorize_url', 'token_url', 'client_id', 'client_secret', 'redirect_url', 'userinfo_url', 'client_scopes', 'ignore_ssl_error');
+          $required_settings          = array('authsource', 'authorize_url', 'token_url', 'client_id', 'client_secret', 'redirect_url', 'userinfo_url', 'client_scopes', 'ignore_ssl_error', 'login_provisioning');
         break;
         case "ldap":
           $_data['host']              = (!empty($_data['host'])) ? str_replace(" ", "", $_data['host']) : "";
@@ -2394,7 +2402,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
           $_data['use_tls']           = isset($_data['use_tls']) && !$_data['use_ssl'] ? boolval($_data['use_tls']) : false;
           $_data['sync_interval']     = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15;
           $_data['sync_interval']     = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval'];
-          $required_settings          = array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error');
+          $required_settings          = array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error', 'login_provisioning');
         break;
       }
 
@@ -2418,6 +2426,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
       }
       $pdo->commit();
 
+      // add redirect_url_extra
+      if (isset($_data['redirect_url_extra'])){
+        $_data['redirect_url_extra'] = (!is_array($_data['redirect_url_extra'])) ? array($_data['redirect_url_extra']) : $_data['redirect_url_extra'];
+
+        $redirect_url_extra = array_filter($_data['redirect_url_extra']);
+        $redirect_url_extra = json_encode($redirect_url_extra);
+
+        $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('redirect_url_extra', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);");
+        $stmt->bindParam(':value', $redirect_url_extra);
+        $stmt->execute();
+      }
+
       // add default template
       if (isset($_data['default_template'])) {
         $_data['default_template'] = (empty($_data['default_template'])) ? "" : $_data['default_template'];
@@ -2752,6 +2772,16 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
         return true;
       }
 
+      // user doesn't exist, check if login provisioning is enabled
+      if (!$iam_settings['login_provisioning']){
+        $_SESSION['return'][] =  array(
+          'type' => 'danger',
+          'log' => array(__FUNCTION__, "Auto-create users on login is deactivated"),
+          'msg' => 'login_failed'
+        );
+        return false;
+      }
+
       if (empty($iam_settings['mappers']) || empty($user_template) || $mapper_key === false){
         if (!empty($iam_settings['default_template'])) {
           $mbox_template = $iam_settings['default_template'];
@@ -2851,7 +2881,19 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
     case "get-redirect":
       if ($iam_settings['authsource'] != 'keycloak' && $iam_settings['authsource'] != 'generic-oidc')
         return false;
-      $authUrl = $iam_provider->getAuthorizationUrl();
+      $options = [];
+      if (isset($iam_settings['redirect_url_extra'])) {
+        // check if the current domain is used in an extra redirect URL
+        $targetDomain = strtolower($_SERVER['HTTP_HOST']);
+        foreach ($iam_settings['redirect_url_extra'] as $testUrl) {
+          $testUrlParsed = parse_url($testUrl);
+          if (isset($testUrlParsed['host']) && strtolower($testUrlParsed['host']) == $targetDomain) {
+            $options['redirect_uri'] = $testUrl;
+            break;
+          }
+        }
+      }
+      $authUrl = $iam_provider->getAuthorizationUrl($options);
       $_SESSION['oauth2state'] = $iam_provider->getState();
       return $authUrl;
     break;

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

@@ -169,7 +169,7 @@ function quarantine($_action, $_data = null) {
           }
         }
         elseif ($release_format == 'raw') {
-          $detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/', 'X-Pre-Release-Spam-Flag $1', $detail_row['msg']);
+          $detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
           $postfix_talk = array(
             array('220', 'HELO quarantine' . chr(10)),
             array('250', 'MAIL FROM: ' . $sender . chr(10)),
@@ -464,7 +464,7 @@ function quarantine($_action, $_data = null) {
             }
           }
           elseif ($release_format == 'raw') {
-            $row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/', 'X-Pre-Release-Spam-Flag $1', $row['msg']);
+            $row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
             $postfix_talk = array(
               array('220', 'HELO quarantine' . chr(10)),
               array('250', 'MAIL FROM: ' . $sender . chr(10)),

+ 8 - 2
data/web/inc/triggers.user.inc.php

@@ -76,7 +76,10 @@ if (isset($_POST["verify_tfa_login"])) {
 
         $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
         $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
-        if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual) {
+        if (intval($user_details['attributes']['sogo_access']) == 1 &&
+            intval($user_details['attributes']['force_pw_update']) != 1 &&
+            getenv('SKIP_SOGO') != "y" &&
+            !$is_dual) {
           header("Location: /SOGo/so/{$_SESSION['mailcow_cc_username']}");
           die();
         } else {
@@ -139,7 +142,10 @@ if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
 
     $user_details = mailbox("get", "mailbox_details", $login_user);
     $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
-    if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual) {
+    if (intval($user_details['attributes']['sogo_access']) == 1 &&
+        intval($user_details['attributes']['force_pw_update']) != 1 &&
+        getenv('SKIP_SOGO') != "y" &&
+        !$is_dual) {
       header("Location: /SOGo/so/{$login_user}");
       die();
     } else {

+ 6 - 4
data/web/index.php

@@ -11,7 +11,7 @@ if (isset($_SESSION['mailcow_cc_role']) && isset($_SESSION['oauth2_request'])) {
 elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
   $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
   $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
-  if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual) {
+  if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual && getenv('SKIP_SOGO') != "y") {
     header("Location: /SOGo/so/{$_SESSION['mailcow_cc_username']}");
   } else {
     header("Location: /user");
@@ -33,16 +33,18 @@ $_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
 
 $has_iam_sso = false;
 if ($iam_provider){
-  $has_iam_sso = identity_provider("get-redirect") ? true : false;
+  $iam_redirect_url = identity_provider("get-redirect");
+  $has_iam_sso = $iam_redirect_url ? true : false;
 }
-
+$custom_login = customize('get', 'custom_login');
 
 $template = 'user_index.twig';
 $template_data = [
   'oauth2_request' => @$_SESSION['oauth2_request'],
   'is_mobileconfig' => str_contains($_SESSION['index_query_string'], 'mobileconfig'),
   'login_delay' => @$_SESSION['ldelay'],
-  'has_iam_sso' => $has_iam_sso
+  'has_iam_sso' => $has_iam_sso,
+  'custom_login' => $custom_login,
 ];
 
 $js_minifier->add('/web/js/site/index.js');

+ 34 - 4
data/web/js/site/admin.js

@@ -51,7 +51,7 @@ jQuery(function($){
     $('.submit_rspamd_regex').attr({"disabled": true});
   });
   $("#show_rspamd_global_filters").click(function() {
-    $.get("inc/ajax/show_rspamd_global_filters.php");
+    $.get("/inc/ajax/show_rspamd_global_filters.php");
     $("#confirm_show_rspamd_global_filters").hide();
     $("#rspamd_global_filters").removeClass("d-none");
   });
@@ -558,7 +558,7 @@ jQuery(function($){
     } else if (table == 'oauth2clientstable') {
       $.each(data, function (i, item) {
         item.action = '<div class="btn-group">' +
-          '<a href="/edit.php?oauth2client=' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
+          '<a href="/edit/oauth2client/' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
           '<a href="#" data-action="delete_selected" data-id="single-oauth2-client" data-api-url="delete/oauth2-client" data-item="' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
           '</div>';
         item.scope = "profile";
@@ -573,7 +573,7 @@ jQuery(function($){
         item.action = '<div class="btn-group">' +
           '<a href="/edit/domainadmin/' + encodeURI(item.username) + '" class="btn btn-xs btn-xs-lg btn-xs-third btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
           '<a href="#" data-action="delete_selected" data-id="single-domain-admin" data-api-url="delete/domain-admin" data-item="' + encodeURI(item.username) + '" class="btn btn-xs btn-xs-lg btn-xs-third btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
-          '<a href="/index.php?duallogin=' + encodeURIComponent(item.username) + '" class="btn btn-xs btn-xs-lg btn-xs-third btn-success"><i class="bi bi-person-fill"></i> Login</a>' +
+          '<a href="/domainadmin/?duallogin=' + encodeURIComponent(item.username) + '" class="btn btn-xs btn-xs-lg btn-xs-third btn-success"><i class="bi bi-person-fill"></i> Login</a>' +
           '</div>';
       });
     } else if (table == 'adminstable') {
@@ -655,7 +655,7 @@ jQuery(function($){
     $(this).html('<i class="bi bi-arrow-repeat icon-spin"></i> ');
     $.ajax({
       type: 'GET',
-      url: 'inc/ajax/relay_check.php',
+      url: '/inc/ajax/relay_check.php',
       dataType: 'text',
       data: $('#test_relayhost_form').serialize(),
       complete: function (data) {
@@ -789,6 +789,18 @@ jQuery(function($){
   $('.iam_ldap_rolemap_del').click(async function(e){
     deleteAttributeMappingRow(this, e);
   });
+  $('.iam_redirect_add_keycloak').click(async function(e){
+    addRedirectUrlRow('#iam_keycloak_redirect_list', '.iam_keycloak_redirect_del', e);
+  });
+  $('.iam_redirect_add_generic').click(async function(e){
+    addRedirectUrlRow('#iam_generic_redirect_list', '.iam_generic_redirect_del', e);
+  });
+  $('.iam_keycloak_redirect_del').click(async function(e){
+    deleteRedirectUrlRow(this, e);
+  });
+  $('.iam_generic_redirect_del').click(async function(e){
+    deleteRedirectUrlRow(this, e);
+  });
   // selecting identity provider
   $('#iam_provider').on('change', function(){
     // toggle password fields
@@ -833,4 +845,22 @@ jQuery(function($){
     if ($(elem).parent().parent().parent().parent().children().length > 1)
       $(elem).parent().parent().parent().remove();
   }
+  function addRedirectUrlRow(list_id, del_class, e) {
+    e.preventDefault();
+
+    var parent = $(list_id)
+    $(parent).children().last().clone().appendTo(parent);
+    var newChild = $(parent).children().last();
+    $(newChild).find('input').val('');
+
+    $(del_class).off('click');
+    $(del_class).click(async function(e){
+      deleteRedirectUrlRow(this, e);
+    });
+  }
+  function deleteRedirectUrlRow(elem, e) {
+    e.preventDefault();
+    if ($(elem).parent().parent().parent().parent().children().length > 2)
+      $(elem).parent().parent().parent().remove();
+  }
 });

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

@@ -98,7 +98,7 @@ jQuery(function($){
               var local_datetime = datetime.toLocaleDateString(undefined, {year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit"});
               var service = '<div class="badge bg-secondary">' + item.service.toUpperCase() + '</div>';
               var app_password = item.app_password ? ' <a href="/edit/app-passwd/' + item.app_password + '"><i class="bi bi-app-indicator"></i> ' + escapeHtml(item.app_password_name || "App") + '</a>' : '';
-              var real_rip = item.real_rip.startsWith("Web") ? item.real_rip : '<a href="https://bgp.he.net/ip/' + item.real_rip + '" target="_blank">' + item.real_rip + "</a>";
+              var real_rip = item.real_rip.startsWith("Web") ? item.real_rip : '<a href="https://bgp.tools/prefix/' + item.real_rip + '" target="_blank">' + item.real_rip + "</a>";
               var ip_location = item.location ? ' <span class="flag-icon flag-icon-' + item.location.toLowerCase() + '"></span>' : '';
               var ip_data = real_rip + ip_location + app_password;
 

+ 3 - 0
data/web/json_api.php

@@ -1976,6 +1976,9 @@ if (isset($_GET['query'])) {
         case "ip_check":
           process_edit_return(customize('edit', 'ip_check', $attr));
         break;
+        case "custom_login":
+          process_edit_return(customize('edit', 'custom_login', $attr));
+        break;
         case "self":
           if ($_SESSION['mailcow_cc_role'] == "domainadmin") {
             process_edit_return(domain_admin('edit', $attr));

+ 1 - 0
data/web/lang/lang.bg-bg.json

@@ -0,0 +1 @@
+{}

+ 20 - 5
data/web/lang/lang.de-de.json

@@ -134,6 +134,7 @@
         "admin_domains": "Domain-Zuweisungen",
         "admins": "Administratoren",
         "admins_ldap": "LDAP-Administratoren",
+        "admin_quicklink": "Quicklink zur Admin-Loginseite ausblenden",
         "advanced_settings": "Erweiterte Einstellungen",
         "api_allow_from": "IP-Adressen oder Netzwerke (CIDR Notation) für Zugriff auf API",
         "api_info": "Die API befindet sich noch in Entwicklung, die Dokumentation kann unter <a href=\"/api\">/api</a> abgerufen werden.",
@@ -155,6 +156,7 @@
         "credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.",
         "customer_id": "Kunde",
         "customize": "UI-Anpassung",
+        "login_page": "Login-Seite",
         "destination": "Ziel",
         "dkim_add_key": "ARC/DKIM-Key hinzufügen",
         "dkim_domains_selector": "Selector",
@@ -173,6 +175,7 @@
         "domain": "Domain",
         "domain_admin": "Administrator hinzufügen",
         "domain_admins": "Domain-Administratoren",
+        "domainadmin_quicklink": "Quicklink zur Domainadmin-Loginseite ausblenden",
         "domain_s": "Domain(s)",
         "duplicate": "Duplizieren",
         "duplicate_dkim": "DKIM duplizieren",
@@ -195,6 +198,8 @@
         "f2b_retry_window": "Wiederholungen im Zeitraum von (s)",
         "f2b_whitelist": "Whitelist für Netzwerke und Hosts",
         "filter_table": "Tabelle filtern",
+        "force_sso_text": "Wenn ein externer OIDC-Provider konfiguriert ist, blendet diese Option die mailcow Loginform aus und zeigt nur den Single Sign-On-Button an.",
+        "force_sso": "mailcow Login deaktivieren und nur Single Sign-On anzeigen",
         "forwarding_hosts": "Weiterleitungs-Hosts",
         "forwarding_hosts_add_hint": "Sie können entweder IPv4-/IPv6-Adressen, Netzwerke in CIDR-Notation, Hostnamen (die zu IP-Adressen aufgelöst werden), oder Domainnamen (die zu IP-Adressen aufgelöst werden, indem ihr SPF-Record abgefragt wird oder, in dessen Abwesenheit, ihre MX-Records) angeben.",
         "forwarding_hosts_hint": "Eingehende Nachrichten werden von den hier gelisteten Hosts bedingungslos akzeptiert. Diese Hosts werden dann nicht mit DNSBLs abgeglichen oder Greylisting unterworfen. Von ihnen empfangener Spam wird nie abgelehnt, optional kann er aber in den Spam-Ordner einsortiert werden. Die übliche Verwendung für diese Funktion ist, um Mailserver anzugeben, auf denen eine Weiterleitung zu Ihrem mailcow-Server eingerichtet wurde.",
@@ -222,6 +227,7 @@
         "iam_host": "Host",
         "iam_host_info": "Gib einen oder mehrere LDAP-Hosts ein, getrennt durch Kommas.",
         "iam_import_users": "Importiere Benutzer",
+        "iam_login_provisioning": "Benutzer beim Login erstellen",
         "iam_mapping": "Attribut Mapping",
         "iam_bindpass": "Bind Passwort",
         "iam_periodic_full_sync": "Vollsynchronisation",
@@ -308,6 +314,7 @@
         "quarantine_release_format_att": "Als Anhang",
         "quarantine_release_format_raw": "Unverändertes Original",
         "quarantine_retention_size": "Rückhaltungen pro Mailbox:<br><small>0 bedeutet <b>inaktiv</b>.</small>",
+        "quicklink_text": "Quicklinks zu anderen Login-Seiten unter der Loginform ein- oder ausblenden",
         "quota_notification_html": "Benachrichtigungs-E-Mail Inhalt:<br><small>Leer lassen, um Standard-Template wiederherzustellen.</small>",
         "quota_notification_sender": "Benachrichtigungs-E-Mail Absender",
         "quota_notification_subject": "Benachrichtigungs-E-Mail Betreff",
@@ -387,6 +394,7 @@
         "unchanged_if_empty": "Unverändert, wenn leer",
         "upload": "Hochladen",
         "username": "Benutzername",
+        "user_quicklink": "Quicklink zur Benutzer-Loginseite ausblenden",
         "validate_license_now": "GUID erneut verifizieren",
         "verify": "Verifizieren",
         "yes": "&#10003;",
@@ -398,7 +406,8 @@
         "allowed_methods": "Access-Control-Allow-Methods",
         "allowed_origins": "Access-Control-Allow-Origin",
         "logo_dark_label": "Invertiert für den Darkmode",
-        "logo_normal_label": "Normal"
+        "logo_normal_label": "Normal",
+        "user_link": "Nutzer-Link"
     },
     "danger": {
         "access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
@@ -806,9 +815,13 @@
         "forgot_password": "> Passwort vergessen?",
         "invalid_pass_reset_token": "Der Rücksetz-Token für das Passwort ist ungültig oder abgelaufen.<br>Bitte fordern Sie einen neuen Link zur Passwortwiederherstellung an.",
         "login": "Anmelden",
-        "login_user": "Benutzer Anmelden",
-        "login_dadmin": "Domain-Administrator Anmelden",
-        "login_admin": "Administrator Anmelden",
+        "login_linkstext": "Nicht der richtige Login?",
+        "login_usertext": "Als Benutzer anmelden",
+        "login_domainadmintext": "Als Domainadmin anmelden",
+        "login_admintext": "Als Admin anmelden",
+        "login_user": "Anmeldung als Benutzer",
+        "login_dadmin": "Anmeldung als Domain-Administrator",
+        "login_admin": "Anmeldung als Administrator",
         "mobileconfig_info": "Bitte als Mailbox-Benutzer einloggen, um das Verbindungsprofil herunterzuladen.",
         "new_password": "Neues Passwort",
         "new_password_confirm": "Neues Passwort bestätigen",
@@ -991,7 +1004,8 @@
         "syncjob_EXIT_TLS_FAILURE": "Problem mit verschlüsselter Verbindung",
         "syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentifizierungsproblem",
         "syncjob_EXIT_OVERQUOTA": "Ziel Mailbox ist über dem Limit",
-        "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Kann keine Verbindung zum Zielserver herstellen"
+        "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Kann keine Verbindung zum Zielserver herstellen",
+        "iam": "Identitätsanbieter"
     },
     "oauth2": {
         "access_denied": "Bitte als Mailbox-Nutzer einloggen, um den Zugriff via OAuth2 zu erlauben.",
@@ -1094,6 +1108,7 @@
         "bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
         "bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
         "cors_headers_edited": "CORS Einstellungen wurden erfolgreich gespeichert",
+        "custom_login_modified": "Login Anpassung wurde erfolgreich gespeichert",
         "db_init_complete": "Datenbankinitialisierung abgeschlossen",
         "delete_filter": "Filter-ID %s wurde gelöscht",
         "delete_filters": "Filter gelöscht: %s",

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

@@ -134,6 +134,7 @@
         "admin_domains": "Domain assignments",
         "admins": "Administrators",
         "admins_ldap": "LDAP Administrators",
+        "admin_quicklink": "Hide Quicklink to Admin Login Page",
         "advanced_settings": "Advanced settings",
         "allowed_methods": "Access-Control-Allow-Methods",
         "allowed_origins": "Access-Control-Allow-Origin",
@@ -161,6 +162,7 @@
         "credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.",
         "customer_id": "Customer ID",
         "customize": "Customize",
+        "login_page": "Login Page",
         "destination": "Destination",
         "dkim_add_key": "Add ARC/DKIM key",
         "dkim_domains_selector": "Selector",
@@ -179,6 +181,7 @@
         "domain": "Domain",
         "domain_admin": "Domain administrator",
         "domain_admins": "Domain administrators",
+        "domainadmin_quicklink": "Hide Quicklink to Domainadmin Login Page",
         "domain_s": "Domain/s",
         "duplicate": "Duplicate",
         "duplicate_dkim": "Duplicate DKIM record",
@@ -202,6 +205,8 @@
         "f2b_whitelist": "Whitelisted networks/hosts",
         "filter": "Filter",
         "filter_table": "Filter table",
+        "force_sso_text": "If an external OIDC provider is configured, this option hides the default mailcow login froms and only shows the Singe Sign-On button",
+        "force_sso": "Disable mailcow Login and show only Singe Sign-On",
         "forwarding_hosts": "Forwarding Hosts",
         "forwarding_hosts_add_hint": "You can either specify IPv4/IPv6 addresses, networks in CIDR notation, host names (which will be resolved to IP addresses), or domain names (which will be resolved to IP addresses by querying SPF records or, in their absence, MX records).",
         "forwarding_hosts_hint": "Incoming messages are unconditionally accepted from any hosts listed here. These hosts are then not checked against DNSBLs or subjected to greylisting. Spam received from them is never rejected, but optionally it can be filed into the Junk folder. The most common use for this is to specify mail servers on which you have set up a rule that forwards incoming emails to your mailcow server.",
@@ -229,6 +234,7 @@
         "iam_host": "Host",
         "iam_host_info": "Enter one or more LDAP hosts, separated by commas.",
         "iam_import_users": "Import Users",
+        "iam_login_provisioning": "Auto-create users on login",
         "iam_mapping": "Attribute Mapping",
         "iam_bindpass": "Bind Password",
         "iam_periodic_full_sync": "Periodic Full Sync",
@@ -317,6 +323,7 @@
         "quarantine_release_format_att": "As attachment",
         "quarantine_release_format_raw": "Unmodified original",
         "quarantine_retention_size": "Retentions per mailbox:<br><small>0 indicates <b>inactive</b>.</small>",
+        "quicklink_text": "Show or hide quick links to other login pages under the login form",
         "quota_notification_html": "Notification email template:<br><small>Leave empty to restore default template.</small>",
         "quota_notification_sender": "Notification email sender",
         "quota_notification_subject": "Notification email subject",
@@ -398,6 +405,7 @@
         "upload": "Upload",
         "username": "Username",
         "user_link": "User-Link",
+        "user_quicklink": "Hide Quicklink to User Login Page",
         "validate_license_now": "Validate GUID against license server",
         "verify": "Verify",
         "yes": "&#10003;"
@@ -809,6 +817,10 @@
         "forgot_password": "> Forgot Password?",
         "invalid_pass_reset_token": "The reset password token is invalid or has expired.<br>Please request a new password reset link.",
         "login": "Login",
+        "login_linkstext": "Not the correct login?",
+        "login_usertext": "Log in as user",
+        "login_domainadmintext": "Log in as domain admin",
+        "login_admintext": "Log in as admin",
         "login_user": "User Login",
         "login_dadmin": "Domain-Administrator Login",
         "login_admin": "Administrator Login",
@@ -1105,6 +1117,7 @@
         "bcc_edited": "BCC map entry %s edited",
         "bcc_saved": "BCC map entry saved",
         "cors_headers_edited": "CORS settings have been saved",
+        "custom_login_modified": "Login customisation was saved successfully",
         "db_init_complete": "Database initialization completed",
         "delete_filter": "Deleted filters ID %s",
         "delete_filters": "Deleted filters: %s",

+ 4 - 1
data/web/lang/lang.fr-fr.json

@@ -730,7 +730,10 @@
         "new_password": "Nouveau mot de passe",
         "new_password_confirm": "Confirmer le nouveau mot de passe",
         "reset_password": "Réinitialiser le mot de passe",
-        "request_reset_password": "Demander le changement du mot de passe"
+        "request_reset_password": "Demander le changement du mot de passe",
+        "login_user": "Connexion Utilisateur",
+        "login_dadmin": "Connexion Administrateur de domaine",
+        "login_admin": "Connexion Administrateur"
     },
     "mailbox": {
         "action": "Action",

+ 1 - 1
data/web/lang/lang.hu-hu.json

@@ -590,4 +590,4 @@
         "app_name": "Alkalmazás neve",
         "app_passwd_protocols": "Engedélyezett protokollok az alkalmazás jelszavához"
     }
-}
+}

+ 43 - 20
data/web/lang/lang.lv-lv.json

@@ -16,7 +16,16 @@
         "login_as": "Pieteikšanās kā pastkastes lietotājam",
         "mailbox_relayhost": "Pasta kastītes relayhost maiņa",
         "prohibited": "Aizliegts ar ACL",
-        "protocol_access": "Protokola piekļuves maiņa"
+        "protocol_access": "Protokola piekļuves maiņa",
+        "pw_reset": "Ļaut atiestatīt mailcow lietotāja paroli",
+        "ratelimit": "Piekļuves biežuma ierobežojums",
+        "quarantine": "Karantīnas darbības",
+        "quarantine_attachments": "Karantīnas pielikumi",
+        "quarantine_category": "Mainīt karantīnas paziņojumu kategoriju",
+        "quarantine_notification": "Mainīt karantīnas paziņojumus",
+        "smtp_ip_access": "Mainīt SMTP atļautos saimniekdatorus",
+        "sogo_access": "Atļaut SOGo piekļuves pārvaldību",
+        "sogo_profile_reset": "Atiestatīt SOGo profilu"
     },
     "add": {
         "activate_filter_warn": "Visi pārējie filtri tiks deaktivizēti, kad aktīvs ir atzīmēts.",
@@ -24,8 +33,8 @@
         "add": "Pievienot",
         "add_domain_only": "Tikai pievienot domēnu",
         "add_domain_restart": "Pievienot domēnu un restartēt SOGo",
-        "alias_address": "Aizstājaddrese/s",
-        "alias_address_info": "<small>Pilna epasta addrese/s vai @piemērs.com, lai notvertu visas domēna ziņas (komatu atdalītas). <b>tikai mailcow domēni</b>.</small>",
+        "alias_address": "Aizstājadrese/s",
+        "alias_address_info": "<small>Pilna epasta adrese/s vai @example.com, lai notvertu visus domēna ziņojumus (atdalītas ar komatu). <b>Tikai mailcow domēni</b>.</small>",
         "alias_domain": "Aizstājdomēni",
         "alias_domain_info": "<small>Tikai derīgi domēna vārdi (komatu atdalīti).</small>",
         "automap": "Mēģiniet automatizēt mapes (\"Nosūtītie vienumi\", \"Nosūtītie\" => \"Nosūtītie\" etc.)",
@@ -63,15 +72,17 @@
         "skipcrossduplicates": "Izlaist dublētus ziņojumus pa mapēm (pirmais nāk, pirmais kalpo)",
         "syncjob": "Pievienot sinhronizācijas darbu",
         "syncjob_hint": "Ņemiet vērā, ka parole ir jāuzglabā vienkāršā tekstā!",
-        "target_address": "Iet uz adresēm",
-        "target_address_info": "<small>Pilna epasta addrese/s (comma-separated).</small>",
+        "target_address": "Mērķa adreses",
+        "target_address_info": "<small>Pilna epasta adrese/s (atdalītas ar komatu).</small>",
         "target_domain": "Mērķa domēns",
         "username": "Lietotājvārds",
         "validate": "Apstiprināt",
         "validation_success": "Apstiprināts veiksmīgi",
         "bcc_dest_format": "BCC galamērķim ir jābūt vienai derīgai e-pasta adresei.<br>Ja ir nepieciešams nosūtīt kopiju vairākām adresēm, jāizveido aizstājvārds un jāizmanto tas šeit.",
         "domain_matches_hostname": "Domēns %s atbilst saimniekdatora nosaukumam",
-        "disable_login": "Neļaut pieteikšanos (ienākošais pasts joprojām tiks pieņemts)"
+        "disable_login": "Neļaut pieteikšanos (ienākošais pasts joprojām tiks pieņemts)",
+        "app_password": "Pievienot lietotnes paroli",
+        "app_passwd_protocols": "Atļautie lietotnes paroles protokoli"
     },
     "admin": {
         "access": "Pieeja",
@@ -114,7 +125,7 @@
         "f2b_whitelist": "Baltā saraksta tīkls/hosts",
         "filter_table": "Filtru tabula",
         "forwarding_hosts": "Hostu pārsūtīšana",
-        "forwarding_hosts_add_hint": "Var norādīt vai nu IPv4/IPv6 addreses, tīklu ar CIDR apzīmējumu, saimniekdatoru nosaukumus (kas tiks atrisināti IP adresēs) vai arī domēna vārdus (kas tiks atrisināti IP adresēs, vaicājot SPF ierakstus, vai, ja tādu nav, MX ierakstus).",
+        "forwarding_hosts_add_hint": "Var norādīt vai nu IPv4/IPv6 adreses, tīklu ar CIDR apzīmējumu, saimniekdatoru nosaukumus (kas tiks atrisināti IP adresēs) vai arī domēna vārdus (kas tiks atrisināti IP adresēs, vaicājot SPF ierakstus, vai, ja tādu nav, MX ierakstus).",
         "forwarding_hosts_hint": "Ienākošie ziņojumi tiek bez nosacījumiem pieņemti no visiem šeit norādītajiem saimniekdatoriem. Tie tad netiek pārbaudīti pret DNSBL vai pakļauti ievietošanai pelēkajā sarakstā. No tiem saņemtās mēstules nekad netiek noraidītas, bet pēc izvēles tās var pārvietot mapē \"Nevēlams\". Visbiežāk to izmanto, lai norādītu pasta serverus, kuros ir uzstādīts nosacījums, kas pārsūta ienākošās e-pasta vēstules uz Tavu mailcow serveri.",
         "help_text": "Pārrakstīt palīdzības tekstu zem pieteikšanās maskas (var izmantot HTML)",
         "host": "Hosts",
@@ -142,7 +153,7 @@
         "recipients": "Adresāts",
         "refresh": "Atsvaidzināt",
         "regen_api_key": "Reģenerēt API atslēgu",
-        "relay_from": "\"No:\" addrese",
+        "relay_from": "\"No:\" adrese",
         "relay_run": "Palaist testu",
         "relayhosts_hint": "Norādīt no sūtītāja atkarīgas piegādes, lai varētu tos atlasīt domēnu konfigurācijas uzvednē.<br>\n  Piegādes pakalpojums vienmēr ir \"smtp\", tādējādi tiks mēģināts TLS, kad piedāvāts. Iekļautais TLS (SMTPS) netiek atbalstīts. Tiek ņemts vērā lietotāja atsevišķais izejošā TLS nosacījuma iestatījums.<br>\n  Ietekmē atlasītos domēnus, tajā skaitā aizstājdomēnus.",
         "remove": "Noņemt",
@@ -222,7 +233,8 @@
         "targetd_not_found": "Mērķa domēns nav atrasts",
         "username_invalid": "Lietotājvārds nevar tikt izmantots",
         "validity_missing": "Lūdzu piešķiriet derīguma termiņu",
-        "domain_cannot_match_hostname": "Domēns nevar atbilst saimniekdatora nosaukumam"
+        "domain_cannot_match_hostname": "Domēns nevar atbilst saimniekdatora nosaukumam",
+        "app_passwd_id_invalid": "Lietotnes paroles Id %s ir nederīgs"
     },
     "diagnostics": {
         "cname_from_a": "Vērtība, kas iegūta no A/AAAA ieraksta. Tas tiek atbalstīts tik ilgi, kamēr ieraksts norāda uz pareizo resursu.",
@@ -257,7 +269,7 @@
         "hostname": "Saimniekdatora nosaukums",
         "inactive": "Neaktīvs",
         "kind": "Veids",
-        "mailbox": "Rediģēt pastkasti",
+        "mailbox": "Labot pastkasti",
         "max_aliases": "Lielākais aizstājvārdu skaits",
         "max_mailboxes": "Maks. iespējamās pastkastes",
         "max_quota": "Maks. kvota uz pastkasti (MiB)",
@@ -283,8 +295,8 @@
         "spam_policy": "Pievienot vai noņemt vienumus baltajā-/melnajā sarakstā",
         "spam_score": "Iestatīt pielāgotu surogātpasta vērtējumu",
         "subfolder2": "Sinhronizēt galamērķa apakšmapē<br><small>(tukšs = neizmantot apakšmapi)</small>",
-        "syncjob": "Rediģēt sinhronizācijas darbu",
-        "target_address": "Iet uz adresi/ēm <small>(komatu atdalītas)</small>",
+        "syncjob": "Labot sinhronizācijas darbu",
+        "target_address": "Mērķa adrese/s <small>(atdalītas ar komatu)</small>",
         "target_domain": "Mērķa domēns",
         "title": "Labot priekšmetu",
         "unchanged_if_empty": "Ja neizmainīts atstājiet tukšu",
@@ -300,8 +312,11 @@
         "sogo_visible": "Aizstājvārds ir redzams SOGo",
         "sogo_visible_info": "Šī iespēja ietekmē tikai tos objektus, kurus var parādīt SOGo (koplietojamās vai nekoplietojamās aizstājadreses, kas norāda uz vismaz vienu vietējo pastkasti). Ja paslēpts, netiks parādīts SOGo kā atlasāms sūtītājs.",
         "mbox_rl_info": "Šis pieprasījumu ierobežojums tiek piemērots SASL pieteikšanās vārdam, tas atbilst jebkurai \"from\" adresei, ko izmanto lietotājs, kurš ir pieteicies. Pastkastes pieprasījumu ierobežojums pārraksta domēna mēroga pieprasījumu ierobežojumu.",
-        "sogo_access": "Nodrošināt tiešu pieteikšanās piekļuvi SOGo",
-        "disable_login": "Neļaut pieteikšanos (ienākošais pasts joprojām tiks pieņemts)"
+        "sogo_access": "Tieša pārvirzīšana uz SOGo",
+        "disable_login": "Neļaut pieteikšanos (ienākošais pasts joprojām tiks pieņemts)",
+        "app_passwd_protocols": "Atļautie lietotnes paroles protokoli",
+        "allowed_protocols": "Atļautie protokoli tiešai lietotāja piekļuvei (neietekmē lietotnes paroles protokolus)",
+        "app_passwd": "Lietotnes parole"
     },
     "footer": {
         "cancel": "Atcelt",
@@ -508,7 +523,9 @@
         "verified_fido2_login": "Apliecināta FIDO2 pieteikšanās",
         "verified_webauthn_login": "Apliecināta WebAuthn pieteikšanās",
         "verified_totp_login": "Apliecināta TOTP pieteikšanās",
-        "verified_yotp_login": "Apliecināta Yubico OTP pieteikšanās"
+        "verified_yotp_login": "Apliecināta Yubico OTP pieteikšanās",
+        "app_passwd_removed": "Noņemta lietotnes parole ar Id %s",
+        "app_passwd_added": "Pievienota jauna lietotnes parole"
     },
     "tfa": {
         "api_register": "%s izmanto Yubico Cloud API. Lūdzu iegūstiet API atslēgu priekš Jūsu atslēgas<a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">here</a>",
@@ -523,7 +540,7 @@
         "scan_qr_code": "Lūdzu, skenējiet šo kodu ar savu autentifikācijas lietojumprogrammu vai ievadiet kodu manuāli.",
         "select": "Lūdzu izvēlaties",
         "set_tfa": "Uzstādīt difi faktoru autentifik;acijas metodi",
-        "tfa": "Divu faktoru autentifikācija",
+        "tfa": "Divpakāpju pieteikšanās",
         "totp": "Uz laiku bāzēta vienreizēja parole (Google Autentifikātors utt.)",
         "webauthn": "WebAuthn autentifikācija",
         "waiting_usb_auth": "<i>Gaida USB ierīci...</i><br><br>Lūdzu, tagad nospiežiet pogu uz Jūsu WebAuthn USB ierīces.",
@@ -618,12 +635,18 @@
         "apple_connection_profile_mailonly": "Šis savienojuma profils iekļauj IMAP un SMTP konfigurācijas parametrus Apple ierīcei.",
         "pushover_info": "Pašpiegādes paziņojumu iestatījumi attieksies uz visu tīro (ne surogātpasta) pastu, kas piegādāts uz <b>%s</b>, ieskaitot aizstājvārdus (kopīgotus, nekopīgotus, ar birkām).",
         "app_hint": "Lietotņu paroles ir aizstājējparoles, lai pieteiktos IMAP, SMTP, CalDAV, CardDAV un EAS. Lietotājvārds paliek nemainīgs. SOGo tīmekļa pasts nav pieejams ar lietotņu parolēm.",
-        "direct_protocol_access": "Šim pastkastes lietotājam ir <b> tieša, ārēja piekļuve</b> zemāk uzskaitītajiem protokoliem un lietotnēm. Šo iestatījumu pārrauga pārvaldītājs. Lietotņu paroles var izveidot, lai nodrošinātu piekļuvi atsevišķiem protokoliem un lietotnēm.<br>Poga \"Pieteikties tīmekļa pastā\" nodrošina vienotu pieteikšanos SOGo un vienmēr ir pieejama.",
+        "direct_protocol_access": "Šim pastkastes lietotājam ir <b> tieša, ārēja piekļuve</b> zemāk uzskaitītajiem protokoliem un lietotnēm. Šo iestatījumu pārrauga pārvaldītājs. Lietotņu paroles var izveidot, lai nodrošinātu piekļuvi atsevišķiem protokoliem un lietotnēm.<br>Poga \"Tīmekļa pasts\" nodrošina vienotu pieteikšanos SOGo un vienmēr ir pieejama.",
         "last_ui_login": "Pēdējā pieteikšanās saskarnē",
         "login_history": "Pieteikšanās vēsture",
         "no_last_login": "Nav informācijas par pēdējām pieteikšanās saskarnē reizēm",
-        "open_webmail_sso": "Pieteikšanās tīmekļa pastā",
-        "last_mail_login": "Pēdējā pasta pieteikšanās"
+        "open_webmail_sso": "Tīmekļa pasts",
+        "last_mail_login": "Pēdējā pasta pieteikšanās",
+        "change_password_hint_app_passwords": "Kontā ir %d lietotņu paroles, kas netiks mainītas. Lai pārvaldītu tās, jādodas uz cilni \"Lietotņu paroles\".",
+        "with_app_password": "ar lietotnes paroli",
+        "apple_connection_profile_with_app_password": "Jauna lietotnes parole ir izveidota un pievienota profilam, lai ierīces iestatīšanas laikā nebūtu nepieciešams ievadīt paroli. Lūgums nekopīgot datni, jo tā nodrošina pilnu piekļuvi pastkastei.",
+        "tfa_info": "Divpakāpju autentificēšanās palīdz aizsargāt kontu.Ja tā ir iespējota, var būt nepieciešamas lietotņu paroles, lai pieteiktos lietotnēs vai pakalpojumos, kas nenodrošina divpakāpju autentificēšanos (piem., e-pasta klienti).",
+        "app_passwds": "Lietotņu paroles",
+        "create_app_passwd": "Izveidot lietotnes paroli"
     },
     "datatables": {
         "paginate": {
@@ -657,4 +680,4 @@
     "fido2": {
         "fido2_auth": "Pieteikties ar FIDO2"
     }
-}
+}

+ 1 - 1
data/web/lang/lang.tr-tr.json

@@ -1309,4 +1309,4 @@
         "q_reject": "Reddedildi",
         "week": "Hafta"
     }
-}
+}

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

@@ -1307,4 +1307,4 @@
         },
         "collapse_all": "Згорнути все"
     }
-}
+}

+ 59 - 9
data/web/lang/lang.zh-cn.json

@@ -108,7 +108,8 @@
         "timeout2": "本地主机连接超时时间",
         "username": "用户名",
         "validate": "验证",
-        "validation_success": "验证成功"
+        "validation_success": "验证成功",
+        "dry": "模拟同步(Dry run)"
     },
     "admin": {
         "access": "权限管理",
@@ -358,7 +359,43 @@
         "ip_check_disabled": "IP 检查已禁用。你可透过以下路径启用<br> <strong>系统 > 配置 > 选项 > 页面自定义</strong>",
         "queue_unban": "解除封禁",
         "allowed_methods": "访问控制允许方式",
-        "allowed_origins": "访问控制允许原"
+        "allowed_origins": "访问控制允许原",
+        "iam": "身份识别提供者",
+        "iam_attribute_field": "Attribute 域",
+        "iam_authorize_url": "Authorization endpoint",
+        "iam_auth_flow": "认证流程",
+        "iam_basedn": "Base DN",
+        "iam_client_id": "客户端 ID",
+        "iam_client_secret": "客户端凭据",
+        "iam_client_scopes": "客户端 Scopes",
+        "iam_default_template": "默认模板",
+        "iam_default_template_description": "如果未为用户分配模板,则在创建邮箱时将使用默认模板,但在更新邮箱时不会使用默认模板。",
+        "iam_description": "配置外部认证提供者<br>如果已设置好属性映射,用户在首次登录时将会自动创建其 Mailbox。",
+        "iam_host": "Host",
+        "iam_host_info": "请输入一个或多个 LDAP 主机,使用英文逗号分隔。",
+        "iam_import_users": "导入用户",
+        "iam_mapping": "属性映射",
+        "iam_bindpass": "密码绑定(Bind Password)",
+        "iam_periodic_full_sync": "周期性全量同步",
+        "iam_port": "端口",
+        "iam_realm": "Realm",
+        "iam_redirect_url": "重定向 Url",
+        "iam_rest_flow": "Mailpassword 流程",
+        "iam_server_url": "服务器 Url",
+        "iam_sso": "单点登录(SSO)",
+        "iam_sync_interval": "同步/导入周期(min)",
+        "iam_test_connection": "测试连接",
+        "iam_token_url": "Token endpoint",
+        "iam_userinfo_url": "User info endpoint",
+        "iam_username_field": "Username 域",
+        "iam_binddn": "Bind DN",
+        "iam_use_ssl": "使用 SSL",
+        "iam_use_tls": "使用 TLS",
+        "iam_version": "版本",
+        "ignore_ssl_error": "忽略 SSL 错误",
+        "iam_auth_flow_info": "除了在单点登录(SSO)中使用的 Authorization Code 流程(在 Keycloak 中是标准流程)之外,mailcow 还支持使用 Credentials 的身份认证流程。Mailpassword 流程尝试通过 Keycloak 的 Admin REST API 验证用户凭据,mailcow 会从 Keycloak 中的 <code>mailcow_password</code> 属性中获取哈希后的密码。",
+        "filter": "过滤",
+        "iam_extra_permission": "要使以下设置生效,Keycloak 中的 mailcow 客户端需要一个 <code>服务账户(Service account)</code> 以及 <code>查看用户(view-users)</code> 的权限。"
     },
     "danger": {
         "access_denied": "访问被拒绝或者表单数据无效",
@@ -495,7 +532,11 @@
         "webauthn_authenticator_failed": "找不到所选的 authenticator",
         "webauthn_publickey_failed": "没有为选定的身份验证器保存公钥",
         "webauthn_username_failed": "所选的 authenticator 属于另一个账户",
-        "demo_mode_enabled": "演示模式已开启"
+        "demo_mode_enabled": "演示模式已开启",
+        "generic_server_error": "服务器错误。请联系您的管理员。",
+        "authsource_in_use": "由于当前有一个或多个用户正在使用该身份提供者(IDP),因此无法更改或删除。",
+        "iam_test_connection": "连接失败",
+        "required_data_missing": "缺少需要的 %s 数据"
     },
     "debug": {
         "chart_this_server": "图表 (此服务器)",
@@ -744,7 +785,10 @@
         "new_password_confirm": "确认新密码",
         "reset_password": "重置密码",
         "request_reset_password": "请求重置密码",
-        "invalid_pass_reset_token": "密码重置 token 无效或已过期。<br> 请重新获取新的密码重置链接。"
+        "invalid_pass_reset_token": "密码重置 token 无效或已过期。<br> 请重新获取新的密码重置链接。",
+        "login_user": "用户登录",
+        "login_dadmin": "域管理员登录",
+        "login_admin": "管理员登录"
     },
     "mailbox": {
         "action": "操作",
@@ -919,7 +963,8 @@
         "max_quota": "每个信箱的最大容量配额",
         "relay_unknown": "转发未知信箱",
         "templates": "模板",
-        "template": "模板"
+        "template": "模板",
+        "iam": "身份提供者(IDP)"
     },
     "oauth2": {
         "access_denied": "请作为邮箱所有者登录以使用 OAuth2 授权。",
@@ -950,7 +995,7 @@
         "neutral_danger": "无危险等级",
         "notified": "已发送通知",
         "qhandler_success": "已成功向系统发送请求,现在你可以关闭这个窗口了。",
-        "qid": "Rspamd QID",
+        "qid": "Rspamd 队列ID(QID",
         "qinfo": "隔离系统会把已被拒绝接收的邮件以及作为拷贝发送到垃圾箱的邮件保存到数据库中 (发件人<em>不</em>会知道)。\r\n  <br>\"学习为垃圾并删除\" 会根据贝叶斯定理将消息作为垃圾学习并计算其模糊特征以拒绝未来收到相似消息。\r\n  <br>请注意,这取决于你的系统资源,学习多个消息可能会花费较长时间。<br>黑名单中项目会被隔离系统排除。",
         "qitem": "隔离项目",
         "quarantine": "隔离",
@@ -1097,7 +1142,8 @@
         "recovery_email_sent": "重置邮件已发送至 %s",
         "template_added": "新增了模板 %s",
         "template_modified": "模板 %s 的修改已保存",
-        "template_removed": "模板 ID %s 已删除"
+        "template_removed": "模板 ID %s 已删除",
+        "iam_test_connection": "连接成功"
     },
     "tfa": {
         "api_register": "%s 使用了 Yubico Cloud API,请<a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">在此</a>为你的密钥获取 API 密钥",
@@ -1292,7 +1338,11 @@
         "password_reset_info": "如果不提供密码重置邮箱,此功能将无法使用。",
         "pushover_sound": "声音",
         "value": "值",
-        "attribute": "属性"
+        "attribute": "属性",
+        "protocols": "协议",
+        "authentication": "认证",
+        "tfa_info": "两步验证有助于保护您的账户安全。启用后,对于不支持两步验证的应用程序或服务(例如邮件客户端),需要使用应用专用密码进行登录。",
+        "overview": "概览"
     },
     "warning": {
         "cannot_delete_self": "不能删除已登录的用户",
@@ -1332,4 +1382,4 @@
         "loadingRecords": "加载中...",
         "zeroRecords": "未找到符合条件的记录"
     }
-}
+}

+ 2 - 1
data/web/sogo-auth.php

@@ -94,7 +94,8 @@ elseif (isset($_SERVER['HTTP_X_ORIGINAL_URI']) && strcasecmp(substr($_SERVER['HT
         !empty($email) &&
         filter_var($email, FILTER_VALIDATE_EMAIL) &&
         is_array($_SESSION[$session_var_user_allowed]) &&
-        in_array($email, $_SESSION[$session_var_user_allowed])
+        in_array($email, $_SESSION[$session_var_user_allowed]) &&
+        !$_SESSION['pending_pw_update']
     ) {
       $username = $email;
       $password = file_get_contents("/etc/sogo-sso/sogo-sso.pass");

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

@@ -51,7 +51,41 @@
           </div></p>
         </form>
       </div>
-      <legend>{{ lang.admin.app_links }}</legend><hr />
+      <legend style="padding-top:20px" unselectable="on">{{ lang.admin.login_page }}</legend><hr />
+      <div>
+        <form class="form" data-id="custom_login" role="form" method="post">
+          <p class="text-muted">{{ lang.admin.quicklink_text }}</p>
+          <div class="ms-2 mb-1">
+            <input class="form-check-input" type="checkbox" value="1" name="hide_user_quicklink" id="hide_user_quicklink" {% if custom_login.hide_user_quicklink == 1 %}checked{% endif %}>
+            <label class="form-check-label" for="hide_user_quicklink">
+              {{ lang.admin.user_quicklink|raw }}
+            </label>
+          </div>
+          <div class="ms-2 mb-1">
+            <input class="form-check-input" type="checkbox" value="1" name="hide_domainadmin_quicklink" id="hide_domainadmin_quicklink" {% if custom_login.hide_domainadmin_quicklink == 1 %}checked{% endif %}>
+            <label class="form-check-label" for="hide_domainadmin_quicklink">
+              {{ lang.admin.domainadmin_quicklink|raw }}
+            </label>
+          </div>
+          <div class="ms-2 mb-4">
+            <input class="form-check-input" type="checkbox" value="1" name="hide_admin_quicklink" id="hide_admin_quicklink" {% if custom_login.hide_admin_quicklink == 1 %}checked{% endif %}>
+            <label class="form-check-label" for="hide_admin_quicklink">
+              {{ lang.admin.admin_quicklink|raw }}
+            </label>
+          </div>
+          <p class="text-muted">{{ lang.admin.force_sso_text|raw }}</p>
+          <div class="ms-2 mb-4">
+            <input class="form-check-input" type="checkbox" value="1" name="force_sso" id="force_sso" {% if custom_login.force_sso == 1 %}checked{% endif %}>
+            <label class="form-check-label" for="force_sso">
+              {{ lang.admin.force_sso|raw }}
+            </label>
+          </div>
+          <p><div class="btn-group">
+            <button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-success" data-action="edit_selected" data-item="admin" data-id="custom_login" data-reload="no" data-api-url='edit/custom_login' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
+          </div></p>
+        </form>
+      </div>
+      <legend style="padding-top:20px">{{ lang.admin.app_links }}</legend><hr />
       <p class="text-muted">{{ lang.admin.merged_vars_hint|raw }}</p>
       <form class="form-inline" data-id="app_links" role="form" method="post">
         <table class="table table-condensed" style="white-space: nowrap;" id="app_link_table">

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

@@ -110,7 +110,7 @@
         <p>
           <span class="badge fs-7 bg-info d-block d-sm-inline-block">
             <i class="bi bi-funnel-fill"></i>
-            <a href="https://bgp.he.net/ip/{{ active_ban.ip }}" target="_blank">
+            <a href="https://bgp.tools/prefix/{{ active_ban.ip }}" target="_blank">
               {{ active_ban.network }}
             </a>
             ({{ active_ban.banned_until }})
@@ -130,7 +130,7 @@
         <p>
           <span class="badge fs-7 bg-danger d-block d-sm-inline-block">
             <i class="bi bi-funnel-fill"></i>
-            <a href="https://bgp.he.net/ip/{{ perm_ban.ip }}" target="_blank">
+            <a href="https://bgp.tools/prefix/{{ perm_ban.ip }}" target="_blank">
               {{ perm_ban.network }}
             </a>
           </span>

+ 99 - 5
data/web/templates/admin/tab-config-identity-provider.twig

@@ -64,10 +64,42 @@
           </div>
           <div class="row mb-2">
             <div class="col-md-3 d-flex align-items-center justify-content-md-end">
-              <label class="control-label" for="iam_keycloak_redirecturl">{{ lang.admin.iam_redirect_url }}:</label>
+              <label class="control-label">{{ lang.admin.iam_redirect_url }}:</label>
             </div>
             <div class="col-12 col-md-9 col-lg-4">
-              <input type="text" class="form-control" id="iam_keycloak_redirecturl" name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
+              <div class="row px-2 align-items-center">
+                <span class="col-10 p-0 pe-2">
+                  <input type="text" class="form-control"  name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
+                </span>
+                <div class="col-2 p-0 d-flex">
+                  <button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_redirect_add_keycloak"><i class="bi bi-plus-lg"></i></button>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="row mb-2" id="iam_keycloak_redirect_list">
+            <input type="hidden" name="redirect_url_extra" value="">
+            {% for key, url in iam_settings.redirect_url_extra %}
+            <div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
+              <div class="row px-2">
+                <div class="col-10 p-0 pe-2">
+                  <input type="text" class="form-control me-2" name="redirect_url_extra" value="{{ iam_settings.redirect_url_extra[key] }}">
+                </div>
+                <div class="col-2 p-0 d-flex">
+                  <button class="iam_keycloak_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
+                </div>
+              </div>
+            </div>
+            {% endfor %}
+            <div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
+              <div class="row px-2">
+                <div class="col-10 p-0 pe-2">
+                  <input type="text" class="form-control me-2" name="redirect_url_extra" value="">
+                </div>
+                <div class="col-2 p-0 d-flex">
+                  <button class="iam_keycloak_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
+                </div>
+              </div>
             </div>
           </div>
           <div class="row mb-4">
@@ -187,6 +219,16 @@
               </div>
             </div>
           </div>
+          <div class="row mb-2">
+            <div class="col-md-3 d-flex align-items-center justify-content-md-end">
+              <label class="control-label">{{ lang.admin.iam_login_provisioning }}</label>
+            </div>
+            <div class="col-12 col-md-9">
+              <div class="form-check form-switch">
+                <input class="form-check-input" type="checkbox" role="switch" name="login_provisioning"  value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
+              </div>
+            </div>
+          </div>
           <div class="row mb-2">
             <div class="col-md-3 d-flex align-items-center justify-content-md-end">
               <label class="control-label">{{ lang.admin.iam_periodic_full_sync }}</label>
@@ -274,10 +316,42 @@
           </div>
           <div class="row mb-2">
             <div class="col-md-3 d-flex align-items-center justify-content-md-end">
-              <label class="control-label" for="iam_redirect_url">{{ lang.admin.iam_redirect_url }}:</label>
+              <label class="control-label">{{ lang.admin.iam_redirect_url }}:</label>
             </div>
             <div class="col-12 col-md-9 col-lg-4">
-              <input type="text" class="form-control" id="iam_redirect_url" name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
+              <div class="row px-2 align-items-center">
+                <span class="col-10 p-0 pe-2">
+                  <input type="text" class="form-control" name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
+                </span>
+                <div class="col-2 p-0 d-flex">
+                  <button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_redirect_add_generic"><i class="bi bi-plus-lg"></i></button>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="row mb-2" id="iam_generic_redirect_list">
+            <input type="hidden" name="redirect_url_extra" value="">
+            {% for key, url in iam_settings.redirect_url_extra %}
+            <div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
+              <div class="row px-2">
+                <div class="col-10 p-0 pe-2">
+                  <input type="text" class="form-control me-2" name="redirect_url_extra" value="{{ iam_settings.redirect_url_extra[key] }}">
+                </div>
+                <div class="col-2 p-0 d-flex">
+                  <button class="iam_generic_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
+                </div>
+              </div>
+            </div>
+            {% endfor %}
+            <div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
+              <div class="row px-2">
+                <div class="col-10 p-0 pe-2">
+                  <input type="text" class="form-control me-2" name="redirect_url_extra" value="">
+                </div>
+                <div class="col-2 p-0 d-flex">
+                  <button class="iam_generic_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
+                </div>
+              </div>
             </div>
           </div>
           <div class="row mb-4">
@@ -366,7 +440,7 @@
               </div>
             </div>
           </div>
-          <div class="row mb-4">
+          <div class="row mb-2">
             <div class="col-md-3 d-flex align-items-center justify-content-md-end">
               <label class="control-label">{{ lang.admin.ignore_ssl_error }}</label>
             </div>
@@ -376,6 +450,16 @@
               </div>
             </div>
           </div>
+          <div class="row mb-4">
+            <div class="col-md-3 d-flex align-items-center justify-content-md-end">
+              <label class="control-label">{{ lang.admin.iam_login_provisioning }}</label>
+            </div>
+            <div class="col-12 col-md-9">
+              <div class="form-check form-switch">
+                <input class="form-check-input" type="checkbox" role="switch" name="login_provisioning"  value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
+              </div>
+            </div>
+          </div>
           <div class="row mt-4 mb-2">
             <div class="offset-md-3 col-12 col-md-9 d-flex flex-wrap">
               <div class="btn-group mb-2">
@@ -582,6 +666,16 @@
               </div>
             </div>
           </div>
+          <div class="row mb-2">
+            <div class="col-md-3 d-flex align-items-center justify-content-md-end">
+              <label class="control-label">{{ lang.admin.iam_login_provisioning }}</label>
+            </div>
+            <div class="col-12 col-md-9">
+              <div class="form-check form-switch">
+                <input class="form-check-input" type="checkbox" role="switch" name="login_provisioning"  value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
+              </div>
+            </div>
+          </div>
           <div class="row mb-2">
             <div class="col-md-3 d-flex align-items-center justify-content-md-end">
               <label class="control-label">{{ lang.admin.iam_periodic_full_sync }}</label>

+ 27 - 16
data/web/templates/admin_index.twig

@@ -5,13 +5,28 @@
 {% block content %}
 <div class="row mb-4" style="margin-top: 60px">
   <div class="col-12 col-md-7 col-lg-6 col-xl-5 ms-auto me-auto">
+
     <div class="card">
-      <div class="card-header d-flex align-items-center">
+      <div class="card-header d-flex align-items-center text-break">
         <i class="bi bi-person-fill me-2"></i> {{ lang.login.login_admin }}
         <div class="ms-auto form-check form-switch my-auto d-flex align-items-center">
           <label class="form-check-label"><i class="bi bi-moon-fill"></i></label>
           <input class="form-check-input ms-2" type="checkbox" id="dark-mode-toggle">
         </div>
+        <div class="ms-4 d-grid d-sm-block">
+          <button type="button" {% if available_languages|length == 1 %}disabled="true"{% endif %} class="text-secondary btn p-0 border-0 bg-transparent ms-auto dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <span class="flag-icon flag-icon-{{ mailcow_locale[-2:] }}"></span>
+          </button>
+          <ul class="dropdown-menu ms-auto login">
+            {% for key, val in available_languages %}
+              <li>
+                <a class="dropdown-item {% if mailcow_locale == key %}active{% endif %}" href="?{{ query_string({'lang': key}) }}">
+                  <span class="flag-icon flag-icon-{{ key[-2:] }}"></span>{{ val }}
+                </a>
+              </li>
+            {% endfor %}
+          </ul>
+        </div>
       </div>
       <div class="card-body">
         <div class="text-center mailcow-logo mb-4">
@@ -37,23 +52,10 @@
             </div>
           </div>
           <div class="d-flex justify-content-between mt-4" style="position: relative">
-            <button type="submit" class="btn btn-xs-lg btn-success" value="Login">{{ lang.login.login }}</button>            <div class="d-grid d-sm-block">
-            <button type="button" {% if available_languages|length == 1 %}disabled="true"{% endif %} class="btn btn-secondary ms-auto dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-              <span class="flag-icon flag-icon-{{ mailcow_locale[-2:] }}"></span>
-            </button>
-            <ul class="dropdown-menu ms-auto login">
-              {% for key, val in available_languages %}
-                <li>
-                  <a class="dropdown-item {% if mailcow_locale == key %}active{% endif %}" href="?{{ query_string({'lang': key}) }}">
-                    <span class="flag-icon flag-icon-{{ key[-2:] }}"></span>{{ val }}
-                  </a>
-                </li>
-              {% endfor %}
-            </ul>
-            </div>
+            <button type="submit" class="btn btn-xs-lg btn-success w-100 mt-2 mx-auto" style="max-width: 400px;" value="Login">{{ lang.login.login }}</button>
           </div>
         </form>
-        <div class="hr-title mt-5"><strong>{{ lang.login.other_logins }}</strong></div>
+        <div class="hr-title"><strong>{{ lang.login.other_logins }}</strong></div>
         <div class="d-flex flex-column align-items-center">
           <a class="btn btn-xs-lg btn-secondary w-100" style="max-width: 400px;" href="#" id="fido2-login"><i class="bi bi-shield-fill-check"></i> {{ lang.login.fido2_webauthn }}</a>
         </div>
@@ -86,6 +88,15 @@
         {% endif %}
       </div>
     </div>
+
+    {% if custom_login.hide_user_quicklink != 1 or custom_login.hide_domainadmin_quicklink != 1 %}
+    <p class="text-center mt-3 text-muted" style="font-size: 0.9rem;">
+      {{ lang.login.login_linkstext }}<br>
+      {% if custom_login.hide_user_quicklink != 1 %}<a href="/">{{ lang.login.login_usertext }}</a>{% endif %}
+      {% if custom_login.hide_user_quicklink != 1 and custom_login.hide_domainadmin_quicklink != 1 %}|{% endif %}
+      {% if custom_login.hide_domainadmin_quicklink != 1 %}<a href="/domainadmin">{{ lang.login.login_domainadmintext }}</a>{% endif %}
+    </p>
+    {% endif %}
   </div>
 </div>
 {% endblock %}

+ 27 - 16
data/web/templates/domainadmin_index.twig

@@ -5,13 +5,28 @@
 {% block content %}
 <div class="row mb-4" style="margin-top: 60px">
   <div class="col-12 col-md-7 col-lg-6 col-xl-5 ms-auto me-auto">
+
     <div class="card">
-      <div class="card-header d-flex align-items-center">
+      <div class="card-header d-flex align-items-center text-break">
         <i class="bi bi-person-fill me-2"></i> {{ lang.login.login_dadmin }}
         <div class="ms-auto form-check form-switch my-auto d-flex align-items-center">
           <label class="form-check-label"><i class="bi bi-moon-fill"></i></label>
           <input class="form-check-input ms-2" type="checkbox" id="dark-mode-toggle">
         </div>
+        <div class="ms-4 d-grid d-sm-block">
+          <button type="button" {% if available_languages|length == 1 %}disabled="true"{% endif %} class="text-secondary btn p-0 border-0 bg-transparent ms-auto dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <span class="flag-icon flag-icon-{{ mailcow_locale[-2:] }}"></span>
+          </button>
+          <ul class="dropdown-menu ms-auto login">
+            {% for key, val in available_languages %}
+              <li>
+                <a class="dropdown-item {% if mailcow_locale == key %}active{% endif %}" href="?{{ query_string({'lang': key}) }}">
+                  <span class="flag-icon flag-icon-{{ key[-2:] }}"></span>{{ val }}
+                </a>
+              </li>
+            {% endfor %}
+          </ul>
+        </div>
       </div>
       <div class="card-body">
         <div class="text-center mailcow-logo mb-4">
@@ -37,23 +52,10 @@
             </div>
           </div>
           <div class="d-flex justify-content-between mt-4" style="position: relative">
-            <button type="submit" class="btn btn-xs-lg btn-success" value="Login">{{ lang.login.login }}</button>            <div class="d-grid d-sm-block">
-            <button type="button" {% if available_languages|length == 1 %}disabled="true"{% endif %} class="btn btn-secondary ms-auto dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-              <span class="flag-icon flag-icon-{{ mailcow_locale[-2:] }}"></span>
-            </button>
-            <ul class="dropdown-menu ms-auto login">
-              {% for key, val in available_languages %}
-                <li>
-                  <a class="dropdown-item {% if mailcow_locale == key %}active{% endif %}" href="?{{ query_string({'lang': key}) }}">
-                    <span class="flag-icon flag-icon-{{ key[-2:] }}"></span>{{ val }}
-                  </a>
-                </li>
-              {% endfor %}
-            </ul>
-            </div>
+            <button type="submit" class="btn btn-xs-lg btn-success w-100 mt-2 mx-auto" style="max-width: 400px;" value="Login">{{ lang.login.login }}</button>
           </div>
         </form>
-        <div class="hr-title mt-5"><strong>{{ lang.login.other_logins }}</strong></div>
+        <div class="hr-title"><strong>{{ lang.login.other_logins }}</strong></div>
         <div class="d-flex flex-column align-items-center">
           <a class="btn btn-xs-lg btn-secondary w-100" style="max-width: 400px;" href="#" id="fido2-login"><i class="bi bi-shield-fill-check"></i> {{ lang.login.fido2_webauthn }}</a>
         </div>
@@ -86,6 +88,15 @@
         {% endif %}
       </div>
     </div>
+
+    {% if custom_login.hide_user_quicklink != 1 or custom_login.hide_admin_quicklink != 1 %}
+    <p class="text-center mt-3 text-muted" style="font-size: 0.9rem;">
+      {{ lang.login.login_linkstext }}<br>
+      {% if custom_login.hide_user_quicklink != 1 %}<a href="/">{{ lang.login.login_usertext }}</a>{% endif %}
+      {% if custom_login.hide_user_quicklink != 1 and custom_login.hide_admin_quicklink != 1 %}|{% endif %}
+      {% if custom_login.hide_admin_quicklink != 1 %}<a href="/admin">{{ lang.login.login_admintext }}</a>{% endif %}
+    </p>
+    {% endif %}
   </div>
 </div>
 {% endblock %}

+ 38 - 22
data/web/templates/user_index.twig

@@ -5,13 +5,30 @@
 {% block content %}
 <div class="row mb-4" style="margin-top: 60px">
   <div class="col-12 col-md-7 col-lg-6 col-xl-5 ms-auto me-auto">
+
     <div class="card">
-      <div class="card-header d-flex align-items-center">
+      <div class="card-header d-flex align-items-center text-break">
         <i class="bi bi-person-fill me-2"></i> {{ lang.login.login_user }}
         <div class="ms-auto form-check form-switch my-auto d-flex align-items-center">
           <label class="form-check-label"><i class="bi bi-moon-fill"></i></label>
           <input class="form-check-input ms-2" type="checkbox" id="dark-mode-toggle">
         </div>
+        {% if not oauth2_request %}
+        <div class="ms-4 d-grid d-sm-block">
+          <button type="button" {% if available_languages|length == 1 %}disabled="true"{% endif %} class="text-secondary btn p-0 border-0 bg-transparent ms-auto dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <span class="flag-icon flag-icon-{{ mailcow_locale[-2:] }}"></span>
+          </button>
+          <ul class="dropdown-menu ms-auto login">
+            {% for key, val in available_languages %}
+              <li>
+                <a class="dropdown-item {% if mailcow_locale == key %}active{% endif %}" href="?{{ query_string({'lang': key}) }}">
+                  <span class="flag-icon flag-icon-{{ key[-2:] }}"></span>{{ val }}
+                </a>
+              </li>
+            {% endfor %}
+          </ul>
+        </div>
+        {% endif %}
       </div>
       <div class="card-body">
         <div class="text-center mailcow-logo mb-4">
@@ -25,6 +42,7 @@
         {% if is_mobileconfig %}
         <div class="my-4 alert alert-info ">{{ lang.login.mobileconfig_info }}</div>
         {% endif %}
+        {% if custom_login.force_sso != 1 %}
         <form method="post" autofill="off">
           <div class="d-flex mt-3">
             <label class="visually-hidden" for="login_user">{{ lang.login.username }}</label>
@@ -40,35 +58,22 @@
               <input name="pass_user" type="password" id="pass_user" class="form-control" placeholder="{{ lang.login.password }}" required="" autocomplete="current-password">
             </div>
           </div>
+          <div class="mt-2 text-muted" style="font-size: 0.9rem;">
+            <a href="/reset-password">{{ lang.login.forgot_password }}</a>
+          </div>
           <div class="d-flex justify-content-between mt-4" style="position: relative">
-            <button type="submit" class="btn btn-xs-lg btn-success" value="Login">{{ lang.login.login }}</button>
-            {% if not oauth2_request %}
-            <div class="d-grid d-sm-block">
-            <button type="button" {% if available_languages|length == 1 %}disabled="true"{% endif %} class="btn btn-secondary ms-auto dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-              <span class="flag-icon flag-icon-{{ mailcow_locale[-2:] }}"></span>
-            </button>
-            <ul class="dropdown-menu ms-auto login">
-              {% for key, val in available_languages %}
-                <li>
-                  <a class="dropdown-item {% if mailcow_locale == key %}active{% endif %}" href="?{{ query_string({'lang': key}) }}">
-                    <span class="flag-icon flag-icon-{{ key[-2:] }}"></span>{{ val }}
-                  </a>
-                </li>
-              {% endfor %}
-            </ul>
-            </div>
-            {% endif %}
+            <button type="submit" class="btn btn-xs-lg btn-success w-100 mt-2 mx-auto" style="max-width: 400px;" value="Login">{{ lang.login.login }}</button>
           </div>
         </form>
-        <div class="mt-3">
-          <a href="/reset-password">{{ lang.login.forgot_password }}</a>
-        </div>
-        <div class="hr-title mt-5"><strong>{{ lang.login.other_logins }}</strong></div>
+        <div class="hr-title"><strong>{{ lang.login.other_logins }}</strong></div>
+        {% endif %}
         <div class="d-flex flex-column align-items-center">
           {% if has_iam_sso %}
           <a class="btn btn-xs-lg btn-secondary w-100 mt-2" style="max-width: 400px;" href="/?iam_sso=1"><i class="bi bi-cloud-arrow-up-fill"></i> {{ lang.admin.iam_sso }}</a>
           {% endif %}
+          {% if custom_login.force_sso != 1 %}
           <a class="btn btn-xs-lg btn-secondary w-100 mt-2" style="max-width: 400px;" href="#" id="fido2-login"><i class="bi bi-shield-fill-check"></i> {{ lang.login.fido2_webauthn }}</a>
+          {% endif %}
         </div>
         {% if login_delay %}
         <p><div class="my-4 alert alert-info">{{ lang.login.delayed|format(login_delay) }}</b></div></p>
@@ -96,9 +101,20 @@
             {% endfor %}
           {% endfor %}
         </div>
+        <div>
+        </div>
         {% endif %}
       </div>
     </div>
+
+    {% if custom_login.hide_admin_quicklink != 1 or custom_login.hide_domainadmin_quicklink != 1 %}
+    <p class="text-center mt-3 text-muted" style="font-size: 0.9rem;">
+      {{ lang.login.login_linkstext }}<br>
+      {% if custom_login.hide_admin_quicklink != 1 %}<a href="/admin">{{ lang.login.login_admintext }}</a>{% endif %}
+      {% if custom_login.hide_admin_quicklink != 1 and custom_login.hide_domainadmin_quicklink != 1 %}|{% endif %}
+      {% if custom_login.hide_domainadmin_quicklink != 1 %}<a href="/domainadmin">{{ lang.login.login_domainadmintext }}</a>{% endif %}
+    </p>
+    {% endif %}
   </div>
 </div>
 {% if not oauth2_request and ui_texts.help_text %}

+ 6 - 3
docker-compose.yml

@@ -84,7 +84,7 @@ services:
             - clamd
 
     rspamd-mailcow:
-      image: ghcr.io/mailcow/rspamd:2.1
+      image: ghcr.io/mailcow/rspamd:2.2
       stop_grace_period: 30s
       depends_on:
         - dovecot-mailcow
@@ -176,6 +176,7 @@ services:
         - COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-mailcow-dockerized}
         - SKIP_FTS=${SKIP_FTS:-y}
         - SKIP_CLAMD=${SKIP_CLAMD:-n}
+        - SKIP_OLEFY=${SKIP_OLEFY:-n}
         - SKIP_SOGO=${SKIP_SOGO:-n}
         - ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
         - MASTER=${MASTER:-y}
@@ -497,7 +498,7 @@ services:
         - /lib/modules:/lib/modules:ro
 
     watchdog-mailcow:
-      image: ghcr.io/mailcow/watchdog:2.07
+      image: ghcr.io/mailcow/watchdog:2.08
       dns:
         - ${IPV4_NETWORK:-172.22.1}.254
       tmpfs:
@@ -538,6 +539,7 @@ services:
         - IP_BY_DOCKER_API=${IP_BY_DOCKER_API:-0}
         - CHECK_UNBOUND=${CHECK_UNBOUND:-1}
         - SKIP_CLAMD=${SKIP_CLAMD:-n}
+        - SKIP_OLEFY=${SKIP_OLEFY:-n}
         - SKIP_LETS_ENCRYPT=${SKIP_LETS_ENCRYPT:-n}
         - SKIP_SOGO=${SKIP_SOGO:-n}
         - HTTPS_PORT=${HTTPS_PORT:-443}
@@ -589,7 +591,7 @@ services:
             - dockerapi
 
     olefy-mailcow:
-      image: ghcr.io/mailcow/olefy:1.14
+      image: ghcr.io/mailcow/olefy:1.15
       restart: always
       environment:
         - TZ=${TZ}
@@ -601,6 +603,7 @@ services:
         - OLEFY_LOGLVL=20
         - OLEFY_MINLENGTH=500
         - OLEFY_DEL_TMP=1
+        - SKIP_OLEFY=${SKIP_OLEFY:-n}
       networks:
         mailcow-network:
           aliases:

+ 4 - 0
generate_config.sh

@@ -382,6 +382,10 @@ SKIP_UNBOUND_HEALTHCHECK=n
 
 SKIP_CLAMD=${SKIP_CLAMD}
 
+# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n
+
+SKIP_OLEFY=n
+
 # Skip SOGo: Will disable SOGo integration and therefore webmail, DAV protocols and ActiveSync support (experimental, unsupported, not fully implemented) - y/n
 
 SKIP_SOGO=n

+ 14 - 0
update.sh

@@ -323,6 +323,7 @@ adapt_new_options() {
   "WATCHDOG_EXTERNAL_CHECKS"
   "WATCHDOG_SUBJECT"
   "SKIP_CLAMD"
+  "SKIP_OLEFY"
   "SKIP_IP_CHECK"
   "ADDITIONAL_SAN"
   "DOVEADM_PORT"
@@ -967,6 +968,7 @@ CONFIG_ARRAY=(
   "WATCHDOG_EXTERNAL_CHECKS"
   "WATCHDOG_SUBJECT"
   "SKIP_CLAMD"
+  "SKIP_OLEFY"
   "SKIP_IP_CHECK"
   "ADDITIONAL_SAN"
   "AUTODISCOVER_SAN"
@@ -1278,6 +1280,18 @@ for option in "${CONFIG_ARRAY[@]}"; do
       echo '# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost' >> mailcow.conf
       echo 'DISABLE_NETFILTER_ISOLATION_RULE=n' >> mailcow.conf
     fi
+  elif [[ "${option}" == "SKIP_CLAMD" ]]; then
+    if ! grep -q "${option}" mailcow.conf; then
+      echo "Adding new option \"${option}\" to mailcow.conf"
+      echo '# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n' >> mailcow.conf
+      echo 'SKIP_CLAMD=n' >> mailcow.conf
+    fi
+  elif [[ "${option}" == "SKIP_OLEFY" ]]; then
+    if ! grep -q "${option}" mailcow.conf; then
+      echo "Adding new option \"${option}\" to mailcow.conf"
+      echo '# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n' >> mailcow.conf
+      echo 'SKIP_OLEFY=n' >> mailcow.conf
+    fi
   elif [[ "${option}" == "REDISPASS" ]]; then
     if ! grep -q "${option}" mailcow.conf; then
       echo "Adding new option \"${option}\" to mailcow.conf"