Browse Source

Merge branch 'staging' into watchdog-no-notify-on-startup

Patrick Schult 1 year ago
parent
commit
96a5891ce7
70 changed files with 3947 additions and 722 deletions
  1. 1 1
      .github/renovate.json
  2. 1 1
      .github/workflows/check_prs_if_on_staging.yml
  3. 1 1
      .github/workflows/close_old_issues_and_prs.yml
  4. 6 2
      README.md
  5. 1 1
      data/Dockerfiles/clamd/Dockerfile
  6. 2 2
      data/Dockerfiles/dovecot/Dockerfile
  7. 7 2
      data/Dockerfiles/dovecot/imapsync_runner.pl
  8. 12 2
      data/Dockerfiles/netfilter/Dockerfile
  9. 29 0
      data/Dockerfiles/netfilter/docker-entrypoint.sh
  10. 110 251
      data/Dockerfiles/netfilter/main.py
  11. 213 0
      data/Dockerfiles/netfilter/modules/IPTables.py
  12. 23 0
      data/Dockerfiles/netfilter/modules/Logger.py
  13. 495 0
      data/Dockerfiles/netfilter/modules/NFTables.py
  14. 0 0
      data/Dockerfiles/netfilter/modules/__init__.py
  15. 3 3
      data/Dockerfiles/phpfpm/Dockerfile
  16. 3 0
      data/Dockerfiles/rspamd/docker-entrypoint.sh
  17. 1 1
      data/Dockerfiles/sogo/Dockerfile
  18. 1 1
      data/Dockerfiles/solr/Dockerfile
  19. 66 41
      data/Dockerfiles/watchdog/watchdog.sh
  20. 2 2
      data/assets/nextcloud/nextcloud.conf
  21. 28 9
      data/conf/postfix/postscreen_access.cidr
  22. 91 0
      data/conf/rspamd/dynmaps/footer.php
  23. 9 0
      data/conf/rspamd/local.d/ratelimit.conf
  24. 46 34
      data/conf/rspamd/lua/rspamd.local.lua
  25. 0 8
      data/conf/rspamd/override.d/ratelimit.conf
  26. 5 1
      data/web/admin.php
  27. 195 0
      data/web/api/openapi.yaml
  28. 3 0
      data/web/edit.php
  29. 70 1
      data/web/inc/functions.fail2ban.inc.php
  30. 15 0
      data/web/inc/functions.inc.php
  31. 155 38
      data/web/inc/functions.mailbox.inc.php
  32. 17 1
      data/web/inc/init_db.inc.php
  33. 4 4
      data/web/inc/lib/vendor/directorytree/ldaprecord/.github/workflows/run-tests.yml
  34. 3 3
      data/web/inc/lib/vendor/php-mime-mail-parser/php-mime-mail-parser/.github/workflows/main.yml
  35. 1 1
      data/web/inc/lib/vendor/robthree/twofactorauth/.github/workflows/test.yml
  36. 1 1
      data/web/inc/lib/vendor/tightenco/collect/.github/workflows/run-tests.yml
  37. 2 2
      data/web/inc/lib/vendor/twig/twig/.github/workflows/ci.yml
  38. 3 3
      data/web/inc/lib/vendor/twig/twig/.github/workflows/documentation.yml
  39. 4 0
      data/web/inc/prerequisites.inc.php
  40. 18 0
      data/web/inc/presets/sieve/sieve_8.yml
  41. 89 86
      data/web/inc/vars.inc.php
  42. 8 0
      data/web/js/build/013-mailcow.js
  43. 1 1
      data/web/js/site/debug.js
  44. 17 0
      data/web/js/site/edit.js
  45. 5 1
      data/web/js/site/quarantine.js
  46. 34 3
      data/web/json_api.php
  47. 101 32
      data/web/lang/lang.cs-cz.json
  48. 18 3
      data/web/lang/lang.de-de.json
  49. 12 2
      data/web/lang/lang.en-gb.json
  50. 12 0
      data/web/lang/lang.fi-fi.json
  51. 17 13
      data/web/lang/lang.fr-fr.json
  52. 6 1
      data/web/lang/lang.hu-hu.json
  53. 1301 0
      data/web/lang/lang.pt-br.json
  54. 10 4
      data/web/lang/lang.ru-ru.json
  55. 162 2
      data/web/lang/lang.si-si.json
  56. 57 19
      data/web/lang/lang.sk-sk.json
  57. 100 35
      data/web/lang/lang.tr-tr.json
  58. 10 4
      data/web/lang/lang.uk-ua.json
  59. 16 0
      data/web/templates/admin/tab-config-f2b.twig
  60. 1 0
      data/web/templates/edit.twig
  61. 23 5
      data/web/templates/edit/domain.twig
  62. 32 0
      data/web/templates/edit/mailbox.twig
  63. 9 1
      data/web/templates/edit/syncjob.twig
  64. 9 2
      data/web/templates/modals/mailbox.twig
  65. 7 0
      data/web/templates/modals/user.twig
  66. 9 9
      docker-compose.yml
  67. 13 6
      generate_config.sh
  68. 122 0
      helper-scripts/generate_caa_record.py
  69. 5 1
      helper-scripts/nextcloud.sh
  70. 94 75
      update.sh

+ 1 - 1
.github/renovate.json

@@ -12,7 +12,7 @@
   "baseBranches": ["staging"],
   "enabledManagers": ["github-actions", "regex", "docker-compose"],
   "ignorePaths": [
-    "data\/web\/inc\/lib\/vendor\/matthiasmullie\/minify\/**"
+    "data\/web\/inc\/lib\/vendor\/**"
   ],
   "regexManagers": [
     {

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

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

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

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

+ 6 - 2
README.md

@@ -2,6 +2,8 @@
 
 [![Translation status](https://translate.mailcow.email/widgets/mailcow-dockerized/-/translation/svg-badge.svg)](https://translate.mailcow.email/engage/mailcow-dockerized/)
 [![Twitter URL](https://img.shields.io/twitter/url/https/twitter.com/mailcow_email.svg?style=social&label=Follow%20%40mailcow_email)](https://twitter.com/mailcow_email)
+![Mastodon Follow](https://img.shields.io/mastodon/follow/109388212176073348?domain=https%3A%2F%2Fmailcow.social&label=Follow%20%40doncow%40mailcow.social&link=https%3A%2F%2Fmailcow.social%2F%40doncow)
+
 
 ## Want to support mailcow?
 
@@ -25,7 +27,9 @@ Please see [the official documentation](https://docs.mailcow.email/) for install
 
 [Telegram mailcow Off-Topic channel](https://t.me/mailcowOfftopic)
 
-[Official Twitter Account](https://twitter.com/mailcow_email)
+[Official 𝕏 (Twitter) Account](https://twitter.com/mailcow_email)
+
+[Official Mastodon Account](https://mailcow.social/@doncow)
 
 Telegram desktop clients are available for [multiple platforms](https://desktop.telegram.org). You can search the groups history for keywords.
 
@@ -38,4 +42,4 @@ mailcow is a registered word mark of The Infrastructure Company GmbH, Parkstr. 4
 
 The project is managed and maintained by The Infrastructure Company GmbH.
 
-Originated from @andryyy (André)
+Originated from @andryyy (André)

+ 1 - 1
data/Dockerfiles/clamd/Dockerfile

@@ -1,6 +1,6 @@
 FROM clamav/clamav:1.0.3_base
 
-LABEL maintainer "André Peters <andre.peters@servercow.de>"
+LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
 
 RUN apk upgrade --no-cache \
   && apk add --update --no-cache \

+ 2 - 2
data/Dockerfiles/dovecot/Dockerfile

@@ -2,9 +2,9 @@ FROM debian:bullseye-slim
 LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
-# renovate: datasource=github-tags depName=dovecot/core versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-tags depName=dovecot/core versioning=semver-coerced extractVersion=(?<version>.*)$
 ARG DOVECOT=2.3.21
-# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=(?<version>.*)$
 ARG GOSU_VERSION=1.16
 ENV LC_ALL C
 

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

@@ -75,7 +75,8 @@ my $sth = $dbh->prepare("SELECT id,
   custom_params,
   subscribeall,
   timeout1,
-  timeout2
+  timeout2,
+  dry
     FROM imapsync
       WHERE active = 1
         AND is_running = 0
@@ -111,13 +112,16 @@ while ($row = $sth->fetchrow_arrayref()) {
   $subscribeall        = @$row[18];
   $timeout1            = @$row[19];
   $timeout2            = @$row[20];
+  $dry                 = @$row[21];
 
   if ($enc1 eq "TLS") { $enc1 = "--tls1"; } elsif ($enc1 eq "SSL") { $enc1 = "--ssl1"; } else { undef $enc1; }
 
   my $template = $run_dir . '/imapsync.XXXXXXX';
   my $passfile1 = File::Temp->new(TEMPLATE => $template);
   my $passfile2 = File::Temp->new(TEMPLATE => $template);
-
+  
+  binmode( $passfile1, ":utf8" );
+  
   print $passfile1 "$password1\n";
   print $passfile2 trim($master_pass) . "\n";
 
@@ -148,6 +152,7 @@ while ($row = $sth->fetchrow_arrayref()) {
   "--host2", "localhost",
   "--user2", $user2 . '*' . trim($master_user),
   "--passfile2", $passfile2->filename,
+  ($dry eq "1" ? ('--dry') : ()),
   '--no-modulesversion',
   '--noreleasecheck'];
 

+ 12 - 2
data/Dockerfiles/netfilter/Dockerfile

@@ -1,6 +1,8 @@
 FROM alpine:3.17
 LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
 
+WORKDIR /app
+
 ENV XTABLES_LIBDIR /usr/lib/xtables
 ENV PYTHON_IPTABLES_XTABLES_VERSION 12
 ENV IPTABLES_LIBDIR /usr/lib
@@ -14,10 +16,13 @@ RUN apk add --virtual .build-deps \
   iptables \
   ip6tables \
   xtables-addons \
+  nftables \
   tzdata \
   py3-pip \
+  py3-nftables \
   musl-dev \
 && pip3 install --ignore-installed --upgrade pip \
+  jsonschema \
   python-iptables \
   redis \
   ipaddress \
@@ -26,5 +31,10 @@ RUN apk add --virtual .build-deps \
 
 #  && pip3 install --upgrade pip python-iptables==0.13.0 redis ipaddress dnspython \
 
-COPY server.py /
-CMD ["python3", "-u", "/server.py"]
+COPY modules /app/modules
+COPY main.py /app/
+COPY ./docker-entrypoint.sh /app/
+
+RUN chmod +x /app/docker-entrypoint.sh
+
+CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]

+ 29 - 0
data/Dockerfiles/netfilter/docker-entrypoint.sh

@@ -0,0 +1,29 @@
+#!/bin/sh
+
+backend=iptables
+
+nft list table ip filter &>/dev/null
+nftables_found=$?
+
+iptables -L -n &>/dev/null
+iptables_found=$?
+
+if [ $nftables_found -lt $iptables_found ]; then
+  backend=nftables
+fi
+
+if [ $nftables_found -gt $iptables_found ]; then
+  backend=iptables
+fi
+
+if [ $nftables_found -eq 0 ] && [ $nftables_found -eq $iptables_found ]; then
+  nftables_lines=$(nft list ruleset | wc -l)
+  iptables_lines=$(iptables-save | wc -l)
+  if [ $nftables_lines -gt $iptables_lines ]; then
+    backend=nftables
+  else
+    backend=iptables
+  fi
+fi
+
+exec python -u /app/main.py $backend

+ 110 - 251
data/Dockerfiles/netfilter/server.py → data/Dockerfiles/netfilter/main.py

@@ -13,10 +13,15 @@ from threading import Thread
 from threading import Lock
 import redis
 import json
-import iptc
 import dns.resolver
 import dns.exception
+import uuid
+from modules.Logger import Logger
+from modules.IPTables import IPTables
+from modules.NFTables import NFTables
 
+
+# connect to redis
 while True:
   try:
     redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
@@ -31,34 +36,33 @@ while True:
     time.sleep(3)
   else:
     break
-
 pubsub = r.pubsub()
 
+# rename fail2ban to netfilter
+if r.exists('F2B_LOG'):
+  r.rename('F2B_LOG', 'NETFILTER_LOG')
+
+
+# globals
 WHITELIST = []
 BLACKLIST= []
-
 bans = {}
-
 quit_now = False
 exit_code = 0
 lock = Lock()
 
-def log(priority, message):
-  tolog = {}
-  tolog['time'] = int(round(time.time()))
-  tolog['priority'] = priority
-  tolog['message'] = message
-  r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
-  print(message)
-
-def logWarn(message):
-  log('warn', message)
 
-def logCrit(message):
-  log('crit', message)
+# init Logger
+logger = Logger(r)
+# init backend
+backend = sys.argv[1]
+if backend == "nftables":
+  logger.logInfo('Using NFTables backend')
+  tables = NFTables("MAILCOW", logger)
+else:
+  logger.logInfo('Using IPTables backend')
+  tables = IPTables("MAILCOW", logger)
 
-def logInfo(message):
-  log('info', message)
 
 def refreshF2boptions():
   global f2boptions
@@ -79,7 +83,7 @@ def refreshF2boptions():
     try:
       f2boptions = json.loads(r.get('F2B_OPTIONS'))
     except ValueError:
-      print('Error loading F2B options: F2B_OPTIONS is not json')
+      logger.logCrit('Error loading F2B options: F2B_OPTIONS is not json')
       quit_now = True
       exit_code = 2
 
@@ -94,6 +98,8 @@ def verifyF2boptions(f2boptions):
   verifyF2boption(f2boptions,'retry_window', 600)
   verifyF2boption(f2boptions,'netban_ipv4', 32)
   verifyF2boption(f2boptions,'netban_ipv6', 128)
+  verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4()))
+  verifyF2boption(f2boptions,'manage_external', 0)
 
 def verifyF2boption(f2boptions, f2boption, f2bdefault):
   f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
@@ -120,43 +126,23 @@ def refreshF2bregex():
       f2bregex = {}
       f2bregex = json.loads(r.get('F2B_REGEX'))
     except ValueError:
-      print('Error loading F2B options: F2B_REGEX is not json')
+      logger.logCrit('Error loading F2B options: F2B_REGEX is not json')
       quit_now = True
       exit_code = 2
 
-if r.exists('F2B_LOG'):
-  r.rename('F2B_LOG', 'NETFILTER_LOG')
-
-def mailcowChainOrder():
-  global lock
-  global quit_now
-  global exit_code
-  while not quit_now:
-    time.sleep(10)
-    with lock:
-      filter4_table = iptc.Table(iptc.Table.FILTER)
-      filter6_table = iptc.Table6(iptc.Table6.FILTER)
-      filter4_table.refresh()
-      filter6_table.refresh()
-      for f in [filter4_table, filter6_table]:
-        forward_chain = iptc.Chain(f, 'FORWARD')
-        input_chain = iptc.Chain(f, 'INPUT')
-        for chain in [forward_chain, input_chain]:
-          target_found = False
-          for position, item in enumerate(chain.rules):
-            if item.target.name == 'MAILCOW':
-              target_found = True
-              if position > 2:
-                logCrit('Error in %s chain order: MAILCOW on position %d, restarting container' % (chain.name, position))
-                quit_now = True
-                exit_code = 2
-          if not target_found:
-            logCrit('Error in %s chain: MAILCOW target not found, restarting container' % (chain.name))
-            quit_now = True
-            exit_code = 2
+def get_ip(address):
+  ip = ipaddress.ip_address(address)
+  if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
+    ip = ip.ipv4_mapped
+  if ip.is_private or ip.is_loopback:
+    return False
+  
+  return ip
 
 def ban(address):
+  global f2boptions
   global lock
+
   refreshF2boptions()
   BAN_TIME = int(f2boptions['ban_time'])
   BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
@@ -165,23 +151,18 @@ def ban(address):
   NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
   NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
 
-  ip = ipaddress.ip_address(address)
-  if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
-    ip = ip.ipv4_mapped
-    address = str(ip)
-  if ip.is_private or ip.is_loopback:
-    return
-
+  ip = get_ip(address)
+  if not ip: return
+  address = str(ip)
   self_network = ipaddress.ip_network(address)
 
   with lock:
     temp_whitelist = set(WHITELIST)
-
   if temp_whitelist:
     for wl_key in temp_whitelist:
       wl_net = ipaddress.ip_network(wl_key, False)
       if wl_net.overlaps(self_network):
-        logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
+        logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
         return
 
   net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
@@ -190,60 +171,44 @@ def ban(address):
   if not net in bans:
     bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
 
+  current_attempt = time.time()
+  if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
+    bans[net]['attempts'] = 0
+
   bans[net]['attempts'] += 1
-  bans[net]['last_attempt'] = time.time()
+  bans[net]['last_attempt'] = current_attempt
 
   if bans[net]['attempts'] >= MAX_ATTEMPTS:
     cur_time = int(round(time.time()))
     NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
-    logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
-    if type(ip) is ipaddress.IPv4Address:
+    logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
+    if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1:
       with lock:
-        chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
-        rule = iptc.Rule()
-        rule.src = net
-        target = iptc.Target(rule, "REJECT")
-        rule.target = target
-        if rule not in chain.rules:
-          chain.insert_rule(rule)
-    else:
+        tables.banIPv4(net)
+    elif int(f2boptions['manage_external']) != 1:
       with lock:
-        chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW')
-        rule = iptc.Rule6()
-        rule.src = net
-        target = iptc.Target(rule, "REJECT")
-        rule.target = target
-        if rule not in chain.rules:
-          chain.insert_rule(rule)
+        tables.banIPv6(net)
+
     r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
   else:
-    logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
+    logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
 
 def unban(net):
   global lock
+
   if not net in bans:
-   logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
+   logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
    r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
    return
-  logInfo('Unbanning %s' % net)
+
+  logger.logInfo('Unbanning %s' % net)
   if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
     with lock:
-      chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
-      rule = iptc.Rule()
-      rule.src = net
-      target = iptc.Target(rule, "REJECT")
-      rule.target = target
-      if rule in chain.rules:
-        chain.delete_rule(rule)
+      tables.unbanIPv4(net)
   else:
     with lock:
-      chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW')
-      rule = iptc.Rule6()
-      rule.src = net
-      target = iptc.Target(rule, "REJECT")
-      rule.target = target
-      if rule in chain.rules:
-        chain.delete_rule(rule)
+      tables.unbanIPv6(net)
+
   r.hdel('F2B_ACTIVE_BANS', '%s' % net)
   r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
   if net in bans:
@@ -251,74 +216,46 @@ def unban(net):
     bans[net]['ban_counter'] += 1
 
 def permBan(net, unban=False):
+  global f2boptions
   global lock
+
+  is_unbanned = False
+  is_banned = False
   if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
     with lock:
-      chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
-      rule = iptc.Rule()
-      rule.src = net
-      target = iptc.Target(rule, "REJECT")
-      rule.target = target
-      if rule not in chain.rules and not unban:
-        logCrit('Add host/network %s to blacklist' % net)
-        chain.insert_rule(rule)
-        r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
-      elif rule in chain.rules and unban:
-        logCrit('Remove host/network %s from blacklist' % net)
-        chain.delete_rule(rule)
-        r.hdel('F2B_PERM_BANS', '%s' % net)
+      if unban:
+        is_unbanned = tables.unbanIPv4(net)
+      elif int(f2boptions['manage_external']) != 1:
+        is_banned = tables.banIPv4(net)
   else:
     with lock:
-      chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW')
-      rule = iptc.Rule6()
-      rule.src = net
-      target = iptc.Target(rule, "REJECT")
-      rule.target = target
-      if rule not in chain.rules and not unban:
-        logCrit('Add host/network %s to blacklist' % net)
-        chain.insert_rule(rule)
-        r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
-      elif rule in chain.rules and unban:
-        logCrit('Remove host/network %s from blacklist' % net)
-        chain.delete_rule(rule)
-        r.hdel('F2B_PERM_BANS', '%s' % net)
+      if unban:
+        is_unbanned = tables.unbanIPv6(net)
+      elif int(f2boptions['manage_external']) != 1:
+        is_banned = tables.banIPv6(net)
 
-def quit(signum, frame):
-  global quit_now
-  quit_now = True
+
+  if is_unbanned:
+    r.hdel('F2B_PERM_BANS', '%s' % net)
+    logger.logCrit('Removed host/network %s from blacklist' % net)
+  elif is_banned:
+    r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
+    logger.logCrit('Added host/network %s to blacklist' % net)
 
 def clear():
   global lock
-  logInfo('Clearing all bans')
+  logger.logInfo('Clearing all bans')
   for net in bans.copy():
     unban(net)
   with lock:
-    filter4_table = iptc.Table(iptc.Table.FILTER)
-    filter6_table = iptc.Table6(iptc.Table6.FILTER)
-    for filter_table in [filter4_table, filter6_table]:
-      filter_table.autocommit = False
-      forward_chain = iptc.Chain(filter_table, "FORWARD")
-      input_chain = iptc.Chain(filter_table, "INPUT")
-      mailcow_chain = iptc.Chain(filter_table, "MAILCOW")
-      if mailcow_chain in filter_table.chains:
-        for rule in mailcow_chain.rules:
-          mailcow_chain.delete_rule(rule)
-        for rule in forward_chain.rules:
-          if rule.target.name == 'MAILCOW':
-            forward_chain.delete_rule(rule)
-        for rule in input_chain.rules:
-          if rule.target.name == 'MAILCOW':
-            input_chain.delete_rule(rule)
-        filter_table.delete_chain("MAILCOW")
-      filter_table.commit()
-      filter_table.refresh()
-      filter_table.autocommit = True
+    tables.clearIPv4Table()
+    tables.clearIPv6Table()
     r.delete('F2B_ACTIVE_BANS')
     r.delete('F2B_PERM_BANS')
     pubsub.unsubscribe()
 
 def watch():
-  logInfo('Watching Redis channel F2B_CHANNEL')
+  logger.logInfo('Watching Redis channel F2B_CHANNEL')
   pubsub.subscribe('F2B_CHANNEL')
 
   global quit_now
@@ -339,10 +276,10 @@ def watch():
               ip = ipaddress.ip_address(addr)
               if ip.is_private or ip.is_loopback:
                 continue
-              logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
+              logger.logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
               ban(addr)
     except Exception as ex:
-      logWarn('Error reading log line from pubsub: %s' % ex)
+      logger.logWarn('Error reading log line from pubsub: %s' % ex)
       quit_now = True
       exit_code = 2
 
@@ -350,87 +287,19 @@ def snat4(snat_target):
   global lock
   global quit_now
 
-  def get_snat4_rule():
-    rule = iptc.Rule()
-    rule.src = os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24'
-    rule.dst = '!' + rule.src
-    target = rule.create_target("SNAT")
-    target.to_source = snat_target
-    match = rule.create_match("comment")
-    match.comment = f'{int(round(time.time()))}'
-    return rule
-
   while not quit_now:
     time.sleep(10)
     with lock:
-      try:
-        table = iptc.Table('nat')
-        table.refresh()
-        chain = iptc.Chain(table, 'POSTROUTING')
-        table.autocommit = False
-        new_rule = get_snat4_rule()
-
-        if not chain.rules:
-          # if there are no rules in the chain, insert the new rule directly
-          logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
-          chain.insert_rule(new_rule)
-        else:
-          for position, rule in enumerate(chain.rules):
-            if not hasattr(rule.target, 'parameter'):
-                continue
-            match = all((
-              new_rule.get_src() == rule.get_src(),
-              new_rule.get_dst() == rule.get_dst(),
-              new_rule.target.parameters == rule.target.parameters,
-              new_rule.target.name == rule.target.name
-            ))
-            if position == 0:
-              if not match:
-                logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
-                chain.insert_rule(new_rule)
-            else:
-              if match:
-                logInfo(f'Remove rule for source network {new_rule.src} to SNAT target {snat_target} from POSTROUTING chain at position {position}')
-                chain.delete_rule(rule)
-
-        table.commit()
-        table.autocommit = True
-      except:
-        print('Error running SNAT4, retrying...')
+      tables.snat4(snat_target, os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24')
 
 def snat6(snat_target):
   global lock
   global quit_now
 
-  def get_snat6_rule():
-    rule = iptc.Rule6()
-    rule.src = os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64')
-    rule.dst = '!' + rule.src
-    target = rule.create_target("SNAT")
-    target.to_source = snat_target
-    return rule
-
   while not quit_now:
     time.sleep(10)
     with lock:
-      try:
-        table = iptc.Table6('nat')
-        table.refresh()
-        chain = iptc.Chain(table, 'POSTROUTING')
-        table.autocommit = False
-        if get_snat6_rule() not in chain.rules:
-          logInfo('Added POSTROUTING rule for source network %s to SNAT target %s' % (get_snat6_rule().src, snat_target))
-          chain.insert_rule(get_snat6_rule())
-          table.commit()
-        else:
-          for position, item in enumerate(chain.rules):
-            if item == get_snat6_rule():
-              if position != 0:
-                chain.delete_rule(get_snat6_rule())
-          table.commit()
-        table.autocommit = True
-      except:
-        print('Error running SNAT6, retrying...')
+      tables.snat6(snat_target, os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
 
 def autopurge():
   while not quit_now:
@@ -451,6 +320,17 @@ def autopurge():
         if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME or TIME_SINCE_LAST_ATTEMPT > MAX_BAN_TIME:
           unban(net)
 
+def mailcowChainOrder():
+  global lock
+  global quit_now
+  global exit_code
+  while not quit_now:
+    time.sleep(10)
+    with lock:
+      quit_now, exit_code = tables.checkIPv4ChainOrder()
+      if quit_now: return
+      quit_now, exit_code = tables.checkIPv6ChainOrder()
+
 def isIpNetwork(address):
   try:
     ipaddress.ip_network(address, False)
@@ -458,7 +338,6 @@ def isIpNetwork(address):
     return False
   return True
 
-
 def genNetworkList(list):
   resolver = dns.resolver.Resolver()
   hostnames = []
@@ -474,12 +353,12 @@ def genNetworkList(list):
       try:
         answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
       except dns.exception.Timeout:
-        logInfo('Hostname %s timedout on resolve' % hostname)
+        logger.logInfo('Hostname %s timedout on resolve' % hostname)
         break
       except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
         continue
       except dns.exception.DNSException as dnsexception:
-        logInfo('%s' % dnsexception)
+        logger.logInfo('%s' % dnsexception)
         continue
       for rdata in answer:
         hostname_ips.append(rdata.to_text())
@@ -499,7 +378,7 @@ def whitelistUpdate():
     with lock:
       if Counter(new_whitelist) != Counter(WHITELIST):
         WHITELIST = new_whitelist
-        logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
+        logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
     time.sleep(60.0 - ((time.time() - start_time) % 60.0))
 
 def blacklistUpdate():
@@ -515,7 +394,7 @@ def blacklistUpdate():
       addban = set(new_blacklist).difference(BLACKLIST)
       delban = set(BLACKLIST).difference(new_blacklist)
       BLACKLIST = new_blacklist
-      logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
+      logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
       if addban:
         for net in addban:
           permBan(net=net)
@@ -524,40 +403,20 @@ def blacklistUpdate():
           permBan(net=net, unban=True)
     time.sleep(60.0 - ((time.time() - start_time) % 60.0))
 
-def initChain():
-  # Is called before threads start, no locking
-  print("Initializing mailcow netfilter chain")
-  # IPv4
-  if not iptc.Chain(iptc.Table(iptc.Table.FILTER), "MAILCOW") in iptc.Table(iptc.Table.FILTER).chains:
-    iptc.Table(iptc.Table.FILTER).create_chain("MAILCOW")
-  for c in ['FORWARD', 'INPUT']:
-    chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
-    rule = iptc.Rule()
-    rule.src = '0.0.0.0/0'
-    rule.dst = '0.0.0.0/0'
-    target = iptc.Target(rule, "MAILCOW")
-    rule.target = target
-    if rule not in chain.rules:
-      chain.insert_rule(rule)
-  # IPv6
-  if not iptc.Chain(iptc.Table6(iptc.Table6.FILTER), "MAILCOW") in iptc.Table6(iptc.Table6.FILTER).chains:
-    iptc.Table6(iptc.Table6.FILTER).create_chain("MAILCOW")
-  for c in ['FORWARD', 'INPUT']:
-    chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
-    rule = iptc.Rule6()
-    rule.src = '::/0'
-    rule.dst = '::/0'
-    target = iptc.Target(rule, "MAILCOW")
-    rule.target = target
-    if rule not in chain.rules:
-      chain.insert_rule(rule)
+def quit(signum, frame):
+  global quit_now
+  quit_now = True
 
-if __name__ == '__main__':
 
+if __name__ == '__main__':
+  refreshF2boptions()
   # In case a previous session was killed without cleanup
   clear()
   # Reinit MAILCOW chain
-  initChain()
+  # Is called before threads start, no locking
+  logger.logInfo("Initializing mailcow netfilter chain")
+  tables.initChainIPv4()
+  tables.initChainIPv6()
 
   watch_thread = Thread(target=watch)
   watch_thread.daemon = True

+ 213 - 0
data/Dockerfiles/netfilter/modules/IPTables.py

@@ -0,0 +1,213 @@
+import iptc
+import time
+
+class IPTables:
+  def __init__(self, chain_name, logger):
+    self.chain_name = chain_name
+    self.logger = logger
+
+  def initChainIPv4(self):
+    if not iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) in iptc.Table(iptc.Table.FILTER).chains:
+      iptc.Table(iptc.Table.FILTER).create_chain(self.chain_name)
+    for c in ['FORWARD', 'INPUT']:
+      chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
+      rule = iptc.Rule()
+      rule.src = '0.0.0.0/0'
+      rule.dst = '0.0.0.0/0'
+      target = iptc.Target(rule, self.chain_name)
+      rule.target = target
+      if rule not in chain.rules:
+        chain.insert_rule(rule)
+
+  def initChainIPv6(self):
+    if not iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name) in iptc.Table6(iptc.Table6.FILTER).chains:
+      iptc.Table6(iptc.Table6.FILTER).create_chain(self.chain_name)
+    for c in ['FORWARD', 'INPUT']:
+      chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
+      rule = iptc.Rule6()
+      rule.src = '::/0'
+      rule.dst = '::/0'
+      target = iptc.Target(rule, self.chain_name)
+      rule.target = target
+      if rule not in chain.rules:
+        chain.insert_rule(rule)
+
+  def checkIPv4ChainOrder(self):
+    filter_table = iptc.Table(iptc.Table.FILTER)
+    filter_table.refresh()
+    return self.checkChainOrder(filter_table)
+
+  def checkIPv6ChainOrder(self):
+    filter_table = iptc.Table6(iptc.Table6.FILTER)
+    filter_table.refresh()
+    return self.checkChainOrder(filter_table)
+
+  def checkChainOrder(self, filter_table):
+    err = False
+    exit_code = None
+
+    forward_chain = iptc.Chain(filter_table, 'FORWARD')
+    input_chain = iptc.Chain(filter_table, 'INPUT')
+    for chain in [forward_chain, input_chain]:
+      target_found = False
+      for position, item in enumerate(chain.rules):
+        if item.target.name == self.chain_name:
+          target_found = True
+          if position > 2:
+            self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
+            err = True
+            exit_code = 2
+      if not target_found:
+        self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
+        err = True
+        exit_code = 2
+
+    return err, exit_code
+
+  def clearIPv4Table(self):
+    self.clearTable(iptc.Table(iptc.Table.FILTER))
+
+  def clearIPv6Table(self):
+    self.clearTable(iptc.Table6(iptc.Table6.FILTER))
+
+  def clearTable(self, filter_table):
+    filter_table.autocommit = False
+    forward_chain = iptc.Chain(filter_table, "FORWARD")
+    input_chain = iptc.Chain(filter_table, "INPUT")
+    mailcow_chain = iptc.Chain(filter_table, self.chain_name)
+    if mailcow_chain in filter_table.chains:
+      for rule in mailcow_chain.rules:
+        mailcow_chain.delete_rule(rule)
+      for rule in forward_chain.rules:
+        if rule.target.name == self.chain_name:
+          forward_chain.delete_rule(rule)
+      for rule in input_chain.rules:
+        if rule.target.name == self.chain_name:
+          input_chain.delete_rule(rule)
+      filter_table.delete_chain(self.chain_name)
+    filter_table.commit()
+    filter_table.refresh()
+    filter_table.autocommit = True
+
+  def banIPv4(self, source):
+    chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
+    rule = iptc.Rule()
+    rule.src = source
+    target = iptc.Target(rule, "REJECT")
+    rule.target = target
+    if rule in chain.rules:
+      return False
+    chain.insert_rule(rule)
+    return True
+
+  def banIPv6(self, source):
+    chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name)
+    rule = iptc.Rule6()
+    rule.src = source
+    target = iptc.Target(rule, "REJECT")
+    rule.target = target
+    if rule in chain.rules:
+      return False
+    chain.insert_rule(rule)
+    return True
+
+  def unbanIPv4(self, source):
+    chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
+    rule = iptc.Rule()
+    rule.src = source
+    target = iptc.Target(rule, "REJECT")
+    rule.target = target
+    if rule not in chain.rules: 
+      return False
+    chain.delete_rule(rule)
+    return True
+
+  def unbanIPv6(self, source):
+    chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name)
+    rule = iptc.Rule6()
+    rule.src = source
+    target = iptc.Target(rule, "REJECT")
+    rule.target = target
+    if rule not in chain.rules:
+      return False
+    chain.delete_rule(rule)
+    return True
+
+  def snat4(self, snat_target, source):
+    try:
+      table = iptc.Table('nat')
+      table.refresh()
+      chain = iptc.Chain(table, 'POSTROUTING')
+      table.autocommit = False
+      new_rule = self.getSnat4Rule(snat_target, source)
+
+      if not chain.rules:
+        # if there are no rules in the chain, insert the new rule directly
+        self.logger.logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
+        chain.insert_rule(new_rule)
+      else:
+        for position, rule in enumerate(chain.rules):
+          if not hasattr(rule.target, 'parameter'):
+              continue
+          match = all((
+            new_rule.get_src() == rule.get_src(),
+            new_rule.get_dst() == rule.get_dst(),
+            new_rule.target.parameters == rule.target.parameters,
+            new_rule.target.name == rule.target.name
+          ))
+          if position == 0:
+            if not match:
+              self.logger.logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
+              chain.insert_rule(new_rule)
+          else:
+            if match:
+              self.logger.logInfo(f'Remove rule for source network {new_rule.src} to SNAT target {snat_target} from POSTROUTING chain at position {position}')
+              chain.delete_rule(rule)
+
+      table.commit()
+      table.autocommit = True
+      return True
+    except:
+      self.logger.logCrit('Error running SNAT4, retrying...')
+      return False
+
+  def snat6(self, snat_target, source):
+    try:
+      table = iptc.Table6('nat')
+      table.refresh()
+      chain = iptc.Chain(table, 'POSTROUTING')
+      table.autocommit = False
+      new_rule = self.getSnat6Rule(snat_target, source)
+
+      if new_rule not in chain.rules:
+        self.logger.logInfo('Added POSTROUTING rule for source network %s to SNAT target %s' % (new_rule.src, snat_target))
+        chain.insert_rule(new_rule)
+      else:
+        for position, item in enumerate(chain.rules):
+          if item == new_rule:
+            if position != 0:
+              chain.delete_rule(new_rule)
+    
+      table.commit()
+      table.autocommit = True
+    except:
+      self.logger.logCrit('Error running SNAT6, retrying...')
+
+
+  def getSnat4Rule(self, snat_target, source):
+    rule = iptc.Rule()
+    rule.src = source
+    rule.dst = '!' + rule.src
+    target = rule.create_target("SNAT")
+    target.to_source = snat_target
+    match = rule.create_match("comment")
+    match.comment = f'{int(round(time.time()))}'
+    return rule
+
+  def getSnat6Rule(self, snat_target, source):
+    rule = iptc.Rule6()
+    rule.src = source
+    rule.dst = '!' + rule.src
+    target = rule.create_target("SNAT")
+    target.to_source = snat_target
+    return rule

+ 23 - 0
data/Dockerfiles/netfilter/modules/Logger.py

@@ -0,0 +1,23 @@
+import time
+import json
+
+class Logger:
+  def __init__(self, redis):
+    self.r = redis
+
+  def log(self, priority, message):
+    tolog = {}
+    tolog['time'] = int(round(time.time()))
+    tolog['priority'] = priority
+    tolog['message'] = message
+    self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
+    print(message)
+
+  def logWarn(self, message):
+    self.log('warn', message)
+
+  def logCrit(self, message):
+    self.log('crit', message)
+
+  def logInfo(self, message):
+    self.log('info', message)

+ 495 - 0
data/Dockerfiles/netfilter/modules/NFTables.py

@@ -0,0 +1,495 @@
+import nftables
+import ipaddress
+
+class NFTables:
+  def __init__(self, chain_name, logger):
+    self.chain_name = chain_name
+    self.logger = logger
+
+    self.nft = nftables.Nftables()
+    self.nft.set_json_output(True)
+    self.nft.set_handle_output(True)
+    self.nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} },
+                            'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } }
+
+    self.search_current_chains()
+
+  def initChainIPv4(self):
+    self.insert_mailcow_chains("ip")
+
+  def initChainIPv6(self):
+    self.insert_mailcow_chains("ip6")
+
+  def checkIPv4ChainOrder(self):
+    return self.checkChainOrder("ip")
+
+  def checkIPv6ChainOrder(self):
+    return self.checkChainOrder("ip6")
+
+  def checkChainOrder(self, filter_table):
+    err = False
+    exit_code = None
+
+    for chain in ['input', 'forward']:
+      chain_position = self.check_mailcow_chains(filter_table, chain)
+      if chain_position is None: continue
+
+      if chain_position is False:
+        self.logger.logCrit(f'MAILCOW target not found in {filter_table} {chain} table, restarting container to fix it...')
+        err = True
+        exit_code = 2
+
+      if chain_position > 0:
+        self.logger.logCrit(f'MAILCOW target is in position {chain_position} in the {filter_table} {chain} table, restarting container to fix it...')
+        err = True
+        exit_code = 2
+
+    return err, exit_code
+
+  def clearIPv4Table(self):
+    self.clearTable("ip")
+
+  def clearIPv6Table(self):
+    self.clearTable("ip6")
+
+  def clearTable(self, _family):
+    is_empty_dict = True
+    json_command = self.get_base_dict()
+    chain_handle = self.get_chain_handle(_family, "filter", self.chain_name)
+    # if no handle, the chain doesn't exists
+    if chain_handle is not None:
+      is_empty_dict = False
+      # flush chain
+      mailcow_chain = {'family': _family, 'table': 'filter', 'name': self.chain_name}
+      flush_chain = {'flush': {'chain': mailcow_chain}}
+      json_command["nftables"].append(flush_chain)
+
+    # remove rule in forward chain
+    # remove rule in input chain
+    chains_family = [self.nft_chain_names[_family]['filter']['input'],
+                    self.nft_chain_names[_family]['filter']['forward'] ]
+
+    for chain_base in chains_family:
+      if not chain_base: continue
+
+      rules_handle = self.get_rules_handle(_family, "filter", chain_base)
+      if rules_handle is not None:
+        for r_handle in rules_handle:
+          is_empty_dict = False
+          mailcow_rule = {'family':_family,
+                          'table': 'filter',
+                          'chain': chain_base,
+                          'handle': r_handle }
+          delete_rules = {'delete': {'rule': mailcow_rule} }
+          json_command["nftables"].append(delete_rules)
+
+    # remove chain
+    # after delete all rules referencing this chain
+    if chain_handle is not None:
+      mc_chain_handle = {'family':_family,
+                        'table': 'filter',
+                        'name': self.chain_name,
+                        'handle': chain_handle }
+      delete_chain = {'delete': {'chain': mc_chain_handle} }
+      json_command["nftables"].append(delete_chain)
+
+    if is_empty_dict == False:
+      if self.nft_exec_dict(json_command):
+        self.logger.logInfo(f"Clear completed: {_family}")
+
+  def banIPv4(self, source):
+    ban_dict = self.get_ban_ip_dict(source, "ip")
+    return self.nft_exec_dict(ban_dict)
+
+  def banIPv6(self, source):
+    ban_dict = self.get_ban_ip_dict(source, "ip6")
+    return self.nft_exec_dict(ban_dict)
+
+  def unbanIPv4(self, source):
+    unban_dict = self.get_unban_ip_dict(source, "ip")
+    if not unban_dict:
+      return False
+    return self.nft_exec_dict(unban_dict)
+
+  def unbanIPv6(self, source):
+    unban_dict = self.get_unban_ip_dict(source, "ip6")
+    if not unban_dict:
+      return False
+    return self.nft_exec_dict(unban_dict)
+
+  def snat4(self, snat_target, source):
+    self.snat_rule("ip", snat_target, source)
+
+  def snat6(self, snat_target, source):
+    self.snat_rule("ip6", snat_target, source)
+
+
+  def nft_exec_dict(self, query: dict):
+    if not query: return False
+
+    rc, output, error = self.nft.json_cmd(query)
+    if rc != 0:
+      #self.logger.logCrit(f"Nftables Error: {error}")
+      return False
+
+    # Prevent returning False or empty string on commands that do not produce output
+    if rc == 0 and len(output) == 0:
+      return True
+
+    return output
+
+  def get_base_dict(self):
+    return {'nftables': [{ 'metainfo': { 'json_schema_version': 1} } ] }
+
+  def search_current_chains(self):
+    nft_chain_priority = {'ip': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} },
+                      'ip6': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} } }
+
+    # Command: 'nft list chains'
+    _list = {'list' : {'chains': 'null'} }
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+    kernel_ruleset = self.nft_exec_dict(command)
+    if kernel_ruleset:
+      for _object in kernel_ruleset['nftables']:
+        chain = _object.get("chain")
+        if not chain: continue
+
+        _family = chain['family']
+        _table = chain['table']
+        _hook = chain.get("hook")
+        _priority = chain.get("prio")
+        _name = chain['name']
+
+        if _family not in self.nft_chain_names: continue
+        if _table not in self.nft_chain_names[_family]: continue
+        if _hook not in self.nft_chain_names[_family][_table]: continue
+        if _priority is None: continue
+
+        _saved_priority = nft_chain_priority[_family][_table][_hook]
+        if _saved_priority is None or _priority < _saved_priority:
+          # at this point, we know the chain has:
+          # hook and priority set
+          # and it has the lowest priority
+          nft_chain_priority[_family][_table][_hook] = _priority
+          self.nft_chain_names[_family][_table][_hook] = _name
+
+  def search_for_chain(self, kernel_ruleset: dict, chain_name: str):
+    found = False
+    for _object in kernel_ruleset["nftables"]:
+      chain = _object.get("chain")
+      if not chain:
+        continue
+      ch_name = chain.get("name")
+      if ch_name == chain_name:
+        found = True
+        break
+    return found
+
+  def get_chain_dict(self, _family: str, _name: str):
+    # nft (add | create) chain [<family>] <table> <name> 
+    _chain_opts = {'family': _family, 'table': 'filter', 'name': _name  }
+    _add = {'add': {'chain': _chain_opts} }
+    final_chain = self.get_base_dict()
+    final_chain["nftables"].append(_add)
+    return final_chain
+
+  def get_mailcow_jump_rule_dict(self, _family: str, _chain: str):
+    _jump_rule = self.get_base_dict()
+    _expr_opt=[]
+    _expr_counter = {'family': _family, 'table': 'filter', 'packets': 0, 'bytes': 0}
+    _counter_dict = {'counter': _expr_counter}
+    _expr_opt.append(_counter_dict)
+
+    _jump_opts = {'jump': {'target': self.chain_name} }
+
+    _expr_opt.append(_jump_opts)
+
+    _rule_params = {'family': _family,
+                    'table': 'filter',
+                    'chain': _chain,
+                    'expr': _expr_opt,
+                    'comment': "mailcow" }
+
+    _add_rule = {'insert': {'rule': _rule_params} }
+
+    _jump_rule["nftables"].append(_add_rule)
+
+    return _jump_rule
+
+  def insert_mailcow_chains(self, _family: str):
+    nft_input_chain = self.nft_chain_names[_family]['filter']['input']
+    nft_forward_chain = self.nft_chain_names[_family]['filter']['forward']
+    # Command: 'nft list table <family> filter'
+    _table_opts = {'family': _family, 'name': 'filter'}
+    _list = {'list': {'table': _table_opts} }
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+    kernel_ruleset = self.nft_exec_dict(command)
+    if kernel_ruleset:
+      # chain
+      if not self.search_for_chain(kernel_ruleset, self.chain_name):
+        cadena = self.get_chain_dict(_family, self.chain_name)
+        if self.nft_exec_dict(cadena):
+          self.logger.logInfo(f"MAILCOW {_family} chain created successfully.")
+
+      input_jump_found, forward_jump_found = False, False
+
+      for _object in kernel_ruleset["nftables"]:
+        if not _object.get("rule"):
+          continue
+
+        rule = _object["rule"]
+        if nft_input_chain and rule["chain"] == nft_input_chain:
+          if rule.get("comment") and rule["comment"] == "mailcow":
+            input_jump_found = True
+        if nft_forward_chain and rule["chain"] == nft_forward_chain:
+          if rule.get("comment") and rule["comment"] == "mailcow":
+            forward_jump_found = True
+
+      if not input_jump_found:
+        command = self.get_mailcow_jump_rule_dict(_family, nft_input_chain)
+        self.nft_exec_dict(command)
+
+      if not forward_jump_found:
+        command = self.get_mailcow_jump_rule_dict(_family, nft_forward_chain)
+        self.nft_exec_dict(command)
+
+  def delete_nat_rule(self, _family:str, _chain: str, _handle:str):
+    delete_command = self.get_base_dict()
+    _rule_opts = {'family': _family,
+                  'table': 'nat',
+                  'chain': _chain,
+                  'handle': _handle  }
+    _delete = {'delete': {'rule': _rule_opts} }
+    delete_command["nftables"].append(_delete)
+
+    return self.nft_exec_dict(delete_command)
+
+  def snat_rule(self, _family: str, snat_target: str, source_address: str):
+    chain_name = self.nft_chain_names[_family]['nat']['postrouting']
+
+    # no postrouting chain, may occur if docker has ipv6 disabled.
+    if not chain_name: return
+
+    # Command: nft list chain <family> nat <chain_name>
+    _chain_opts = {'family': _family, 'table': 'nat', 'name': chain_name}
+    _list = {'list':{'chain': _chain_opts} }
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+    kernel_ruleset = self.nft_exec_dict(command)
+    if not kernel_ruleset:
+      return
+
+    rule_position = 0
+    rule_handle = None
+    rule_found = False
+    for _object in kernel_ruleset["nftables"]:
+      if not _object.get("rule"):
+        continue
+
+      rule = _object["rule"]
+      if not rule.get("comment") or not rule["comment"] == "mailcow":
+        rule_position +=1
+        continue
+
+      rule_found = True
+      rule_handle = rule["handle"]
+      break
+
+    dest_net = ipaddress.ip_network(source_address)
+    target_net = ipaddress.ip_network(snat_target)
+
+    if rule_found:
+      saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"]
+      saddr_len = int(rule["expr"][0]["match"]["right"]["prefix"]["len"])
+
+      daddr_ip = rule["expr"][1]["match"]["right"]["prefix"]["addr"]
+      daddr_len = int(rule["expr"][1]["match"]["right"]["prefix"]["len"])
+
+      target_ip = rule["expr"][3]["snat"]["addr"]
+
+      saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len))
+      daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len))
+      current_target_net = ipaddress.ip_network(target_ip)
+
+      match = all((
+                dest_net == saddr_net,
+                dest_net == daddr_net,
+                target_net == current_target_net
+              ))
+      try:
+        if rule_position == 0:
+          if not match:
+            # Position 0 , it is a mailcow rule , but it does not have the same parameters
+            if self.delete_nat_rule(_family, chain_name, rule_handle):
+              self.logger.logInfo(f'Remove rule for source network {saddr_net} to SNAT target {target_net} from {_family} nat {chain_name} chain, rule does not match configured parameters')
+        else:
+          # Position > 0 and is mailcow rule
+          if self.delete_nat_rule(_family, chain_name, rule_handle):
+            self.logger.logInfo(f'Remove rule for source network {saddr_net} to SNAT target {target_net} from {_family} nat {chain_name} chain, rule is at position {rule_position}')
+      except:
+          self.logger.logCrit(f"Error running SNAT on {_family}, retrying..." )
+    else:
+      # rule not found
+      json_command = self.get_base_dict()
+      try:
+        snat_dict = {'snat': {'addr': str(target_net.network_address)} }
+
+        expr_counter = {'family': _family, 'table': 'nat', 'packets': 0, 'bytes': 0}
+        counter_dict = {'counter': expr_counter}
+
+        prefix_dict = {'prefix': {'addr': str(dest_net.network_address), 'len': int(dest_net.prefixlen)} }
+        payload_dict = {'payload': {'protocol': _family, 'field': "saddr"} }
+        match_dict1 = {'match': {'op': '==', 'left': payload_dict, 'right': prefix_dict} }
+
+        payload_dict2 = {'payload': {'protocol': _family, 'field': "daddr"} }
+        match_dict2 = {'match': {'op': '!=', 'left': payload_dict2, 'right': prefix_dict } }
+        expr_list = [
+                    match_dict1,
+                    match_dict2,
+                    counter_dict,
+                    snat_dict
+                    ]
+        rule_fields = {'family': _family,
+                        'table': 'nat',
+                        'chain': chain_name,
+                        'comment': "mailcow",
+                        'expr': expr_list }
+
+        insert_dict = {'insert': {'rule': rule_fields} }
+        json_command["nftables"].append(insert_dict)
+        if self.nft_exec_dict(json_command):
+          self.logger.logInfo(f'Added {_family} nat {chain_name} rule for source network {dest_net} to {target_net}')
+      except:
+        self.logger.logCrit(f"Error running SNAT on {_family}, retrying...")
+
+  def get_chain_handle(self, _family: str, _table: str, chain_name: str):
+    chain_handle = None
+    # Command: 'nft list chains {family}'
+    _list = {'list': {'chains': {'family': _family} } }
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+    kernel_ruleset = self.nft_exec_dict(command)
+    if kernel_ruleset:
+      for _object in kernel_ruleset["nftables"]:
+        if not _object.get("chain"):
+          continue
+        chain = _object["chain"]
+        if chain["family"] == _family and chain["table"] == _table and chain["name"] == chain_name:
+          chain_handle = chain["handle"]
+          break
+    return chain_handle
+
+  def get_rules_handle(self, _family: str, _table: str, chain_name: str):
+    rule_handle = []
+    # Command: 'nft list chain {family} {table} {chain_name}'
+    _chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
+    _list = {'list': {'chain': _chain_opts} }
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+
+    kernel_ruleset = self.nft_exec_dict(command)
+    if kernel_ruleset:
+      for _object in kernel_ruleset["nftables"]:
+        if not _object.get("rule"):
+          continue
+
+        rule = _object["rule"]
+        if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
+          if rule.get("comment") and rule["comment"] == "mailcow":
+            rule_handle.append(rule["handle"])
+    return rule_handle
+
+  def get_ban_ip_dict(self, ipaddr: str, _family: str):
+    json_command = self.get_base_dict()
+
+    expr_opt = []
+    ipaddr_net = ipaddress.ip_network(ipaddr)
+    right_dict = {'prefix': {'addr': str(ipaddr_net.network_address), 'len': int(ipaddr_net.prefixlen) } }
+
+    left_dict = {'payload': {'protocol': _family, 'field': 'saddr'} }
+    match_dict = {'op': '==', 'left': left_dict, 'right': right_dict }
+    expr_opt.append({'match': match_dict})
+
+    counter_dict = {'counter': {'family': _family, 'table': "filter", 'packets': 0, 'bytes': 0} }
+    expr_opt.append(counter_dict)
+
+    expr_opt.append({'drop': "null"})
+
+    rule_dict = {'family': _family, 'table': "filter", 'chain': self.chain_name, 'expr': expr_opt}
+
+    base_dict = {'insert': {'rule': rule_dict} }
+    json_command["nftables"].append(base_dict)
+
+    return json_command
+
+  def get_unban_ip_dict(self, ipaddr:str, _family: str):
+    json_command = self.get_base_dict()
+    # Command: 'nft list chain {s_family} filter  MAILCOW'
+    _chain_opts = {'family': _family, 'table': 'filter', 'name': self.chain_name}
+    _list = {'list': {'chain': _chain_opts} }
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+    kernel_ruleset = self.nft_exec_dict(command)
+    rule_handle = None
+    if kernel_ruleset:
+      for _object in kernel_ruleset["nftables"]:
+        if not _object.get("rule"):
+          continue
+
+        rule = _object["rule"]["expr"][0]["match"]
+        left_opt = rule["left"]["payload"]
+        if not left_opt["protocol"] == _family:
+          continue
+        if not left_opt["field"] =="saddr":
+          continue
+
+        # ip currently banned
+        rule_right = rule["right"]
+        if isinstance(rule_right, dict):
+          current_rule_ip = rule_right["prefix"]["addr"] + '/' + str(rule_right["prefix"]["len"])
+        else:
+          current_rule_ip = rule_right
+        current_rule_net = ipaddress.ip_network(current_rule_ip)
+
+        # ip to ban
+        candidate_net = ipaddress.ip_network(ipaddr)
+
+        if current_rule_net == candidate_net:
+          rule_handle = _object["rule"]["handle"]
+          break
+
+      if rule_handle is not None:
+        mailcow_rule = {'family': _family, 'table': 'filter', 'chain': self.chain_name, 'handle': rule_handle}
+        delete_rule = {'delete': {'rule': mailcow_rule} }
+        json_command["nftables"].append(delete_rule)
+      else:
+        return False
+
+    return json_command
+
+  def check_mailcow_chains(self, family: str, chain: str):
+    position = 0
+    rule_found = False
+    chain_name = self.nft_chain_names[family]['filter'][chain]
+
+    if not chain_name: return None
+
+    _chain_opts = {'family': family, 'table': 'filter', 'name': chain_name}
+    _list = {'list': {'chain': _chain_opts}}
+    command = self.get_base_dict()
+    command['nftables'].append(_list)
+    kernel_ruleset = self.nft_exec_dict(command)
+    if kernel_ruleset:
+      for _object in kernel_ruleset["nftables"]:
+        if not _object.get("rule"):
+          continue
+        rule = _object["rule"]
+        if rule.get("comment") and rule["comment"] == "mailcow":
+          rule_found = True
+          break
+
+        position+=1
+
+    return position if rule_found else False

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


+ 3 - 3
data/Dockerfiles/phpfpm/Dockerfile

@@ -3,15 +3,15 @@ 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.22
-# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
 ARG IMAGICK_PECL_VERSION=3.7.0
 # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
 ARG MAILPARSE_PECL_VERSION=3.1.6
 # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
 ARG MEMCACHED_PECL_VERSION=3.2.0
-# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
 ARG REDIS_PECL_VERSION=6.0.1
-# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
 ARG COMPOSER_VERSION=2.6.5
 
 RUN apk add -U --no-cache autoconf \

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

@@ -79,6 +79,9 @@ EOF
   redis-cli -h redis-mailcow SLAVEOF NO ONE
 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
+
 chown -R _rspamd:_rspamd /var/lib/rspamd \
   /etc/rspamd/local.d \
   /etc/rspamd/override.d \

+ 1 - 1
data/Dockerfiles/sogo/Dockerfile

@@ -3,7 +3,7 @@ LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
 
 ARG DEBIAN_FRONTEND=noninteractive
 ARG SOGO_DEBIAN_REPOSITORY=http://packages.sogo.nu/nightly/5/debian/
-# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
 ARG GOSU_VERSION=1.16
 ENV LC_ALL C
 

+ 1 - 1
data/Dockerfiles/solr/Dockerfile

@@ -2,7 +2,7 @@ FROM solr:7.7-slim
 
 USER root
 
-# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^v(?<version>.*)$
+# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=(?<version>.*)$
 ARG GOSU_VERSION=1.16
 
 COPY solr.sh /

+ 66 - 41
data/Dockerfiles/watchdog/watchdog.sh

@@ -19,9 +19,11 @@ fi
 
 if [[ "${WATCHDOG_VERBOSE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
   SMTP_VERBOSE="--verbose"
+  CURL_VERBOSE="--verbose"
   set -xv
 else
   SMTP_VERBOSE=""
+  CURL_VERBOSE=""
   exec 2>/dev/null
 fi
 
@@ -97,7 +99,9 @@ log_msg() {
   echo $(date) $(printf '%s\n' "${1}")
 }
 
-function mail_error() {
+function notify_error() {
+  # Check if one of the notification options is enabled
+  [[ -z ${WATCHDOG_NOTIFY_EMAIL} ]] && [[ -z ${WATCHDOG_NOTIFY_WEBHOOK} ]] && return 0
   THROTTLE=
   [[ -z ${1} ]] && return 1
   # If exists, body will be the content of "/tmp/${1}", even if ${2} is set
@@ -122,37 +126,57 @@ function mail_error() {
   else
     SUBJECT="${WATCHDOG_SUBJECT}: ${1}"
   fi
-  IFS=',' read -r -a MAIL_RCPTS <<< "${WATCHDOG_NOTIFY_EMAIL}"
-  for rcpt in "${MAIL_RCPTS[@]}"; do
-    RCPT_DOMAIN=
-    RCPT_MX=
-    RCPT_DOMAIN=$(echo ${rcpt} | awk -F @ {'print $NF'})
-    CHECK_FOR_VALID_MX=$(dig +short ${RCPT_DOMAIN} mx)
-    if [[ -z ${CHECK_FOR_VALID_MX} ]]; then
-      log_msg "Cannot determine MX for ${rcpt}, skipping email notification..."
-      return 1
-    fi
-    [ -f "/tmp/${1}" ] && BODY="/tmp/${1}"
-    timeout 10s ./smtp-cli --missing-modules-ok \
-      "${SMTP_VERBOSE}" \
-      --charset=UTF-8 \
-      --subject="${SUBJECT}" \
-      --body-plain="${BODY}" \
-      --add-header="X-Priority: 1" \
-      --to=${rcpt} \
-      --from="watchdog@${MAILCOW_HOSTNAME}" \
-      --hello-host=${MAILCOW_HOSTNAME} \
-      --ipv4
-    if [[ $? -eq 1 ]]; then # exit code 1 is fine
-      log_msg "Sent notification email to ${rcpt}"
-    else
-      if [[ "${SMTP_VERBOSE}" == "" ]]; then
-        log_msg "Error while sending notification email to ${rcpt}. You can enable verbose logging by setting 'WATCHDOG_VERBOSE=y' in mailcow.conf."
+
+  # Send mail notification if enabled
+  if [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]]; then
+    IFS=',' read -r -a MAIL_RCPTS <<< "${WATCHDOG_NOTIFY_EMAIL}"
+    for rcpt in "${MAIL_RCPTS[@]}"; do
+      RCPT_DOMAIN=
+      RCPT_MX=
+      RCPT_DOMAIN=$(echo ${rcpt} | awk -F @ {'print $NF'})
+      CHECK_FOR_VALID_MX=$(dig +short ${RCPT_DOMAIN} mx)
+      if [[ -z ${CHECK_FOR_VALID_MX} ]]; then
+        log_msg "Cannot determine MX for ${rcpt}, skipping email notification..."
+        return 1
+      fi
+      [ -f "/tmp/${1}" ] && BODY="/tmp/${1}"
+      timeout 10s ./smtp-cli --missing-modules-ok \
+        "${SMTP_VERBOSE}" \
+        --charset=UTF-8 \
+        --subject="${SUBJECT}" \
+        --body-plain="${BODY}" \
+        --add-header="X-Priority: 1" \
+        --to=${rcpt} \
+        --from="watchdog@${MAILCOW_HOSTNAME}" \
+        --hello-host=${MAILCOW_HOSTNAME} \
+        --ipv4
+      if [[ $? -eq 1 ]]; then # exit code 1 is fine
+        log_msg "Sent notification email to ${rcpt}"
       else
-        log_msg "Error while sending notification email to ${rcpt}."
+        if [[ "${SMTP_VERBOSE}" == "" ]]; then
+          log_msg "Error while sending notification email to ${rcpt}. You can enable verbose logging by setting 'WATCHDOG_VERBOSE=y' in mailcow.conf."
+        else
+          log_msg "Error while sending notification email to ${rcpt}."
+        fi
       fi
+    done
+  fi
+
+  # Send webhook notification if enabled
+  if [[ ! -z ${WATCHDOG_NOTIFY_WEBHOOK} ]]; then
+    if [[ -z ${WATCHDOG_NOTIFY_WEBHOOK_BODY} ]]; then
+      log_msg "No webhook body set, skipping webhook notification..."
+      return 1
     fi
-  done
+
+    # Replace subject and body placeholders
+    WEBHOOK_BODY=$(echo ${WATCHDOG_NOTIFY_WEBHOOK_BODY} | sed "s|\$SUBJECT\|\${SUBJECT}|$SUBJECT|g" | sed "s|\$BODY\|\${BODY}|$BODY|")
+    
+    # POST to webhook
+    curl -X POST -H "Content-Type: application/json" ${CURL_VERBOSE} -d "${WEBHOOK_BODY}" ${WATCHDOG_NOTIFY_WEBHOOK}
+
+    log_msg "Sent notification using webhook"
+  fi
 }
 
 get_container_ip() {
@@ -197,7 +221,7 @@ get_container_ip() {
 # One-time check
 if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
   if [[ -z "$(get_ipv6)" ]]; then
-    mail_error "ipv6-config" "enable_ipv6 is true in docker-compose.yml, but an IPv6 link could not be established. Please verify your IPv6 connection."
+    notify_error "ipv6-config" "enable_ipv6 is true in docker-compose.yml, but an IPv6 link could not be established. Please verify your IPv6 connection."
   fi
 fi
 
@@ -746,8 +770,8 @@ olefy_checks() {
 }
 
 # Notify about start
-if [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
-  mail_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
+if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+  notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
 fi
 
 # Create watchdog agents
@@ -1029,33 +1053,33 @@ while true; do
   fi
   if [[ ${com_pipe_answer} == "ratelimit" ]]; then
     log_msg "At least one ratelimit was applied"
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}"
+    notify_error "${com_pipe_answer}"
   elif [[ ${com_pipe_answer} == "mail_queue_status" ]]; then
     log_msg "Mail queue status is critical"
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}"
+    notify_error "${com_pipe_answer}"
   elif [[ ${com_pipe_answer} == "external_checks" ]]; then
     log_msg "Your mailcow is an open relay!"
     # Define $2 to override message text, else print service was restarted at ...
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please stop mailcow now and check your network configuration!"
+    notify_error "${com_pipe_answer}" "Please stop mailcow now and check your network configuration!"
   elif [[ ${com_pipe_answer} == "mysql_repl_checks" ]]; then
     log_msg "MySQL replication is not working properly"
     # Define $2 to override message text, else print service was restarted at ...
     # Once mail per 10 minutes
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please check the SQL replication status" 600
+    notify_error "${com_pipe_answer}" "Please check the SQL replication status" 600
   elif [[ ${com_pipe_answer} == "dovecot_repl_checks" ]]; then
     log_msg "Dovecot replication is not working properly"
     # Define $2 to override message text, else print service was restarted at ...
     # Once mail per 10 minutes
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please check the Dovecot replicator status" 600
+    notify_error "${com_pipe_answer}" "Please check the Dovecot replicator status" 600
   elif [[ ${com_pipe_answer} == "certcheck" ]]; then
     log_msg "Certificates are about to expire"
     # Define $2 to override message text, else print service was restarted at ...
     # Only mail once a day
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please renew your certificate" 86400
+    notify_error "${com_pipe_answer}" "Please renew your certificate" 86400
   elif [[ ${com_pipe_answer} == "acme-mailcow" ]]; then
     log_msg "acme-mailcow did not complete successfully"
     # Define $2 to override message text, else print service was restarted at ...
-    [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please check acme-mailcow for further information."
+    notify_error "${com_pipe_answer}" "Please check acme-mailcow for further information."
   elif [[ ${com_pipe_answer} == "fail2ban" ]]; then
     F2B_RES=($(timeout 4s ${REDIS_CMDLINE} --raw GET F2B_RES 2> /dev/null))
     if [[ ! -z "${F2B_RES}" ]]; then
@@ -1065,7 +1089,7 @@ while true; do
         log_msg "Banned ${host}"
         rm /tmp/fail2ban 2> /dev/null
         timeout 2s whois "${host}" > /tmp/fail2ban
-        [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && [[ ${WATCHDOG_NOTIFY_BAN} =~ ^([yY][eE][sS]|[yY])+$ ]] && mail_error "${com_pipe_answer}" "IP ban: ${host}"
+        [[ ${WATCHDOG_NOTIFY_BAN} =~ ^([yY][eE][sS]|[yY])+$ ]] && notify_error "${com_pipe_answer}" "IP ban: ${host}"
       done
     fi
   elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
@@ -1085,7 +1109,7 @@ while true; do
       else
         log_msg "Sending restart command to ${CONTAINER_ID}..."
         curl --silent --insecure -XPOST https://dockerapi/containers/${CONTAINER_ID}/restart
-        [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}"
+        notify_error "${com_pipe_answer}"
         log_msg "Wait for restarted container to settle and continue watching..."
         sleep 35
       fi
@@ -1095,3 +1119,4 @@ while true; do
     kill -USR1 ${BACKGROUND_TASKS[*]}
   fi
 done
+

+ 2 - 2
data/assets/nextcloud/nextcloud.conf

@@ -86,7 +86,7 @@ server {
     deny all;
   }
 
-  location ~ ^\/(?:index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|oc[ms]-provider\/.+)\.php(?:$|\/) {
+  location ~ ^\/(?:index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+)\.php(?:$|\/) {
     fastcgi_split_path_info ^(.+?\.php)(\/.*|)$;
     set $path_info $fastcgi_path_info;
     try_files $fastcgi_script_name =404;
@@ -105,7 +105,7 @@ server {
     fastcgi_read_timeout 1200;
   }
 
-  location ~ ^\/(?:updater|oc[ms]-provider)(?:$|\/) {
+  location ~ ^\/(?:updater|ocs-provider)(?:$|\/) {
     try_files $uri/ =404;
     index index.php;
   }

+ 28 - 9
data/conf/postfix/postscreen_access.cidr

@@ -1,6 +1,6 @@
-# Whitelist generated by Postwhite v3.4 on Sun Oct  1 00:14:59 UTC 2023
+# Whitelist generated by Postwhite v3.4 on Fri Dec  1 00:15:18 UTC 2023
 # https://github.com/stevejenkins/postwhite/
-# 2019 total rules
+# 2038 total rules
 2a00:1450:4000::/36	permit
 2a01:111:f400::/48	permit
 2a01:111:f403:8000::/50	permit
@@ -10,10 +10,10 @@
 2a02:a60:0:5::/64	permit
 2c0f:fb50:4000::/36	permit
 2.207.151.53	permit
-3.14.230.16	permit
 3.70.123.177	permit
 3.93.157.0/24	permit
 3.129.120.190	permit
+3.137.78.75	permit
 3.210.190.0/24	permit
 8.20.114.31	permit
 8.25.194.0/23	permit
@@ -183,8 +183,6 @@
 50.18.125.237	permit
 50.18.126.162	permit
 50.31.32.0/19	permit
-50.31.156.96/27	permit
-50.31.205.0/24	permit
 51.137.58.21	permit
 51.140.75.55	permit
 51.144.100.179	permit
@@ -211,6 +209,7 @@
 52.96.222.194	permit
 52.96.222.226	permit
 52.96.223.2	permit
+52.96.228.130	permit
 52.96.229.242	permit
 52.100.0.0/14	permit
 52.103.0.0/17	permit
@@ -303,22 +302,31 @@
 64.147.123.27	permit
 64.147.123.28	permit
 64.147.123.29	permit
+64.147.123.128/27	permit
 64.207.219.7	permit
 64.207.219.8	permit
 64.207.219.9	permit
+64.207.219.10	permit
+64.207.219.11	permit
+64.207.219.12	permit
 64.207.219.13	permit
 64.207.219.14	permit
 64.207.219.15	permit
 64.207.219.71	permit
 64.207.219.72	permit
 64.207.219.73	permit
+64.207.219.74	permit
 64.207.219.75	permit
+64.207.219.76	permit
 64.207.219.77	permit
 64.207.219.78	permit
 64.207.219.79	permit
 64.207.219.135	permit
 64.207.219.136	permit
 64.207.219.137	permit
+64.207.219.138	permit
+64.207.219.139	permit
+64.207.219.140	permit
 64.207.219.141	permit
 64.207.219.142	permit
 64.207.219.143	permit
@@ -396,7 +404,6 @@
 66.196.81.232/31	permit
 66.196.81.234	permit
 66.211.168.230/31	permit
-66.211.170.86/31	permit
 66.211.170.88/29	permit
 66.211.184.0/23	permit
 66.218.74.64/30	permit
@@ -620,7 +627,9 @@
 82.165.229.130	permit
 82.165.230.21	permit
 82.165.230.22	permit
+84.116.6.0/23	permit
 84.116.36.0/24	permit
+84.116.50.0/23	permit
 85.158.136.0/21	permit
 86.61.88.25	permit
 87.198.219.130	permit
@@ -1192,7 +1201,6 @@
 104.130.96.0/28	permit
 104.130.122.0/23	permit
 104.214.25.77	permit
-104.245.209.192/26	permit
 106.10.144.64/27	permit
 106.10.144.100/31	permit
 106.10.144.103	permit
@@ -1369,6 +1377,8 @@
 128.245.0.0/20	permit
 128.245.64.0/20	permit
 128.245.176.0/20	permit
+128.245.240.0/24	permit
+128.245.241.0/24	permit
 128.245.242.0/24	permit
 128.245.242.16	permit
 128.245.242.17	permit
@@ -1378,6 +1388,7 @@
 128.245.245.0/24	permit
 128.245.246.0/24	permit
 128.245.247.0/24	permit
+128.245.248.0/21	permit
 129.41.77.70	permit
 129.41.169.249	permit
 129.80.5.164	permit
@@ -1418,6 +1429,7 @@
 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
@@ -1454,7 +1466,6 @@
 146.20.215.0/24	permit
 146.20.215.182	permit
 146.88.28.0/24	permit
-147.160.158.0/24	permit
 147.243.1.47	permit
 147.243.1.48	permit
 147.243.1.153	permit
@@ -1492,6 +1503,8 @@
 158.101.211.207	permit
 158.120.80.0/21	permit
 158.247.16.0/20	permit
+159.92.154.0/24	permit
+159.92.155.0/24	permit
 159.92.157.0/24	permit
 159.92.157.16	permit
 159.92.157.17	permit
@@ -1501,6 +1514,9 @@
 159.92.160.0/24	permit
 159.92.161.0/24	permit
 159.92.162.0/24	permit
+159.92.163.0/24	permit
+159.92.164.0/22	permit
+159.92.168.0/21	permit
 159.112.240.0/20	permit
 159.112.242.162	permit
 159.135.132.128/25	permit
@@ -1549,6 +1565,8 @@
 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
 170.10.68.0/22	permit
 170.10.128.0/24	permit
 170.10.129.0/24	permit
@@ -1700,13 +1718,14 @@
 198.244.60.0/22	permit
 198.245.80.0/20	permit
 198.245.81.0/24	permit
-199.15.176.173	permit
 199.15.213.187	permit
 199.15.226.37	permit
 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.84.0/24	permit
 199.67.86.0/24	permit
 199.67.88.0/24	permit

+ 91 - 0
data/conf/rspamd/dynmaps/footer.php

@@ -0,0 +1,91 @@
+<?php
+// File size is limited by Nginx site to 10M
+// To speed things up, we do not include prerequisites
+header('Content-Type: text/plain');
+require_once "vars.inc.php";
+// Do not show errors, we log to using error_log
+ini_set('error_reporting', 0);
+// Init database
+//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
+$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
+$opt = [
+    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+    PDO::ATTR_EMULATE_PREPARES   => false,
+];
+try {
+  $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
+}
+catch (PDOException $e) {
+  error_log("FOOTER: " . $e . PHP_EOL);
+  http_response_code(501);
+  exit;
+}
+
+if (!function_exists('getallheaders'))  {
+  function getallheaders() {
+    if (!is_array($_SERVER)) {
+      return array();
+    }
+    $headers = array();
+    foreach ($_SERVER as $name => $value) {
+      if (substr($name, 0, 5) == 'HTTP_') {
+        $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
+      }
+    }
+    return $headers;
+  }
+}
+
+// Read headers
+$headers = getallheaders();
+// Get Domain
+$domain = $headers['Domain'];
+// Get Username
+$username = $headers['Username'];
+// Get From
+$from = $headers['From'];
+// define empty footer
+$empty_footer = json_encode(array(
+  'html' => '',
+  'plain' => '',
+  'vars' => array()
+));
+
+error_log("FOOTER: checking for domain " . $domain . ", user " . $username . " and address " . $from . PHP_EOL);
+
+try {
+  $stmt = $pdo->prepare("SELECT `plain`, `html`, `mbox_exclude` FROM `domain_wide_footer` 
+    WHERE `domain` = :domain");
+  $stmt->execute(array(
+    ':domain' => $domain
+  ));
+  $footer = $stmt->fetch(PDO::FETCH_ASSOC);
+  if (in_array($from, json_decode($footer['mbox_exclude']))){
+    $footer = false;
+  }
+  if (empty($footer)){
+    echo $empty_footer;
+    exit;
+  }
+  error_log("FOOTER: " . json_encode($footer) . PHP_EOL);
+
+  $stmt = $pdo->prepare("SELECT `custom_attributes` FROM `mailbox` WHERE `username` = :username");
+  $stmt->execute(array(
+    ':username' => $username
+  ));
+  $custom_attributes = $stmt->fetch(PDO::FETCH_ASSOC)['custom_attributes'];
+  if (empty($custom_attributes)){
+    $custom_attributes = (object)array();
+  }
+}
+catch (Exception $e) {
+  error_log("FOOTER: " . $e->getMessage() . PHP_EOL);
+  http_response_code(502);
+  exit;
+}
+
+
+// return footer
+$footer["vars"] = $custom_attributes;
+echo json_encode($footer);

+ 9 - 0
data/conf/rspamd/local.d/ratelimit.conf

@@ -0,0 +1,9 @@
+# Uncomment below to apply the ratelimits globally. Use Ratelimits inside mailcow UI to overwrite them for a specific domain/mailbox.
+# rates {
+#     # Format: "1 / 1h" or "20 / 1m" etc.
+#     to = "100 / 1s";
+#     to_ip = "100 / 1s";
+#     to_ip_from = "100 / 1s";
+#     bounce_to = "100 / 1h";
+#     bounce_to_ip = "7 / 1m";
+# }

+ 46 - 34
data/conf/rspamd/lua/rspamd.local.lua

@@ -527,20 +527,21 @@ rspamd_config:register_symbol({
   name = 'MOO_FOOTER',
   type = 'prefilter',
   callback = function(task)
+    local cjson = require "cjson"
     local lua_mime = require "lua_mime"
     local lua_util = require "lua_util"
     local rspamd_logger = require "rspamd_logger"
-    local rspamd_redis = require "rspamd_redis"
-    local ucl = require "ucl"
-    local redis_params = rspamd_parse_redis_server('footer')
+    local rspamd_http = require "rspamd_http"
     local envfrom = task:get_from(1)
     local uname = task:get_user()
     if not envfrom or not uname then
       return false
     end
     local uname = uname:lower()
-    local env_from_domain = envfrom[1].domain:lower() -- get smtp from domain in lower case
+    local env_from_domain = envfrom[1].domain:lower()
+    local env_from_addr = envfrom[1].addr:lower()
 
+    -- determine newline type
     local function newline(task)
       local t = task:get_newlines_type()
     
@@ -552,20 +553,19 @@ rspamd_config:register_symbol({
     
       return '\r\n'
     end
-    local function redis_cb_footer(err, data)
+    -- retrieve footer
+    local function footer_cb(err_message, code, data, headers)
       if err or type(data) ~= 'string' then
         rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
       else
+        
         -- parse json string
-        local parser = ucl.parser()
-        local res,err = parser:parse_string(data)
-        if not res then
+        local footer = cjson.decode(data)
+        if not footer then
           rspamd_logger.infox(rspamd_config, "parsing domain wide footer for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
         else
-          local footer = parser:get_object()
-
-          if footer and type(footer) == "table" and (footer.html or footer.plain) then
-            rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s", uname, footer.html, footer.plain)
+          if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "")  then
+            rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s, vars=%s", uname, footer.html, footer.plain, footer.vars)
 
             local envfrom_mime = task:get_from(2)
             local from_name = ""
@@ -575,6 +575,7 @@ rspamd_config:register_symbol({
               from_name = envfrom[1].name
             end
 
+            -- default replacements
             local replacements = {
               auth_user = uname,
               from_user = envfrom[1].user,
@@ -582,10 +583,20 @@ rspamd_config:register_symbol({
               from_addr = envfrom[1].addr,
               from_domain = envfrom[1].domain:lower()
             }
-            if footer.html then
+            -- add custom mailbox attributes
+            if footer.vars and type(footer.vars) == "string" then
+              local footer_vars = cjson.decode(footer.vars)
+
+              if type(footer_vars) == "table" then
+                for key, value in pairs(footer_vars) do
+                  replacements[key] = value
+                end
+              end
+            end
+            if footer.html and footer.html ~= "" then
               footer.html = lua_util.jinja_template(footer.html, replacements, true)
             end
-            if footer.plain then
+            if footer.plain and footer.plain ~= "" then
               footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
             end
   
@@ -631,15 +642,19 @@ rspamd_config:register_symbol({
             end
             local out_parts = {}
             for _,o in ipairs(out) do
-               if type(o) ~= 'table' then
-                 out_parts[#out_parts + 1] = o
-                 out_parts[#out_parts + 1] = newline_s
-               else
-                 out_parts[#out_parts + 1] = o[1]
-                 if o[2] then
-                   out_parts[#out_parts + 1] = newline_s
-                 end
-               end
+              if type(o) ~= 'table' then
+                out_parts[#out_parts + 1] = o
+                out_parts[#out_parts + 1] = newline_s
+              else
+                local removePrefix = "--\x0D\x0AContent-Type"
+                if string.lower(string.sub(tostring(o[1]), 1, string.len(removePrefix))) == string.lower(removePrefix) then
+                  o[1] = string.sub(tostring(o[1]), string.len("--\x0D\x0A") + 1)
+                end
+                out_parts[#out_parts + 1] = o[1]
+                if o[2] then
+                  out_parts[#out_parts + 1] = newline_s
+                end
+              end
             end
             task:set_message(out_parts)
           else
@@ -649,17 +664,14 @@ rspamd_config:register_symbol({
       end
     end
 
-    local redis_ret_footer = rspamd_redis_make_request(task,
-      redis_params, -- connect params
-      env_from_domain, -- hash key
-      false, -- is write
-      redis_cb_footer, --callback
-      'HGET', -- command
-      {"DOMAIN_WIDE_FOOTER", env_from_domain} -- arguments
-    )
-    if not redis_ret_footer then
-      rspamd_logger.infox(rspamd_config, "cannot make request to load footer for domain")
-    end
+    -- fetch footer
+    rspamd_http.request({
+      task=task,
+      url='http://nginx:8081/footer.php',
+      body='',
+      callback=footer_cb,
+      headers={Domain=env_from_domain,Username=uname,From=env_from_addr},
+    })
 
     return true
   end,

+ 0 - 8
data/conf/rspamd/override.d/ratelimit.conf

@@ -1,11 +1,3 @@
-rates {
-    # Format: "1 / 1h" or "20 / 1m" etc. - global ratelimits are disabled by default
-    to = "100 / 1s";
-    to_ip = "100 / 1s";
-    to_ip_from = "100 / 1s";
-    bounce_to = "100 / 1h";
-    bounce_to_ip = "7 / 1m";
-}
 whitelisted_rcpts = "postmaster,mailer-daemon";
 max_rcpt = 25;
 custom_keywords = "/etc/rspamd/lua/ratelimit.lua";

+ 5 - 1
data/web/admin.php

@@ -85,6 +85,8 @@ $cors_settings = cors('get');
 $cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allowed_origins']);
 $cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']);
 
+$f2b_data = fail2ban('get');
+
 $template = 'admin.twig';
 $template_data = [
   'tfa_data' => $tfa_data,
@@ -101,7 +103,8 @@ $template_data = [
   'domains' => $domains,
   'all_domains' => $all_domains,
   'mailboxes' => $mailboxes,
-  'f2b_data' => fail2ban('get'),
+  'f2b_data' => $f2b_data,
+  'f2b_banlist_url' => getBaseUrl() . "/api/v1/get/fail2ban/banlist/" . $f2b_data['banlist_id'],
   'q_data' => quarantine('settings'),
   'qn_data' => quota_notification('get'),
   'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'),
@@ -113,6 +116,7 @@ $template_data = [
   'password_complexity' => password_complexity('get'),
   'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
   'cors_settings' => $cors_settings,
+  'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
   'lang_admin' => json_encode($lang['admin']),
   'lang_datatables' => json_encode($lang['datatables'])
 ];

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

@@ -3137,6 +3137,86 @@ paths:
                     type: string
               type: object
       summary: Update domain
+  /api/v1/edit/domain/footer:
+    post:
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                  - log:
+                      - mailbox
+                      - edit
+                      - domain_wide_footer
+                      - domains:
+                          - mailcow.tld
+                        html: "<br>foo {= foo =}"
+                        plain: "<foo {= foo =}"
+                        mbox_exclude:
+                          - moo@mailcow.tld
+                      - null
+                    msg:
+                      - domain_footer_modified
+                      - mailcow.tld
+                    type: success
+              schema:
+                properties:
+                  log:
+                    description: contains request object
+                    items: {}
+                    type: array
+                  msg:
+                    items: {}
+                    type: array
+                  type:
+                    enum:
+                      - success
+                      - danger
+                      - error
+                    type: string
+                type: object
+          description: OK
+          headers: {}
+      tags:
+        - Domains
+      description: >-
+        You can update the footer of one or more domains per request.
+      operationId: Update domain wide footer
+      requestBody:
+        content:
+          application/json:
+            schema:
+              example:
+                attr:
+                  html: "<br>foo {= foo =}"
+                  plain: "foo {= foo =}"
+                  mbox_exclude:
+                    - moo@mailcow.tld
+                items: mailcow.tld
+              properties:
+                attr:
+                  properties:
+                    html:
+                      description: Footer text in HTML format
+                      type: string
+                    plain:
+                      description: Footer text in PLAIN text format
+                      type: string
+                    mbox_exclude:
+                      description: Array of mailboxes to exclude from domain wide footer
+                      type: object
+                  type: object
+                items:
+                  description: contains a list of domain names where you want to update the footer
+                  type: array
+                  items:
+                    type: string
+              type: object
+      summary: Update domain wide footer
   /api/v1/edit/fail2ban:
     post:
       responses:
@@ -3336,6 +3416,86 @@ paths:
                   type: object
               type: object
       summary: Update mailbox
+  /api/v1/edit/mailbox/custom-attribute:
+    post:
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                  - log:
+                      - mailbox
+                      - edit
+                      - mailbox_custom_attribute
+                      - mailboxes:
+                          - moo@mailcow.tld
+                        attribute:
+                          - role
+                          - foo
+                        value:
+                          - cow
+                          - bar
+                      - null
+                    msg:
+                      - mailbox_modified
+                      - moo@mailcow.tld
+                    type: success
+              schema:
+                properties:
+                  log:
+                    description: contains request object
+                    items: {}
+                    type: array
+                  msg:
+                    items: {}
+                    type: array
+                  type:
+                    enum:
+                      - success
+                      - danger
+                      - error
+                    type: string
+                type: object
+          description: OK
+          headers: {}
+      tags:
+        - Mailboxes
+      description: >-
+        You can update custom attributes of one or more mailboxes per request.
+      operationId: Update mailbox custom attributes
+      requestBody:
+        content:
+          application/json:
+            schema:
+              example:
+                attr:
+                  attribute:
+                    - role
+                    - foo
+                  value:
+                    - cow
+                    - bar
+                items:
+                  - moo@mailcow.tld
+              properties:
+                attr:
+                  properties:
+                    attribute:
+                      description: Array of attribute keys
+                      type: object
+                    value:
+                      description: Array of attribute values
+                      type: object
+                  type: object
+                items:
+                  description: contains list of mailboxes you want update
+                  type: object
+              type: object
+      summary: Update mailbox custom attributes
   /api/v1/edit/mailq:
     post:
       responses:
@@ -5581,6 +5741,7 @@ paths:
                         sogo_access: "1"
                         tls_enforce_in: "0"
                         tls_enforce_out: "0"
+                      custom_attributes: {}
                       domain: domain3.tld
                       is_relayed: 0
                       local_part: info
@@ -5646,6 +5807,40 @@ paths:
                       items:
                         type: string
       summary: Edit Cross-Origin Resource Sharing (CORS) settings
+  "/api/v1/get/spam-score/{mailbox}":
+    get:
+      parameters:
+        - description: name of mailbox or empty for current user - admin user will retrieve the global spam filter score
+          in: path
+          name: mailbox
+          required: true
+          schema:
+            type: string
+        - description: e.g. api-key-string
+          example: api-key-string
+          in: header
+          name: X-API-Key
+          required: false
+          schema:
+            type: string
+      responses:
+        "401":
+          $ref: "#/components/responses/Unauthorized"
+        "200":
+          content:
+            application/json:
+              examples:
+                response:
+                  value:
+                    spam_score: "8,15"
+          description: OK
+          headers: {}
+      tags:
+        - Mailboxes
+      description: >-
+        Using this endpoint you can get the global spam filter score or the spam filter score of a certain mailbox.
+      operationId: Get mailbox or global spam filter score
+      summary: Get mailbox or global spam filter score
 
 tags:
   - name: Domains

+ 3 - 0
data/web/edit.php

@@ -58,6 +58,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
             'dkim' => dkim('details', $domain),
             'domain_details' => $result,
             'domain_footer' => $domain_footer,
+            'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
+            'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address')
           ];
       }
     }
@@ -218,6 +220,7 @@ $js_minifier->add('/web/js/site/pwgen.js');
 $template_data['result'] = $result;
 $template_data['return_to'] = $_SESSION['return_to'];
 $template_data['lang_user'] = json_encode($lang['user']);
+$template_data['lang_admin'] = json_encode($lang['admin']);
 $template_data['lang_datatables'] = json_encode($lang['datatables']);
 
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 70 - 1
data/web/inc/functions.fail2ban.inc.php

@@ -1,5 +1,5 @@
 <?php
-function fail2ban($_action, $_data = null) {
+function fail2ban($_action, $_data = null, $_extra = null) {
   global $redis;
   $_data_log = $_data;
   switch ($_action) {
@@ -247,6 +247,7 @@ function fail2ban($_action, $_data = null) {
         $netban_ipv6 = intval((isset($_data['netban_ipv6'])) ? $_data['netban_ipv6'] : $is_now['netban_ipv6']);
         $wl = (isset($_data['whitelist'])) ? $_data['whitelist'] : $is_now['whitelist'];
         $bl = (isset($_data['blacklist'])) ? $_data['blacklist'] : $is_now['blacklist'];
+        $manage_external = (isset($_data['manage_external'])) ? intval($_data['manage_external']) : 0;
       }
       else {
         $_SESSION['return'][] = array(
@@ -266,6 +267,8 @@ function fail2ban($_action, $_data = null) {
       $f2b_options['netban_ipv6'] = ($netban_ipv6 > 128) ? 128 : $netban_ipv6;
       $f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts;
       $f2b_options['retry_window'] = ($retry_window < 1) ? 1 : $retry_window;
+      $f2b_options['banlist_id'] = $is_now['banlist_id'];
+      $f2b_options['manage_external'] = ($manage_external > 0) ? 1 : 0;
       try {
         $redis->Set('F2B_OPTIONS', json_encode($f2b_options));
         $redis->Del('F2B_WHITELIST');
@@ -329,5 +332,71 @@ function fail2ban($_action, $_data = null) {
         'msg' => 'f2b_modified'
       );
     break;
+    case 'banlist':
+      try {
+        $f2b_options = json_decode($redis->Get('F2B_OPTIONS'), true);
+      } 
+      catch (RedisException $e) {
+        $_SESSION['return'][] = array(
+          'type' => 'danger',
+          'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
+          'msg' => array('redis_error', $e)
+        );
+        http_response_code(500);
+        return false;
+      }
+      if (is_array($_extra)) {
+        $_extra = $_extra[0];
+      }
+      if ($_extra != $f2b_options['banlist_id']){
+        http_response_code(404);
+        return false;
+      }
+
+      switch ($_data) {
+        case 'get':
+          try {
+            $bl = $redis->hKeys('F2B_BLACKLIST');
+            $active_bans = $redis->hKeys('F2B_ACTIVE_BANS');
+          } 
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
+              'msg' => array('redis_error', $e)
+            );
+            http_response_code(500);
+            return false;
+          }
+          $banlist = implode("\n", array_merge($bl, $active_bans));
+          return $banlist;
+        break;
+        case 'refresh':
+          if ($_SESSION['mailcow_cc_role'] != "admin") {
+            return false;
+          }
+
+          $f2b_options['banlist_id'] = uuid4();
+          try {
+            $redis->Set('F2B_OPTIONS', json_encode($f2b_options));
+          } 
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+
+          $_SESSION['return'][] = array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
+            'msg' => 'f2b_banlist_refreshed'
+          );
+          return true;
+        break;
+      }
+    break;
   }
 }

+ 15 - 0
data/web/inc/functions.inc.php

@@ -2246,6 +2246,21 @@ function cors($action, $data = null) {
     break;
   }
 }
+function getBaseURL() {
+  $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
+  $host = $_SERVER['HTTP_HOST'];
+  $base_url = $protocol . '://' . $host;
+
+  return $base_url;
+}
+function uuid4() {
+  $data = openssl_random_pseudo_bytes(16);
+
+  $data[6] = chr(ord($data[6]) & 0x0f | 0x40);
+  $data[8] = chr(ord($data[8]) & 0x3f | 0x80);
+
+  return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
+}
 
 function get_logs($application, $lines = false) {
   if ($lines === false) {

+ 155 - 38
data/web/inc/functions.mailbox.inc.php

@@ -325,6 +325,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $timeout2             = intval($_data['timeout2']);
           $skipcrossduplicates  = intval($_data['skipcrossduplicates']);
           $automap              = intval($_data['automap']);
+          $dry                  = intval($_data['dry']);
           $port1                = $_data['port1'];
           $host1                = strtolower($_data['host1']);
           $password1            = $_data['password1'];
@@ -435,8 +436,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             );
             return false;
           }
-          $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `maxage`, `subfolder2`, `host1`, `authmech1`, `user1`, `password1`, `mins_interval`, `port1`, `enc1`, `delete2duplicates`, `custom_params`, `active`)
-            VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :maxage, :subfolder2, :host1, :authmech1, :user1, :password1, :mins_interval, :port1, :enc1, :delete2duplicates, :custom_params, :active)");
+          $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `host1`, `authmech1`, `user1`, `password1`, `mins_interval`, `port1`, `enc1`, `delete2duplicates`, `custom_params`, `active`)
+            VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :host1, :authmech1, :user1, :password1, :mins_interval, :port1, :enc1, :delete2duplicates, :custom_params, :active)");
           $stmt->execute(array(
             ':user2' => $username,
             ':custom_params' => $custom_params,
@@ -450,6 +451,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             ':skipcrossduplicates' => $skipcrossduplicates,
             ':maxbytespersecond' => $maxbytespersecond,
             ':subscribeall' => $subscribeall,
+            ':dry' => $dry,
             ':subfolder2' => $subfolder2,
             ':host1' => $host1,
             ':authmech1' => 'PLAIN',
@@ -2031,6 +2033,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               $success = (isset($_data['success'])) ? NULL : $is_now['success'];
               $delete2duplicates = (isset($_data['delete2duplicates'])) ? intval($_data['delete2duplicates']) : $is_now['delete2duplicates'];
               $subscribeall = (isset($_data['subscribeall'])) ? intval($_data['subscribeall']) : $is_now['subscribeall'];
+              $dry = (isset($_data['dry'])) ? intval($_data['dry']) : $is_now['dry'];
               $delete1 = (isset($_data['delete1'])) ? intval($_data['delete1']) : $is_now['delete1'];
               $delete2 = (isset($_data['delete2'])) ? intval($_data['delete2']) : $is_now['delete2'];
               $automap = (isset($_data['automap'])) ? intval($_data['automap']) : $is_now['automap'];
@@ -2164,6 +2167,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               `timeout1` = :timeout1,
               `timeout2` = :timeout2,
               `subscribeall` = :subscribeall,
+              `dry` = :dry,
               `active` = :active
                 WHERE `id` = :id");
             $stmt->execute(array(
@@ -2189,6 +2193,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               ':timeout1' => $timeout1,
               ':timeout2' => $timeout2,
               ':subscribeall' => $subscribeall,
+              ':dry' => $dry,
               ':active' => $active,
             ));
             $_SESSION['return'][] = array(
@@ -3259,6 +3264,62 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           );
           return true;
         break;
+        case 'mailbox_custom_attribute':
+          $_data['attribute'] = isset($_data['attribute']) ? $_data['attribute'] : array();
+          $_data['attribute'] = is_array($_data['attribute']) ? $_data['attribute'] : array($_data['attribute']);
+          $_data['attribute'] = array_map(function($value) { return str_replace(' ', '', $value); }, $_data['attribute']);
+          $_data['value']     = isset($_data['value']) ? $_data['value'] : array();
+          $_data['value']     = is_array($_data['value']) ? $_data['value'] : array($_data['value']);
+          $attributes         = (object)array_combine($_data['attribute'], $_data['value']);
+          $mailboxes          = is_array($_data['mailboxes']) ? $_data['mailboxes'] : array($_data['mailboxes']);
+
+          foreach ($mailboxes as $mailbox) {
+            if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => array('username_invalid', $mailbox)
+              );
+              continue;
+            }
+            $is_now = mailbox('get', 'mailbox_details', $mailbox);            
+            if(!empty($is_now)){
+              if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $is_now['domain'])) {
+                $_SESSION['return'][] = array(
+                  'type' => 'danger',
+                  'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                  'msg' => 'access_denied'
+                );
+                continue;
+              }
+            }
+            else {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'access_denied'
+              );
+              continue;
+            }
+
+
+            $stmt = $pdo->prepare("UPDATE `mailbox`
+              SET `custom_attributes` = :custom_attributes
+              WHERE username = :username");
+            $stmt->execute(array(
+              ":username" => $mailbox,
+              ":custom_attributes" => json_encode($attributes)
+            ));             
+            
+            $_SESSION['return'][] = array(
+              'type' => 'success',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'msg' => array('mailbox_modified', $mailbox)
+            );
+          }
+          
+          return true;
+        break;
         case 'resource':
           if (!is_array($_data['name'])) {
             $names = array();
@@ -3338,44 +3399,89 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             );
           }
         break;
-        case 'domain_wide_footer':
-          $domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46);
-          if (!is_valid_domain_name($domain)) {
-            $_SESSION['return'][] = array(
-              'type' => 'danger',
-              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => 'domain_invalid'
-            );
-            return false;
+        case 'domain_wide_footer':  
+          if (!is_array($_data['domains'])) {
+            $domains = array();
+            $domains[] = $_data['domains'];
           }
-          if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) {
-            $_SESSION['return'][] = array(
-              'type' => 'danger',
-              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => 'access_denied'
-            );
-            return false;
+          else {
+            $domains = $_data['domains'];
           }
 
           $footers = array();
-          $footers['html'] = isset($_data['footer_html']) ? $_data['footer_html'] : '';
-          $footers['plain'] = isset($_data['footer_plain']) ? $_data['footer_plain'] : '';
-          try {
-            $redis->hSet('DOMAIN_WIDE_FOOTER', $domain, json_encode($footers));
+          $footers['html'] = isset($_data['html']) ? $_data['html'] : '';
+          $footers['plain'] = isset($_data['plain']) ? $_data['plain'] : '';
+          $footers['mbox_exclude'] = array();
+          if (isset($_data["mbox_exclude"])){
+            if (!is_array($_data["mbox_exclude"])) {
+              $_data["mbox_exclude"] = array($_data["mbox_exclude"]);
+            }
+            foreach ($_data["mbox_exclude"] as $mailbox) {
+              if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
+                $_SESSION['return'][] = array(
+                  'type' => 'danger',
+                  'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                  'msg' => array('username_invalid', $mailbox)
+                );
+                continue;
+              }
+              $is_now = mailbox('get', 'mailbox_details', $mailbox);            
+              if(empty($is_now)){
+                $_SESSION['return'][] = array(
+                  'type' => 'danger',
+                  'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                  'msg' => array('username_invalid', $mailbox)
+                );
+                continue;
+              }
+              
+              array_push($footers['mbox_exclude'], $mailbox);
+            }
           }
-          catch (RedisException $e) {
+          foreach ($domains as $domain) {
+            $domain = idn_to_ascii(strtolower(trim($domain)), 0, INTL_IDNA_VARIANT_UTS46);
+            if (!is_valid_domain_name($domain)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'domain_invalid'
+              );
+              return false;
+            }
+            if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => 'access_denied'
+              );
+              return false;
+            }
+
+            try {
+              $stmt = $pdo->prepare("DELETE FROM `domain_wide_footer` WHERE `domain`= :domain");
+              $stmt->execute(array(':domain' => $domain));
+              $stmt = $pdo->prepare("INSERT INTO `domain_wide_footer` (`domain`, `html`, `plain`, `mbox_exclude`) VALUES (:domain, :html, :plain, :mbox_exclude)");
+              $stmt->execute(array(
+                ':domain' => $domain,
+                ':html' => $footers['html'],
+                ':plain' => $footers['plain'],
+                ':mbox_exclude' => json_encode($footers['mbox_exclude']),
+              ));
+            }
+            catch (PDOException $e) {
+              $_SESSION['return'][] = array(
+                'type' => 'danger',
+                'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                'msg' => $e->getMessage()
+              );
+              return false;
+            }
             $_SESSION['return'][] = array(
-              'type' => 'danger',
+              'type' => 'success',
               'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => array('redis_error', $e)
+              'msg' => array('domain_footer_modified', htmlspecialchars($domain))
             );
-            return false;
           }
-          $_SESSION['return'][] = array(
-            'type' => 'success',
-            'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-            'msg' => array('domain_footer_modified', htmlspecialchars($domain))
-          );
         break;
       }
     break;
@@ -3929,13 +4035,17 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
             return false;
           }
-          $stmt = $pdo->prepare("SELECT `id` FROM `alias` WHERE `address` != `goto` AND `domain` = :domain");
+          $stmt = $pdo->prepare("SELECT `id`, `address` FROM `alias` WHERE `address` != `goto` AND `domain` = :domain");
           $stmt->execute(array(
             ':domain' => $_data,
           ));
           $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
           while($row = array_shift($rows)) {
-            $aliases[] = $row['id'];
+            if ($_extra == "address"){
+              $aliases[] = $row['address'];
+            } else {
+              $aliases[] = $row['id'];
+            }
           }
           return $aliases;
         break;
@@ -4287,6 +4397,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               `mailbox`.`modified`,
               `quota2`.`bytes`,
               `attributes`,
+              `custom_attributes`,
               `quota2`.`messages`
                 FROM `mailbox`, `quota2`, `domain`
                   WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
@@ -4307,6 +4418,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               `mailbox`.`modified`,
               `quota2replica`.`bytes`,
               `attributes`,
+              `custom_attributes`,
               `quota2replica`.`messages`
                 FROM `mailbox`, `quota2replica`, `domain`
                   WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
@@ -4329,6 +4441,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $mailboxdata['quota'] = $row['quota'];
           $mailboxdata['messages'] = $row['messages'];
           $mailboxdata['attributes'] = json_decode($row['attributes'], true);
+          $mailboxdata['custom_attributes'] = json_decode($row['custom_attributes'], true);
           $mailboxdata['quota_used'] = intval($row['bytes']);
           $mailboxdata['percent_in_use'] = ($row['quota'] == 0) ? '- ' : round((intval($row['bytes']) / intval($row['quota'])) * 100);
           $mailboxdata['created'] = $row['created'];
@@ -4509,19 +4622,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           }
 
           try {
-            $footers = $redis->hGet('DOMAIN_WIDE_FOOTER', $domain);
-            $footers = json_decode($footers, true);
+            $stmt = $pdo->prepare("SELECT `html`, `plain`, `mbox_exclude` FROM `domain_wide_footer`
+              WHERE `domain` = :domain");
+            $stmt->execute(array(
+              ':domain' => $domain
+            ));
+            $footer = $stmt->fetch(PDO::FETCH_ASSOC);
           }
-          catch (RedisException $e) {
+          catch (PDOException $e) {
             $_SESSION['return'][] = array(
               'type' => 'danger',
               'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => array('redis_error', $e)
+              'msg' => $e->getMessage()
             );
             return false;
           }
 
-          return $footers;
+          return $footer;
         break;
       }
     break;

+ 17 - 1
data/web/inc/init_db.inc.php

@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
     global $pdo;
 
-    $db_version = "14022023_1000";
+    $db_version = "21112023_1644";
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -267,6 +267,20 @@ function init_db_schema() {
         ),
         "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
       ),
+      "domain_wide_footer" => array(
+        "cols" => array(
+          "domain" => "VARCHAR(255) NOT NULL",
+          "html" => "LONGTEXT",
+          "plain" => "LONGTEXT",
+          "mbox_exclude" => "JSON NOT NULL DEFAULT ('[]')",
+        ),
+        "keys" => array(
+          "primary" => array(
+            "" => array("domain")
+          )
+        ),
+        "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+      ),
       "tags_domain" => array(
         "cols" => array(
           "tag_name" => "VARCHAR(255) NOT NULL",
@@ -344,6 +358,7 @@ function init_db_schema() {
           "local_part" => "VARCHAR(255) NOT NULL",
           "domain" => "VARCHAR(255) NOT NULL",
           "attributes" => "JSON",
+          "custom_attributes" => "JSON NOT NULL DEFAULT ('{}')",
           "kind" => "VARCHAR(100) NOT NULL DEFAULT ''",
           "multiple_bookings" => "INT NOT NULL DEFAULT -1",
           "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
@@ -704,6 +719,7 @@ function init_db_schema() {
           "timeout1" => "SMALLINT NOT NULL DEFAULT '600'",
           "timeout2" => "SMALLINT NOT NULL DEFAULT '600'",
           "subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'",
+          "dry" => "TINYINT(1) NOT NULL DEFAULT '0'",
           "is_running" => "TINYINT(1) NOT NULL DEFAULT '0'",
           "returned_text" => "LONGTEXT",
           "last_run" => "TIMESTAMP NULL DEFAULT NULL",

+ 4 - 4
data/web/inc/lib/vendor/directorytree/ldaprecord/.github/workflows/run-tests.yml

@@ -19,10 +19,10 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v2
+        uses: actions/checkout@v4
 
       - name: Cache dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: ~/.composer/cache/files
           key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
@@ -52,10 +52,10 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v2
+        uses: actions/checkout@v4
 
       - name: Cache dependencies
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: ~/.composer/cache/files
           key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}

+ 3 - 3
data/web/inc/lib/vendor/php-mime-mail-parser/php-mime-mail-parser/.github/workflows/main.yml

@@ -12,7 +12,7 @@ jobs:
         dependency-version: [prefer-lowest, prefer-stable]
     steps:
       - name: Checkout code
-        uses: actions/checkout@v1
+        uses: actions/checkout@v4
 
       - name: Setup PHP
         uses: shivammathur/setup-php@v2
@@ -31,7 +31,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout code
-        uses: actions/checkout@v1
+        uses: actions/checkout@v4
 
       - name: Install dependencies
         run: composer update --no-progress --ignore-platform-reqs
@@ -43,7 +43,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout code
-        uses: actions/checkout@v1
+        uses: actions/checkout@v4
 
       - name: Setup PHP
         uses: shivammathur/setup-php@v2

+ 1 - 1
data/web/inc/lib/vendor/robthree/twofactorauth/.github/workflows/test.yml

@@ -13,7 +13,7 @@ jobs:
         php-version: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0']
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v4
 
     - uses: shivammathur/setup-php@v2
       with:

+ 1 - 1
data/web/inc/lib/vendor/tightenco/collect/.github/workflows/run-tests.yml

@@ -25,7 +25,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout@v1
+        uses: actions/checkout@v4
 
       - name: Setup PHP
         uses: shivammathur/setup-php@v2

+ 2 - 2
data/web/inc/lib/vendor/twig/twig/.github/workflows/ci.yml

@@ -32,7 +32,7 @@ jobs:
 
         steps:
             - name: "Checkout code"
-              uses: actions/checkout@v2
+              uses: actions/checkout@v4
 
             - name: "Install PHP with extensions"
               uses: shivammathur/setup-php@v2
@@ -86,7 +86,7 @@ jobs:
 
         steps:
             - name: "Checkout code"
-              uses: actions/checkout@v2
+              uses: actions/checkout@v4
 
             - name: "Install PHP with extensions"
               uses: shivammathur/setup-php@v2

+ 3 - 3
data/web/inc/lib/vendor/twig/twig/.github/workflows/documentation.yml

@@ -18,7 +18,7 @@ jobs:
 
         steps:
             -   name: "Checkout code"
-                uses: actions/checkout@v2
+                uses: actions/checkout@v4
 
             -   name: "Set-up PHP"
                 uses: shivammathur/setup-php@v2
@@ -33,7 +33,7 @@ jobs:
                 run: echo "::set-output name=dir::$(composer config cache-files-dir)"
 
             -   name: Cache dependencies
-                uses: actions/cache@v2
+                uses: actions/cache@v3
                 with:
                     path: ${{ steps.composercache.outputs.dir }}
                     key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@@ -54,7 +54,7 @@ jobs:
 
         steps:
             - name: "Checkout code"
-              uses: actions/checkout@v2
+              uses: actions/checkout@v4
 
             - name: "Run DOCtor-RST"
               uses: docker://oskarstark/doctor-rst

+ 4 - 0
data/web/inc/prerequisites.inc.php

@@ -70,6 +70,8 @@ try {
   }
 }
 catch (Exception $e) {
+// Stop when redis is not available
+http_response_code(500);
 ?>
 <center style='font-family:sans-serif;'>Connection to Redis failed.<br /><br />The following error was reported:<br/><?=$e->getMessage();?></center>
 <?php
@@ -98,6 +100,7 @@ try {
 }
 catch (PDOException $e) {
 // Stop when SQL connection fails
+http_response_code(500);
 ?>
 <center style='font-family:sans-serif;'>Connection to database failed.<br /><br />The following error was reported:<br/>  <?=$e->getMessage();?></center>
 <?php
@@ -105,6 +108,7 @@ exit;
 }
 // Stop when dockerapi is not available
 if (fsockopen("tcp://dockerapi", 443, $errno, $errstr) === false) {
+  http_response_code(500);
 ?>
 <center style='font-family:sans-serif;'>Connection to dockerapi container failed.<br /><br />The following error was reported:<br/><?=$errno;?> - <?=$errstr;?></center>
 <?php

+ 18 - 0
data/web/inc/presets/sieve/sieve_8.yml

@@ -0,0 +1,18 @@
+headline: lang.sieve_preset_8
+content: |
+  require "fileinto";
+  require "mailbox";
+  require "variables";
+  require "subaddress";
+  require "envelope";
+  require "duplicate";
+  require "imap4flags";
+  if header :matches "To" "*mail@domain.tld*" {
+    redirect "anothermail@anotherdomain.tld";
+    setflag "\\seen"; /* Mark mail as read */
+    fileInto "INBOX/SubFolder"; /* Move mail on subfolder after */
+  } else {
+    # The rest goes into INBOX
+    # default is "implicit keep", we do it explicitly here
+    keep;
+  }

+ 89 - 86
data/web/inc/vars.inc.php

@@ -97,6 +97,7 @@ $AVAILABLE_LANGUAGES = array(
   'lv-lv' => 'latviešu (Latvian)',
   'nl-nl' => 'Nederlands (Dutch)',
   'pl-pl' => 'Język Polski (Polish)',
+  'pt-br' => 'Português brasileiro (Brazilian Portuguese)',
   'pt-pt' => 'Português (Portuguese)',
   'ro-ro' => 'Română (Romanian)',
   'ru-ru' => 'Pусский (Russian)',
@@ -235,118 +236,120 @@ $RSPAMD_MAPS = array(
 
 $IMAPSYNC_OPTIONS = array(
   'whitelist' => array(
+    'abort',     
+    'authmd51',        
+    'authmd52',           
     'authmech1',
     'authmech2',
     'authuser1', 
     'authuser2', 
+    'debug',   
     'debugcontent', 
-    'disarmreadreceipts', 
-    'logdir',
     'debugcrossduplicates', 
-    'maxsize',
-    'minsize',
-    'minage',
-    'search', 
-    'noabletosearch', 
-    'pidfile', 
-    'pidfilelocking', 
-    'search1',
-    'search2', 
-    'sslargs1',
-    'sslargs2', 
-    'syncduplicates',
-    'usecache', 
-    'synclabels', 
-    'truncmess',  
+    'debugflags',    
+    'debugfolders',            
+    'debugimap',    
+    'debugimap1',     
+    'debugimap2',   
+    'debugmemory',       
+    'debugssl',              
+    'delete1emptyfolders',
+    'delete2folders',    
+    'disarmreadreceipts', 
+    'domain1',
+    'domain2',
+    'domino1',   
     'domino2',  
-    'expunge1',  
+    'dry',
+    'errorsmax',
+    'exchange1',   
+    'exchange2',   
+    'exitwhenover',
+    'expunge1',
+    'f1f2',  
     'filterbuggyflags',  
+    'folder',
+    'folderfirst',
+    'folderlast',
+    'folderrec',
+    'gmail1',     
+    'gmail2',    
+    'idatefromheader',   
+    'include',
+    'inet4',
+    'inet6',
     'justconnect',  
     'justfolders',  
-    'maxlinelength',
-    'useheader',  
-    'noabletosearch1',  
-    'nolog',  
-    'prefix1',
-    'prefix2',
-    'sep1',
-    'sep2',
-    'nofoldersizesatend',
     'justfoldersizes',  
-    'proxyauth1',  
-    'skipemptyfolders',
-    'include',
-    'subfolder1',
-    'subscribed',
-    'subscribe',   
-    'debug',   
-    'debugimap2',   
-    'domino1',   
-    'exchange1',   
-    'exchange2',   
     'justlogin',   
     'keepalive1',   
     'keepalive2',   
+    'log',
+    'logdir',
+    'logfile',        
+    'maxbytesafter',
+    'maxlinelength',
+    'maxmessagespersecond',
+    'maxsize',
+    'maxsleep',
+    'minage',
+    'minsize',
+    'noabletosearch', 
+    'noabletosearch1',  
     'noabletosearch2',   
+    'noexpunge1',        
     'noexpunge2',   
+    'nofoldersizesatend',
+    'noid',       
+    'nolog',  
+    'nomixfolders',          
     'noresyncflags',   
     'nossl1',   
-    'nouidexpunge2',   
-    'syncinternaldates',
-    'idatefromheader',   
-    'useuid',    
-    'debugflags',    
-    'debugimap',    
-    'delete1emptyfolders',
-    'delete2folders',    
-    'gmail2',    
-    'office1',    
-    'testslive6',     
-    'debugimap1',     
-    'errorsmax',
-    'tests',     
-    'gmail1',     
-    'maxmessagespersecond',
-    'maxbytesafter',
-    'maxsleep',
-    'abort',     
-    'resyncflags',     
-    'resynclabels',     
-    'syncacls',
+    'nossl2',            
     'nosyncacls',      
+    'notls1', 
+    'notls2',              
+    'nouidexpunge2',   
     'nousecache',      
-    'office2',      
-    'testslive',       
-    'debugmemory',       
-    'exitwhenover',
-    'noid',       
-    'noexpunge1',        
-    'authmd51',        
-    'logfile',        
-    'proxyauth2',         
-    'domain1',
-    'domain2',
     'oauthaccesstoken1',
     'oauthaccesstoken2',
     'oauthdirect1',
     'oauthdirect2',
-    'folder',
-    'folderrec',
-    'folderfirst',
-    'folderlast',
-    'nomixfolders',          
-    'authmd52',           
-    'debugfolders',            
-    'nossl2',            
+    'office1',    
+    'office2',      
+    'pidfile', 
+    'pidfilelocking', 
+    'prefix1',
+    'prefix2',
+    'proxyauth1',  
+    'proxyauth2',         
+    'resyncflags',     
+    'resynclabels',     
+    'search', 
+    'search1',
+    'search2', 
+    'sep1',
+    'sep2',
+    'showpasswords',
+    'skipemptyfolders',
     'ssl2',            
+    'sslargs1',
+    'sslargs2', 
+    'subfolder1',
+    'subscribe',   
+    'subscribed',
+    'syncacls',
+    'syncduplicates',
+    'syncinternaldates',
+    'synclabels', 
+    'tests',     
+    'testslive',       
+    'testslive6',     
     'tls2',             
-    'notls2',              
-    'debugssl',              
-    'notls1', 
-    'inet4',
-    'inet6',
-    'log',
-    'showpasswords'
+    'truncmess',  
+    'usecache', 
+    'useheader',  
+    'useuid'    
   ),
   'blacklist' => array(
     'skipmess',

+ 8 - 0
data/web/js/build/013-mailcow.js

@@ -391,3 +391,11 @@ function addTag(tagAddElem, tag = null){
   $(tagValuesElem).val(JSON.stringify(value_tags));
   $(tagInputElem).val('');
 }
+function copyToClipboard(id) {
+  var copyText = document.getElementById(id);
+  copyText.select();
+  copyText.setSelectionRange(0, 99999);
+  // only works with https connections
+  navigator.clipboard.writeText(copyText.value);
+  mailcow_alert_box(lang.copy_to_clipboard, "success");
+}

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

@@ -1684,7 +1684,7 @@ function showVersionModal(title, version){
 function parseGithubMarkdownLinks(inputText) {
   var replacedText, replacePattern1;
 
-  replacePattern1 = /(\b(https?):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
+  replacePattern1 = /(\b(https?):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])(?![^<]*>)/gim;
   replacedText = inputText.replace(replacePattern1, (matched, index, original, input_string) => {
     if (matched.includes('github.com')){
       // return short link if it's github link

+ 17 - 0
data/web/js/site/edit.js

@@ -199,6 +199,23 @@ jQuery(function($){
     });
   }
 
+  function add_table_row(table_id, type) {
+    var row = $('<tr />');
+    if (type == "mbox_attr") {
+      cols = '<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="attribute" required></td>';
+      cols += '<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="value" required></td>';
+      cols += '<td><a href="#" role="button" class="btn btn-sm btn-xs-lg btn-secondary h-100 w-100" type="button">' + lang_admin.remove_row + '</a></td>';
+    }
+    row.append(cols);
+    table_id.append(row);
+  }
+  $('#mbox_attr_table').on('click', 'tr a', function (e) {
+    e.preventDefault();
+    $(this).parents('tr').remove();
+  });
+  $('#add_mbox_attr_row').click(function() {
+    add_table_row($('#mbox_attr_table'), "mbox_attr");
+  });
 
   // detect element visibility changes
   function onVisible(element, callback) {

+ 5 - 1
data/web/js/site/quarantine.js

@@ -220,7 +220,7 @@ jQuery(function($){
             if (value.score > 0) highlightClass = 'negative';
             else if (value.score < 0) highlightClass = 'positive';
             else highlightClass = 'neutral';
-            $('#qid_detail_symbols').append('<span data-bs-toggle="tooltip" class="rspamd-symbol ' + highlightClass + '" title="' + (value.options ? value.options.join(', ') : '') + '">' + value.name + ' (<span class="score">' + value.score + '</span>)</span>');
+            $('#qid_detail_symbols').append('<span data-bs-toggle="tooltip" class="rspamd-symbol ' + highlightClass + '" title="' + (value.options ? escapeHtml(value.options.join(', ')) : '') + '">' + value.name + ' (<span class="score">' + value.score + '</span>)</span>');
           });
           $('[data-bs-toggle="tooltip"]').tooltip();
         }
@@ -295,3 +295,7 @@ jQuery(function($){
       $(".table_collapse_option").hide();
   }
 });
+
+
+
+

+ 34 - 3
data/web/json_api.php

@@ -503,6 +503,16 @@ if (isset($_GET['query'])) {
           print(json_encode($getArgs));
           $_SESSION['challenge'] = $WebAuthn->getChallenge();
           return;
+        break;          
+        case "fail2ban":
+          if (!isset($_SESSION['mailcow_cc_role'])){
+            switch ($object) {
+              case 'banlist':
+                header('Content-Type: text/plain');
+                echo fail2ban('banlist', 'get', $extra);
+              break;
+            }
+          }
         break;
       }
       if (isset($_SESSION['mailcow_cc_role'])) {
@@ -1324,6 +1334,10 @@ if (isset($_GET['query'])) {
           break;
           case "fail2ban":
             switch ($object) {
+              case 'banlist':
+                header('Content-Type: text/plain');
+                echo fail2ban('banlist', 'get', $extra);
+              break;
               default:
                 $data = fail2ban('get');
                 process_get_return($data);
@@ -1591,6 +1605,12 @@ if (isset($_GET['query'])) {
               }
             }
           break;
+          case "spam-score":
+            $score = mailbox('get', 'spam_score', $object);
+            if ($score)
+              $score = array("score" => preg_replace("/\s+/", "", $score));
+            process_get_return($score);
+          break;
         break;
         // return no route found if no case is matched
         default:
@@ -1867,8 +1887,6 @@ if (isset($_GET['query'])) {
         case "quota_notification_bcc":
           process_edit_return(quota_notification_bcc('edit', $attr));
         break;
-        case "domain-wide-footer":
-          process_edit_return(mailbox('edit', 'domain_wide_footer', $attr));
         break;
         case "mailq":
           process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));
@@ -1881,6 +1899,9 @@ if (isset($_GET['query'])) {
             case "template":
               process_edit_return(mailbox('edit', 'mailbox_templates', array_merge(array('ids' => $items), $attr)));
             break;
+            case "custom-attribute":
+              process_edit_return(mailbox('edit', 'mailbox_custom_attribute', array_merge(array('mailboxes' => $items), $attr)));
+            break;
             default:
               process_edit_return(mailbox('edit', 'mailbox', array_merge(array('username' => $items), $attr)));
             break;
@@ -1900,6 +1921,9 @@ if (isset($_GET['query'])) {
             case "template":
               process_edit_return(mailbox('edit', 'domain_templates', array_merge(array('ids' => $items), $attr)));
             break;
+            case "footer":
+              process_edit_return(mailbox('edit', 'domain_wide_footer', array_merge(array('domains' => $items), $attr)));
+            break;
             default:
               process_edit_return(mailbox('edit', 'domain', array_merge(array('domain' => $items), $attr)));
             break;
@@ -1933,7 +1957,14 @@ if (isset($_GET['query'])) {
           process_edit_return(fwdhost('edit', array_merge(array('fwdhost' => $items), $attr)));
         break;
         case "fail2ban":
-          process_edit_return(fail2ban('edit', array_merge(array('network' => $items), $attr)));
+          switch ($object) {
+            case 'banlist':
+              process_edit_return(fail2ban('banlist', 'refresh', $items));
+            break;
+            default:
+              process_edit_return(fail2ban('edit', array_merge(array('network' => $items), $attr)));
+            break;
+          }
         break;
         case "ui_texts":
           process_edit_return(customize('edit', 'ui_texts', $attr));

+ 101 - 32
data/web/lang/lang.cs-cz.json

@@ -107,7 +107,8 @@
         "username": "Uživatelské jméno",
         "validate": "Ověřit",
         "validation_success": "Úspěšně ověřeno",
-        "tags": "Štítky"
+        "tags": "Štítky",
+        "dry": "Simulovat synchronizaci"
     },
     "admin": {
         "access": "Přístupy",
@@ -147,6 +148,8 @@
         "ays": "Opravdu chcete pokračovat?",
         "ban_list_info": "Seznam blokovaných IP adres je zobrazen níže: <b>síť (zbývající čas blokování) - [akce]</b>.<br />IP adresy zařazené pro odblokování budou z aktivního seznamu odebrány během několika sekund.<br />Červeně označené položky jsou pernamentní bloky z blacklistu.",
         "change_logo": "Změnit logo",
+        "logo_normal_label": "Normální",
+        "logo_dark_label": "Inverzní pro tmavý režim",
         "configuration": "Nastavení",
         "convert_html_to_text": "Převést HTML do prostého textu",
         "credentials_transport_warning": "<b>Upozornění</b>: Přidání položky do transportní mapy aktualizuje také přihlašovací údaje všech záznamů s odpovídajícím skokem.",
@@ -206,6 +209,9 @@
         "include_exclude": "Zahrnout/Vyloučit",
         "include_exclude_info": "Ve výchozím nastavení (bez výběru), jsou adresovány <b>všechny mailové schránky</b>",
         "includes": "Zahrnout tyto přijemce",
+        "ip_check": "Kontrola IP",
+        "ip_check_disabled": "Kontrola IP je zakázána. Můžete ji povolit v nabídce<br> <strong>Systém > Nastavení > Možnosti > Přizpůsobení</strong>",
+        "ip_check_opt_in": "Přihlásit se k používání služby třetí strany <strong>ipv4.mailcow.email</strong> a <strong>ipv6.mailcow.email</strong> pro zjištění externích IP adres.",
         "is_mx_based": "Na základě MX",
         "last_applied": "Naposledy použité",
         "license_info": "Licence není povinná, pomůžete však dalšímu vývoji.<br><a href=\"https://www.servercow.de/mailcow?lang=en#sal\" target=\"_blank\" alt=\"SAL order\">Registrujte si své GUID</a>, nebo si <a href=\"https://www.servercow.de/mailcow?lang=en#support\" target=\"_blank\" alt=\"Support order\">zaplaťte podporu pro svou instalaci mailcow.</a>",
@@ -213,7 +219,7 @@
         "loading": "Prosím čekejte...",
         "login_time": "Čas přihlášení",
         "logo_info": "Obrázek bude zmenšen na výšku 40 pixelů pro horní navigační lištu a na max. šířku 250 pixelů pro úvodní stránku.",
-        "lookup_mx": "Ověřit cíl proti MX záznamu (.outlook.com bude směrovat všechnu poštu pro MX *.outlook.com přes tento uzel)",
+        "lookup_mx": "Cíl je regulární výraz, který se porovná s názvem MX (<code>.*\\.google\\.com</code> pro směrování veškeré pošty cílené na MX, který končí na google.com přes tento skok)",
         "main_name": "Název webu (\"mailcow UI\")",
         "merged_vars_hint": "Šedé řádky byly přidány z <code>vars.(local.)inc.php</code> a zde je nelze upravit.",
         "message": "Zpráva",
@@ -232,6 +238,7 @@
         "oauth2_renew_secret": "Vytvořit nový tajný klíč",
         "oauth2_revoke_tokens": "Odvolat všechny klientské tokeny",
         "optional": "volitelné",
+        "options": "Možnosti",
         "password": "Heslo",
         "password_length": "Délka hesla",
         "password_policy": "Politika hesel",
@@ -338,8 +345,8 @@
         "yes": "&#10003;",
         "f2b_ban_time_increment": "Délka banu je prodlužována s každým dalším banem",
         "f2b_max_ban_time": "Maximální délka banu (s)",
-        "ip_check": "Kontrola IP",
-        "ip_check_disabled": "Kontrola IP je vypnuta. Můžete ji zapnout v <br> <strong>System > Nastavení > Options > Přizpůsobení</strong>"
+        "cors_settings": "Nastavení CORS",
+        "queue_unban": "zrušit ban"
     },
     "danger": {
         "access_denied": "Přístup odepřen nebo jsou neplatná data ve formuláři",
@@ -443,6 +450,9 @@
         "target_domain_invalid": "Cílová doména %s je neplatná",
         "targetd_not_found": "Cílová doména %s nenalezena",
         "targetd_relay_domain": "Cílová doména %s je předávaná",
+        "template_exists": "Šablona %s již existuje",
+        "template_id_invalid": "Šablona ID %s je neplatná",
+        "template_name_invalid": "Název šablony je neplatný",
         "temp_error": "Dočasná chyba",
         "text_empty": "Text nesmí být prázdný",
         "tfa_token_invalid": "Neplatný TFA token",
@@ -458,30 +468,39 @@
         "username_invalid": "Uživatelské jméno %s nelze použít",
         "validity_missing": "Zdejte dobu platnosti",
         "value_missing": "Prosím, uveďte všechny hodnoty",
-        "yotp_verification_failed": "Yubico OTP ověření selhalo: %s"
+        "yotp_verification_failed": "Yubico OTP ověření selhalo: %s",
+        "webauthn_authenticator_failed": "Zvolený ověřovací prostředek nebyl nalezen",
+        "cors_invalid_method": "Zadaná neplatná metoda Allow-Method",
+        "cors_invalid_origin": "Zadán neplatný Allow-Origin",
+        "webauthn_publickey_failed": "Pro vybraný ověřovací prostředek nebyl uložen žádný veřejný klíč",
+        "webauthn_username_failed": "Zvolený ověřovací prostředek patří k jinému účtu",
+        "extended_sender_acl_denied": "chybějící ACL pro nastavení externích adres odesílatele",
+        "demo_mode_enabled": "Demo režim je zapnutý"
     },
     "datatables": {
-      "emptyTable": "Tabulka neobsahuje žádná data",
-      "info": "Zobrazuji _START_ až _END_ z celkem _TOTAL_ záznamů",
-      "infoEmpty": "Zobrazuji 0 až 0 z 0 záznamů",
-      "infoFiltered": "(filtrováno z celkem _MAX_ záznamů)",
-      "loadingRecords": "Načítám...",
-      "zeroRecords": "Žádné záznamy nebyly nalezeny",
-      "paginate": {
-        "first": "První",
-        "last": "Poslední",
-        "next": "Další",
-        "previous": "Předchozí"
-      },
-      "aria": {
-        "sortAscending": ": aktivujte pro seřazení vzestupně",
-        "sortDescending": ": aktivujte pro seřazení sestupně"
-      },
-      "lengthMenu": "Zobrazit _MENU_ výsledků",
-      "processing": "Zpracovávání...",
-      "search": "Vyhledávání:",
-      "decimal": ",",
-      "thousands": " "
+        "emptyTable": "Tabulka neobsahuje žádná data",
+        "info": "Zobrazuji _START_ až _END_ z celkem _TOTAL_ záznamů",
+        "infoEmpty": "Zobrazuji 0 až 0 z 0 záznamů",
+        "infoFiltered": "(filtrováno z celkem _MAX_ záznamů)",
+        "loadingRecords": "Načítám...",
+        "zeroRecords": "Žádné záznamy nebyly nalezeny",
+        "paginate": {
+            "first": "První",
+            "last": "Poslední",
+            "next": "Další",
+            "previous": "Předchozí"
+        },
+        "aria": {
+            "sortAscending": ": aktivujte pro seřazení vzestupně",
+            "sortDescending": ": aktivujte pro seřazení sestupně"
+        },
+        "lengthMenu": "Zobrazit _MENU_ výsledků",
+        "processing": "Zpracovávání...",
+        "search": "Vyhledávání:",
+        "decimal": ",",
+        "thousands": " ",
+        "collapse_all": "Sbalit vše",
+        "expand_all": "Rozbalit vše"
     },
     "debug": {
         "chart_this_server": "Graf (tento server)",
@@ -508,7 +527,20 @@
         "success": "Úspěch",
         "system_containers": "Systém a kontejnery",
         "uptime": "Doba běhu",
-        "username": "Uživatelské meno"
+        "username": "Uživatelské meno",
+        "architecture": "Architektura",
+        "error_show_ip": "Nepodařilo se přeložit veřejné IP adresy",
+        "show_ip": "Zobrazit veřejné IP adresy",
+        "container_running": "Běží",
+        "container_stopped": "Zastaven",
+        "current_time": "Systémový čas",
+        "timezone": "Časové pásmo",
+        "update_available": "K dispozici je aktualizace",
+        "no_update_available": "Systém je na nejnovější verzi",
+        "update_failed": "Nepodařilo se zkontrolovat aktualizace",
+        "wip": "Nedokončená vývojová verze",
+        "memory": "Paměť",
+        "container_disabled": "Kontejner je zastaven nebo zakázán"
     },
     "diagnostics": {
         "cname_from_a": "Hodnota odvozena z A/AAAA záznamu. Lze použít, pokud záznam ukazuje na správný zdroj.",
@@ -633,7 +665,19 @@
         "title": "Úprava objektu",
         "unchanged_if_empty": "Pokud se nemění, ponechte prázdné",
         "username": "Uživatelské jméno",
-        "validate_save": "Ověřit a uložit"
+        "validate_save": "Ověřit a uložit",
+        "domain_footer_info": "Patičky pro celou doménu se přidávají ke všem odchozím e-mailům spojeným s adresou v rámci této domény. <br> Pro patičku lze použít následující proměnné:",
+        "domain_footer_info_vars": {
+            "from_name": "{= from_name =}   - Jméno odesílatele, např. pro \"Mailcow &lt;moo@mailcow.tld&gt;\" vrátí \"Mailcow\"",
+            "auth_user": "{= auth_user =} - Ověřené uživatelské jméno zadané MTA",
+            "from_user": "{= from_user =}    - uživatelská část odesílatele, např. pro \"moo@mailcow.tld\" vrátí \"moo\"",
+            "from_domain": "{= from_domain =} - Doména odesílatele",
+            "from_addr": "{= from_addr =} - E-mailová adresa odesílatele"
+        },
+        "domain_footer": "Patička pro celou doménu",
+        "domain_footer_html": "HTML text",
+        "domain_footer_plain": "Prostý text",
+        "pushover_sound": "Zvukové upozornění"
     },
     "fido2": {
         "confirm": "Potvrdit",
@@ -670,6 +714,7 @@
         "apps": "Aplikace",
         "debug": "Systémové informace",
         "email": "E-Mail",
+        "mailcow_system": "Systém",
         "mailcow_config": "Nastavení",
         "quarantine": "Karanténa",
         "restart_netfilter": "Restartovat netfilter",
@@ -705,6 +750,7 @@
         "add_mailbox": "Přidat mailovou schránku",
         "add_recipient_map_entry": "Přidat mapu příjemce",
         "add_resource": "Přidat zdroj",
+        "add_template": "Přidat šablonu",
         "add_tls_policy_map": "Přidat mapu TLS pravidel",
         "address_rewriting": "Přepisování adres",
         "alias": "Alias",
@@ -747,6 +793,7 @@
         "domain": "Doména",
         "domain_admins": "Správci domén",
         "domain_aliases": "Doménové aliasy",
+        "domain_templates": "Šablony domén",
         "domain_quota": "Kvóta",
         "domain_quota_total": "Celková kvóta domény",
         "domains": "Domény",
@@ -775,6 +822,7 @@
         "mailbox_defaults": "Výchozí nastavení",
         "mailbox_defaults_info": "Definuje výchozí nastavení pro nové schránky",
         "mailbox_defquota": "Výchozí velikost schránky",
+        "mailbox_templates": "Šablony schránek",
         "mailbox_quota": "Max. velikost schránky",
         "mailboxes": "Mailové schránky",
         "max_aliases": "Max. počet aliasů",
@@ -842,6 +890,8 @@
         "table_size_show_n": "Zobrazit %s položek",
         "target_address": "Cílová adresa",
         "target_domain": "Cílová doména",
+        "templates": "Šablony",
+        "template": "Šablona",
         "tls_enforce_in": "Vynutit TLS pro příchozí",
         "tls_enforce_out": "Vynutit TLS pro odchozí",
         "tls_map_dest": "Cíl",
@@ -857,7 +907,8 @@
         "username": "Uživatelské jméno",
         "waiting": "Čekání",
         "weekly": "Každý týden",
-        "yes": "&#10003;"
+        "yes": "&#10003;",
+        "relay_unknown": "Předávání neexistujících schránek"
     },
     "oauth2": {
         "access_denied": "K udělení přístupu se přihlašte jako vlastník mailové schránky.",
@@ -922,7 +973,19 @@
         "type": "Typ"
     },
     "queue": {
-        "queue_manager": "Správce fronty"
+        "queue_manager": "Správce fronty",
+        "delete": "Vymazat vše",
+        "info": "Poštovní fronta obsahuje všechny e-maily, které čekají na doručení. Pokud e-mail uvízne v poštovní frontě na delší dobu, systém jej automaticky odstraní.<br>Chybové hlášení příslušného e-mailu poskytuje informace o tom, proč se e-mail nepodařilo doručit.",
+        "flush": "Vyprázdnit frontu",
+        "legend": "Funkce operací poštovní fronty:",
+        "ays": "Potvrďte, že chcete opravdu odstranit všechny položky z aktuální fronty.",
+        "deliver_mail": "Doručit",
+        "deliver_mail_legend": "Opětovný pokus o doručení vybraných e-mailů.",
+        "hold_mail": "Podržet",
+        "hold_mail_legend": "Podrží vybrané e-maily. (Zabrání dalším pokusům o doručení)",
+        "show_message": "Zobrazit zprávu",
+        "unhold_mail": "Uvolnit",
+        "unhold_mail_legend": "Uvolnit vybrané e-maily k doručení. (Pouze v případě předchozího podržení)"
     },
     "ratelimit": {
         "disabled": "Vypnuto",
@@ -1006,6 +1069,9 @@
         "settings_map_added": "Přidána položka mapování nastavení",
         "settings_map_removed": "Položka mapování nastavení: %s smazána",
         "sogo_profile_reset": "SOGo profil uživatele %s vyresetován",
+        "template_added": "Přidána šablona %s",
+        "template_modified": "Změny šablony %s byly uloženy",
+        "template_removed": "Šablona ID %s byla odstraněna",
         "tls_policy_map_entry_deleted": "Položka mapy TLS pravidel ID %s smazána",
         "tls_policy_map_entry_saved": "Položka mapy TLS pravidel \"%s\" uložena",
         "ui_texts": "Změny UI textů uloženy",
@@ -1013,7 +1079,9 @@
         "verified_fido2_login": "Ověřené FIDO2 přihlášení",
         "verified_totp_login": "TOTP přihlášení ověřeno",
         "verified_webauthn_login": "WebAuthn přihlášení ověřeno",
-        "verified_yotp_login": "Yubico OTP přihlášení ověřeno"
+        "verified_yotp_login": "Yubico OTP přihlášení ověřeno",
+        "cors_headers_edited": "Nastavení CORS byla uložena",
+        "domain_footer_modified": "Změny patičky domény %s byly uloženy"
     },
     "tfa": {
         "api_register": "%s používá Yubico Cloud API. Prosím získejte API klíč pro své Yubico <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">ZDE</a>",
@@ -1199,7 +1267,8 @@
         "weeks": "týdny",
         "with_app_password": "s heslem aplikace",
         "year": "rok",
-        "years": "let"
+        "years": "let",
+        "pushover_sound": "Zvukové upozornění"
     },
     "warning": {
         "cannot_delete_self": "Nelze smazat právě přihlášeného uživatele",

+ 18 - 3
data/web/lang/lang.de-de.json

@@ -58,6 +58,7 @@
         "domain": "Domain",
         "domain_matches_hostname": "Domain %s darf nicht dem Hostnamen entsprechen",
         "domain_quota_m": "Domain-Speicherplatz gesamt (MiB)",
+        "dry": "Synchronisation simulieren",
         "enc_method": "Verschlüsselung",
         "exclude": "Elemente ausschließen (Regex)",
         "full_name": "Vor- und Nachname",
@@ -147,6 +148,7 @@
         "change_logo": "Logo ändern",
         "configuration": "Konfiguration",
         "convert_html_to_text": "Konvertiere HTML zu reinem Text",
+        "copy_to_clipboard": "Text wurde in die Zwischenablage kopiert!",
         "cors_settings": "CORS Einstellungen",
         "credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.",
         "customer_id": "Kunde",
@@ -180,6 +182,8 @@
         "f2b_blacklist": "Blacklist für Netzwerke und Hosts",
         "f2b_filter": "Regex-Filter",
         "f2b_list_info": "Ein Host oder Netzwerk auf der Blacklist wird immer eine Whitelist-Einheit überwiegen. <b>Die Aktualisierung der Liste dauert einige Sekunden.</b>",
+        "f2b_manage_external": "Fail2Ban extern verwalten",
+        "f2b_manage_external_info": "Fail2ban wird die Banlist weiterhin pflegen, jedoch werden keine aktiven Regeln zum blockieren gesetzt. Die unten generierte Banlist, kann verwendet werden, um den Datenverkehr extern zu blockieren.",
         "f2b_max_attempts": "Max. Versuche",
         "f2b_max_ban_time": "Maximale Bannzeit in Sekunden",
         "f2b_netban_ipv4": "Netzbereich für IPv4-Banns (8-32)",
@@ -345,7 +349,9 @@
         "oauth2_apps": "OAuth2 Apps",
         "queue_unban": "entsperren",
         "allowed_methods": "Access-Control-Allow-Methods",
-        "allowed_origins": "Access-Control-Allow-Origin"
+        "allowed_origins": "Access-Control-Allow-Origin",
+        "logo_dark_label": "Invertiert für den Darkmode",
+        "logo_normal_label": "Normal"
     },
     "danger": {
         "access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
@@ -573,6 +579,7 @@
         "client_secret": "Client-Secret",
         "comment_info": "Ein privater Kommentar ist für den Benutzer nicht einsehbar. Ein öffentlicher Kommentar wird als Tooltip im Interface des Benutzers angezeigt.",
         "created_on": "Erstellt am",
+        "custom_attributes": "benutzerdefinierte Attribute",
         "delete1": "Lösche Nachricht nach Übertragung vom Quell-Server",
         "delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind",
         "delete2duplicates": "Lösche Duplikate im Ziel",
@@ -613,6 +620,7 @@
         "max_quota": "Max. Größe per Mailbox (MiB)",
         "maxage": "Maximales Alter in Tagen einer Nachricht, die kopiert werden soll<br><small>(0 = alle Nachrichten kopieren)</small>",
         "maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
+        "mbox_exclude": "Mailboxen ausschließen",
         "mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.",
         "mins_interval": "Intervall (min)",
         "multiple_bookings": "Mehrfaches Buchen",
@@ -672,7 +680,11 @@
         "unchanged_if_empty": "Unverändert, wenn leer",
         "username": "Benutzername",
         "validate_save": "Validieren und speichern",
-        "pushover_sound": "Ton"
+        "pushover_sound": "Ton",
+        "domain_footer_info_vars": {
+            "auth_user": "{= auth_user =}   - Angemeldeter Benutzername vom MTA",
+            "from_user": "{= from_user =}   - Von Teil des Benutzers z.B. \"moo@mailcow.tld\" wird \"moo\" zurückgeben."
+        }
     },
     "fido2": {
         "confirm": "Bestätigen",
@@ -857,7 +869,7 @@
         "sieve_preset_5": "Auto-Responder (Vacation, Urlaub)",
         "sieve_preset_6": "E-Mails mit Nachricht abweisen",
         "sieve_preset_7": "Weiterleiten und behalten oder verwerfen",
-        "sieve_preset_8": "Nachricht verwerfen, wenn Absender und Alias-Ziel identisch sind.",
+        "sieve_preset_8": "E-Mail eines bestimmten Absenders umleiten, als gelesen markieren und in Unterordner sortieren",
         "sieve_preset_header": "Beispielinhalte zur Einsicht stehen nachstehend bereit. Siehe auch <a href=\"https://de.wikipedia.org/wiki/Sieve\" target=\"_blank\">Wikipedia</a>.",
         "sogo_visible": "Alias Sichtbarkeit in SOGo",
         "sogo_visible_n": "Alias in SOGo verbergen",
@@ -1026,6 +1038,7 @@
         "domain_removed": "Domain %s wurde entfernt",
         "dovecot_restart_success": "Dovecot wurde erfolgreich neu gestartet",
         "eas_reset": "ActiveSync Gerät des Benutzers %s wurde zurückgesetzt",
+        "f2b_banlist_refreshed": "Banlist ID wurde erfolgreich erneuert.",
         "f2b_modified": "Änderungen an Fail2ban-Parametern wurden gespeichert",
         "forwarding_host_added": "Weiterleitungs-Host %s wurde hinzugefügt",
         "forwarding_host_removed": "Weiterleitungs-Host %s wurde entfernt",
@@ -1124,6 +1137,7 @@
         "apple_connection_profile_complete": "Dieses Verbindungsprofil beinhaltet neben IMAP- und SMTP-Konfigurationen auch Pfade für die Konfiguration von CalDAV (Kalender) und CardDAV (Adressbücher) für ein Apple-Gerät.",
         "apple_connection_profile_mailonly": "Dieses Verbindungsprofil beinhaltet IMAP- und SMTP-Konfigurationen für ein Apple-Gerät.",
         "apple_connection_profile_with_app_password": "Es wird ein neues App-Passwort erzeugt und in das Profil eingefügt, damit bei der Einrichtung kein Passwort eingegeben werden muss. Geben Sie das Profil nicht weiter, da es einen vollständigen Zugriff auf Ihr Postfach ermöglicht.",
+        "attribute": "Attribut",
         "change_password": "Passwort ändern",
         "change_password_hint_app_passwords": "Ihre Mailbox hat %d App-Passwörter, die nicht geändert werden. Um diese zu verwalten, gehen Sie bitte zum App-Passwörter-Tab.",
         "clear_recent_successful_connections": "Alle erfolgreichen Verbindungen bereinigen",
@@ -1243,6 +1257,7 @@
         "tls_policy_warning": "<strong>Vorsicht:</strong> Entscheiden Sie sich unverschlüsselte Verbindungen abzulehnen, kann dies dazu führen, dass Kontakte Sie nicht mehr erreichen.<br>Nachrichten, die die Richtlinie nicht erfüllen, werden durch einen Hard-Fail im Mailsystem abgewiesen.<br>Diese Einstellung ist aktiv für die primäre Mailbox, für alle Alias-Adressen, die dieser Mailbox <b>direkt zugeordnet</b> sind (lediglich eine einzige Ziel-Adresse) und der Adressen, die sich aus Alias-Domains ergeben. Ausgeschlossen sind temporäre Aliasse (\"Spam-Alias-Adressen\"), Catch-All Alias-Adressen sowie Alias-Adressen mit mehreren Zielen.",
         "user_settings": "Benutzereinstellungen",
         "username": "Benutzername",
+        "value": "Wert",
         "verify": "Verifizieren",
         "waiting": "Warte auf Ausführung",
         "week": "Woche",

+ 12 - 2
data/web/lang/lang.en-gb.json

@@ -58,6 +58,7 @@
         "domain": "Domain",
         "domain_matches_hostname": "Domain %s matches hostname",
         "domain_quota_m": "Total domain quota (MiB)",
+        "dry": "Simulate synchronization",
         "enc_method": "Encryption method",
         "exclude": "Exclude objects (regex)",
         "full_name": "Full name",
@@ -153,6 +154,7 @@
         "logo_dark_label": "Inverted for dark mode",
         "configuration": "Configuration",
         "convert_html_to_text": "Convert HTML to plain text",
+        "copy_to_clipboard": "Text copied to clipboard!",
         "cors_settings": "CORS Settings",
         "credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.",
         "customer_id": "Customer ID",
@@ -186,6 +188,8 @@
         "f2b_blacklist": "Blacklisted networks/hosts",
         "f2b_filter": "Regex filters",
         "f2b_list_info": "A blacklisted host or network will always outweigh a whitelist entity. <b>List updates will take a few seconds to be applied.</b>",
+        "f2b_manage_external": "Manage Fail2Ban externally",
+        "f2b_manage_external_info": "Fail2ban will still maintain the banlist, but it will not actively set rules to block traffic. Use the generated banlist below to externally block the traffic.",
         "f2b_max_attempts": "Max. attempts",
         "f2b_max_ban_time": "Max. ban time (s)",
         "f2b_netban_ipv4": "IPv4 subnet size to apply ban on (8-32)",
@@ -575,6 +579,7 @@
         "client_secret": "Client secret",
         "comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview",
         "created_on": "Created on",
+        "custom_attributes": "Custom attributes",
         "delete1": "Delete from source when completed",
         "delete2": "Delete messages on destination that are not on source",
         "delete2duplicates": "Delete duplicates on destination",
@@ -591,7 +596,8 @@
             "from_user": "{= from_user =}   - From user part of envelope, e.g for \"moo@mailcow.tld\" it returns \"moo\"",
             "from_name": "{= from_name =}   - From name of envelope, e.g for \"Mailcow &lt;moo@mailcow.tld&gt;\" it returns \"Mailcow\"",
             "from_addr": "{= from_addr =}   - From address part of envelope",
-            "from_domain": "{= from_domain =} - From domain part of envelope"
+            "from_domain": "{= from_domain =} - From domain part of envelope",
+            "custom": "{= foo =}         - If mailbox has the custom attribute \"foo\" with value \"bar\" it returns \"bar\""
         },
         "domain_footer_plain": "PLAIN footer",
         "domain_quota": "Domain quota",
@@ -622,6 +628,7 @@
         "max_quota": "Max. quota per mailbox (MiB)",
         "maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>",
         "maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
+        "mbox_exclude": "Exclude mailboxes",
         "mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.",
         "mins_interval": "Interval (min)",
         "multiple_bookings": "Multiple bookings",
@@ -873,7 +880,7 @@
         "sieve_preset_5": "Auto responder (vacation)",
         "sieve_preset_6": "Reject mail with reponse",
         "sieve_preset_7": "Redirect and keep/drop",
-        "sieve_preset_8": "Discard message sent to an alias address the sender is part of",
+        "sieve_preset_8": "Redirect e-mail from a specific sender, mark as read and sort into subfolder",
         "sieve_preset_header": "Please see the example presets below. For more details see <a href=\"https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)\" target=\"_blank\">Wikipedia</a>.",
         "sogo_visible": "Alias is visible in SOGo",
         "sogo_visible_n": "Hide alias in SOGo",
@@ -1042,6 +1049,7 @@
         "domain_removed": "Domain %s has been removed",
         "dovecot_restart_success": "Dovecot was restarted successfully",
         "eas_reset": "ActiveSync devices for user %s were reset",
+        "f2b_banlist_refreshed": "Banlist ID has been successfully refreshed.",
         "f2b_modified": "Changes to Fail2ban parameters have been saved",
         "forwarding_host_added": "Forwarding host %s has been added",
         "forwarding_host_removed": "Forwarding host %s has been removed",
@@ -1140,6 +1148,7 @@
         "apple_connection_profile_complete": "This connection profile includes IMAP and SMTP parameters as well as CalDAV (calendars) and CardDAV (contacts) paths for an Apple device.",
         "apple_connection_profile_mailonly": "This connection profile includes IMAP and SMTP configuration parameters for an Apple device.",
         "apple_connection_profile_with_app_password": "A new app password is generated and added to the profile so that no password needs to be entered when setting up your device. Please do not share the file as it grants full access to your mailbox.",
+        "attribute": "Attribute",
         "change_password": "Change password",
         "change_password_hint_app_passwords": "Your account has %d app passwords that will not be changed. To manage these, go to the App passwords tab.",
         "clear_recent_successful_connections": "Clear seen successful connections",
@@ -1270,6 +1279,7 @@
         "tls_policy_warning": "<strong>Warning:</strong> If you decide to enforce encrypted mail transfer, you may lose emails.<br>Messages to not satisfy the policy will be bounced with a hard fail by the mail system.<br>This option applies to your primary email address (login name), all addresses derived from alias domains as well as alias addresses <b>with only this single mailbox</b> as target.",
         "user_settings": "User settings",
         "username": "Username",
+        "value": "Value",
         "verify": "Verify",
         "waiting": "Waiting",
         "week": "week",

+ 12 - 0
data/web/lang/lang.fi-fi.json

@@ -891,5 +891,17 @@
         "no_active_admin": "Viimeistä aktiivista järjestelmänvalvojaa ei voi poistaa käytöstä",
         "session_token": "Lomakkeen tunnus sanoma ei kelpaa: tunnus sanoman risti riita",
         "session_ua": "Lomakkeen tunnus sanoma ei kelpaa: käyttäjä agentin tarkistus virhe"
+    },
+    "datatables": {
+        "emptyTable": "Tietoja ei ole saatavilla taulukossa",
+        "expand_all": "Laajenna kaikki",
+        "lengthMenu": "Näytä menu merkinnät",
+        "loadingRecords": "Ladataan...",
+        "processing": "Ole hyvä ja odota...",
+        "search": "Etsi:",
+        "paginate": {
+            "first": "Ensimmäinen",
+            "last": "Edellinen"
+        }
     }
 }

+ 17 - 13
data/web/lang/lang.fr-fr.json

@@ -89,7 +89,7 @@
         "relay_transport_info": "<div class=\"badge fs-6 bg-info\">Info</div> Vous pouvez définir des cartes de transport vers une destination personnalisée pour ce domaine. sinon, une recherche MX sera effectuée.",
         "relay_unknown_only": "Relayer uniquement les boîtes inexistantes. Les boîtes existantes seront livrées localement.",
         "relayhost_wrapped_tls_info": "Veuillez <b>ne pas</b> utiliser des ports TLS wrappés (généralement utilisés sur le port 465).<br>\r\nUtilisez n'importe quel port non encapsulé et lancez STARTTLS. Une politique TLS pour appliquer TLS peut être créée dans \"Cartes de politique TLS\".",
-        "select": "Veuillez sélectionner...",
+        "select": "Veuillez sélectionner",
         "select_domain": "Sélectionner d'abord un domaine",
         "sieve_desc": "Description courte",
         "sieve_type": "Type de filtre",
@@ -207,7 +207,7 @@
         "last_applied": "Dernière application",
         "license_info": "Une licence n’est pas requise, mais contribue au développement.<br><a href=\"https://www.servercow.de/mailcow?lang=en#sal\" target=\"_blank\" alt=\"SAL order\">Enregistrer votre GUID ici</a> or <a href=\"https://www.servercow.de/mailcow?lang=en#support\" target=\"_blank\" alt=\"Support order\">acheter le support pour votre intallation Mailcow.</a>",
         "link": "Lien",
-        "loading": "Veuillez patienter...",
+        "loading": "Veuillez patienter",
         "logo_info": "Votre image sera redimensionnée à une hauteur de 40 pixels pour la barre de navigation du haut et à un maximum de 250 pixels en largeur pour la page d'accueil. Un graphique extensible est fortement recommandé.",
         "lookup_mx": "Faire correspondre la destination à MX (.outlook.com pour acheminer tous les messages ciblés vers un MX * .outlook.com sur ce tronçon)",
         "main_name": "\"mailcow UI\" nom",
@@ -326,7 +326,11 @@
         "password_policy_lowerupper": "Doit contenir des caractères minuscules et majuscules",
         "password_policy_numbers": "Doit contenir au moins un chiffre",
         "ip_check": "Vérification IP",
-        "ip_check_disabled": "La vérification IP est désactivée. Vous pouvez l'activer sous<br> <strong>Système > Configuration > Options > Personnaliser</strong>"
+        "ip_check_disabled": "La vérification IP est désactivée. Vous pouvez l'activer sous<br> <strong>Système > Configuration > Options > Personnaliser</strong>",
+        "logo_normal_label": "Normal",
+        "logo_dark_label": "Inversé pour le mode sombre",
+        "allowed_methods": "Access-Control-Allow-Methods",
+        "allowed_origins": "Access-Control-Allow-Origin"
     },
     "danger": {
         "access_denied": "Accès refusé ou données de formulaire non valides",
@@ -598,9 +602,9 @@
         "delete_these_items": "Veuillez confirmer les modifications apportées à l’identifiant d’objet suivant",
         "hibp_nok": "Trouvé ! Il s’agit d’un mot de passe potentiellement dangereux !",
         "hibp_ok": "Aucune correspondance trouvée.",
-        "loading": "Veuillez patienter...",
+        "loading": "Veuillez patienter",
         "restart_container": "Redémarrer le conteneur",
-        "restart_container_info": "<b>Important:</b> Un redémarrage en douceur peut prendre un certain temps, veuillez attendre qu’il soit terminé..",
+        "restart_container_info": "<b>Important:</b> Un redémarrage en douceur peut prendre un certain temps, veuillez attendre qu’il soit terminé.",
         "restart_now": "Redémarrer maintenant",
         "restarting_container": "Redémarrage du conteneur, cela peut prendre un certain temps"
     },
@@ -935,7 +939,7 @@
         "disable_tfa": "Désactiver TFA jusqu’à la prochaine ouverture de session réussie",
         "enter_qr_code": "Votre code TOTP si votre appareil ne peut pas scanner les codes QR",
         "error_code": "Code d'erreur",
-        "init_webauthn": "Initialisation, veuillez patienter...",
+        "init_webauthn": "Initialisation, veuillez patienter",
         "key_id": "Un identifiant pour votre Périphérique",
         "key_id_totp": "Un identifiant pour votre clé",
         "none": "Désactiver",
@@ -948,8 +952,8 @@
         "tfa_token_invalid": "Token TFA invalide",
         "totp": "OTP (One Time Password = Mot de passe à usage unique : Google Authenticator, Authy, etc.)",
         "webauthn": "Authentification WebAuthn",
-        "waiting_usb_auth": "<i>En attente d’un périphérique USB...</i><br><br>S’il vous plaît appuyez maintenant sur le bouton de votre périphérique USB WebAuthn.",
-        "waiting_usb_register": "<i>En attente d’un périphérique USB...</i><br><br>Veuillez entrer votre mot de passe ci-dessus et confirmer votre inscription WebAuthn en appuyant sur le bouton de votre périphérique USB WebAuthn.",
+        "waiting_usb_auth": "<i>En attente d’un périphérique USB</i><br><br>S’il vous plaît appuyez maintenant sur le bouton de votre périphérique USB WebAuthn.",
+        "waiting_usb_register": "<i>En attente d’un périphérique USB</i><br><br>Veuillez entrer votre mot de passe ci-dessus et confirmer votre inscription WebAuthn en appuyant sur le bouton de votre périphérique USB WebAuthn.",
         "yubi_otp": "Authentification OTP Yubico"
     },
     "fido2": {
@@ -999,9 +1003,9 @@
         "eas_reset": "Réinitialiser le cache de l’appareil Activesync",
         "eas_reset_help": "Dans de nombreux cas, une réinitialisation du cache de l’appareil aidera à récupérer un profil Activesync cassé.<br><b>Attention :</b> Tous les éléments seront à nouveau téléchargés !",
         "eas_reset_now": "Réinitialiser maintenant",
-        "edit": "Editer",
-        "email": "Email",
-        "email_and_dav": "Email, calendriers et contacts",
+        "edit": "Éditer",
+        "email": "E-mail",
+        "email_and_dav": "E-mail, calendriers et contacts",
         "encryption": "Cryptage",
         "excludes": "Exclut",
         "expire_in": "Expire dans",
@@ -1015,7 +1019,7 @@
         "is_catch_all": "Attrape-tout pour le domaine(s)",
         "last_mail_login": "Dernière connexion mail",
         "last_run": "Dernière exécution",
-        "loading": "Chargement...",
+        "loading": "Chargement",
         "mailbox_details": "Détails de la boîte",
         "messages": "messages",
         "never": "jamais",
@@ -1051,7 +1055,7 @@
         "shared_aliases": "Adresses alias partagées",
         "shared_aliases_desc": "Les alias partagés ne sont pas affectés par les paramètres spécifiques à l’utilisateur tels que le filtre anti-spam ou la politique de chiffrement. Les filtres anti-spam correspondants ne peuvent être effectués que par un administrateur en tant que politique de domaine.",
         "show_sieve_filters": "Afficher le filtre de tamis actif de l’utilisateur",
-        "sogo_profile_reset": "Remise é zéro du profil SOGo",
+        "sogo_profile_reset": "Remise à zéro du profil SOGo",
         "sogo_profile_reset_help": "Ceci détruira un profil Sogo des utilisateurs et <b>supprimera toutes les données de contact et de calendrier irrécupérables</b>.",
         "sogo_profile_reset_now": "Remise à zéro du profil maintenant",
         "spam_aliases": "Alias de courriel temporaire",

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

@@ -394,7 +394,12 @@
         "filters": "Szűrők",
         "login_as": "Bejelentkezés mint",
         "quarantine": "Karantén műveletek",
-        "bcc_maps": "BCC címek"
+        "bcc_maps": "BCC címek",
+        "domain_relayhost": "Relayhost megváltoztatása egy domainhez",
+        "protocol_access": "Protokoll-hozzáférés módosítása",
+        "quarantine_attachments": "Karantén mellékletek",
+        "quarantine_category": "Karantén értesítési kategória módosítása",
+        "quarantine_notification": "Karantén értesítések módosítása"
     },
     "diagnostics": {
         "dns_records": "DNS bejegyzések"

+ 1301 - 0
data/web/lang/lang.pt-br.json

@@ -0,0 +1,1301 @@
+{
+    "acl": {
+        "alias_domains": "Adicionar domínios alternativos",
+        "app_passwds": "Gerenciar senhas de aplicativos",
+        "bcc_maps": "Mapas BCC",
+        "delimiter_action": "Ação delimitadora",
+        "domain_desc": "Alterar descrição do domínio",
+        "domain_relayhost": "Alterar relayhost para um domínio",
+        "eas_reset": "Redefinir dispositivos EAS",
+        "extend_sender_acl": "Permitir estender a ACL do remetente por endereços externos",
+        "filters": "Filtros",
+        "login_as": "Faça login como usuário da caixa de correio",
+        "mailbox_relayhost": "Alterar relayhost para uma caixa de correio",
+        "prohibited": "Proibido pela ACL",
+        "protocol_access": "Alterar o acesso ao protocolo",
+        "pushover": "Pushover",
+        "quarantine": "Ações de quarentena",
+        "quarantine_attachments": "Anexos de quarentena",
+        "quarantine_category": "Alterar categoria de notificação de quarentena",
+        "quarantine_notification": "Alterar notificações de quarentena",
+        "ratelimit": "Limite de taxa",
+        "recipient_maps": "Mapas de destinatários",
+        "smtp_ip_access": "Alterar hosts permitidos para SMTP",
+        "sogo_access": "Permitir o gerenciamento do acesso ao SoGo",
+        "sogo_profile_reset": "Redefinir perfil SoGo",
+        "spam_alias": "Aliases temporários",
+        "spam_policy": "Lista negra/lista branca",
+        "spam_score": "Pontuação de spam",
+        "syncjobs": "Trabalhos de sincronização",
+        "tls_policy": "Política de TLS",
+        "unlimited_quota": "Cota ilimitada para caixas de correio"
+    },
+    "add": {
+        "activate_filter_warn": "Todos os outros filtros serão desativados quando a opção ativa estiver marcada.",
+        "active": "Ativo",
+        "add": "Adicionar",
+        "add_domain_only": "Adicionar somente domínio",
+        "add_domain_restart": "Adicionar domínio e reiniciar o SoGo",
+        "alias_address": "Endereço (s) de alias",
+        "alias_address_info": "<small>Endereço de e-mail completo/es ou @example .com, para capturar todas as mensagens de um domínio (separadas por vírgula). somente <b>domínios mailcow</b></small>.",
+        "alias_domain": "Domínio de alias",
+        "alias_domain_info": "<small>Somente nomes de domínio válidos (separados por vírgula).</small>",
+        "app_name": "Nome do aplicativo",
+        "app_password": "Adicionar senha do aplicativo",
+        "app_passwd_protocols": "Protocolos permitidos para a senha do aplicativo",
+        "automap": "Tente mapear pastas automaticamente (“Itens enviados”, “Enviados” => “Enviados” etc.)",
+        "backup_mx_options": "Opções de relé",
+        "bcc_dest_format": "O destino do BCC deve ser um único endereço de e-mail válido. <br>Se precisar enviar uma cópia para vários endereços, crie um alias e use-o aqui.",
+        "comment_info": "Um comentário privado não é visível para o usuário, enquanto um comentário público é mostrado como dica de ferramenta ao passar o mouse sobre ele na visão geral do usuário",
+        "custom_params": "Parâmetros personalizados",
+        "custom_params_hint": "Certo: --param=xy, errado: --param xy",
+        "delete1": "Excluir da fonte quando concluído",
+        "delete2": "Excluir mensagens no destino que não estão na origem",
+        "delete2duplicates": "Excluir duplicatas no destino",
+        "description": "Descrição",
+        "destination": "Destino",
+        "disable_login": "Não permitir login (e-mails recebidos ainda são aceitos)",
+        "domain": "Domínio",
+        "domain_matches_hostname": "O domínio %s corresponde ao nome do host",
+        "domain_quota_m": "Cota total de domínio (MiB)",
+        "enc_method": "Método de criptografia",
+        "exclude": "Excluir objetos (regex)",
+        "full_name": "Nome completo",
+        "gal": "Lista de endereços global",
+        "gal_info": "A GAL contém todos os objetos de um domínio e não pode ser editada por nenhum usuário. Faltam informações de disponibilidade no SoGo, se desativadas! <b>Reinicie o SoGo para aplicar as alterações.</b>",
+        "generate": "geram",
+        "goto_ham": "Aprenda como <span class=\"text-success\"><b>presunto</b></span>",
+        "goto_null": "Descarte e-mails silenciosamente",
+        "goto_spam": "Aprenda como <span class=\"text-danger\"><b>spam</b></span>",
+        "hostname": "Anfitrião",
+        "inactive": "Inativo",
+        "kind": "Gentil",
+        "mailbox_quota_def": "Cota de caixa de correio padrão",
+        "mailbox_quota_m": "Cota máxima por caixa de correio (MiB)",
+        "mailbox_username": "Nome de usuário (parte esquerda de um endereço de e-mail)",
+        "max_aliases": "Máximo de aliases possíveis",
+        "max_mailboxes": "Número máximo de caixas de correio possíveis",
+        "mins_interval": "Intervalo de votação (minutos)",
+        "multiple_bookings": "Várias reservas",
+        "nexthop": "Próximo salto",
+        "password": "Senha",
+        "password_repeat": "Senha de confirmação (repetição)",
+        "port": "Porto",
+        "post_domain_add": "O contêiner SoGo, “sogo-mailcow”, precisa ser reiniciado após a adição de um novo domínio! <br><br>Além disso, a configuração do DNS dos domínios deve ser revisada. Depois que a configuração de DNS for aprovada, reinicie “acme-mailcow” para gerar automaticamente certificados para seu novo domínio (autoconfig). <domain>, descoberta automática. <domain>). <br>Essa etapa é opcional e será repetida a cada 24 horas.",
+        "private_comment": "Comentário privado",
+        "public_comment": "Comentário público",
+        "quota_mb": "Cota (MiB)",
+        "relay_all": "Retransmita todos os destinatários",
+        "relay_all_info": "↪ Se você optar por <b>não</b> retransmitir todos os destinatários, precisará adicionar uma caixa de correio (“cega”) para cada destinatário que deve ser retransmitido.",
+        "relay_domain": "Retransmitir este domínio",
+        "relay_transport_info": "<div class=\"badge fs-6 bg-info\">Informações</div> Você pode definir mapas de transporte para um destino personalizado para esse domínio. Se não for definido, uma pesquisa MX será feita.",
+        "relay_unknown_only": "Retransmita somente caixas de correio não existentes. As caixas de correio existentes serão entregues localmente.",
+        "relayhost_wrapped_tls_info": "Por favor, <b>não</b> use portas com cobertura TLS (usadas principalmente na porta 465). <br>\r\nUse qualquer porta não encapsulada e emita STARTTLS. Uma política de TLS para impor o TLS pode ser criada em “mapas de políticas de TLS”.",
+        "select": "Selecione...",
+        "select_domain": "Selecione primeiro um domínio",
+        "sieve_desc": "Breve descrição",
+        "sieve_type": "Tipo de filtro",
+        "skipcrossduplicates": "Ignore mensagens duplicadas entre pastas (primeiro a chegar, primeiro a ser servido)",
+        "subscribeall": "Assine todas as pastas",
+        "syncjob": "Adicionar tarefa de sincronização",
+        "syncjob_hint": "Esteja ciente de que as senhas precisam ser salvas em texto simples!",
+        "tags": "Etiquetas",
+        "target_address": "Vá para endereços",
+        "target_address_info": "<small>Endereço (s) de e-mail completo (separado por vírgula).</small>",
+        "target_domain": "Domínio de destino",
+        "timeout1": "Tempo limite para conexão com o host remoto",
+        "timeout2": "Tempo limite para conexão com o host local",
+        "username": "Nome de usuário",
+        "validate": "Validar",
+        "validation_success": "Validado com sucesso",
+        "dry": "Simular sincronização"
+    },
+    "admin": {
+        "access": "Acesso",
+        "action": "Ação",
+        "activate_api": "Ativar API",
+        "activate_send": "Ativar botão de envio",
+        "active": "Ativo",
+        "active_rspamd_settings_map": "Mapa de configurações ativas",
+        "add": "Adicionar",
+        "add_admin": "Adicionar administrador",
+        "add_domain_admin": "Adicionar administrador de domínio",
+        "add_forwarding_host": "Adicionar host de encaminhamento",
+        "add_relayhost": "Adicionar transporte dependente do remetente",
+        "add_relayhost_hint": "Esteja ciente de que os dados de autenticação, se houver, serão armazenados como texto simples.",
+        "add_row": "Adicionar linha",
+        "add_settings_rule": "Adicionar regra de configurações",
+        "add_transport": "Adicionar transporte",
+        "add_transports_hint": "Esteja ciente de que os dados de autenticação, se houver, serão armazenados como texto simples.",
+        "additional_rows": " linhas adicionais foram adicionadas",
+        "admin": "Administrador",
+        "admin_details": "Editar detalhes do administrador",
+        "admin_domains": "Atribuições de domínio",
+        "admins": "Administradores",
+        "admins_ldap": "Administradores do LDAP",
+        "advanced_settings": "Configurações avançadas",
+        "allowed_methods": "Métodos de permissão de controle de acesso",
+        "allowed_origins": "Controle de acesso, permissão de origem",
+        "api_allow_from": "Permita o acesso à API a partir dessas notações de rede IPS/CIDR",
+        "api_info": "A API é um trabalho em andamento. A documentação pode ser encontrada em <a href=\"/api\">/api</a>",
+        "api_key": "Chave de API",
+        "api_read_only": "Acesso somente para leitura",
+        "api_read_write": "Acesso de leitura e gravação",
+        "api_skip_ip_check": "Ignorar verificação de IP para API",
+        "app_links": "Links de aplicativos",
+        "app_name": "Nome do aplicativo",
+        "apps_name": "Nome “mailcow Apps”",
+        "arrival_time": "Hora de chegada (hora do servidor)",
+        "authed_user": "Usuário autoritário",
+        "ays": "Tem certeza de que deseja continuar?",
+        "ban_list_info": "Veja uma lista de IPs banidos abaixo: <b>rede (tempo restante de banimento) - [ações]</b>. <br />Os IPs na fila para serem desbanidos serão removidos da lista de banimentos ativos em alguns segundos. <br />Rótulos vermelhos indicam proibições permanentes ativas na lista negra.",
+        "change_logo": "Alterar logotipo",
+        "logo_normal_label": "Normal",
+        "logo_dark_label": "Invertido para o modo escuro",
+        "configuration": "Configuração",
+        "convert_html_to_text": "Converter HTML em texto sem formatação",
+        "cors_settings": "Configurações do CORS",
+        "credentials_transport_warning": "<b>Aviso</b>: Adicionar uma nova entrada no mapa de transporte atualizará as credenciais de todas as entradas com uma coluna correspondente do próximo salto.",
+        "customer_id": "ID do cliente",
+        "customize": "Personalizar",
+        "destination": "Destino",
+        "dkim_add_key": "Adicionar chave ARC/DKIM",
+        "dkim_domains_selector": "Seletor",
+        "dkim_domains_wo_keys": "Selecione domínios com chaves ausentes",
+        "dkim_from": "De",
+        "dkim_from_title": "Domínio de origem do qual copiar dados",
+        "dkim_key_length": "Comprimento da chave DKIM (bits)",
+        "dkim_key_missing": "Falta a chave",
+        "dkim_key_unused": "Chave não usada",
+        "dkim_key_valid": "Chave válida",
+        "dkim_keys": "Teclas ARC/DKIM",
+        "dkim_overwrite_key": "Substituir a chave DKIM existente",
+        "dkim_private_key": "Chave privada",
+        "dkim_to": "Para",
+        "dkim_to_title": "Domínio/s de destino - serão sobrescritos",
+        "domain": "Domínio",
+        "domain_admin": "Administrador de domínio",
+        "domain_admins": "Administradores de domínio",
+        "domain_s": "Domínio/s",
+        "duplicate": "Duplicado",
+        "duplicate_dkim": "Registro DKIM duplicado",
+        "edit": "Editar",
+        "empty": "Sem resultados",
+        "excludes": "Exclui esses destinatários",
+        "f2b_ban_time": "Tempo (s) de proibição",
+        "f2b_ban_time_increment": "O tempo de banimento é incrementado com cada banimento",
+        "f2b_blacklist": "Redes/hosts na lista negra",
+        "f2b_filter": "Filtros Regex",
+        "f2b_list_info": "Um host ou rede na lista negra sempre superará uma entidade na lista branca. <b>As atualizações da lista levarão alguns segundos para serem aplicadas.</b>",
+        "f2b_max_attempts": "Máximo de tentativas",
+        "f2b_max_ban_time": "Tempo (s) máximo (s) de banimento",
+        "f2b_netban_ipv4": "Tamanho da sub-rede IPv4 a ser proibida (8-32)",
+        "f2b_netban_ipv6": "Tamanho da sub-rede IPv6 a ser proibida (8-128)",
+        "f2b_parameters": "Parâmetros do Fail2ban",
+        "f2b_regex_info": "Registros considerados: SoGo, Postfix, Dovecot, PHP-FPM.",
+        "f2b_retry_window": "Repita a (s) janela (s) para o máximo de tentativas",
+        "f2b_whitelist": "Redes/hosts incluídos na lista branca",
+        "filter_table": "Tabela de filtros",
+        "forwarding_hosts": "Anfitriões de encaminhamento",
+        "forwarding_hosts_add_hint": "Você pode especificar endereços IPv4/IPv6, redes em notação CIDR, nomes de host (que serão resolvidos para endereços IP) ou nomes de domínio (que serão resolvidos para endereços IP consultando registros SPF ou, na ausência deles, registros MX).",
+        "forwarding_hosts_hint": "As mensagens recebidas são aceitas incondicionalmente de qualquer host listado aqui. Esses hosts não são então verificados em relação aos DNSBLs nem submetidos à lista cinza. O spam recebido deles nunca é rejeitado, mas, opcionalmente, pode ser arquivado na pasta Lixo eletrônico. O uso mais comum para isso é especificar servidores de e-mail nos quais você configurou uma regra que encaminha e-mails recebidos para o servidor mailcow.",
+        "from": "De",
+        "generate": "geram",
+        "guid": "GUID - ID de instância exclusiva",
+        "guid_and_license": "GUID e licença",
+        "hash_remove_info": "A remoção de um hash de limite de taxa (se ainda existir) reiniciará completamente seu contador. <br>\r\n Cada hash é indicado por uma cor individual.",
+        "help_text": "Substituir texto de ajuda abaixo da máscara de login (HTML permitido)",
+        "host": "Anfitrião",
+        "html": "HTML",
+        "import": "Importar",
+        "import_private_key": "Importar chave privada",
+        "in_use_by": "Em uso por",
+        "inactive": "Inativo",
+        "include_exclude": "Incluir/Excluir",
+        "include_exclude_info": "Por padrão - sem seleção - <b>todas as caixas de correio são endereçadas</b>",
+        "includes": "Inclua esses destinatários",
+        "ip_check": "Verificação de IP",
+        "ip_check_disabled": "A verificação de IP está desativada. Você pode ativá-lo em <br><strong>Sistema > Configuração > Opções > Personalizar</strong>",
+        "ip_check_opt_in": "<strong>Opte por usar o serviço de terceiros <strong>ipv4.mailcow.email e ipv6.mailcow.email</strong> para resolver endereços IP externos.</strong>",
+        "is_mx_based": "Baseado em MX",
+        "last_applied": "Aplicado pela última vez",
+        "license_info": "Uma licença não é necessária, mas ajuda no desenvolvimento futuro. <br><a href=\"https://www.servercow.de/mailcow?lang=en#sal\" target=\"_blank\" alt=\"SAL order\">Registre seu GUID aqui</a> ou <a href=\"https://www.servercow.de/mailcow?lang=en#support\" target=\"_blank\" alt=\"Support order\">compre suporte para a instalação do mailcow</a>.",
+        "link": "Link",
+        "loading": "Por favor, espere...",
+        "login_time": "Hora do login",
+        "logo_info": "Sua imagem será dimensionada para uma altura de 40 px para a barra de navegação superior e uma largura máxima de 250 px para a página inicial. Um gráfico escalável é altamente recomendado.",
+        "lookup_mx": "Destination é uma expressão regular que corresponde ao nome MX (<code>.*\\ .google\\ .com</code> para rotear todos os e-mails direcionados a um MX que termina em google.com nesse salto)",
+        "main_name": "nome “mailcow UI”",
+        "merged_vars_hint": "<code>As linhas acinzentadas foram mescladas a partir das barras. (local.) inc.php</code> e não pode ser modificado.",
+        "message": "Mensagem",
+        "message_size": "Tamanho da mensagem",
+        "nexthop": "Próximo salto",
+        "no": "✕",
+        "no_active_bans": "Sem proibições ativas",
+        "no_new_rows": "Não há mais linhas disponíveis",
+        "no_record": "Sem registro",
+        "oauth2_apps": "Aplicativos OAuth2",
+        "oauth2_add_client": "Adicionar cliente OAuth2",
+        "oauth2_client_id": "ID do cliente",
+        "oauth2_client_secret": "Segredo do cliente",
+        "oauth2_info": "A implementação do OAuth2 suporta o tipo de concessão “Código de Autorização” e emite tokens de atualização. <br>\r\nO servidor também emite automaticamente novos tokens de atualização, após o uso de um token de atualização. <br><br>\r\n• O escopo padrão é <i>perfil</i>. Somente usuários de caixas de correio podem ser autenticados no OAuth2. <i>Se o parâmetro do escopo for omitido, ele retornará ao perfil.</i> <br>\r\n• O parâmetro <i>state</i> deve ser enviado pelo cliente como parte da solicitação de autorização. <br><br>\r\nCaminhos para solicitações para a API OAuth2: <br>\r\n<ul>\r\n <li><code>Ponto final de autorização: /oauth/authorize</code></li>\r\n <li><code>Ponto final do token: /oauth/token</code></li>\r\n <li>Página de recursos: <code>/oauth/profile</code></li></ul>\r\nA regeneração do segredo do cliente não expirará os códigos de autorização existentes, mas eles falharão na renovação do token. <br><br>\r\nA revogação dos tokens do cliente causará o encerramento imediato de todas as sessões ativas. Todos os clientes precisam se autenticar novamente.",
+        "oauth2_redirect_uri": "URI de redirecionamento",
+        "oauth2_renew_secret": "Gere um novo segredo de cliente",
+        "oauth2_revoke_tokens": "Revogar todos os tokens do cliente",
+        "optional": "opcionais",
+        "options": "Opções",
+        "password": "Senha",
+        "password_length": "Tamanho da senha",
+        "password_policy": "Política de senha",
+        "password_policy_chars": "Deve conter pelo menos um caractere alfabético",
+        "password_policy_length": "O tamanho mínimo da senha é %d",
+        "password_policy_lowerupper": "Deve conter caracteres maiúsculos e minúsculos",
+        "password_policy_numbers": "Deve conter pelo menos um número",
+        "password_policy_special_chars": "Deve conter caracteres especiais",
+        "password_repeat": "Senha de confirmação (repetição)",
+        "priority": "Prioridade",
+        "private_key": "Chave privada",
+        "quarantine": "Quarentena",
+        "quarantine_bcc": "Envie uma cópia de todas as notificações (BCC) para esse destinatário: <br><small>deixe em branco para desativar. <b>Correio não assinado e não verificado. Deve ser entregue somente internamente</b></small>.",
+        "quarantine_exclude_domains": "Excluir domínios e domínios de alias",
+        "quarantine_max_age": "Idade máxima em dias <br><small>O valor deve ser igual ou superior a 1 dia.</small>",
+        "quarantine_max_score": "Descarte a notificação se a pontuação de spam de um e-mail for maior que esse valor: O <br><small>padrão</small> é 9999,0",
+        "quarantine_max_size": "Tamanho máximo em MiB (elementos maiores são descartados): <br><small>0 <b>não</b> indica ilimitado</small>.",
+        "quarantine_notification_html": "Modelo de e-mail de notificação: <br><small>deixe em branco para restaurar o modelo padrão.</small>",
+        "quarantine_notification_sender": "Remetente do e-mail de notificação",
+        "quarantine_notification_subject": "Assunto do e-mail de notificação",
+        "quarantine_redirect": "<b>Redirecione todas as notificações</b> para esse destinatário: <br><small>deixe em branco para desativar. <b>Correio não assinado e não verificado. Deve ser entregue somente internamente</b></small>.",
+        "quarantine_release_format": "Formato dos itens lançados",
+        "quarantine_release_format_att": "Como anexo",
+        "quarantine_release_format_raw": "Original não modificado",
+        "quarantine_retention_size": "<b>Retenções por caixa de correio: <small>0</small> indica inativo.</b> <br>",
+        "quota_notification_html": "Modelo de e-mail de notificação: <br><small>deixe em branco para restaurar o modelo padrão.</small>",
+        "quota_notification_sender": "Remetente do e-mail de notificação",
+        "quota_notification_subject": "Assunto do e-mail de notificação",
+        "quota_notifications": "Notificações de cotas",
+        "quota_notifications_info": "As notificações de cota são enviadas aos usuários uma vez ao ultrapassar 80% e uma vez ao ultrapassar 95% de uso.",
+        "quota_notifications_vars": "{{percent}} é igual à cota atual do usuário <br>{{username}} é o nome da caixa de correio",
+        "queue_unban": "não banido",
+        "r_active": "Restrições ativas",
+        "r_inactive": "Restrições inativas",
+        "r_info": "Os elementos acinzentados/desativados na lista de restrições ativas não são conhecidos como restrições válidas para mailcow e não podem ser movidos. De qualquer forma, restrições desconhecidas serão definidas em ordem de aparição. <br>Você pode adicionar novos elementos em <code>inc/vars.local.inc.php</code> para poder alterná-los.",
+        "rate_name": "Nome da tarifa",
+        "recipients": "Destinatários",
+        "refresh": "Atualizar",
+        "regen_api_key": "Regenerar chave de API",
+        "regex_maps": "Mapas Regex",
+        "relay_from": "Endereço “De:”",
+        "relay_rcpt": "Endereço “Para:”",
+        "relay_run": "Execute o teste",
+        "relayhosts": "Transportes dependentes do remetente",
+        "relayhosts_hint": "Defina transportes dependentes do remetente para poder selecioná-los em uma caixa de diálogo de configuração de domínios. <br>\r\n O serviço de transporte é sempre “smtp:” e, portanto, experimentará o TLS quando oferecido. O Wrapped TLS (SMTPS) não é suportado. A configuração da política de TLS de saída individual de um usuário é levada em consideração. <br>\r\n Afeta domínios selecionados, incluindo domínios de alias.",
+        "remove": "Remover",
+        "remove_row": "Remover linha",
+        "reset_default": "Redefinir para o padrão",
+        "reset_limit": "Remover o hash",
+        "routing": "Roteamento",
+        "rsetting_add_rule": "Adicionar regra",
+        "rsetting_content": "Conteúdo da regra",
+        "rsetting_desc": "Breve descrição",
+        "rsetting_no_selection": "Selecione uma regra",
+        "rsetting_none": "Nenhuma regra disponível",
+        "rsettings_insert_preset": "Inserir exemplo de predefinição “%s”",
+        "rsettings_preset_1": "Desative tudo, exceto o DKIM e o limite de taxa para usuários autenticados",
+        "rsettings_preset_2": "Postmasters querem spam",
+        "rsettings_preset_3": "Permitir somente remetentes específicos para uma caixa de correio (ou seja, uso somente como caixa de correio interna)",
+        "rsettings_preset_4": "Desativar Rspamd para um domínio",
+        "rspamd_com_settings": "Um nome de configuração será gerado automaticamente, veja os exemplos de predefinições abaixo. Para obter mais detalhes, consulte a documentação <a href=\"https://rspamd.com/doc/configuration/settings.html#settings-structure\" target=\"_blank\">do Rspamd</a>",
+        "rspamd_global_filters": "Mapas de filtro globais",
+        "rspamd_global_filters_agree": "Eu vou ter cuidado!",
+        "rspamd_global_filters_info": "Os mapas de filtros globais contêm diferentes tipos de listas negras e brancas globais.",
+        "rspamd_global_filters_regex": "Seus nomes explicam seu propósito. <code>Todo o conteúdo deve conter uma expressão regular válida no formato “/padrão/opções” (por exemplo, /. + @domain\\ .tld/i</code>). <br>\r\n Embora verificações rudimentares estejam sendo executadas em cada linha de regex, a funcionalidade do Rspamd pode ser interrompida se não conseguir ler a sintaxe corretamente. <br>\r\n O Rspamd tentará ler o conteúdo do mapa quando alterado. Se você tiver problemas, <a href=\"\" data-toggle=\"modal\" data-container=\"rspamd-mailcow\" data-target=\"#RestartContainer\">reinicie o Rspamd</a> para forçar o recarregamento do mapa. <br>Os elementos da lista negra são excluídos da quarentena.",
+        "rspamd_settings_map": "Mapa de configurações do Rspamd",
+        "sal_level": "Nível de humor",
+        "save": "Salvar alterações",
+        "search_domain_da": "Domínios de pesquisa",
+        "send": "Enviar",
+        "sender": "Remetente",
+        "service": "Serviço",
+        "service_id": "ID do serviço",
+        "source": "Fonte",
+        "spamfilter": "Filtro de spam",
+        "subject": "Assunto",
+        "success": "Sucesso",
+        "sys_mails": "E-mails do sistema",
+        "text": "Texto",
+        "time": "Hora",
+        "title": "Título",
+        "title_name": "Título do site “mailcow UI”",
+        "to_top": "Voltar ao topo",
+        "transport_dest_format": "Regex ou sintaxe: example.org, .example.org, *, box@example.org (vários valores podem ser separados por vírgula)",
+        "transport_maps": "Mapas de transporte",
+        "transport_test_rcpt_info": "• Use null@hosted.mailcow.de para testar a retransmissão para um destino estrangeiro.",
+        "transports_hint": "• Uma entrada no mapa de transporte <b>anula um mapa de</b> transporte dependente do remetente</b>. <br>\r\n• Os transportes baseados em MX são preferencialmente usados. <br>\r\n• As configurações de política de TLS de saída por usuário são ignoradas e só podem ser aplicadas por entradas do mapa de políticas de TLS. <br>\r\n• O serviço de transporte para transportes definidos é sempre “smtp:” e, portanto, tentará o TLS quando oferecido. O Wrapped TLS (SMTPS) não é suportado. <br>\r\n• Endereços correspondentes a “/localhost$/” sempre serão transportados via “local:”, portanto, um destino “*” não se aplicará a esses endereços. <br>\r\n• Para determinar as credenciais para um próximo salto exemplar “[host] :25\", o Postfix <b>sempre</b> consulta por “host” antes de pesquisar por “[host] :25\". Esse comportamento impossibilita o uso de “host” e “[host] :25\" ao mesmo tempo.",
+        "ui_footer": "Rodapé (HTML permitido)",
+        "ui_header_announcement": "Anúncios",
+        "ui_header_announcement_active": "Definir anúncio ativo",
+        "ui_header_announcement_content": "Texto (HTML permitido)",
+        "ui_header_announcement_help": "O anúncio é visível para todos os usuários conectados e na tela de login da interface do usuário.",
+        "ui_header_announcement_select": "Selecione o tipo de anúncio",
+        "ui_header_announcement_type": "Tipo",
+        "ui_header_announcement_type_danger": "Muito importante",
+        "ui_header_announcement_type_info": "Informações",
+        "ui_header_announcement_type_warning": "Importante",
+        "ui_texts": "Rótulos e textos da interface do usuário",
+        "unban_pending": "pendência não banida",
+        "unchanged_if_empty": "Se inalterado, deixe em branco",
+        "upload": "Carregar",
+        "username": "Nome de usuário",
+        "validate_license_now": "Valide o GUID em relação ao servidor de licenças",
+        "verify": "Verificar",
+        "yes": "✓"
+    },
+    "danger": {
+        "access_denied": "Acesso negado ou dados de formulário inválidos",
+        "alias_domain_invalid": "O domínio alias %s é inválido",
+        "alias_empty": "O endereço do alias não deve estar vazio",
+        "alias_goto_identical": "O alias e o endereço de destino não devem ser idênticos",
+        "alias_invalid": "O endereço de alias %s é inválido",
+        "aliasd_targetd_identical": "O domínio do alias não deve ser igual ao domínio de destino: %s",
+        "aliases_in_use": "O máximo de aliases deve ser maior ou igual a %d",
+        "app_name_empty": "O nome do aplicativo não pode estar vazio",
+        "app_passwd_id_invalid": "ID de senha do aplicativo %s inválida",
+        "bcc_empty": "O destino do BCC não pode estar vazio",
+        "bcc_exists": "Existe um mapa BCC %s para o tipo %s",
+        "bcc_must_be_email": "O destino %s do BCC não é um endereço de e-mail válido",
+        "comment_too_long": "Comentário muito longo, máximo de 160 caracteres permitidos",
+        "cors_invalid_method": "Método de permissão inválido especificado",
+        "cors_invalid_origin": "Origem de permissão inválida especificada",
+        "defquota_empty": "A cota padrão por caixa de correio não deve ser 0.",
+        "demo_mode_enabled": "O modo de demonstração está ativado",
+        "description_invalid": "A descrição do recurso para %s é inválida",
+        "dkim_domain_or_sel_exists": "Existe uma chave DKIM para “%s” e não será substituída",
+        "dkim_domain_or_sel_invalid": "Domínio ou seletor DKIM inválido: %s",
+        "domain_cannot_match_hostname": "O domínio não pode corresponder ao nome do host",
+        "domain_exists": "O domínio %s já existe",
+        "domain_invalid": "O nome do domínio está vazio ou é inválido",
+        "domain_not_empty": "Não é possível remover o domínio não vazio %s",
+        "domain_not_found": "Domínio %s não encontrado",
+        "domain_quota_m_in_use": "A cota de domínio deve ser maior ou igual a %s MiB",
+        "extended_sender_acl_denied": "ACL ausente para definir endereços de remetentes externos",
+        "extra_acl_invalid": "O endereço do remetente externo “%s” é inválido",
+        "extra_acl_invalid_domain": "O remetente externo “%s” usa um domínio inválido",
+        "fido2_verification_failed": "Falha na verificação do FIDO2: %s",
+        "file_open_error": "O arquivo não pode ser aberto para gravação",
+        "filter_type": "Tipo de filtro errado",
+        "from_invalid": "O remetente não deve estar vazio",
+        "global_filter_write_error": "Não foi possível gravar o arquivo de filtro: %s",
+        "global_map_invalid": "ID de mapa global %s inválida",
+        "global_map_write_error": "Não foi possível gravar a ID do mapa global %s: %s",
+        "goto_empty": "Um endereço de alias deve conter pelo menos um endereço de destino válido",
+        "goto_invalid": "O endereço Goto %s é inválido",
+        "ham_learn_error": "Erro de aprendizado do Ham: %s",
+        "imagick_exception": "Erro: exceção Imagick ao ler a imagem",
+        "img_invalid": "Não é possível validar o arquivo de imagem",
+        "img_tmp_missing": "Não é possível validar o arquivo de imagem: Arquivo temporário não encontrado",
+        "invalid_bcc_map_type": "Tipo de mapa BCC inválido",
+        "invalid_destination": "O formato de destino “%s” é inválido",
+        "invalid_filter_type": "Tipo de filtro inválido",
+        "invalid_host": "Host inválido especificado: %s",
+        "invalid_mime_type": "Tipo de mime inválido",
+        "invalid_nexthop": "O formato do próximo salto é inválido",
+        "invalid_nexthop_authenticated": "O próximo salto existe com credenciais diferentes. Primeiro, atualize as credenciais existentes para o próximo salto.",
+        "invalid_recipient_map_new": "Novo destinatário inválido especificado: %s",
+        "invalid_recipient_map_old": "Destinatário original inválido especificado: %s",
+        "ip_list_empty": "A lista de IPs permitidos não pode estar vazia",
+        "is_alias": "%s já é conhecido como endereço de alias",
+        "is_alias_or_mailbox": "%s já é conhecido como alias, caixa de correio ou endereço de alias expandido a partir de um domínio de alias.",
+        "is_spam_alias": "%s já é conhecido como endereço de alias temporário (endereço de alias de spam)",
+        "last_key": "A última chave não pode ser excluída. Em vez disso, desative o TFA.",
+        "login_failed": "Falha no login",
+        "mailbox_defquota_exceeds_mailbox_maxquota": "A cota padrão excede o limite máximo da cota",
+        "mailbox_invalid": "O nome da caixa de correio é inválido",
+        "mailbox_quota_exceeded": "A cota excede o limite do domínio (máx. %d MiB)",
+        "mailbox_quota_exceeds_domain_quota": "A cota máxima excede o limite da cota do domínio",
+        "mailbox_quota_left_exceeded": "Não há espaço restante (espaço restante: %d MiB)",
+        "mailboxes_in_use": "O máximo de caixas de correio deve ser maior ou igual a %d",
+        "malformed_username": "Nome de usuário malformado",
+        "map_content_empty": "O conteúdo do mapa não pode estar vazio",
+        "max_alias_exceeded": "Número máximo de aliases excedido",
+        "max_mailbox_exceeded": "Número máximo de caixas de correio excedido (%d de %d)",
+        "max_quota_in_use": "A cota da caixa de correio deve ser maior ou igual a %d MiB",
+        "maxquota_empty": "A cota máxima por caixa de correio não deve ser 0.",
+        "mysql_error": "Erro do MySQL: %s",
+        "network_host_invalid": "Rede ou host inválidos: %s",
+        "next_hop_interferes": "%s interfere com o nexthop %s",
+        "next_hop_interferes_any": "Um próximo salto existente interfere com %s",
+        "nginx_reload_failed": "Falha na recarga do Nginx: %s",
+        "no_user_defined": "Nenhum usuário definido",
+        "object_exists": "O objeto %s já existe",
+        "object_is_not_numeric": "O valor %s não é numérico",
+        "password_complexity": "A senha não atende à política",
+        "password_empty": "A senha não deve estar vazia",
+        "password_mismatch": "A senha de confirmação não corresponde",
+        "policy_list_from_exists": "Existe um registro com nome próprio",
+        "policy_list_from_invalid": "O registro tem formato inválido",
+        "private_key_error": "Erro de chave privada: %s",
+        "pushover_credentials_missing": "Token Pushover e/ou chave ausente",
+        "pushover_key": "A tecla Pushover tem um formato errado",
+        "pushover_token": "O token Pushover tem um formato errado",
+        "quota_not_0_not_numeric": "A cota deve ser numérica e >= 0",
+        "recipient_map_entry_exists": "Existe uma entrada de mapa de destinatários “%s”",
+        "redis_error": "Erro do Redis: %s",
+        "relayhost_invalid": "A entrada de mapa %s é inválida",
+        "release_send_failed": "A mensagem não pôde ser liberada: %s",
+        "reset_f2b_regex": "O filtro Regex não pôde ser redefinido a tempo. Tente novamente ou aguarde mais alguns segundos e recarregue o site.",
+        "resource_invalid": "O nome do recurso %s é inválido",
+        "rl_timeframe": "O prazo do limite de taxa está incorreto",
+        "rspamd_ui_pw_length": "A senha do Rspamd UI deve ter pelo menos 6 caracteres",
+        "script_empty": "O script não pode estar vazio",
+        "sender_acl_invalid": "O valor %s da ACL do remetente é inválido",
+        "set_acl_failed": "Falha ao definir a ACL",
+        "settings_map_invalid": "ID do mapa de configurações %s inválida",
+        "sieve_error": "Erro do analisador do Sieve: %s",
+        "spam_learn_error": "Erro de aprendizado de spam: %s",
+        "subject_empty": "O assunto não deve estar vazio",
+        "target_domain_invalid": "O domínio de destino %s é inválido",
+        "targetd_not_found": "Domínio de destino %s não encontrado",
+        "targetd_relay_domain": "O domínio de destino %s é um domínio de retransmissão",
+        "template_exists": "O modelo %s já existe",
+        "template_id_invalid": "ID de modelo %s inválida",
+        "template_name_invalid": "Nome do modelo inválido",
+        "temp_error": "Erro temporário",
+        "text_empty": "O texto não deve estar vazio",
+        "tfa_token_invalid": "Token TFA inválido",
+        "tls_policy_map_dest_invalid": "O destino da política é inválido",
+        "tls_policy_map_entry_exists": "Existe uma entrada “%s” no mapa de políticas TLS",
+        "tls_policy_map_parameter_invalid": "O parâmetro de política é inválido",
+        "totp_verification_failed": "Falha na verificação do TOTP",
+        "transport_dest_exists": "O destino de transporte “%s” existe",
+        "webauthn_verification_failed": "Falha na verificação do WebAuthn: %s",
+        "webauthn_authenticator_failed": "O autenticador selecionado não foi encontrado",
+        "webauthn_publickey_failed": "Nenhuma chave pública foi armazenada para o autenticador selecionado",
+        "webauthn_username_failed": "O autenticador selecionado pertence a outra conta",
+        "unknown": "Ocorreu um erro desconhecido",
+        "unknown_tfa_method": "Método TFA desconhecido",
+        "unlimited_quota_acl": "Cota ilimitada proibida pela ACL",
+        "username_invalid": "O nome de usuário %s não pode ser usado",
+        "validity_missing": "Por favor, atribua um período de validade",
+        "value_missing": "Forneça todos os valores",
+        "yotp_verification_failed": "Falha na verificação do Yubico OTP: %s"
+    },
+    "datatables": {
+        "collapse_all": "Recolher tudo",
+        "decimal": ".",
+        "emptyTable": "Não há dados disponíveis na tabela",
+        "expand_all": "Expandir tudo",
+        "info": "Exibindo _START_ a _END_ de _TOTAL_ entradas",
+        "infoEmpty": "Exibindo 0 a 0 de 0 inscrições",
+        "infoFiltered": "(filtrado do total de entradas _MAX_)",
+        "infoPostFix": "",
+        "thousands": ",",
+        "lengthMenu": "Show _MENU_ entries",
+        "loadingRecords": "Loading...",
+        "processing": "Please wait...",
+        "search": "Search:",
+        "zeroRecords": "No matching records found",
+        "paginate": {
+            "first": "First",
+            "last": "Last",
+            "next": "Next",
+            "previous": "Previous"
+        },
+        "aria": {
+            "sortAscending": ": activate to sort column ascending",
+            "sortDescending": ": activate to sort column descending"
+        }
+    },
+    "debug": {
+        "architecture": "Arquitetura",
+        "chart_this_server": "Gráfico (este servidor)",
+        "containers_info": "Informações do contêiner",
+        "container_running": "Executando",
+        "container_disabled": "Contêiner parado ou desativado",
+        "container_stopped": "Parado",
+        "cores": "Núcleos",
+        "current_time": "Hora do sistema",
+        "disk_usage": "Uso do disco",
+        "docs": "Documentos",
+        "error_show_ip": "Não foi possível resolver os endereços IP públicos",
+        "external_logs": "Registros externos",
+        "history_all_servers": "Histórico (todos os servidores)",
+        "in_memory_logs": "Registros na memória",
+        "jvm_memory_solr": "Uso de memória JVM",
+        "last_modified": "Última modificação",
+        "log_info": "<p>Os <b>registros na memória do</b> mailcow são coletados em listas do Redis e reduzidos para LOG_LINES (%d) a cada minuto para reduzir o martelamento.\r\n Os <br>registros na memória não devem ser persistentes. Todos os aplicativos que fazem login na memória também fazem login no daemon do Docker e, portanto, no driver de registro padrão.\r\n </p><br>O tipo de registro na memória deve ser usado para depurar pequenos problemas com contêineres.\r\n <p>Os <b>registros externos</b> são coletados por meio da API do aplicativo em questão.</p>\r\n <p>Os <b>registros estáticos</b> são principalmente registros de atividades, que não são registrados no Dockerd, mas ainda precisam ser persistentes (exceto os registros da API).</p>",
+        "login_time": "Hora",
+        "logs": "Registros",
+        "memory": "Memória",
+        "online_users": "Usuários online",
+        "restart_container": "Reiniciar",
+        "service": "Serviço",
+        "show_ip": "Mostrar IP público",
+        "size": "Tamanho",
+        "solr_dead": "O Solr está iniciando, desativado ou morreu.",
+        "solr_status": "Status do solr",
+        "started_at": "Começou em",
+        "started_on": "Começou em",
+        "static_logs": "Registros estáticos",
+        "success": "Sucesso",
+        "system_containers": "Sistema e contêineres",
+        "timezone": "Fuso horário",
+        "uptime": "Tempo de atividade",
+        "update_available": "Há uma atualização disponível",
+        "no_update_available": "O sistema está na versão mais recente",
+        "update_failed": "Não foi possível verificar se havia uma atualização",
+        "username": "Nome de usuário",
+        "wip": "Trabalho em andamento no momento"
+    },
+    "diagnostics": {
+        "cname_from_a": "Valor derivado do registro A/AAAA. Isso é suportado desde que o registro aponte para o recurso correto.",
+        "dns_records": "Registros DNS",
+        "dns_records_24hours": "Observe que as alterações feitas no DNS podem levar até 24 horas para que seu estado atual seja refletido corretamente nesta página. O objetivo é uma forma de você ver facilmente como configurar seus registros DNS e verificar se todos os seus registros estão armazenados corretamente no DNS.",
+        "dns_records_data": "Dados corretos",
+        "dns_records_docs": "Consulte também <a target=\"_blank\" href=\"https://docs.mailcow.email/prerequisite/prerequisite-dns/\">a documentação</a>.",
+        "dns_records_name": "Nome",
+        "dns_records_status": "Estado atual",
+        "dns_records_type": "Tipo",
+        "optional": "Esse registro é opcional."
+    },
+    "edit": {
+        "acl": "ACL (permissão)",
+        "active": "Ativo",
+        "admin": "Editar administrador",
+        "advanced_settings": "Configurações avançadas",
+        "alias": "Editar alias",
+        "allow_from_smtp": "<b>Permita que esses IPs usem apenas SMTP</b>",
+        "allow_from_smtp_info": "Deixe em branco para permitir todos os remetentes. Endereços e <br>redes IPv4/IPv6.",
+        "allowed_protocols": "Protocolos permitidos",
+        "app_name": "Nome do aplicativo",
+        "app_passwd": "Senha do aplicativo",
+        "app_passwd_protocols": "Protocolos permitidos para a senha do aplicativo",
+        "automap": "Tente mapear pastas automaticamente (“Itens enviados”, “Enviados” => “Enviados” etc.)",
+        "backup_mx_options": "Opções de relé",
+        "bcc_dest_format": "O destino do BCC deve ser um único endereço de e-mail válido. <br>Se precisar enviar uma cópia para vários endereços, crie um alias e use-o aqui.",
+        "client_id": "ID do cliente",
+        "client_secret": "Segredo do cliente",
+        "comment_info": "Um comentário privado não é visível para o usuário, enquanto um comentário público é mostrado como dica de ferramenta ao passar o mouse sobre ele na visão geral do usuário",
+        "created_on": "Criado em",
+        "delete1": "Excluir da fonte quando concluído",
+        "delete2": "Excluir mensagens no destino que não estão na origem",
+        "delete2duplicates": "Excluir duplicatas no destino",
+        "delete_ays": "Confirme o processo de exclusão.",
+        "description": "Descrição",
+        "disable_login": "Não permitir login (e-mails recebidos ainda são aceitos)",
+        "domain": "Editar domínio",
+        "domain_admin": "Editar administrador de domínio",
+        "domain_footer": "Rodapé amplo do domínio",
+        "domain_footer_html": "rodapé HTML",
+        "domain_footer_info": "Os rodapés de todo o domínio são adicionados a todos os e-mails enviados associados a um endereço dentro desse domínio. <br>As seguintes variáveis podem ser usadas para o rodapé:",
+        "domain_footer_info_vars": {
+            "auth_user": "{= auth_user =} - Nome de usuário autenticado especificado por um MTA",
+            "from_user": "{= from_user =} - Da parte do envelope do usuário, por exemplo, para \"moo@mailcow.tld\", ele retorna “moo”",
+            "from_name": "{= from_name =} - Do nome do envelope, por exemplo, para “Mailcow < moo@mailcow.tld >”, ele retorna “Mailcow”",
+            "from_addr": "{= from_addr =} - Do endereço, parte do envelope",
+            "from_domain": "{= from_domain =} - Da parte do domínio do envelope",
+            "custom": "{= foo =}         - Se o mailbox tiver o atributo personalizado \"foo\" com valor \"bar\", retornará \"bar\""
+        },
+        "domain_footer_plain": "Rodapé simples",
+        "domain_quota": "Cota de domínio",
+        "domains": "Domínios",
+        "dont_check_sender_acl": "Desativar a verificação de remetente para o domínio %s (+ domínios de alias)",
+        "edit_alias_domain": "Editar domínio Alias",
+        "encryption": "Criptografia",
+        "exclude": "Excluir objetos (regex)",
+        "extended_sender_acl": "Endereços de remetentes externos",
+        "extended_sender_acl_info": "Uma chave de domínio DKIM deve ser importada, se disponível. <br>\r\n Lembre-se de adicionar esse servidor ao registro TXT SPF correspondente. <br>\r\n Sempre que um domínio ou domínio de alias é adicionado a esse servidor, que se sobrepõe a um endereço externo, o endereço externo é removido. <br>\r\n Use @domain .tld para permitir o envio como * @domain .tld.",
+        "force_pw_update": "Forçar a atualização da senha no próximo login",
+        "force_pw_update_info": "Esse usuário só poderá fazer login em %s. As senhas do aplicativo permanecem utilizáveis.",
+        "full_name": "Nome completo",
+        "gal": "Lista de endereços global",
+        "gal_info": "A GAL contém todos os objetos de um domínio e não pode ser editada por nenhum usuário. Faltam informações de disponibilidade no SoGo, se desativadas! <b>Reinicie o SoGo para aplicar as alterações.</b>",
+        "generate": "geram",
+        "grant_types": "Tipos de subsídios",
+        "hostname": "Nome do host",
+        "inactive": "Inativo",
+        "kind": "Gentil",
+        "last_modified": "Última modificação",
+        "lookup_mx": "Destination é uma expressão regular que corresponde ao nome MX (<code>.*\\ .google\\ .com</code> para rotear todos os e-mails direcionados a um MX que termina em google.com nesse salto)",
+        "mailbox": "Editar caixa de correio",
+        "mailbox_quota_def": "Cota de caixa de correio padrão",
+        "mailbox_relayhost_info": "Aplicado somente à caixa de correio e aos aliases diretos, substitui um host de retransmissão de domínio.",
+        "max_aliases": "Máximo de aliases",
+        "max_mailboxes": "Número máximo de caixas de correio possíveis",
+        "max_quota": "Cota máxima por caixa de correio (MiB)",
+        "maxage": "Duração máxima das mensagens em dias que serão pesquisadas remotamente <br><small>(0 = ignorar a idade</small>)",
+        "maxbytespersecond": "Máximo de bytes por segundo <br><small>(0 = ilimitado</small>)",
+        "mbox_rl_info": "Esse limite de taxa é aplicado ao nome de login do SASL e corresponde a qualquer endereço “de” usado pelo usuário conectado. Um limite de taxa de caixa de correio substitui um limite de taxa em todo o domínio.",
+        "mins_interval": "Intervalo (min)",
+        "multiple_bookings": "Várias reservas",
+        "none_inherit": "Nenhum/Herdar",
+        "nexthop": "Próximo salto",
+        "password": "Senha",
+        "password_repeat": "Senha de confirmação (repetição)",
+        "previous": "Página anterior",
+        "private_comment": "Comentário privado",
+        "public_comment": "Comentário público",
+        "pushover": "Pushover",
+        "pushover_evaluate_x_prio": "Escale e-mails de alta prioridade [<code>X-Priority:</code> 1]",
+        "pushover_info": "As configurações de notificação push serão aplicadas a todos os e-mails limpos (sem spam) entregues a <b>%s</b>, incluindo aliases (compartilhados, não compartilhados, marcados).",
+        "pushover_only_x_prio": "Considere somente e-mails de alta prioridade [<code>X-Priority: 1</code>]",
+        "pushover_sender_array": "Considere apenas os seguintes endereços de e-mail do remetente <small>(separados por vírgula)</small>",
+        "pushover_sender_regex": "Considere o seguinte regex do remetente",
+        "pushover_text": "Texto de notificação",
+        "pushover_title": "Título da notificação",
+        "pushover_sound": "Som",
+        "pushover_vars": "Quando nenhum filtro de remetente for definido, todos os e-mails serão considerados. <br>Os filtros Regex, bem como as verificações exatas do remetente, podem ser definidos individualmente e serão considerados sequencialmente. Eles não dependem um do outro. <br>Variáveis utilizáveis para texto e título (observe as políticas de proteção de dados)",
+        "pushover_verify": "Verifique as credenciais",
+        "quota_mb": "Cota (MiB)",
+        "quota_warning_bcc": "Aviso de cota BCC",
+        "quota_warning_bcc_info": "Os avisos serão enviados em cópias separadas para os seguintes destinatários. O assunto será sufixado pelo nome de usuário correspondente entre colchetes, por exemplo: <code>Aviso de cota (</code>user@example.com).",
+        "ratelimit": "Limite de taxa",
+        "redirect_uri": "URL de redirecionamento/retorno de chamada",
+        "relay_all": "Retransmita todos os destinatários",
+        "relay_all_info": "↪ Se você optar por <b>não</b> retransmitir todos os destinatários, precisará adicionar uma caixa de correio (“cega”) para cada destinatário que deve ser retransmitido.",
+        "relay_domain": "Retransmitir este domínio",
+        "relay_transport_info": "<div class=\"badge fs-6 bg-info\">Informações</div> Você pode definir mapas de transporte para um destino personalizado para esse domínio. Se não for definido, uma pesquisa MX será feita.",
+        "relay_unknown_only": "Retransmita somente caixas de correio não existentes. As caixas de correio existentes serão entregues localmente.",
+        "relayhost": "Transportes dependentes do remetente",
+        "remove": "Remover",
+        "resource": "Recurso",
+        "save": "Salvar alterações",
+        "scope": "Escopo",
+        "sender_acl": "Permitir enviar como",
+        "sender_acl_disabled": "<span class=\"badge fs-6 bg-danger\">A verificação do remetente está desativada</span>",
+        "sender_acl_info": "Se o usuário A da caixa de correio tiver permissão para enviar como usuário B da caixa de correio, o endereço do remetente não será exibido automaticamente como campo “de” selecionável no SoGo. <br>\r\n O usuário B da caixa de correio precisa criar uma delegação no SoGo para permitir que o usuário A da caixa de correio selecione seu endereço como remetente. Para delegar uma caixa de correio no SoGo, use o menu (três pontos) à direita do nome da sua caixa de correio no canto superior esquerdo enquanto estiver na visualização de e-mail. Esse comportamento não se aplica a endereços de alias.",
+        "sieve_desc": "Breve descrição",
+        "sieve_type": "Tipo de filtro",
+        "skipcrossduplicates": "Ignore mensagens duplicadas entre pastas (primeiro a chegar, primeiro a ser servido)",
+        "sogo_access": "Conceder acesso de login direto ao SoGo",
+        "sogo_access_info": "O login único de dentro da interface do usuário de e-mail continua funcionando. Essa configuração não afeta o acesso a todos os outros serviços nem exclui ou altera o perfil SoGo existente de um usuário.",
+        "sogo_visible": "O alias é visível no SoGo",
+        "sogo_visible_info": "Essa opção afeta somente objetos, que podem ser exibidos no SoGo (endereços de alias compartilhados ou não compartilhados apontando para pelo menos uma caixa de correio local). Se estiver oculto, um alias não aparecerá como remetente selecionável no SoGo.",
+        "spam_alias": "Crie ou altere endereços de alias com limite de tempo",
+        "spam_filter": "Filtro de spam",
+        "spam_policy": "Adicionar ou remover itens da lista branca/negra",
+        "spam_score": "Defina uma pontuação de spam personalizada",
+        "subfolder2": "Sincronizar na subpasta no destino <br><small>(vazio = não usar subpasta</small>)",
+        "syncjob": "Editar tarefa de sincronização",
+        "target_address": "<small>Ir para endereço/es (separados por vírgula)</small>",
+        "target_domain": "Domínio de destino",
+        "timeout1": "Tempo limite para conexão com o host remoto",
+        "timeout2": "Tempo limite para conexão com o host local",
+        "title": "Editar objeto",
+        "unchanged_if_empty": "Se inalterado, deixe em branco",
+        "username": "Nome de usuário",
+        "validate_save": "Valide e salve",
+        "custom_attributes": "Atributos personalizados",
+        "mbox_exclude": "Excluir caixas de email"
+    },
+    "fido2": {
+        "confirm": "Confirme",
+        "fido2_auth": "Faça login com FIDO2",
+        "fido2_success": "Dispositivo registrado com sucesso",
+        "fido2_validation_failed": "Falha na validação",
+        "fn": "Nome amigável",
+        "known_ids": "IDs conhecidos",
+        "none": "Deficiente",
+        "register_status": "Status do registro",
+        "rename": "Renomear",
+        "set_fido2": "Registre o dispositivo FIDO2",
+        "set_fido2_touchid": "Registre o Touch ID no Apple M1",
+        "set_fn": "Defina um nome amigável",
+        "start_fido2_validation": "Inicie a validação do FIDO2"
+    },
+    "footer": {
+        "cancel": "Cancelar",
+        "confirm_delete": "Confirme a exclusão",
+        "delete_now": "Excluir agora",
+        "delete_these_items": "Confirme suas alterações no seguinte ID de objeto",
+        "hibp_check": "Verifique em haveibeenpwned.com",
+        "hibp_nok": "Combinado! Essa é uma senha potencialmente perigosa!",
+        "hibp_ok": "Nenhuma combinação encontrada.",
+        "loading": "Por favor, espere...",
+        "nothing_selected": "Nada selecionado",
+        "restart_container": "Reiniciar contêiner",
+        "restart_container_info": "<b>Importante:</b> uma reinicialização normal pode demorar um pouco para ser concluída. Aguarde a conclusão.",
+        "restart_now": "Reinicie agora",
+        "restarting_container": "Reiniciando o contêiner, isso pode demorar um pouco"
+    },
+    "header": {
+        "administration": "Configuração e detalhes",
+        "apps": "Aplicativos",
+        "debug": "Informações",
+        "email": "Correio eletrônico",
+        "mailcow_system": "Sistema",
+        "mailcow_config": "Configuração",
+        "quarantine": "Quarentena",
+        "restart_netfilter": "Reinicie o filtro de rede",
+        "restart_sogo": "Reinicie o SoGo",
+        "user_settings": "Configurações do usuário"
+    },
+    "info": {
+        "awaiting_tfa_confirmation": "Aguardando a confirmação do TFA",
+        "no_action": "Nenhuma ação aplicável",
+        "session_expires": "Sua sessão expirará em cerca de 15 segundos"
+    },
+    "login": {
+        "delayed": "O login foi atrasado em %s segundos.",
+        "fido2_webauthn": "Login do FIDO2/WebAuthn",
+        "login": "Login",
+        "mobileconfig_info": "Faça login como usuário da caixa de correio para baixar o perfil de conexão Apple solicitado.",
+        "other_logins": "Login com chave",
+        "password": "Senha",
+        "username": "Nome de usuário"
+    },
+    "mailbox": {
+        "action": "Ação",
+        "activate": "Ativar",
+        "active": "Ativo",
+        "add": "Adicionar",
+        "add_alias": "Adicionar alias",
+        "add_alias_expand": "Expanda o alias em domínios de alias",
+        "add_bcc_entry": "Adicionar mapa BCC",
+        "add_domain": "Adicionar domínio",
+        "add_domain_alias": "Adicionar alias de domínio",
+        "add_domain_record_first": "Por favor, adicione um domínio primeiro",
+        "add_filter": "Adicionar filtro",
+        "add_mailbox": "Adicionar caixa de correio",
+        "add_recipient_map_entry": "Adicionar mapa do destinatário",
+        "add_resource": "Adicionar recurso",
+        "add_template": "Adicionar modelo",
+        "add_tls_policy_map": "Adicionar mapa de política TLS",
+        "address_rewriting": "Reescrita de endereço",
+        "alias": "Pseudônimo",
+        "alias_domain_alias_hint": "Os aliases <b>não</b> são aplicados automaticamente aos aliases de domínio. Um endereço de alias <code>my-alias @domain</code> <b>não</b> cobre o endereço <code>my-alias @alias -domain (onde “alias-domain” é um domínio</code> de alias imaginário para “domain”). <br>Use um filtro de peneira para redirecionar e-mails para uma caixa de correio externa (consulte a guia “Filtros” ou use SoGo -> Forwarder). Use “Expandir alias em domínios de alias” para adicionar automaticamente os aliases ausentes.",
+        "alias_domain_backupmx": "Domínio de alias inativo para domínio de retransmissão",
+        "aliases": "Pseudônimos",
+        "all_domains": "Todos os domínios",
+        "allow_from_smtp": "<b>Permita que esses IPs usem apenas SMTP</b>",
+        "allow_from_smtp_info": "Deixe em branco para permitir todos os remetentes. Endereços e <br>redes IPv4/IPv6.",
+        "allowed_protocols": "Protocolos permitidos para acesso direto do usuário (não afeta os protocolos de senha do aplicativo)",
+        "backup_mx": "Domínio de retransmissão",
+        "bcc": "BCC",
+        "bcc_destination": "Destino BCC",
+        "bcc_destinations": "Destino BCC",
+        "bcc_info": "Os mapas BCC são usados para encaminhar silenciosamente cópias de todas as mensagens para outro endereço. Uma entrada do tipo mapa do destinatário é usada quando o destino local atua como destinatário de um e-mail. Os mapas do remetente estão em conformidade com o mesmo princípio. <br/>\r\n O destino local não será informado sobre uma falha na entrega.",
+        "bcc_local_dest": "Destino local",
+        "bcc_map": "mapa do BCC",
+        "bcc_map_type": "Tipo BCC",
+        "bcc_maps": "Mapas BCC",
+        "bcc_rcpt_map": "Mapa do destinatário",
+        "bcc_sender_map": "Mapa do remetente",
+        "bcc_to_rcpt": "Mudar para o tipo de mapa do destinatário",
+        "bcc_to_sender": "Mudar para o tipo de mapa do remetente",
+        "bcc_type": "Tipo BCC",
+        "booking_null": "Sempre mostre como gratuito",
+        "booking_0_short": "Sempre grátis",
+        "booking_custom": "Limite rígido para uma quantidade personalizada de reservas",
+        "booking_custom_short": "Limite rígido",
+        "booking_ltnull": "Ilimitado, mas mostre como ocupado quando reservado",
+        "booking_lt0_short": "Limite flexível",
+        "catch_all": "Apanhável",
+        "created_on": "Criado em",
+        "daily": "Diariamente",
+        "deactivate": "Desativar",
+        "description": "Descrição",
+        "disable_login": "Não permitir login (e-mails recebidos ainda são aceitos)",
+        "disable_x": "Desativar",
+        "dkim_domains_selector": "Seletor",
+        "dkim_key_length": "Comprimento da chave DKIM (bits)",
+        "domain": "Domínio",
+        "domain_admins": "Administradores de domínio",
+        "domain_aliases": "Aliases de domínio",
+        "domain_templates": "Modelos de domínio",
+        "domain_quota": "Cota",
+        "domain_quota_total": "Cota total de domínio",
+        "domains": "Domínios",
+        "edit": "Editar",
+        "empty": "Sem resultados",
+        "enable_x": "Habilitar",
+        "excludes": "Exclui",
+        "filter_table": "Tabela de filtros",
+        "filters": "Filtros",
+        "fname": "Nome completo",
+        "force_pw_update": "Forçar a atualização da senha no próximo login",
+        "gal": "Lista de endereços global",
+        "goto_ham": "Aprenda como <b>presunto</b>",
+        "goto_spam": "Aprenda como <b>spam</b>",
+        "hourly": "A cada hora",
+        "in_use": "Em uso (%)",
+        "inactive": "Inativo",
+        "insert_preset": "Inserir exemplo de predefinição “%s”",
+        "kind": "Gentil",
+        "last_mail_login": "Último login por e-mail",
+        "last_modified": "Última modificação",
+        "last_pw_change": "Última alteração de senha",
+        "last_run": "Última corrida",
+        "last_run_reset": "Programe a seguir",
+        "mailbox": "Caixa de correio",
+        "mailbox_defaults": "Configurações padrão",
+        "mailbox_defaults_info": "Defina as configurações padrão para novas caixas de correio.",
+        "mailbox_defquota": "Tamanho padrão da caixa de correio",
+        "mailbox_templates": "Modelos de caixa de correio",
+        "mailbox_quota": "Tamanho máximo de uma caixa de correio",
+        "mailboxes": "Caixas de correio",
+        "max_aliases": "Máximo de aliases",
+        "max_mailboxes": "Número máximo de caixas de correio possíveis",
+        "max_quota": "Cota máxima por caixa de correio",
+        "mins_interval": "Intervalo (min)",
+        "msg_num": "Mensagem #",
+        "multiple_bookings": "Várias reservas",
+        "never": "Nunca",
+        "no": "✕",
+        "no_record": "Nenhum registro para o objeto %s",
+        "no_record_single": "Sem registro",
+        "open_logs": "Registros abertos",
+        "owner": "Proprietário",
+        "private_comment": "Comentário privado",
+        "public_comment": "Comentário público",
+        "q_add_header": "quando movido para a pasta Lixo",
+        "q_all": " quando movido para a pasta de lixo eletrônico e ao ser rejeitado",
+        "q_reject": "na rejeição",
+        "quarantine_category": "Categoria de notificação de quarentena",
+        "quarantine_notification": "Notificações de quarentena",
+        "quick_actions": "Ações",
+        "recipient": "Destinatário",
+        "recipient_map": "Mapa do destinatário",
+        "recipient_map_info": "Os mapas de destinatários são usados para substituir o endereço de destino em uma mensagem antes que ela seja entregue.",
+        "recipient_map_new": "Novo destinatário",
+        "recipient_map_new_info": "O destino do mapa do destinatário deve ser um endereço de e-mail válido.",
+        "recipient_map_old": "Destinatário original",
+        "recipient_map_old_info": "O destino original do mapa de um destinatário deve ser um endereço de e-mail válido ou um nome de domínio.",
+        "recipient_maps": "Mapas de destinatários",
+        "relay_all": "Retransmita todos os destinatários",
+        "relay_unknown": "Retransmitir caixas de correio desconhecidas",
+        "remove": "Remover",
+        "resources": "Recursos",
+        "running": "Executando",
+        "sender": "Remetente",
+        "set_postfilter": "Marcar como postfilter",
+        "set_prefilter": "Marcar como pré-filtro",
+        "sieve_info": "Você pode armazenar vários filtros por usuário, mas somente um pré-filtro e um pós-filtro podem estar ativos ao mesmo tempo. <br>\r\nCada filtro será processado na ordem descrita. Nem um script com falha nem um “keep;” emitido interromperão o processamento de outros scripts. Mudanças nos scripts globais do Sieve acionarão a reinicialização do Dovecot. Pré-filtro de <br><br>peneira global • Pré-filtro • Scripts de usuário • Pós-filtro • Pós-filtro de peneira global",
+        "sieve_preset_1": "Descarte e-mails com prováveis tipos de arquivo perigosos",
+        "sieve_preset_2": "Sempre marque o e-mail de um remetente específico como visto",
+        "sieve_preset_3": "Descarte silenciosamente, interrompa todo o processamento adicional da peneira",
+        "sieve_preset_4": "Arquive na CAIXA DE ENTRADA, pule o processamento adicional por filtros de peneira",
+        "sieve_preset_5": "Resposta automática (férias)",
+        "sieve_preset_6": "Rejeitar e-mail com resposta",
+        "sieve_preset_7": "Redirecionar e manter/soltar",
+        "sieve_preset_8": "Redirecionar e-mail de um remetente específico, marcar como lido e classificar em subpasta",
+        "sieve_preset_header": "Veja os exemplos de predefinições abaixo. Para obter mais detalhes, consulte a <a href=\"https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)\" target=\"_blank\">Wikipedia</a>.",
+        "sogo_visible": "O alias é visível no SoGo",
+        "sogo_visible_n": "Ocultar alias no SoGo",
+        "sogo_visible_y": "Mostrar alias no SoGo",
+        "spam_aliases": "Apelido temporário",
+        "stats": "Estatísticas",
+        "status": "Status",
+        "sync_jobs": "Trabalhos de sincronização",
+        "syncjob_check_log": "Registro de verificação",
+        "syncjob_last_run_result": "Resultado da última corrida",
+        "syncjob_EX_OK": "Sucesso",
+        "syncjob_EXIT_CONNECTION_FAILURE": "Problema de conexão",
+        "syncjob_EXIT_TLS_FAILURE": "Problema com conexão criptografada",
+        "syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema de autenticação",
+        "syncjob_EXIT_OVERQUOTA": "A caixa de correio de destino está acima da cota",
+        "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Não é possível se conectar ao servidor remoto",
+        "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nome de usuário ou senha incorretos",
+        "table_size": "Tamanho da mesa",
+        "table_size_show_n": "Mostrar itens %s",
+        "target_address": "Vá para o endereço",
+        "target_domain": "Domínio de destino",
+        "templates": "Modelos",
+        "template": "Modelo",
+        "tls_enforce_in": "Imponha a entrada de TLS",
+        "tls_enforce_out": "Imponha a saída TLS",
+        "tls_map_dest": "Destino",
+        "tls_map_dest_info": "Exemplos: example.org, .example.org, [mail.example.org] :25",
+        "tls_map_parameters": "Parâmetros",
+        "tls_map_parameters_info": "Vazio ou parâmetros, por exemplo: protocols=! Cifras SSLv2 = média, exclusão = 3DES",
+        "tls_map_policy": "Política",
+        "tls_policy_maps": "Mapas de políticas de TLS",
+        "tls_policy_maps_enforced_tls": "Essas políticas também substituirão o comportamento dos usuários de caixas de correio que impõem conexões TLS de saída. <code>Se nenhuma política existir abaixo, esses usuários aplicarão os valores padrão especificados como <code>smtp_tls_mandatory_protocols e smtp_tls_mandatory_ciphers</code>.</code>",
+        "tls_policy_maps_info": "Esse mapa de políticas substitui as regras de transporte TLS de saída, independentemente das configurações de política de TLS do usuário. <br>\r\n Consulte <a href=\"http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps\" target=\"_blank\">a documentação do “smtp_tls_policy_maps” para obter mais informações</a>.",
+        "tls_policy_maps_long": "Substituições do mapa de políticas de TLS de saída",
+        "toggle_all": "Alternar tudo",
+        "username": "Nome de usuário",
+        "waiting": "Esperando",
+        "weekly": "Semanalmente",
+        "yes": "✓"
+    },
+    "oauth2": {
+        "access_denied": "Faça login como proprietário da caixa de correio para conceder acesso via OAuth2.",
+        "authorize_app": "Autorizar aplicativo",
+        "deny": "Negar",
+        "permit": "Autorizar aplicativo",
+        "profile": "Perfil",
+        "profile_desc": "Exibir informações pessoais: nome de usuário, nome completo, criado, modificado, ativo",
+        "scope_ask_permission": "Um aplicativo solicitou as seguintes permissões"
+    },
+    "quarantine": {
+        "action": "Ação",
+        "atts": "Anexos",
+        "check_hash": "Arquivo de pesquisa hash @ VT",
+        "confirm": "Confirme",
+        "confirm_delete": "Confirme a exclusão desse elemento.",
+        "danger": "Perigo",
+        "deliver_inbox": "Entregar na caixa de entrada",
+        "disabled_by_config": "A configuração atual do sistema desativa a funcionalidade de quarentena. Defina “retenções por caixa de correio” e um “tamanho máximo” para os elementos de quarentena.",
+        "download_eml": "Baixar (.eml)",
+        "empty": "Sem resultados",
+        "high_danger": "Alto",
+        "info": "Informações",
+        "junk_folder": "Pasta de lixo eletrônico",
+        "learn_spam_delete": "Aprenda como spam e exclua",
+        "low_danger": "Baixo",
+        "medium_danger": "Médio",
+        "neutral_danger": "Neutro",
+        "notified": "Notificado",
+        "qhandler_success": "Solicitação enviada com sucesso para o sistema. Agora você pode fechar a janela.",
+        "qid": "Respand AID",
+        "qinfo": "O sistema de quarentena salvará as mensagens rejeitadas no banco de dados (o remetente <em>não</em> terá a impressão de uma mensagem entregue), bem como as mensagens, que são entregues como cópia na pasta Lixo eletrônico de uma caixa de correio.\r\n <br>“Aprenda como spam e exclua” aprenderá uma mensagem como spam por meio do teorema bayesiano e também calculará hashes difusos para negar mensagens semelhantes no futuro.\r\n <br>Esteja ciente de que aprender várias mensagens pode ser demorado, dependendo do seu sistema. <br>Os elementos da lista negra são excluídos da quarentena.",
+        "qitem": "Item de quarentena",
+        "quarantine": "Quarentena",
+        "quick_actions": "Ações",
+        "quick_delete_link": "Abrir link de exclusão rápida",
+        "quick_info_link": "Abrir link de informações",
+        "quick_release_link": "Abrir link de lançamento rápido",
+        "rcpt": "Destinatário",
+        "received": "Recebido",
+        "recipients": "Destinatários",
+        "refresh": "Atualizar",
+        "rejected": "Rejeitado",
+        "release": "Lançamento",
+        "release_body": "Anexamos sua mensagem como arquivo eml a esta mensagem.",
+        "release_subject": "Item de quarentena potencialmente prejudicial %s",
+        "remove": "Remover",
+        "rewrite_subject": "Reescrever assunto",
+        "rspamd_result": "Resultado do Rspamd",
+        "sender": "Remetente (SMTP)",
+        "sender_header": "Remetente (cabeçalho “De”)",
+        "settings_info": "Quantidade máxima de elementos a serem colocados em quarentena: %s <br>Tamanho máximo do e-mail: %s MiB",
+        "show_item": "Mostrar item",
+        "spam": "Spam",
+        "spam_score": "Ponto",
+        "subj": "Assunto",
+        "table_size": "Tamanho da mesa",
+        "table_size_show_n": "Mostrar itens %s",
+        "text_from_html_content": "Conteúdo (html convertido)",
+        "text_plain_content": "Conteúdo (texto/simples)",
+        "toggle_all": "Alternar tudo",
+        "type": "Tipo"
+    },
+    "queue": {
+        "delete": "Excluir tudo",
+        "flush": "Fila de descarga",
+        "info": "A fila de e-mails contém todos os e-mails que estão aguardando a entrega. Se um e-mail ficar preso na fila de e-mails por um longo tempo, ele será automaticamente excluído pelo sistema. <br>A mensagem de erro do respectivo e-mail fornece informações sobre o motivo pelo qual o e-mail não pôde ser entregue.",
+        "legend": "Funções de ações da fila de e-mails:",
+        "ays": "Confirme que você deseja excluir todos os itens da fila atual.",
+        "deliver_mail": "Entregar",
+        "deliver_mail_legend": "Tentativas de reenviar os e-mails selecionados.",
+        "hold_mail": "Espere",
+        "hold_mail_legend": "Guarda os e-mails selecionados. (Evita novas tentativas de entrega)",
+        "queue_manager": "Gerenciador de filas",
+        "show_message": "Mostrar mensagem",
+        "unban": "fila desbloqueada",
+        "unhold_mail": "Dessegurar",
+        "unhold_mail_legend": "Libera e-mails selecionados para entrega. (Requer retenção prévia)"
+    },
+    "ratelimit": {
+        "disabled": "Deficiente",
+        "second": "msgs//segundo",
+        "minute": "msgs//minuto",
+        "hour": "msgs//hora",
+        "day": "msgs//dia"
+    },
+    "start": {
+        "help": "Mostrar/ocultar painel de ajuda",
+        "imap_smtp_server_auth_info": "Use seu endereço de e-mail completo e o mecanismo de autenticação PLAIN. <br>\r\nSeus dados de login serão criptografados pela criptografia obrigatória do lado do servidor.",
+        "mailcow_apps_detail": "Use um aplicativo mailcow para acessar seus e-mails, calendário, contatos e muito mais.",
+        "mailcow_panel_detail": "<b>Os administradores de domínio</b> criam, modificam ou excluem caixas de correio e aliases, alteram domínios e leem mais informações sobre seus domínios atribuídos. <br>\r\n<b>Os usuários de caixas de correio</b> podem criar aliases com limite de tempo (aliases de spam), alterar suas configurações de senha e filtro de spam."
+    },
+    "success": {
+        "acl_saved": "ACL para o objeto %s salvo",
+        "admin_added": "O administrador %s foi adicionado",
+        "admin_api_modified": "As alterações na API foram salvas",
+        "admin_modified": "As alterações no administrador foram salvas",
+        "admin_removed": "O administrador %s foi removido",
+        "alias_added": "O endereço de alias %s (%d) foi adicionado",
+        "alias_domain_removed": "O domínio alias %s foi removido",
+        "alias_modified": "As alterações no endereço de alias %s foram salvas",
+        "alias_removed": "O alias %s foi removido",
+        "aliasd_added": "Domínio de alias %s adicionado",
+        "aliasd_modified": "As alterações no domínio alias %s foram salvas",
+        "app_links": "Alterações salvas nos links do aplicativo",
+        "app_passwd_added": "Foi adicionada uma nova senha de aplicativo",
+        "app_passwd_removed": "ID de senha do aplicativo %s removida",
+        "bcc_deleted": "Entradas do mapa BCC excluídas: %s",
+        "bcc_edited": "Entrada %s do mapa BCC editada",
+        "bcc_saved": "Entrada do mapa BCC salva",
+        "cors_headers_edited": "As configurações do CORS foram salvas",
+        "db_init_complete": "Inicialização do banco de dados concluída",
+        "delete_filter": "ID de filtros excluídos %s",
+        "delete_filters": "Filtros excluídos: %s",
+        "deleted_syncjob": "ID de trabalho de sincronização excluída %s",
+        "deleted_syncjobs": "Trabalhos de sincronização excluídos: %s",
+        "dkim_added": "A chave DKIM %s foi salva",
+        "domain_add_dkim_available": "Já existia uma chave DKIM",
+        "dkim_duplicated": "A chave DKIM para o domínio %s foi copiada para %s",
+        "dkim_removed": "A chave DKIM %s foi removida",
+        "domain_added": "Domínio adicionado %s",
+        "domain_admin_added": "O administrador de domínio %s foi adicionado",
+        "domain_admin_modified": "As alterações no administrador de domínio %s foram salvas",
+        "domain_admin_removed": "O administrador do domínio %s foi removido",
+        "domain_footer_modified": "As alterações no rodapé do domínio %s foram salvas",
+        "domain_modified": "As alterações no domínio %s foram salvas",
+        "domain_removed": "O domínio %s foi removido",
+        "dovecot_restart_success": "O Dovecot foi reiniciado com sucesso",
+        "eas_reset": "Os dispositivos ActiveSync para o usuário %s foram redefinidos",
+        "f2b_modified": "As alterações nos parâmetros do Fail2ban foram salvas",
+        "forwarding_host_added": "O host de encaminhamento %s foi adicionado",
+        "forwarding_host_removed": "O host de encaminhamento %s foi removido",
+        "global_filter_written": "O filtro foi gravado com sucesso no arquivo",
+        "hash_deleted": "Hash excluído",
+        "ip_check_opt_in_modified": "A verificação de IP foi salva com sucesso",
+        "item_deleted": "Item %s excluído com sucesso",
+        "item_released": "Item %s lançado",
+        "items_deleted": "Item %s excluído com sucesso",
+        "items_released": "Os itens selecionados foram lançados",
+        "learned_ham": "Identificação %s como ham aprendida com sucesso",
+        "license_modified": "As alterações na licença foram salvas",
+        "logged_in_as": "Conectado como %s",
+        "mailbox_added": "A caixa de correio %s foi adicionada",
+        "mailbox_modified": "As alterações na caixa de correio %s foram salvas",
+        "mailbox_removed": "A caixa de correio %s foi removida",
+        "nginx_reloaded": "O Nginx foi recarregado",
+        "object_modified": "As alterações no objeto %s foram salvas",
+        "password_policy_saved": "A política de senha foi salva com sucesso",
+        "pushover_settings_edited": "Configurações de Pushover definidas com sucesso. Verifique as credenciais.",
+        "qlearn_spam": "A ID da mensagem %s foi detectada como spam e excluída",
+        "queue_command_success": "Comando de fila concluído com sucesso",
+        "recipient_map_entry_deleted": "A ID do mapa do destinatário %s foi excluída",
+        "recipient_map_entry_saved": "A entrada “%s” do mapa do destinatário foi salva",
+        "relayhost_added": "A entrada de mapa %s foi adicionada",
+        "relayhost_removed": "A entrada de mapa %s foi removida",
+        "reset_main_logo": "Redefinir para o logotipo padrão",
+        "resource_added": "O recurso %s foi adicionado",
+        "resource_modified": "As alterações na caixa de correio %s foram salvas",
+        "resource_removed": "O recurso %s foi removido",
+        "rl_saved": "Limite de taxa para o objeto %s salvo",
+        "rspamd_ui_pw_set": "Senha do Rspamd UI definida com sucesso",
+        "saved_settings": "Configurações salvas",
+        "settings_map_added": "Entrada de mapa de configurações adicionada",
+        "settings_map_removed": "ID do mapa de configurações removida %s",
+        "sogo_profile_reset": "O perfil SoGo para o usuário %s foi redefinido",
+        "template_added": "Modelo adicionado %s",
+        "template_modified": "As alterações no modelo %s foram salvas",
+        "template_removed": "A ID do modelo %s foi excluída",
+        "tls_policy_map_entry_deleted": "O ID do mapa de política TLS %s foi excluído",
+        "tls_policy_map_entry_saved": "A entrada “%s” do mapa de políticas TLS foi salva",
+        "ui_texts": "Alterações salvas nos textos da interface do usuário",
+        "upload_success": "Arquivo carregado com sucesso",
+        "verified_fido2_login": "Login FIDO2 verificado",
+        "verified_totp_login": "Login TOTP verificado",
+        "verified_webauthn_login": "Login verificado do WebAuthn",
+        "verified_yotp_login": "Login OTP verificado do Yubico"
+    },
+    "tfa": {
+        "api_register": "%s usa a API Yubico Cloud. Obtenha uma chave de API para sua chave <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aqui</a>",
+        "confirm": "Confirme",
+        "confirm_totp_token": "Confirme suas alterações inserindo o token gerado",
+        "delete_tfa": "Desativar o TFA",
+        "disable_tfa": "Desative o TFA até o próximo login bem-sucedido",
+        "enter_qr_code": "Seu código TOTP, caso seu dispositivo não consiga escanear códigos QR",
+        "error_code": "Código de erro",
+        "init_webauthn": "Inicializando, aguarde...",
+        "key_id": "Um identificador para o seu dispositivo",
+        "key_id_totp": "Um identificador para sua chave",
+        "none": "Desativar",
+        "reload_retry": "- (recarregue o navegador se o erro persistir)",
+        "scan_qr_code": "Escaneie o código a seguir com seu aplicativo autenticador ou insira o código manualmente.",
+        "select": "Por favor, selecione",
+        "set_tfa": "Defina o método de autenticação de dois fatores",
+        "start_webauthn_validation": "Iniciar validação",
+        "tfa": "Autenticação de dois fatores",
+        "tfa_token_invalid": "Token TFA inválido",
+        "totp": "OTP baseado em tempo (Google Authenticator, Authy etc.)",
+        "u2f_deprecated": "Parece que sua chave foi registrada usando o método U2F obsoleto. Desativaremos a autenticação de dois fatores para você e excluiremos sua chave.",
+        "u2f_deprecated_important": "Registre sua chave no painel de administração com o novo método WebAuthn.",
+        "webauthn": "Autenticação WebAuthn",
+        "waiting_usb_auth": "<i>Aguardando o dispositivo USB...</i> <br><br>Toque no botão no seu dispositivo USB agora.",
+        "waiting_usb_register": "<i>Aguardando o dispositivo USB...</i> <br><br>Digite sua senha acima e confirme seu registro tocando no botão no seu dispositivo USB.",
+        "yubi_otp": "Autenticação Yubico OTP"
+    },
+    "user": {
+        "action": "Ação",
+        "active": "Ativo",
+        "active_sieve": "Filtro ativo",
+        "advanced_settings": "Configurações avançadas",
+        "alias": "Pseudônimo",
+        "alias_create_random": "Gere um alias aleatório",
+        "alias_extend_all": "Estenda os aliases em 1 hora",
+        "alias_full_date": "D.M.Y., H: S T",
+        "alias_remove_all": "Remover todos os aliases",
+        "alias_select_validity": "Período de validade",
+        "alias_time_left": "Tempo restante",
+        "alias_valid_until": "Válido até",
+        "aliases_also_send_as": "Também é permitido enviar como usuário",
+        "aliases_send_as_all": "Não verifique o acesso do remetente aos seguintes domínios e seus domínios de alias",
+        "app_hint": "As senhas de aplicativos são senhas alternativas para seu login IMAP, SMTP, CalDAV, CardDAV e EAS. O nome de usuário permanece inalterado. O webmail do SoGo não está disponível por meio de senhas de aplicativos.",
+        "allowed_protocols": "Protocolos permitidos",
+        "app_name": "Nome do aplicativo",
+        "app_passwds": "Senhas de aplicativos",
+        "apple_connection_profile": "Perfil de conexão da Apple",
+        "apple_connection_profile_complete": "Esse perfil de conexão inclui parâmetros IMAP e SMTP, bem como caminhos CalDAV (calendários) e CardDAV (contatos) para um dispositivo Apple.",
+        "apple_connection_profile_mailonly": "Esse perfil de conexão inclui parâmetros de configuração IMAP e SMTP para um dispositivo Apple.",
+        "apple_connection_profile_with_app_password": "Uma nova senha de aplicativo é gerada e adicionada ao perfil para que nenhuma senha precise ser inserida ao configurar seu dispositivo. Não compartilhe o arquivo, pois ele concede acesso total à sua caixa de correio.",
+        "change_password": "Alterar senha",
+        "change_password_hint_app_passwords": "Sua conta tem %d senhas de aplicativos que não serão alteradas. Para gerenciá-las, acesse a guia Senhas do aplicativo.",
+        "clear_recent_successful_connections": "Conexões bem-sucedidas e claras",
+        "client_configuration": "Mostrar guias de configuração para clientes de e-mail e smartphones",
+        "create_app_passwd": "Crie a senha do aplicativo",
+        "create_syncjob": "Criar um novo trabalho de sincronização",
+        "created_on": "Criado em",
+        "daily": "Diariamente",
+        "day": "dia",
+        "delete_ays": "Confirme o processo de exclusão.",
+        "direct_aliases": "Endereços de alias diretos",
+        "direct_aliases_desc": "Os endereços de alias diretos são afetados pelo filtro de spam e pelas configurações da política TLS.",
+        "direct_protocol_access": "Esse usuário da caixa de correio tem <b>acesso externo direto</b> aos seguintes protocolos e aplicativos. Essa configuração é controlada pelo administrador. As senhas de aplicativos podem ser criadas para conceder acesso a protocolos e aplicativos individuais. <br>O botão “Login no webmail” fornece login único no SoGo e está sempre disponível.",
+        "eas_reset": "Redefinir o cache do dispositivo ActiveSync",
+        "eas_reset_help": "Em muitos casos, uma redefinição do cache do dispositivo ajudará a recuperar um perfil quebrado do ActiveSync. <br><b>Atenção:</b> Todos os elementos serão baixados novamente!",
+        "eas_reset_now": "Reinicie agora",
+        "edit": "Editar",
+        "email": "E-mail",
+        "email_and_dav": "E-mail, calendários e contatos",
+        "empty": "Sem resultados",
+        "encryption": "Criptografia",
+        "excludes": "Exclui",
+        "expire_in": "Expirar em",
+        "fido2_webauthn": "FIDO2/WebAuthn",
+        "force_pw_update": "Você <b>deve</b> definir uma nova senha para poder acessar os serviços relacionados ao groupware.",
+        "from": "desde",
+        "generate": "geram",
+        "hour": "hora",
+        "hourly": "A cada hora",
+        "hours": "horas",
+        "in_use": "Usado",
+        "interval": "Intervalo",
+        "is_catch_all": "Abrangente para domínio/s",
+        "last_mail_login": "Último login por e-mail",
+        "last_pw_change": "Última alteração de senha",
+        "last_run": "Última corrida",
+        "last_ui_login": "Último login na interface do usuário",
+        "loading": "Carregando...",
+        "login_history": "Histórico de login",
+        "mailbox": "Caixa de correio",
+        "mailbox_details": "Detalhes",
+        "mailbox_general": "Geral",
+        "mailbox_settings": "Configurações",
+        "messages": "mensagens",
+        "month": "mês",
+        "months": "meses",
+        "never": "Nunca",
+        "new_password": "Nova senha",
+        "new_password_repeat": "Senha de confirmação (repetição)",
+        "no_active_filter": "Nenhum filtro ativo disponível",
+        "no_last_login": "Nenhuma informação de login da última interface",
+        "no_record": "Sem registro",
+        "open_logs": "Registros abertos",
+        "open_webmail_sso": "Faça login no webmail",
+        "password": "Senha",
+        "password_now": "Senha atual (confirme as alterações)",
+        "password_repeat": "Senha (repetição)",
+        "pushover_evaluate_x_prio": "Escale e-mails de alta prioridade [<code>X-Priority:</code> 1]",
+        "pushover_info": "As configurações de notificação push serão aplicadas a todos os e-mails limpos (sem spam) entregues a <b>%s</b>, incluindo aliases (compartilhados, não compartilhados, marcados).",
+        "pushover_only_x_prio": "Considere somente e-mails de alta prioridade [<code>X-Priority: 1</code>]",
+        "pushover_sender_array": "Considere os seguintes endereços de e-mail do remetente <small>(separados por vírgula)</small>",
+        "pushover_sender_regex": "Combine os remetentes pelo seguinte regex",
+        "pushover_text": "Texto de notificação",
+        "pushover_title": "Título da notificação",
+        "pushover_sound": "Som",
+        "pushover_vars": "Quando nenhum filtro de remetente for definido, todos os e-mails serão considerados. <br>Os filtros Regex, bem como as verificações exatas do remetente, podem ser definidos individualmente e serão considerados sequencialmente. Eles não dependem um do outro. <br>Variáveis utilizáveis para texto e título (observe as políticas de proteção de dados)",
+        "pushover_verify": "Verifique as credenciais",
+        "q_add_header": "Pasta de lixo eletrônico",
+        "q_all": "Todas as categorias",
+        "q_reject": "Rejeitado",
+        "quarantine_category": "Categoria de notificação de quarentena",
+        "quarantine_category_info": "A categoria de notificação “Rejeitado” inclui e-mails que foram rejeitados, enquanto “Pasta de lixo eletrônico” notificará o usuário sobre e-mails que foram colocados na pasta de lixo eletrônico.",
+        "quarantine_notification": "Notificações de quarentena",
+        "quarantine_notification_info": "Depois que uma notificação for enviada, os itens serão marcados como “notificados” e nenhuma outra notificação será enviada para esse item específico.",
+        "recent_successful_connections": "Conexões bem-sucedidas vistas",
+        "remove": "Remover",
+        "running": "Executando",
+        "save": "Salvar alterações",
+        "save_changes": "Salvar alterações",
+        "sender_acl_disabled": "<span class=\"badge fs-6 bg-danger\">A verificação do remetente está desativada</span>",
+        "shared_aliases": "Endereços de alias compartilhados",
+        "shared_aliases_desc": "Os aliases compartilhados não são afetados pelas configurações específicas do usuário, como o filtro de spam ou a política de criptografia. Os filtros de spam correspondentes só podem ser criados por um administrador como uma política de todo o domínio.",
+        "show_sieve_filters": "Mostrar filtro de filtragem de usuário ativo",
+        "sogo_profile_reset": "Redefinir perfil SoGo",
+        "sogo_profile_reset_help": "Isso destruirá o perfil SoGo de um usuário e <b>excluirá todos os dados de contato e calendário irrecuperáveis</b>.",
+        "sogo_profile_reset_now": "Redefina o perfil agora",
+        "spam_aliases": "Aliases de e-mail temporários",
+        "spam_score_reset": "Redefinir para o padrão do servidor",
+        "spamfilter": "Filtro de spam",
+        "spamfilter_behavior": "Avaliação",
+        "spamfilter_bl": "Lista negra",
+        "spamfilter_bl_desc": "Endereços de e-mail na lista negra para <b>sempre</b> serem classificados como spam e rejeitados. E-mails rejeitados <b>não</b> serão copiados para a quarentena. Podem ser usados curingas. Um filtro só é aplicado a aliases diretos (aliases com uma única caixa de correio de destino), excluindo aliases abrangentes e a própria caixa de correio.",
+        "spamfilter_default_score": "Valores padrão",
+        "spamfilter_green": "Verde: esta mensagem não é spam",
+        "spamfilter_hint": "O primeiro valor descreve a “pontuação baixa de spam”, o segundo representa a “alta pontuação de spam”.",
+        "spamfilter_red": "Vermelho: Esta mensagem é spam e será rejeitada pelo servidor",
+        "spamfilter_table_action": "Ação",
+        "spamfilter_table_add": "Adicionar item",
+        "spamfilter_table_domain_policy": "n/a (política de domínio)",
+        "spamfilter_table_empty": "Não há dados para exibir",
+        "spamfilter_table_remove": "remover",
+        "spamfilter_table_rule": "Regra",
+        "spamfilter_wl": "Lista branca",
+        "spamfilter_wl_desc": "Os endereços de e-mail incluídos na lista branca são programados para <b>nunca</b> serem classificados como spam. Podem ser usados curingas. Um filtro só é aplicado a aliases diretos (aliases com uma única caixa de correio de destino), excluindo aliases abrangentes e a própria caixa de correio.",
+        "spamfilter_yellow": "Amarelo: esta mensagem pode ser spam, será marcada como spam e movida para sua pasta de lixo eletrônico",
+        "status": "Status",
+        "sync_jobs": "Trabalhos de sincronização",
+        "syncjob_check_log": "Registro de verificação",
+        "syncjob_last_run_result": "Resultado da última corrida",
+        "syncjob_EX_OK": "Sucesso",
+        "syncjob_EXIT_CONNECTION_FAILURE": "Problema de conexão",
+        "syncjob_EXIT_TLS_FAILURE": "Problema com conexão criptografada",
+        "syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema de autenticação",
+        "syncjob_EXIT_OVERQUOTA": "A caixa de correio de destino está acima da cota",
+        "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Não é possível se conectar ao servidor remoto",
+        "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nome de usuário ou senha incorretos",
+        "tag_handling": "Definir o tratamento para e-mails marcados",
+        "tag_help_example": "Exemplo de um endereço de e-mail marcado: me <b>+Facebook</b> @example .org",
+        "tag_help_explain": "Na subpasta: uma nova subpasta com o nome da tag será criada abaixo da CAIXA DE ENTRADA (“Caixa de entrada/Facebook”). <br>\r\nNo assunto: o nome das tags será anexado ao assunto do e-mail, por exemplo: “[Facebook] Minhas notícias”.",
+        "tag_in_none": "Não faça nada",
+        "tag_in_subfolder": "Na subpasta",
+        "tag_in_subject": "No assunto",
+        "text": "Texto",
+        "title": "Título",
+        "tls_enforce_in": "Imponha a entrada de TLS",
+        "tls_enforce_out": "Imponha a saída TLS",
+        "tls_policy": "Política de criptografia",
+        "tls_policy_warning": "<strong>Aviso:</strong> Se você decidir impor a transferência de e-mail criptografada, poderá perder e-mails. <br>As mensagens que não satisfizerem a política serão devolvidas com uma falha grave pelo sistema de correio. <br>Essa opção se aplica ao seu endereço de e-mail principal (nome de login), a todos os endereços derivados de domínios de alias, bem como aos endereços de alias <b>com apenas essa única caixa de correio</b> como destino.",
+        "user_settings": "Configurações do usuário",
+        "username": "Nome de usuário",
+        "verify": "Verificar",
+        "waiting": "Esperando",
+        "week": "semana",
+        "weekly": "Semanalmente",
+        "weeks": "semanas",
+        "with_app_password": "com senha do aplicativo",
+        "year": "ano",
+        "years": "anos",
+        "attribute": "Atributo",
+        "value": "Valor"
+    },
+    "warning": {
+        "cannot_delete_self": "Não é possível excluir o usuário conectado",
+        "domain_added_sogo_failed": "Domínio adicionado, mas falha ao reiniciar o SoGo. Verifique os registros do servidor.",
+        "dovecot_restart_failed": "Falha ao reiniciar o Dovecot, verifique os registros",
+        "fuzzy_learn_error": "Erro de aprendizado de hash difuso: %s",
+        "hash_not_found": "Hash não encontrado ou já foi excluído",
+        "ip_invalid": "IP inválido ignorado: %s",
+        "is_not_primary_alias": "Alias não primário ignorado %s",
+        "no_active_admin": "Não é possível desativar o último administrador ativo",
+        "quota_exceeded_scope": "Cota de domínio excedida: somente caixas de correio ilimitadas podem ser criadas nesse escopo de domínio.",
+        "session_token": "Token de formulário inválido: incompatibilidade de token",
+        "session_ua": "Token de formulário inválido: erro de validação do agente de usuário"
+    }
+}

+ 10 - 4
data/web/lang/lang.ru-ru.json

@@ -107,7 +107,8 @@
         "validate": "Проверить",
         "validation_success": "Проверка прошла успешно",
         "tags": "Теги",
-        "app_passwd_protocols": "Разрешенные протоколы для пароля приложения"
+        "app_passwd_protocols": "Разрешенные протоколы для пароля приложения",
+        "dry": "Имитировать синхронизацию"
     },
     "admin": {
         "access": "Настройки доступа",
@@ -625,11 +626,14 @@
             "auth_user": "{= auth_user =} - Аутентифицированное имя пользователя, указанное MTA",
             "from_user": "{= from_user =} - Из пользовательской части envelope, например, для \"moo@mailcow.tld\" возвращается \"moo\"",
             "from_addr": "{= from_addr =} - Из адресной части envelope",
-            "from_domain": "{= from_domain =} - из доменной части envelope"
+            "from_domain": "{= from_domain =} - из доменной части envelope",
+            "custom": "{= foo =}         - Если почтовый ящик имеет пользовательский атрибут \"foo\" со значением \"bar\", он возвращает \"bar\"."
         },
         "domain_footer": "Нижний колонтитул домена",
         "domain_footer_html": "HTML нижний колонтитул",
-        "domain_footer_plain": "ПРОСТОЙ нижний колонтитул"
+        "domain_footer_plain": "ПРОСТОЙ нижний колонтитул",
+        "mbox_exclude": "Исключить почтовые ящики",
+        "custom_attributes": "Пользовательские атрибуты"
     },
     "fido2": {
         "confirm": "Подтвердить",
@@ -1198,7 +1202,9 @@
         "apple_connection_profile_with_app_password": "Новый пароль приложения генерируется и добавляется в профиль, поэтому при настройке устройства не требуется вводить пароль. Не предоставляйте доступ к файлу, поскольку он предоставляет полный доступ к вашему почтовому ящику.",
         "direct_protocol_access": "Этот пользователь почтового ящика имеет <b>прямой, внешний доступ</b> к следующим протоколам и приложениям. Эта настройка контролируется вашим администратором. Для предоставления доступа к отдельным протоколам и приложениям могут быть созданы пароли приложений.<br> Кнопка \"Вход в веб-почту\" обеспечивает единый вход в SOGo и всегда доступна.",
         "with_app_password": "с паролем приложения",
-        "change_password_hint_app_passwords": "В вашей учетной записи есть {{number_of_app_passwords}} паролей приложений, которые не будут изменены. Чтобы управлять ими, перейдите на вкладку \"Пароли приложений\"."
+        "change_password_hint_app_passwords": "В вашей учетной записи есть {{number_of_app_passwords}} паролей приложений, которые не будут изменены. Чтобы управлять ими, перейдите на вкладку \"Пароли приложений\".",
+        "attribute": "Атрибут",
+        "value": "Значение"
     },
     "warning": {
         "cannot_delete_self": "Вы не можете удалить сами себя",

+ 162 - 2
data/web/lang/lang.si-si.json

@@ -342,7 +342,12 @@
         "username": "Uporabniško ime",
         "validate_license_now": "Potrdi GUID z licenčnim strežnikom",
         "verify": "Preveri",
-        "yes": "&#10003;"
+        "yes": "&#10003;",
+        "logo_normal_label": "Navadno",
+        "logo_dark_label": "Za temni način",
+        "cors_settings": "Nastavitve CORS",
+        "allowed_methods": "Dovoljene metode za upravljanje dostopa",
+        "allowed_origins": "Upravljanje-dostopa-Dovoljeni-Viri"
     },
     "danger": {
         "alias_goto_identical": "Alias in goto naslov morata biti identična",
@@ -391,6 +396,161 @@
         "invalid_destination": "Ciljna oblika \"%s\" ni veljavna",
         "invalid_filter_type": "Neveljavna vrsta filtra",
         "invalid_host": "Naveden je neveljaven gostitelj (host): %s",
-        "invalid_mime_type": "Neveljaven mime type"
+        "invalid_mime_type": "Neveljaven mime type",
+        "max_quota_in_use": "Kvota poštnega predala mora biti večja ali enaka %d MB",
+        "password_complexity": "Geslo ne ustreza varnostni politiki",
+        "pushover_credentials_missing": "Manjka Pushover token ali ključ",
+        "release_send_failed": "Sporočila ni bilo mogoče sprostiti: %s",
+        "tls_policy_map_dest_invalid": "Cilj politike ni veljaven",
+        "webauthn_authenticator_failed": "Izbrani avtentikator ni bil najden",
+        "reset_f2b_regex": "Regex filter ni bilo možno ponastaviti v ustreznem času. Prosim poskusite ponovno ali počakajte nekaj sekund in ponovno naložite stran.",
+        "target_domain_invalid": "Ciljna domena %s ni veljavna",
+        "validity_missing": "Prosim nastavite obdobje veljavnosti",
+        "invalid_recipient_map_old": "Naveden neveljaven izvirni prejemnik: %s",
+        "ip_list_empty": "Seznam dovoljenih IPjev ne sme biti prazen",
+        "is_alias": "%s je že znan kot alias naslov",
+        "is_alias_or_mailbox": "%s je že znan kot alias, poštni naslov, ali alias izveden iz alias domene",
+        "is_spam_alias": "%s že obstaja kot začasen alias (spam alias naslov)",
+        "last_key": "Zadnji ključ ne more biti izbrisan, prosim raje deaktivirajte dvofaktorsko avtentikacijo (TFA)",
+        "login_failed": "Prijava ni uspela",
+        "mailbox_defquota_exceeds_mailbox_maxquota": "Privzeta kvota presega najvišjo omejitev",
+        "mailbox_invalid": "Ime poštnega predala ni veljavno",
+        "mailbox_quota_exceeded": "Kvota presega omejitev domene (maksimalno %d MB)",
+        "mailbox_quota_exceeds_domain_quota": "Najvišja kvota presega omejitev domene",
+        "mailbox_quota_left_exceeded": "Ni dovolj prostora (preostali prostor: %d MB)",
+        "mailboxes_in_use": "Največje število poštnih predalov mora biti večje ali enako %d",
+        "malformed_username": "Nepravilno oblikovano uporabniško ime",
+        "map_content_empty": "Preslikava vsebine ne more biti prazna",
+        "max_alias_exceeded": "Preseženo največje število aliasov",
+        "max_mailbox_exceeded": "Preseženo največje število poštnih predalov (%d od %d)",
+        "maxquota_empty": "Največja kvota na poštni predal ne more biti 0",
+        "mysql_error": "Napaka MySQL: %s",
+        "network_host_invalid": "Nepravilno omrežje ali gostitel: %s",
+        "next_hop_interferes": "% moti naslednji skok %s",
+        "next_hop_interferes_any": "Obstoječi naslednji skok moti %s",
+        "nginx_reload_failed": "Ponovni zagon Nginx ni uspel: %s",
+        "no_user_defined": "Uporabnik ni določen",
+        "object_exists": "Objekt %s že obstaja",
+        "object_is_not_numeric": "Vrednost %s ni numerična",
+        "password_empty": "Geslo ne sme biti prazno",
+        "password_mismatch": "Potrditev gesla se ne ujema z geslom",
+        "policy_list_from_exists": "Zapis z tem imenom že obstaja",
+        "policy_list_from_invalid": "Zapis ima nepravilno obliko",
+        "private_key_error": "Napaka zasebnega ključa: %s",
+        "pushover_key": "Pushover ključ ni v pravilni obliki",
+        "pushover_token": "Pushover token ni v pravilni obliki",
+        "quota_not_0_not_numeric": "Quota mora biti število in večje ali enako 0",
+        "recipient_map_entry_exists": "Preslikava prejemnika \"%s\" že obstaja",
+        "redis_error": "Napaka Redis: %s",
+        "relayhost_invalid": "Vnos preslikave %s ni pravilen",
+        "resource_invalid": "Ime vira je neveljavno",
+        "rl_timeframe": "Časovni okvir za rate limit je nepravilen",
+        "rspamd_ui_pw_length": "Rspamd UI geslo mora biti dolgo vsaj 6 znakov",
+        "script_empty": "Script ne more biti prazen",
+        "sender_acl_invalid": "Vrednost ACL pošiljatelja %s ni veljavna",
+        "set_acl_failed": "Ni uspelo nastaviti ACL",
+        "settings_map_invalid": "ID preslikave nastavitev %s ni veljaven",
+        "sieve_error": "Napaka Sieve parserja: %s",
+        "spam_learn_error": "Napaka pri učenju spama: %s",
+        "subject_empty": "Predmet ne sme biti prazno",
+        "targetd_not_found": "Ciljna domena %s ni bila najdena",
+        "targetd_relay_domain": "Ciljna domena %s je relay domena",
+        "template_exists": "Predloga %s že obstaja",
+        "template_id_invalid": "ID predloge %s ni veljaven",
+        "template_name_invalid": "Ime predloge ni veljavno",
+        "text_empty": "Besedilo ne sme biti prazno",
+        "tfa_token_invalid": "Neveljaven token TFA",
+        "tls_policy_map_entry_exists": "Vpis preslikave TLS \"%s\" že obstaja",
+        "tls_policy_map_parameter_invalid": "Parameter politike ni pravilen",
+        "totp_verification_failed": "Neuspešno preverjanje TOTP",
+        "transport_dest_exists": "Cilj transporta \"%s\" že obstaja",
+        "webauthn_verification_failed": "Preverjanje WebAuthn ni uspelo: %s",
+        "webauthn_publickey_failed": "Na izbranem avtentikatorju ni shranjenega javnega ključa",
+        "webauthn_username_failed": "Izbrani avtentikator pripada drugemu uporabniškemu računu",
+        "unknown": "Pojavila se je neznana napaka",
+        "unknown_tfa_method": "Neznana metoda TFA",
+        "unlimited_quota_acl": "Neomejena kvota je prepovedana z ACL",
+        "username_invalid": "Uporabniško ime %s ne more biti uporabljeno",
+        "value_missing": "Prosim vnesite vse vrednosti",
+        "yotp_verification_failed": "Preverjanje Yubico OTP ni uspelo: %s",
+        "temp_error": "Začasna napaka",
+        "cors_invalid_method": "Navedena neveljavna Allow metoda",
+        "cors_invalid_origin": "Naveden neveljaven Allow-Origin",
+        "invalid_recipient_map_new": "Naveden neveljaven nov prejemnik: %s"
+    },
+    "debug": {
+        "containers_info": "Informacije o vsebniku (containerju)",
+        "architecture": "Arhitektura",
+        "chart_this_server": "Diagram (ta strežnik)",
+        "container_running": "Aktiven",
+        "container_disabled": "Ustavljen ali onemogočen",
+        "container_stopped": "Ustavljen",
+        "cores": "Jedra",
+        "current_time": "Sistemski čas",
+        "disk_usage": "Zasedenost diska",
+        "docs": "Dokumenti",
+        "error_show_ip": "Ni mogoče preveriti javnega IP naslova",
+        "external_logs": "Zunanji dnevniki",
+        "last_modified": "Nazadnje spremenjeno",
+        "history_all_servers": "Zgodovina (vsi strežniki)",
+        "in_memory_logs": "In-memory dnevniki",
+        "jvm_memory_solr": "JVM zasedenost spomina",
+        "service": "Servis",
+        "show_ip": "Prikaži javni IP",
+        "size": "Velikost",
+        "solr_dead": "Solr se zaganja, je onemogočen ali se je ustavil.",
+        "solr_status": "Status Solr",
+        "started_at": "Zagnano ob",
+        "started_on": "Zagnano na",
+        "static_logs": "Statični dnevniki",
+        "success": "Uspešno",
+        "system_containers": "Sistem in Containerji",
+        "timezone": "Časovni pas",
+        "uptime": "Čas delovanja",
+        "update_available": "Posodobitev je na voljo",
+        "no_update_available": "Sistem je na najnovejši verziji",
+        "update_failed": "Ni mogoče preveriti za posodobitve",
+        "username": "Uporabniško ime",
+        "wip": "Trenutno v delu"
+    },
+    "datatables": {
+        "infoFiltered": "(filtrirano od _MAX_ skupaj zapisov)",
+        "collapse_all": "Strni vse",
+        "decimal": ",",
+        "emptyTable": "Ni podatkov",
+        "expand_all": "Razširi vse",
+        "info": "Prikazano _START_ do _END_ od _TOTAL_ zapisov",
+        "infoEmpty": "Prikazano 0 do 0 od 0 zapisov",
+        "thousands": ".",
+        "lengthMenu": "Prikaži _MENU_ zapise",
+        "loadingRecords": "Nalaganje...",
+        "processing": "Prosim počakajte...",
+        "search": "Iskanje:",
+        "zeroRecords": "Ni ujemajočih zapisov",
+        "paginate": {
+            "first": "Prva",
+            "last": "Zadnja",
+            "previous": "Prejšnja",
+            "next": "Naslednja"
+        },
+        "aria": {
+            "sortAscending": ": aktivirajte za razvrstitev stolpca naraščajoče",
+            "sortDescending": ": aktivirajte za razvrstitev stolpca padajoče"
+        }
+    },
+    "diagnostics": {
+        "cname_from_a": "Vrednost pridobljena iz A/AAAA zapisa. To je podprto, če zapis kaže na pravilen resurs.",
+        "dns_records": "DNS zapisi",
+        "dns_records_24hours": "Prosim upoštevajte, da lahko traja do 24 ur da se spremembe v DNS pravilno prikažejo na tej strani. Namen je da lahko enostavno vidite, kako konfigurirati svoje DNS zapise in preverite ali so vaši zapisi pravilno shranjeni v DNS.",
+        "dns_records_data": "Pravilni podatki",
+        "dns_records_docs": "Prosim preverite tudi <a target=\"_blank\" href=\"https://docs.mailcow.email/prerequisite/prerequisite-dns/\">dokumentacijo</a>.",
+        "dns_records_name": "Ime",
+        "dns_records_status": "Trenutno stanje",
+        "dns_records_type": "Vrsta",
+        "optional": "Ta zapis je opcijski."
+    },
+    "edit": {
+        "acl": "ACL (Dovoljenje)",
+        "active": "Aktivno"
     }
 }

+ 57 - 19
data/web/lang/lang.sk-sk.json

@@ -41,7 +41,7 @@
         "alias_domain": "Alias doména",
         "alias_domain_info": "<small>Len platné mená domén (oddelené čiarkou).</small>",
         "app_name": "Meno aplikácie",
-        "app_passwd_protocols": "Povolené protokoly pre heslá aplikácií",
+        "app_passwd_protocols": "Povolené protokoly k heslu aplikácie",
         "app_password": "Pridať heslo aplikácie",
         "automap": "Skúsiť automaticky mapovať priečinky (\"Sent items\", \"Sent\" => \"Sent\" atd.)",
         "backup_mx_options": "Možnosti preposielania",
@@ -107,8 +107,8 @@
         "username": "Používateľské meno",
         "validate": "Overiť",
         "validation_success": "Úspešne overené",
-        "app_passwd_protocols": "Povolené protokoly k heslu aplikácie",
-        "tags": "Štítky"
+        "tags": "Štítky",
+        "dry": "Simulovať synchronizáciu"
     },
     "admin": {
         "access": "Prístup",
@@ -148,6 +148,8 @@
         "ays": "Naozaj chcete pokračovať?",
         "ban_list_info": "Zoznam zakázaných IP je zobrazený nižšie: <b>sieť (zostávajúci čas zákazu) - [akcia]</b>.<br />IP adresy zaradené na unban budú odstránené z aktívneho zoznamu v priebehu niekoľkých sekúnd.<br />Červené položky zobrazujú permanentné blokovanie.",
         "change_logo": "Zmeniť logo",
+        "logo_normal_label": "Normálne",
+        "logo_dark_label": "Inverzné pre tmavý režim",
         "configuration": "Konfigurácia",
         "convert_html_to_text": "Konvertovať HTML do obyčajného textu",
         "credentials_transport_warning": "<b>Upozornenie</b>: Pridaním ďalšieho záznamu do transportnej mapy bude mať za následok aktualizovanie údajov pre všetky záznamy so zhodným ďalším skokom.",
@@ -207,6 +209,9 @@
         "include_exclude": "Zahrnúť/Vylúčiť",
         "include_exclude_info": "Ak nič nevyberiete tak bude adresované <b>všetkým schránkam</b>",
         "includes": "Zahrnúť týchto príjemcov",
+        "ip_check": "Kontrola IP",
+        "ip_check_disabled": "Kontrola IP je vypnutá. Môžete ju zapnúť v ponuke<br> <strong>Systém > Konfigurácia > Možnosti > Prispôsobiť</strong>",
+        "ip_check_opt_in": "Prihlásiť sa k používaniu služby tretej strany <strong>ipv4.mailcow.email</strong> a <strong>ipv6.mailcow.email</strong> za účelom zistenia externých IP adries.",
         "is_mx_based": "Na základe MX",
         "last_applied": "Naposledy aplikované",
         "license_info": "Licencia nie je potrebná, ale napomáha ďalšiemu vývoju.<br><a href=\"https://www.servercow.de/mailcow?lang=en#sal\" target=\"_blank\" alt=\"SAL order\">Registrujte váš GUID tu</a> alebo <a href=\"https://www.servercow.de/mailcow?lang=en#support\" target=\"_blank\" alt=\"Objednávka podpory\">zakúpte si podporu pre vašu mailcow inštaláciu.</a>",
@@ -233,6 +238,7 @@
         "oauth2_renew_secret": "Vygenerovať nový tajný kľuč",
         "oauth2_revoke_tokens": "Odobrať všetky tokeny klienta",
         "optional": "voliteľné",
+        "options": "Možnosti",
         "password": "Heslo",
         "password_length": "Dĺžka hesla",
         "password_policy": "Politika hesiel",
@@ -440,6 +446,9 @@
         "target_domain_invalid": "Cieľová doména %s je neplatná",
         "targetd_not_found": "Cieľová doména %s sa nenašla",
         "targetd_relay_domain": "Cieľová doména %s je posielaná ďalej (relay)",
+        "template_exists": "Šablóna %s už existuje",
+        "template_id_invalid": "Šablóna ID %s je neplatná",
+        "template_name_invalid": "Názov šablóny je neplatný",
         "temp_error": "Dočasná chyba",
         "text_empty": "Text nemôže byť prázdny",
         "tfa_token_invalid": "Neplatný TFA token",
@@ -478,7 +487,9 @@
         },
         "emptyTable": "Nie sú k dispozícii žiadne dáta.",
         "decimal": ",",
-        "thousands": " "
+        "thousands": " ",
+        "collapse_all": "Zbaliť všetko",
+        "expand_all": "Rozbaliť všetko"
     },
     "debug": {
         "chart_this_server": "Graf (tento server)",
@@ -529,7 +540,7 @@
         "allowed_protocols": "Povolené protokoly",
         "app_name": "Meno aplikácie",
         "app_passwd": "Heslo aplikácie",
-        "app_passwd_protocols": "Povolené protokoly pre heslá aplikácií",
+        "app_passwd_protocols": "Povolené protokoly",
         "automap": "Skúsiť automapovať priečinky (\"Sent items\", \"Sent\" => \"Sent\" atd.)",
         "backup_mx_options": "Možnosti preposielania",
         "bcc_dest_format": "Cieľ kópie musí byť jedna platná emailová adresa. Pokiaľ potrebujete posielať kópie na viac adries, vytvorte Alias a použite ho tu.",
@@ -614,8 +625,8 @@
         "sieve_desc": "Krátky popis",
         "sieve_type": "Typ filtru",
         "skipcrossduplicates": "Preskočiť duplikované správy naprieč priečinkami (akceptuje sa prvý nález)",
-        "sogo_access": "Udeliť priamy prístup k prihláseniu do služby SOGo",
-        "sogo_access_info": "Jednotné prihlásenie (SSO) z mail UI zostáva funkčné. Toto nastavenie nemá vplyv na prístup k všetkým ostatným službám, ani neodstraňuje alebo nemení existujúci profil používateľa SOGo.",
+        "sogo_access": "Prideliť priame prihlásenie do SOGo",
+        "sogo_access_info": "Jednotné prihlásenie z používateľského mail rozhrania zostáva funkčné. Toto nastavenie nemá vplyv na prístup k ostatným službám, ani neodstraňuje alebo nemení existujúci profil používateľa SOGo.",
         "sogo_visible": "Alias je viditeľný v SOGo",
         "sogo_visible_info": "Táto voľba ovplyvňuje len objekty, ktoré dokážu byť zobrazené v SOGo (zdieľané alebo nezdieľané alias adresy ukazujúc na minimálne jednu lokálnu mailovú schránku). Ak je skrytý, alias nebude prezentovaný ako voliteľný odosielateľ v SOGo.",
         "spam_alias": "Vytvoriť alebo zmeniť časovo limitované alias adresy",
@@ -632,9 +643,17 @@
         "unchanged_if_empty": "Ak nemeníte, nechajte prázdne",
         "username": "Používateľské meno",
         "validate_save": "Validovať a uložiť",
-        "sogo_access": "Prideliť priame prihlásenie do SOGo",
-        "sogo_access_info": "Jednotné prihlásenie z používateľského mail rozhrania zostáva funkčné. Toto nastavenie nemá vplyv na prístup k ostatným službám, ani neodstraňuje alebo nemení existujúci profil používateľa SOGo.",
-        "app_passwd_protocols": "Povolené protokoly"
+        "domain_footer_info_vars": {
+            "from_addr": "{= from_addr =} - E-mailová adresa odosielateľa",
+            "from_domain": "{= from_domain =} - Doména odosielateľa",
+            "auth_user": "{= auth_user =} - Prihlasovacie meno odosielateľa",
+            "from_user": "{= from_user =}   - Používateľská časť e-mailovej adresy odosielateľa, napr. pre \"moo@mailcow.tld\" vráti \"moo\"",
+            "from_name": "{= from_name =}   - Meno odosielateľa, napr. pre \"Mailcow &lt;moo@mailcow.tld&gt;\" vráti \"Mailcow\""
+        },
+        "domain_footer": "Pätička pre celú doménu",
+        "domain_footer_html": "HTML text",
+        "domain_footer_info": "Pätička pre celú doménu sa pridáva do všetkých odchádzajúcich e-mailov spojených s adresou v rámci tejto domény. <br> Pre pätičku je možné použiť nasledujúce premenné:",
+        "domain_footer_plain": "Obyčajný text"
     },
     "fido2": {
         "confirm": "Potvrdiť",
@@ -671,6 +690,7 @@
         "apps": "Aplikácie",
         "debug": "Systémové informácie",
         "email": "E-Mail",
+        "mailcow_system": "Systém",
         "mailcow_config": "Konfigurácia",
         "quarantine": "Karanténa",
         "restart_netfilter": "Reštartovať netfilter",
@@ -706,6 +726,7 @@
         "add_mailbox": "Pridať mailovú schránku",
         "add_recipient_map_entry": "Pridať mapu príjemcu",
         "add_resource": "Pridať zdroj",
+        "add_template": "Pridať šablónu",
         "add_tls_policy_map": "Pridať TLS mapu pravidiel",
         "address_rewriting": "Prepisovanie adries",
         "alias": "Alias",
@@ -748,6 +769,7 @@
         "domain": "Doména",
         "domain_admins": "Administrátori domény",
         "domain_aliases": "Alias domény",
+        "domain_templates": "Šablóny domén",
         "domain_quota": "Kvóta",
         "domain_quota_total": "Celkové kvóta domény",
         "domains": "Domény",
@@ -776,6 +798,7 @@
         "mailbox_defaults": "Predvolené nastavenia",
         "mailbox_defaults_info": "Definuje predvolené nastavenia pre nové schránky.",
         "mailbox_defquota": "Predvolená veľkosť schránky",
+        "mailbox_templates": "Šablóny schránok",
         "mailbox_quota": "Max. veľkosť schránky",
         "mailboxes": "Mailové schránky",
         "max_aliases": "Max. počet aliasov",
@@ -843,6 +866,8 @@
         "table_size_show_n": "Zobraziť %s položiek",
         "target_address": "Cieľová adresa",
         "target_domain": "Cieľová doména",
+        "templates": "Šablóny",
+        "template": "Šablóna",
         "tls_enforce_in": "Vynútiť TLS pre prichádzajúcu poštu",
         "tls_enforce_out": "Vynútiť TLS pre odchádzajúcu poštu",
         "tls_map_dest": "Cieľ",
@@ -923,7 +948,19 @@
         "type": "Typ"
     },
     "queue": {
-        "queue_manager": "Správca fronty"
+        "queue_manager": "Správca fronty",
+        "delete": "Vymazať všetko",
+        "flush": "Vyprázdnit frontu",
+        "info": "Poštová fronta obsahuje všetky e-maily, ktoré čakajú na doručenie. Ak e-mail uviazne v poštovej fronte na dlhší čas, systém ho automaticky vymaže.<br>Chybové hlásenie príslušného e-mailu poskytuje informácie o tom, prečo sa e-mail nepodarilo doručiť.",
+        "legend": "Možnosti akcií nad poštovou frontou:",
+        "ays": "Potvrďte, že chcete naozaj odstrániť všetky položky z aktuálnej fronty.",
+        "deliver_mail": "Doručiť",
+        "deliver_mail_legend": "Pokus o opätovné doručenie vybraných e-mailov.",
+        "show_message": "Zobraziť správu",
+        "unhold_mail": "Uvoľniť",
+        "unhold_mail_legend": "Uvoľniť vybrané e-maily na doručenie. (Len v prípade predchádzajúceho podržania)",
+        "hold_mail": "Podržať",
+        "hold_mail_legend": "Podržať vybrané e-maily. (Zabráni ďalším pokusom o doručenie)"
     },
     "ratelimit": {
         "disabled": "Vypnuté",
@@ -1007,6 +1044,9 @@
         "settings_map_added": "Pridaná mapa nastavení",
         "settings_map_removed": "Odstránená mapa nastavení ID %s",
         "sogo_profile_reset": "SOGo profil pre používateľa %s resetovaný",
+        "template_added": "Pridaná šablóna %s",
+        "template_modified": "Zmeny šablóny %s boli uložené",
+        "template_removed": "Šablóna ID %s bola odstránená",
         "tls_policy_map_entry_deleted": "Položka mapy TLS pravidiel %s vymazaná",
         "tls_policy_map_entry_saved": "Položka mapy TLS pravidiel \"%s\" uložená",
         "ui_texts": "Zmeny v UI textoch uložené",
@@ -1014,7 +1054,8 @@
         "verified_fido2_login": "Overené FIDO2 prihlásenie",
         "verified_totp_login": "Overené TOTP prihlásenie",
         "verified_webauthn_login": "Overené WebAuthn prihlásenie",
-        "verified_yotp_login": "Overené Yubico OTP prihlásenie"
+        "verified_yotp_login": "Overené Yubico OTP prihlásenie",
+        "domain_footer_modified": "Zmeny v pätičke domény %s boli uložené"
     },
     "tfa": {
         "api_register": "%s využíva Yubico Cloud API. Prosím, zaobstarajte si API kľúč pre váš kľúč <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">tu</a>",
@@ -1065,9 +1106,9 @@
         "apple_connection_profile": "Apple konfiguračný profil",
         "apple_connection_profile_complete": "Tento profil zahŕňa IMAP a SMTP parametre, ako aj CalDAV (kalendáre) a CardDAV (kontakty) pre zariadenia Apple.",
         "apple_connection_profile_mailonly": "Tento profil zahŕňa IMAP a SMTP konfiguračné parametre pre zariadenia Apple.",
-        "apple_connection_profile_with_app_password": "Nové heslo aplikácie sa vygeneruje a pridá do profilu, takže pri nastavovaní zariadenia nie je potrebné zadávať žiadne heslo. Súbor nezdieľajte, pretože poskytuje úplný prístup k vašej poštovej schránke.",
+        "apple_connection_profile_with_app_password": "Nové heslo aplikácie sa vygeneruje a pridá do profilu, takže pri nastavovaní zariadenia nie je potrebné zadávať žiadne heslo. Súbor nezdieľajte, pretože poskytuje úplný prístup k vašej mail schránke.",
         "change_password": "Zmeniť heslo",
-        "change_password_hint_app_passwords": "Váš účet má %d hesiel aplikácií, ktoré nebudú zmenené. Ak ich chcete spravovať, prejdite na kartu Heslá aplikácií.",
+        "change_password_hint_app_passwords": "Vaše konto má %d hesiel aplikácií, ktoré nebudú zmenené. Ak ich chcete spravovať, prejdite na kartu Heslá aplikácií.",
         "clear_recent_successful_connections": "Vymazať nedávne úspešné prihlásenia",
         "client_configuration": "Zobraziť konfiguračné pokyny pre emailových klientov a smartfóny",
         "create_app_passwd": "Vytvoriť heslo aplikácie",
@@ -1078,7 +1119,7 @@
         "delete_ays": "Potvrďte zmazanie.",
         "direct_aliases": "Priame alias adresy",
         "direct_aliases_desc": "Priame aliasy sú ovplyvnené spam filtrom a nastavením TLS pravidiel.",
-        "direct_protocol_access": "Tento používateľ mailovej schránky má <b>priamy, externý prístup</b> k nasledujúcim protokolom a aplikáciám. Toto nastavenie má pod kontrolou Váš správca. Na udelenie prístupu k jednotlivým protokolom a aplikáciám je možné vytvoriť heslá aplikácií.<br>Tlačidlo \" Prihláste sa do webmailu\" poskytuje jednotné prihlásenie do systému SOGo a je vždy k dispozícii.",
+        "direct_protocol_access": "Tento používateľ mailovej schránky má <b>priamy, externý prístup</b> k nasledujúcim protokolom a aplikáciám. Toto nastavenie kontroluje administrátor. Na udelenie prístupu k jednotlivým protokolom a aplikáciám je možné vytvoriť heslá aplikácií.<br>Tlačidlo \"Prihlásenie do webmailu\" poskytuje jednotné prihlásenie do systému SOGo a je vždy k dispozícii.",
         "eas_reset": "Resetovať medzipamäť u ActiveSync zariadení",
         "eas_reset_help": "Vo väčšine prípadov, reset medzipamäte ActiveSync pomôže opravit nefunkčný profil.<br><b>Pozor:</b> Všetky potrebné dáta budú opäť stiahnuté!",
         "eas_reset_now": "Reset ActiveSync",
@@ -1202,10 +1243,7 @@
         "weeks": "týždne",
         "with_app_password": "s heslom aplikácie",
         "year": "rok",
-        "years": "rokov",
-        "apple_connection_profile_with_app_password": "Nové heslo aplikácie sa vygeneruje a pridá do profilu, takže pri nastavovaní zariadenia nie je potrebné zadávať žiadne heslo. Súbor nezdieľajte, pretože poskytuje úplný prístup k vašej mail schránke.",
-        "change_password_hint_app_passwords": "Vaše konto má %d hesiel aplikácií, ktoré nebudú zmenené. Ak ich chcete spravovať, prejdite na kartu Heslá aplikácií.",
-        "direct_protocol_access": "Tento používateľ mailovej schránky má <b>priamy, externý prístup</b> k nasledujúcim protokolom a aplikáciám. Toto nastavenie kontroluje administrátor. Na udelenie prístupu k jednotlivým protokolom a aplikáciám je možné vytvoriť heslá aplikácií.<br>Tlačidlo \"Prihlásenie do webmailu\" poskytuje jednotné prihlásenie do systému SOGo a je vždy k dispozícii."
+        "years": "rokov"
     },
     "warning": {
         "cannot_delete_self": "Nemožno vymazať prihláseného používateľa",

+ 100 - 35
data/web/lang/lang.tr-tr.json

@@ -1,61 +1,113 @@
 {
     "acl": {
-        "alias_domains": "Takma alan adı ekle",
+        "alias_domains": "Alias domain ekle",
         "app_passwds": "Uygulama şifrelerini yönet",
-        "delimiter_action": "Sınırlama işlemi",
-        "domain_relayhost": "Bir alan adı için relayhost sunucusunu değiştir",
+        "bcc_maps": "BCC haritası",
+        "delimiter_action": "Sınırlandırma işlemi",
+        "domain_desc": "Alan adı açıklamasını değiştir",
+        "domain_relayhost": "Alan adı için relayhost sunucusunu değiştir",
         "eas_reset": "EAS cihazlarını sıfırla",
-        "mailbox_relayhost": "Bir posta kutusunun relayhost sunucularını değiştir",
-        "pushover": "Bildirim",
-        "quarantine": "Karantina işlemleri",
+        "extend_sender_acl": "Gönderen ACL'sini harici adreslerle genişletmeye izin ver",
+        "filters": "Filtreler",
+        "login_as": "E-posta kullanıcısı olarak giriş yapın",
+        "mailbox_relayhost": "Bir e-posta için relayhost'u değiştirin",
+        "prohibited": "ACL tarafından yasaklandı",
+        "protocol_access": "Protokol erişimini değiştirin",
+        "pushover": "Pushover",
+        "quarantine": "Karantina eylemleri",
         "quarantine_attachments": "Ekleri karantinaya al",
+        "quarantine_category": "Karantina bildirim kategorisini değiştir",
         "quarantine_notification": "Karantina bildirimlerini değiştir",
-        "smtp_ip_access": "SMTP sunucularının değiştirilmesine izin ver",
-        "sogo_access": "SOGo erişiminin yönetilmesine izin ver",
-        "domain_desc": "Alan adı açıklamasını değiştir",
-        "extend_sender_acl": "Gönderenin acl'sini harici adreslere göre genişletmeye izin ver",
-        "spam_policy": "Engellenenler / İzin verilenler",
-        "filters": "Fitreler"
+        "ratelimit": "Rate limit",
+        "recipient_maps": "Alıcı haritaları",
+        "smtp_ip_access": "SMTP için izin verilen host değerlerini değiştirme",
+        "sogo_access": "SOGo erişiminin yönetilmesine izin verin",
+        "sogo_profile_reset": "SOGo profilini sıfırla",
+        "spam_alias": "Geçici alias değerleri",
+        "spam_policy": "Kara Liste/Beyaz Liste",
+        "spam_score": "Spam skoru",
+        "syncjobs": "Görevleri senkronize et",
+        "tls_policy": "TLS ilkesi",
+        "unlimited_quota": "E-postalar için sınırsız kota"
     },
     "add": {
         "activate_filter_warn": "Aktif edilirse diğer tüm filtreler devre dışı bırakılacak.",
+        "active": "Aktif",
+        "add": "Ekle",
         "add_domain_only": "Sadece alan adı ekle",
-        "alias_address": "Takma ad adres(leri)",
+        "add_domain_restart": "Alan adını ekleyin ve SOGo'yu yeniden başlatın",
+        "alias_address": "Takma adres",
+        "alias_address_info": "<small>Bir alan adına ilişkin tüm iletileri yakalamak için tam e-posta adresi veya @example.com olacak şeklinde girin (virgülle ayırın).<b>sadece mailcow alan adları</b>.</small>",
         "alias_domain": "Takma alan adı",
-        "alias_domain_info": "<small>Sadece geçerli alan adları (virgülle ayırın).</small>",
-        "backup_mx_options": "İletme ayarları",
-        "delete2": "Kaynakta olmayan hedefteki mesajları sil",
+        "alias_domain_info": "<small>Sadece geçerli alan adları (virgülle ayrılmış).</small>",
+        "app_name": "Uygulama adı",
+        "app_password": "Uygulama şifresi ekle",
+        "app_passwd_protocols": "Uygulama şifresi için izin verilen protokoller",
+        "automap": "Klasörleri otomatik eşleştirmeyi deneyin (\"Gönderilen postalar\", \"Gönderilen\" => \"Gönderilen\" vb.)",
+        "backup_mx_options": "Relay ayarları",
+        "bcc_dest_format": "BCC hedefi tek bir geçerli e-posta adresi olmalıdır.<br>Bir kopyayı birden fazla adrese göndermeniz gerekiyorsa, bir takma ad oluşturun ve bunu burada kullanın.",
+        "comment_info": "Özel bir yorum kullanıcı tarafından görülemezken, herkese açık bir yorum kullanıcının genel görünümünde üzerine gelindiğinde tooltip olarak gösterilir",
+        "custom_params": "Özel parametreler",
+        "custom_params_hint": "Doğru: --param=xy, yanlış: --param xy",
+        "delete1": "Tamamlandığında kaynaktan sil",
+        "delete2": "Eğer kaynakta yoksa hedefteki mesajları sil",
         "delete2duplicates": "Hedefteki kopyaları sil",
-        "disable_login": "Giriş yapmaya izin verme ( Gelen mailler yine de kabul edilir)",
+        "description": "Açıklama",
+        "destination": "Hedef",
+        "disable_login": "Giriş yapmaya izin verme (gelen mailler yine de kabul edilir)",
         "domain": "Alan adı",
         "domain_matches_hostname": "Alan adı %s ana bilgisayar adıyla eşleşiyor",
-        "add_domain_restart": "Alan adı ekleyin ve SOGo'yu yeniden başlatın",
-        "alias_address_info": "<small>Bir alan adına ilişkin tüm iletileri yakalamak için tam e-posta adresi veya @example.com olacak şeklinde girin (virgülle ayırın).<b>sadece mailcow alan adları</b>.</small>",
         "domain_quota_m": "Toplam alan adı kotası (MiB)",
+        "enc_method": "Şifreleme yöntemi",
+        "exclude": "Hariç tutma kuralı (regex)",
+        "full_name": "Tam isim",
+        "gal": "Global Adres Listesi",
+        "gal_info": "GAL bir alan alanının tüm nesnelerini içerir ve herhangi bir kullanıcı tarafından düzenlenemez. Eğer devre dışı bırakırsanız SOGo üzerindeki free/busy bilgileri kaybolur! <b>Değişiklikleri uygulamak için SOGo'yu yeniden başlatın.</b>",
         "generate": "oluştur",
         "goto_ham": "Ham olarak<span class=\"text-success\"><b>işaretle</b></span>",
-        "goto_null": "Postaları sessizce çöpe at",
+        "goto_null": "Postaları çöpe at",
         "goto_spam": "Spam olarak<span class=\"text-danger\"><b>işaretle</b></span>",
         "hostname": "Ana sunucu",
+        "inactive": "İnaktif",
         "kind": "Tür",
-        "mailbox_quota_m": "Posta kutusu başına maksimum kota (MiB)",
-        "max_aliases": "Maksimum olası takma adı",
-        "max_mailboxes": "Maksimum olası posta kutusu",
-        "nexthop": "Sonraki atlama",
+        "mailbox_quota_def": "Varsayılan e-posta kotası",
+        "mailbox_quota_m": "E-posta başına maksimum kota (MiB)",
+        "mailbox_username": "Kullanıcı adı (e-posta adresinin sol kısmı)",
+        "max_aliases": "Maksimum takma adı limiti",
+        "max_mailboxes": "Maksimum e-posta hesabı",
+        "mins_interval": "Sorgulama döngüsü (dakika)",
+        "multiple_bookings": "Birden fazla rezervasyon",
+        "nexthop": "Next hop",
+        "password": "Şifre",
+        "password_repeat": "Şifre (tekrar)",
         "port": "Port",
-        "public_comment": "Genel yorum",
-        "relay_all": "Tüm alıcılara ilet",
-        "relay_all_info": "Eğer <b>hiçbir</b> alıcıya iletilmemesini seçerseniz, aktarılması gereken her alıcı için bir (\"kör\") posta kutusu eklemeniz gerekecektir.",
-        "relay_domain": "Bu alan adını ilet",
-        "relay_transport_info": "<div class=\"label label-info\">Bilgi</div> Bu etki alanı için özel bir hedef için aktarım eşlemeleri tanımlayabilirsiniz. Ayarlanmazsa, bir MX araması yapılacaktır.",
-        "relay_unknown_only": "Yalnızca mevcut olmayan posta kutularını ilet. Mevcut posta kutuları yerel olarak teslim edilecektir.",
-        "relayhost_wrapped_tls_info": "Lütfen TLS ile örtülmüş portları <b> kullanmayın</b> (çoğu 465 portunda çalışır).<br>\nÖrtülmemiş port kullan ve STARTTLS üzerinden yayınla. TLS'yi zorlamak için bir TLS ilkesi \"TLS ilke eşlemeleri\" sayfası içinde oluşturulabilir.",
-        "skipcrossduplicates": "Klasörler arasında yinelenen mesajları atlayın (ilk mesaj seçilir)",
+        "post_domain_add": "SOGo konteynerinin \"sogo-mailcow\" yeni bir alan adı eklendikten sonra yeniden başlatılması gerekiyor!<br><br>Ayrıca alan adlarının DNS yapılandırması da gözden geçirilmelidir. DNS yapılandırması onaylandıktan sonra, yeni etki alanınız için otomatik olarak sertifika oluşturmak üzere \"acme-mailcow\"u yeniden başlatın (autoconfig.&lt;domain&gt;, autodiscover.&lt;domain&gt;).<br>Bu adım isteğe bağlıdır ve her 24 saatte bir yeniden denenecektir.",
+        "private_comment": "Özel not",
+        "public_comment": "Herkese açık not",
+        "quota_mb": "Kota (MiB)",
+        "relay_all": "Tüm alıcıları aktar",
+        "relay_all_info": "↪ Tüm alıcıları <b>aktarmamayı</b> seçerseniz, aktarılması gereken her alıcı için bir (\"kör\") posta kutusu eklemeniz gerekecektir.",
+        "relay_domain": "Bu etki alanını aktarın",
+        "relay_transport_info": "<div class=\"badge fs-6 bg-info\">Bilgi</div> Bu alan adı nezdinde özel bir hedef için transport haritası tanımlayabilirsiniz. Eğer ayarlanmazsa, MX taraması yapılacaktır.",
+        "relay_unknown_only": "Yalnızca mevcut olmayan e-postaları aktarın. Mevcut e-postalar local olarak teslim edilecektir.",
+        "relayhost_wrapped_tls_info": "Lütfen örtülü TLS portları <b>kullanmayın</b> (çoğunlukla 465 portunda kullanılır).<br>\r\nÖrtülmüş olmayan herhangi bir bağlantı noktası kullanın ve STARTTLS üzerinden yayınlayın. TLS'yi zorlamak için bir TLS ilkesi \"TLS ilke eşlemeleri\" sayfası içinde oluşturulabilir.",
+        "select": "Lütfen seçiniz...",
+        "select_domain": "Lütfen önce alan adı seçin",
+        "sieve_desc": "Kısa açıklama",
+        "sieve_type": "Filtre türü",
+        "skipcrossduplicates": "Klasörler arasında yinelenen(kopya) mesajları es geçin (ilk gelen mail baz alınır)",
+        "subscribeall": "Tüm klasörlere abone ol",
+        "syncjob": "Senkronizasyon görevi ekle",
+        "syncjob_hint": "Parolaların düz metin olarak kaydedilmesi gerektiğini unutmayın!",
+        "tags": "Etiketler",
         "target_address": "Adreslere git",
-        "target_address_info": "<small>Tam e-posta adres(leri) girin ( virgülle ayırın).</small>",
+        "target_address_info": "<small>Tam e-posta adresleri (virgülle ayrılmış).</small>",
         "target_domain": "Hedef alan adı",
-        "timeout1": "Uzak ana bilgisayara bağlantısı zaman aşımına uğradı",
-        "timeout2": "Yerel ana bilgisayara bağlantı zaman aşımına uğradı"
+        "timeout1": "Uzak ana bilgisayara bağlantı için zaman aşımı",
+        "timeout2": "Yerel ana bilgisayara bağlantı için zaman aşımı",
+        "username": "Kullanıcı Adı",
+        "validate": "Doğrula",
+        "validation_success": "Doğrulama başarılı"
     },
     "admin": {
         "action": "İşlem",
@@ -82,5 +134,18 @@
         "f2b_ban_time": "Yasaklama süresi (saniye)",
         "f2b_max_attempts": "Maksimum giriş denemesi",
         "f2b_retry_window": "Maksimum girişim için deneme pencere(leri)"
+    },
+    "warning": {
+        "cannot_delete_self": "Cannot delete logged in user",
+        "domain_added_sogo_failed": "Alan adı eklendi ancak SOGo yeniden başlatılamadı, lütfen sunucu log kayıtlarını kontrol edin.",
+        "dovecot_restart_failed": "Dovecot yeniden başlatılamadı, lütfen log kayıtlarını kontrol edin",
+        "fuzzy_learn_error": "Fuzzy hash hatayı öğrendi: %s",
+        "hash_not_found": "Hash bulunamadı veya zaten silinmiş",
+        "ip_invalid": "Geçersiz IP atlandı: %s",
+        "is_not_primary_alias": "Birincil olmayan alias %s atlandı",
+        "no_active_admin": "Son etkin yönetici devre dışı bırakılamaz",
+        "quota_exceeded_scope": "Domain kotası aşıldı: Bu domain kapsamında yalnızca sınırsız e-posta oluşturulabilir!",
+        "session_token": "Form token geçersiz: Token uyuşmadı",
+        "session_ua": "Form token geçersiz: User-Agent doğrulama hatası"
     }
 }

+ 10 - 4
data/web/lang/lang.uk-ua.json

@@ -107,7 +107,8 @@
         "kind": "Вид",
         "delete1": "Видалити з джерела після завершення",
         "delete2duplicates": "Видалити дублікати на місці призначення",
-        "domain_quota_m": "Загальна квота домену (МіБ)"
+        "domain_quota_m": "Загальна квота домену (МіБ)",
+        "dry": "Імітувати синхронізацію"
     },
     "admin": {
         "access": "Налаштування доступу",
@@ -650,10 +651,13 @@
             "auth_user": "{= auth_user =} - Аутентифіковане ім'я користувача, вказане MTA",
             "from_user": "{= from_user =} - З користувацької частини envelope, наприклад, для \"moo@mailcow.tld\" повертає \"moo\"",
             "from_addr": "{= from_addr =} - З адресної частини envelope",
-            "from_domain": "{= from_domain =} - З доменної частини envelope"
+            "from_domain": "{= from_domain =} - З доменної частини envelope",
+            "custom": "{= foo =}         - Якщо поштова скринька має кастомний атрибут \"foo\" зі значенням \"bar\", то повертається \"bar\""
         },
         "domain_footer_html": "Нижній колонтитул HTML",
-        "domain_footer_plain": "ЗВИЧАЙНИЙ нижній колонтитул"
+        "domain_footer_plain": "ЗВИЧАЙНИЙ нижній колонтитул",
+        "custom_attributes": "Користувацькі атрибути",
+        "mbox_exclude": "Виключити поштові скриньки"
     },
     "fido2": {
         "confirm": "Підтвердити",
@@ -1248,7 +1252,9 @@
         "tls_policy_warning": "<strong>Попередження:</strong> якщо ви увімкнете примусове шифрування пошти, ви можете зіткнутися з втратою листів.<br>Повідомлення, які не відповідають політиці, будуть відкидатися з повідомленням поштовим сервером про серйозний збій.<br>Цей параметр застосовується до вашої основної адреси електронної пошти (логіну), усім особистим псевдонімам та псевдонімам доменів. Маються на увазі лише псевдоніми <b>з однією поштовою скринькою</b>, як одержувач.",
         "year": "рік",
         "years": "років",
-        "pushover_sound": "Звук"
+        "pushover_sound": "Звук",
+        "value": "Значення",
+        "attribute": "Атрибут"
     },
     "warning": {
         "domain_added_sogo_failed": "Домен був доданий, але перезавантажити SOGo не вдалося, будь ласка, перевірте журнали сервера.",

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

@@ -42,6 +42,13 @@
             <input type="number" class="form-control" id="f2b_netban_ipv6" name="netban_ipv6" value="{{ f2b_data.netban_ipv6 }}" required>
           </div>
         </div>
+        <div class="mb-4">
+          <div class="form-check form-switch">
+            <input class="form-check-input" type="checkbox" id="f2b_manage_external" value="1" name="manage_external" {% if f2b_data.manage_external == 1 %}checked{% endif %}>
+            <label class="form-check-label" for="f2b_manage_external">{{ lang.admin.f2b_manage_external }}</label>
+          </div>
+          <p class="text-muted">{{ lang.admin.f2b_manage_external_info }}</p>
+        </div>
         <hr>
         <p class="text-muted">{{ lang.admin.f2b_list_info|raw }}</p>
         <div class="mb-2">
@@ -90,6 +97,15 @@
       {% if not f2b_data.active_bans and not f2b_data.perm_bans %}
         <i>{{ lang.admin.no_active_bans }}</i>
       {% endif %}
+      <form class="form-inline" data-id="f2b_banlist" role="form" method="post">
+        <div class="input-group mb-3">
+          <input type="text" class="form-control" aria-label="Banlist url" value="{{ f2b_banlist_url}}" id="banlist_url">
+          {% if is_https %}
+          <button class="btn btn-secondary" type="button" onclick="copyToClipboard('banlist_url')"><i class="bi bi-clipboard"></i></button>
+          {% endif %}
+          <button class="btn btn-secondary" type="button" data-action="edit_selected" data-item="{{ f2b_data.banlist_id }}" data-id="f2b_banlist" data-api-url='edit/fail2ban/banlist' data-api-attr='{}'><i class="bi bi-arrow-clockwise"></i></button>
+        </div>
+      </form>
       {% for active_ban in f2b_data.active_bans %}
         <p>
           <span class="badge fs-7 bg-info d-block d-sm-inline-block">

+ 1 - 0
data/web/templates/edit.twig

@@ -24,6 +24,7 @@
 
 <script type='text/javascript'>
   var lang_user = {{ lang_user|raw }};
+  var lang_admin = {{ lang_admin|raw }};
   var lang_datatables = {{ lang_datatables|raw }};
   var csrf_token = '{{ csrf_token }}';
   var pagination_size = Math.trunc('{{ pagination_size }}');

+ 23 - 5
data/web/templates/edit/domain.twig

@@ -168,7 +168,7 @@
                     <label class="control-label col-sm-2">{{ lang.edit.ratelimit }}</label>
                     <div class="col-sm-10">
                       <div class="input-group">
-                        <input name="rl_value" type="number" value="{{ rl.value }}" autocomplete="off" class="form-control placeholder="{{ lang.ratelimit.disabled }}">
+                        <input name="rl_value" type="number" value="{{ rl.value }}" autocomplete="off" class="form-control" placeholder="{{ lang.ratelimit.disabled }}">
                         <select name="rl_frame" class="form-control">
                         {% include 'mailbox/rl-frame.twig' %}
                         </select>
@@ -285,23 +285,41 @@
 {{ lang.edit.domain_footer_info_vars.from_user }}
 {{ lang.edit.domain_footer_info_vars.from_name }}
 {{ lang.edit.domain_footer_info_vars.from_addr }}
-{{ lang.edit.domain_footer_info_vars.from_domain }}</pre>
+{{ lang.edit.domain_footer_info_vars.from_domain }}
+{{ lang.edit.domain_footer_info_vars.custom }}</pre>
                     <form class="form-horizontal mt-4" data-id="domain_footer">
+                      <div class="row mb-4">
+                        <label class="control-label col-sm-2" for="mbox_exclude">{{ lang.edit.mbox_exclude }}</label>
+                        <div class="col-sm-10">
+                          <select data-live-search="true" data-width="100%" style="width:100%" id="editMboxExclude" name="mbox_exclude" size="10" multiple>
+                            {% for mailbox in mailboxes %}
+                              <option value="{{ mailbox }}" {% if mailbox in domain_footer.mbox_exclude %}selected{% endif %}>
+                                {{ mailbox }}
+                              </option>
+                            {% endfor %}
+                            {% for alias in aliases %}
+                              <option data-subtext="Alias" value="{{ alias }}" {% if alias in domain_footer.mbox_exclude %}selected{% endif %}>
+                                {{ alias }}
+                              </option>
+                            {% endfor %}
+                          </select>
+                        </div>
+                      </div>
                       <div class="row mb-2">
                         <label class="control-label col-sm-2" for="domain_footer_html">{{ lang.edit.domain_footer_html }}:</label>
                         <div class="col-sm-10">
-                          <textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_html" name="footer_html">{{ domain_footer.html }}</textarea>
+                          <textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_html" name="html">{{ domain_footer.html }}</textarea>
                         </div>
                       </div>
                       <div class="row mb-4">
                         <label class="control-label col-sm-2" for="domain_footer_plain">{{ lang.edit.domain_footer_plain }}:</label>
                         <div class="col-sm-10">
-                          <textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_plain" name="footer_plain">{{ domain_footer.plain }}</textarea>
+                          <textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_plain" name="plain">{{ domain_footer.plain }}</textarea>
                         </div>
                       </div>
                       <div class="row">
                         <div class="offset-sm-2 col-sm-10">
-                          <button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="domain_footer" data-item="domain_footer" data-api-url='edit/domain-wide-footer' data-api-attr='{"domain":"{{ domain }}"}' href="#">{{ lang.edit.save }}</button>
+                          <button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="domain_footer" data-item="{{ domain }}" data-api-url='edit/domain/footer' data-api-attr='{}' href="#">{{ lang.edit.save }}</button>
                         </div>
                       </div>
                     </form>

+ 32 - 0
data/web/templates/edit/mailbox.twig

@@ -5,6 +5,7 @@
 <div id="mailbox-content" class="responsive-tabs">
     <ul class="nav nav-tabs" role="tablist">
       <li role="presentation" class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#medit">{{ lang.edit.mailbox }}</button></li>
+      <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mattr">{{ lang.edit.custom_attributes }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mpushover">{{ lang.edit.pushover }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#macl">{{ lang.edit.acl }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mrl">{{ lang.edit.ratelimit }}</button></li>
@@ -275,6 +276,37 @@
             </div>
         </div>
       </div>
+      <div id="mattr" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-attr">
+        <div class="card mb-4">
+          <div class="card-header d-flex d-md-none fs-5">
+            <button class="btn flex-grow-1 text-start" data-bs-target="#collapse-tab-mattr" data-bs-toggle="collapse" aria-controls="collapse-tab-mattr">
+              {{ lang.edit.mailbox }} <span class="badge bg-info table-lines"></span>
+            </button>
+          </div>
+          <div id="collapse-tab-mattr" class="card-body collapse show" data-bs-parent="#mailbox-content">
+            <form class="form-inline" data-id="mbox_attr" role="form" method="post">
+              <table class="table table-condensed" style="white-space: nowrap;" id="mbox_attr_table">
+                <tr>
+                  <th>{{ lang.user.attribute }}</th>
+                  <th>{{ lang.user.value }}</th>
+                  <th style="width:100px;">&nbsp;</th>
+                </tr>
+                {% for key, val in result.custom_attributes %}
+                  <tr>
+                    <td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="attribute" required value="{{ key }}"></td>
+                    <td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="value" required value="{{ val }}"></td>
+                    <td><a href="#" role="button" class="btn btn-sm btn-xs-lg btn-secondary h-100 w-100" type="button">{{ lang.admin.remove_row }}</a></td>
+                  </tr>
+                {% endfor %}
+              </table>
+              <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="{{ mailbox }}" data-id="mbox_attr" data-api-url='edit/mailbox/custom-attribute' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
+                <button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" type="button" id="add_mbox_attr_row">{{ lang.admin.add_row }}</button>
+              </div></p>
+            </form>
+          </div>
+        </div>
+      </div>
       <div id="mpushover" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-pushover">
         <div class="card mb-4">
             <div class="card-header d-flex d-md-none fs-5">

+ 9 - 1
data/web/templates/edit/syncjob.twig

@@ -11,6 +11,7 @@
     <input type="hidden" value="0" name="skipcrossduplicates">
     <input type="hidden" value="0" name="active">
     <input type="hidden" value="0" name="subscribeall">
+    <input type="hidden" value="0" name="dry">
     <div class="row mb-2">
       <label class="control-label col-sm-2" for="host1">{{ lang.edit.hostname }}</label>
       <div class="col-sm-10">
@@ -95,7 +96,7 @@
     <div class="row mb-4">
       <label class="control-label col-sm-2" for="custom_params">{{ lang.add.custom_params }}</label>
       <div class="col-sm-10">
-        <input type="text" class="form-control" name="custom_params" id="custom_params" value="{{ result.custom_params }}" placeholder="--dry --some-param=xy --other-param=yx">
+        <input type="text" class="form-control" name="custom_params" id="custom_params" value="{{ result.custom_params }}" placeholder="--some-param=xy --other-param=yx">
         <small class="text-muted">{{ lang.add.custom_params_hint }}</small>
       </div>
     </div>
@@ -141,6 +142,13 @@
         </div>
       </div>
     </div>
+    <div class="row mb-2">
+      <div class="offset-sm-2 col-sm-10">
+        <div class="form-check">
+          <label><input type="checkbox" class="form-check-input" value="1" name="dry"{% if result.dry == '1' %} checked{% endif %}> {{ lang.add.dry }} (--dry)</label>
+        </div>
+      </div>
+    </div>
     <div class="row mb-4">
       <div class="offset-sm-2 col-sm-10">
         <div class="form-check">

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

@@ -955,7 +955,7 @@
           <div class="row mb-4">
             <label class="control-label col-sm-2 text-sm-end" for="custom_params">{{ lang.add.custom_params }}</label>
             <div class="col-sm-10">
-              <input type="text" class="form-control" name="custom_params" placeholder="--dry --some-param=xy --other-param=yx">
+              <input type="text" class="form-control" name="custom_params" placeholder="--some-param=xy --other-param=yx">
               <small class="text-muted">{{ lang.add.custom_params_hint }}</small>
             </div>
           </div>
@@ -994,13 +994,20 @@
               </div>
             </div>
           </div>
-          <div class="row mb-4">
+          <div class="row mb-2">
             <div class="offset-sm-2 col-sm-10">
               <div class="form-check">
                 <label><input type="checkbox" class="form-check-input" value="1" name="subscribeall" checked> {{ lang.add.subscribeall }} (--subscribeall)</label>
               </div>
             </div>
           </div>
+          <div class="row mb-4">
+            <div class="offset-sm-2 col-sm-10">
+              <div class="form-check">
+                <label><input type="checkbox" class="form-check-input" value="1" name="dry"> {{ lang.add.dry }} (--dry)</label>
+              </div>
+            </div>
+          </div>
           <div class="row mb-2">
             <div class="offset-sm-2 col-sm-10">
               <div class="form-check">

+ 7 - 0
data/web/templates/modals/user.twig

@@ -167,6 +167,13 @@
               </div>
             </div>
           </div>
+          <div class="row mb-2">
+            <div class="offset-sm-2 col-sm-10">
+              <div class="form-check">
+                <label><input type="checkbox" class="form-check-input" value="1" name="dry" checked> {{ lang.add.dry }} (--dry)</label>
+              </div>
+            </div>
+          </div>
           <div class="row mb-4">
             <div class="offset-sm-2 col-sm-10">
               <div class="form-check">

+ 9 - 9
docker-compose.yml

@@ -58,12 +58,11 @@ services:
             - redis
 
     clamd-mailcow:
-      image: mailcow/clamd:1.62
+      image: mailcow/clamd:1.63
       restart: always
       depends_on:
         unbound-mailcow:
           condition: service_healthy
-          restart: true
       dns:
         - ${IPV4_NETWORK:-172.22.1}.254
       environment:
@@ -78,7 +77,7 @@ services:
             - clamd
 
     rspamd-mailcow:
-      image: mailcow/rspamd:1.92
+      image: mailcow/rspamd:1.94
       stop_grace_period: 30s
       depends_on:
         - dovecot-mailcow
@@ -172,7 +171,7 @@ services:
             - phpfpm
 
     sogo-mailcow:
-      image: mailcow/sogo:1.119
+      image: mailcow/sogo:1.120
       environment:
         - DBNAME=${DBNAME}
         - DBUSER=${DBUSER}
@@ -219,7 +218,7 @@ services:
             - sogo
 
     dovecot-mailcow:
-      image: mailcow/dovecot:1.25
+      image: mailcow/dovecot:1.26
       depends_on:
         - mysql-mailcow
       dns:
@@ -305,7 +304,6 @@ services:
           condition: service_started
         unbound-mailcow:
           condition: service_healthy
-          restart: true
       volumes:
         - ./data/hooks/postfix:/hooks:Z
         - ./data/conf/postfix:/opt/postfix/conf:z
@@ -436,7 +434,7 @@ services:
             - acme
 
     netfilter-mailcow:
-      image: mailcow/netfilter:1.52
+      image: mailcow/netfilter:1.53
       stop_grace_period: 30s
       depends_on:
         - dovecot-mailcow
@@ -459,7 +457,7 @@ services:
         - /lib/modules:/lib/modules:ro
 
     watchdog-mailcow:
-      image: mailcow/watchdog:1.98
+      image: mailcow/watchdog:1.99
       dns:
         - ${IPV4_NETWORK:-172.22.1}.254
       tmpfs:
@@ -490,6 +488,8 @@ services:
         - WATCHDOG_NOTIFY_BAN=${WATCHDOG_NOTIFY_BAN:-y}
         - WATCHDOG_NOTIFY_START=${WATCHDOG_NOTIFY_START:-y}
         - WATCHDOG_SUBJECT=${WATCHDOG_SUBJECT:-Watchdog ALERT}
+        - WATCHDOG_NOTIFY_WEBHOOK=${WATCHDOG_NOTIFY_WEBHOOK:-}
+        - WATCHDOG_NOTIFY_WEBHOOK_BODY=${WATCHDOG_NOTIFY_WEBHOOK_BODY:-}
         - WATCHDOG_EXTERNAL_CHECKS=${WATCHDOG_EXTERNAL_CHECKS:-n}
         - WATCHDOG_MYSQL_REPLICATION_CHECKS=${WATCHDOG_MYSQL_REPLICATION_CHECKS:-n}
         - WATCHDOG_VERBOSE=${WATCHDOG_VERBOSE:-n}
@@ -529,7 +529,7 @@ services:
             - watchdog
 
     dockerapi-mailcow:
-      image: mailcow/dockerapi:2.05
+      image: mailcow/dockerapi:2.06
       security_opt:
         - label=disable
       restart: always

+ 13 - 6
generate_config.sh

@@ -26,10 +26,10 @@ for bin in openssl curl docker git awk sha1sum grep cut; do
 done
 
 if docker compose > /dev/null 2>&1; then
-    if docker compose version --short | grep "^2." > /dev/null 2>&1; then
+    if docker compose version --short | grep -e "^2." -e "^v2." > /dev/null 2>&1; then
       COMPOSE_VERSION=native
-      echo -e "\e[31mFound Docker Compose Plugin (native).\e[0m"
-      echo -e "\e[31mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
+      echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
+      echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
       sleep 2
       echo -e "\e[33mNotice: You´ll have to update this Compose Version via your Package Manager manually!\e[0m"
     else
@@ -41,8 +41,8 @@ elif docker-compose > /dev/null 2>&1; then
   if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
     if docker-compose version --short | grep "^2." > /dev/null 2>&1; then
       COMPOSE_VERSION=standalone
-      echo -e "\e[31mFound Docker Compose Standalone.\e[0m"
-      echo -e "\e[31mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
+      echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
+      echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
       sleep 2
       echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
     else
@@ -398,6 +398,13 @@ USE_WATCHDOG=y
 #WATCHDOG_NOTIFY_EMAIL=a@example.com,b@example.com,c@example.com
 #WATCHDOG_NOTIFY_EMAIL=
 
+# Send notifications to a webhook URL that receives a POST request with the content type "application/json".
+# You can use this to send notifications to services like Discord, Slack and others.
+#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+# JSON body included in the webhook POST request. Needs to be in single quotes.
+# Following variables are available: SUBJECT, BODY
+#WATCHDOG_NOTIFY_WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
+
 # Notify about banned IP (includes whois lookup)
 WATCHDOG_NOTIFY_BAN=n
 
@@ -556,4 +563,4 @@ else
   echo -e "\e[33mCannot determine current git repository version...\e[0m"
 fi
 
-detect_bad_asn
+detect_bad_asn

+ 122 - 0
helper-scripts/generate_caa_record.py

@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+# Based on github.com/diafygi/acme-tiny, original copyright:
+# Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny
+import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
+try:
+    from urllib.request import urlopen, Request # Python 3
+except ImportError: # pragma: no cover
+    from urllib2 import urlopen, Request # Python 2
+
+DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
+
+LOGGER = logging.getLogger(__name__)
+LOGGER.addHandler(logging.StreamHandler())
+LOGGER.setLevel(logging.INFO)
+
+def get_id(account_key, log=LOGGER, directory_url=DEFAULT_DIRECTORY_URL, contact=None):
+    directory, acct_headers, alg, jwk = None, None, None, None # global variables
+
+    # helper functions - base64 encode for jose spec
+    def _b64(b):
+        return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "")
+
+    # helper function - run external commands
+    def _cmd(cmd_list, stdin=None, cmd_input=None, err_msg="Command Line Error"):
+        proc = subprocess.Popen(cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        out, err = proc.communicate(cmd_input)
+        if proc.returncode != 0:
+            raise IOError("{0}\n{1}".format(err_msg, err))
+        return out
+
+    # helper function - make request and automatically parse json response
+    def _do_request(url, data=None, err_msg="Error", depth=0):
+        try:
+            resp = urlopen(Request(url, data=data, headers={"Content-Type": "application/jose+json", "User-Agent": "acme-tiny"}))
+            resp_data, code, headers = resp.read().decode("utf8"), resp.getcode(), resp.headers
+        except IOError as e:
+            resp_data = e.read().decode("utf8") if hasattr(e, "read") else str(e)
+            code, headers = getattr(e, "code", None), {}
+        try:
+            resp_data = json.loads(resp_data) # try to parse json results
+        except ValueError:
+            pass # ignore json parsing errors
+        if depth < 100 and code == 400 and resp_data['type'] == "urn:ietf:params:acme:error:badNonce":
+            raise IndexError(resp_data) # allow 100 retrys for bad nonces
+        if code not in [200, 201, 204]:
+            raise ValueError("{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format(err_msg, url, data, code, resp_data))
+        return resp_data, code, headers
+
+    # helper function - make signed requests
+    def _send_signed_request(url, payload, err_msg, depth=0):
+        payload64 = "" if payload is None else _b64(json.dumps(payload).encode('utf8'))
+        new_nonce = _do_request(directory['newNonce'])[2]['Replay-Nonce']
+        protected = {"url": url, "alg": alg, "nonce": new_nonce}
+        protected.update({"jwk": jwk} if acct_headers is None else {"kid": acct_headers['Location']})
+        protected64 = _b64(json.dumps(protected).encode('utf8'))
+        protected_input = "{0}.{1}".format(protected64, payload64).encode('utf8')
+        out = _cmd(["openssl", "dgst", "-sha256", "-sign", account_key], stdin=subprocess.PIPE, cmd_input=protected_input, err_msg="OpenSSL Error")
+        data = json.dumps({"protected": protected64, "payload": payload64, "signature": _b64(out)})
+        try:
+            return _do_request(url, data=data.encode('utf8'), err_msg=err_msg, depth=depth)
+        except IndexError: # retry bad nonces (they raise IndexError)
+            return _send_signed_request(url, payload, err_msg, depth=(depth + 1))
+
+    # helper function - poll until complete
+    def _poll_until_not(url, pending_statuses, err_msg):
+        result, t0 = None, time.time()
+        while result is None or result['status'] in pending_statuses:
+            assert (time.time() - t0 < 3600), "Polling timeout" # 1 hour timeout
+            time.sleep(0 if result is None else 2)
+            result, _, _ = _send_signed_request(url, None, err_msg)
+        return result
+
+    # parse account key to get public key
+    log.info("Parsing account key...")
+    out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error")
+    pub_pattern = r"modulus:[\s]+?00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)"
+    pub_hex, pub_exp = re.search(pub_pattern, out.decode('utf8'), re.MULTILINE|re.DOTALL).groups()
+    pub_exp = "{0:x}".format(int(pub_exp))
+    pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
+    alg, jwk = "RS256", {
+        "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
+        "kty": "RSA",
+        "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
+    }
+    accountkey_json = json.dumps(jwk, sort_keys=True, separators=(',', ':'))
+    thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
+
+    # get the ACME directory of urls
+    log.info("Getting directory...")
+    directory, _, _ = _do_request(directory_url, err_msg="Error getting directory")
+    log.info("Directory found!")
+
+    # create account and get the global key identifier
+    log.info("Registering account...")
+    reg_payload = {"termsOfServiceAgreed": True} if contact is None else {"termsOfServiceAgreed": True, "contact": contact}
+    account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering")
+    log.info("Registered!" if code == 201 else "Already registered!")
+
+    return acct_headers['Location']
+
+def main(argv=None):
+    parser = argparse.ArgumentParser(
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        description=textwrap.dedent("""\
+            Generate a CAA record for Mailcow.
+
+            Example Usage: python mailcow_gencaa.py --account-key data/assets/ssl/acme/account.pem
+            """)
+    )
+    parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key")
+    parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
+    parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt")
+    parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key")
+
+    args = parser.parse_args(argv)
+    LOGGER.setLevel(args.quiet or LOGGER.level)
+    id = get_id(args.account_key, log=LOGGER, directory_url=args.directory_url, contact=args.contact)
+    print("Use this as your CAA record:")
+    print('issue 128 "letsencrypt.org;accounturi={}"'.format(id))
+
+if __name__ == "__main__": # pragma: no cover
+    main(sys.argv[1:])

+ 5 - 1
helper-scripts/nextcloud.sh

@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 # renovate: datasource=github-releases depName=nextcloud/server versioning=semver extractVersion=^v(?<version>.*)$
-NEXTCLOUD_VERSION=27.1.2
+NEXTCLOUD_VERSION=27.1.4
 
 echo -ne "Checking prerequisites..."
 sleep 1
@@ -106,6 +106,10 @@ elif [[ ${NC_UPDATE} == "y" ]]; then
     exit 1
   else
     docker exec -it -u www-data $(docker ps -f name=php-fpm-mailcow -q) bash -c "php /web/nextcloud/updater/updater.phar"
+    NC_SUBD=$(docker exec -i -u www-data $(docker ps -f name=php-fpm-mailcow -q) /web/nextcloud/occ config:system:get overwritehost)
+    mv ./data/conf/nginx/nextcloud.conf ./data/conf/nginx/nextcloud.conf-$(date +%s).bak
+    cp ./data/assets/nextcloud/nextcloud.conf ./data/conf/nginx/
+    sed -i "s/NC_SUBD/${NC_SUBD}/g" ./data/conf/nginx/nextcloud.conf
   fi
 
 elif [[ ${NC_INSTALL} == "y" ]]; then

+ 94 - 75
update.sh

@@ -32,51 +32,44 @@ prefetch_images() {
 }
 
 docker_garbage() {
+  SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
   IMGS_TO_DELETE=()
-  for container in $(grep -oP "image: \Kmailcow.+" "${SCRIPT_DIR}/docker-compose.yml"); do
-    REPOSITORY=${container/:*}
-    TAG=${container/*:}
-    V_MAIN=${container/*.}
-    V_SUB=${container/*.}
-    EXISTING_TAGS=$(docker images | grep ${REPOSITORY} | awk '{ print $2 }')
-    for existing_tag in ${EXISTING_TAGS[@]}; do
-      V_MAIN_EXISTING=${existing_tag/*.}
-      V_SUB_EXISTING=${existing_tag/*.}
-      # Not an integer
-      [[ ! $V_MAIN_EXISTING =~ ^[0-9]+$ ]] && continue
-      [[ ! $V_SUB_EXISTING =~ ^[0-9]+$ ]] && continue
-
-      if [[ $V_MAIN_EXISTING == "latest" ]]; then
-        echo "Found deprecated label \"latest\" for repository $REPOSITORY, it should be deleted."
-        IMGS_TO_DELETE+=($REPOSITORY:$existing_tag)
-      elif [[ $V_MAIN_EXISTING -lt $V_MAIN ]]; then
-        echo "Found tag $existing_tag for $REPOSITORY, which is older than the current tag $TAG and should be deleted."
-        IMGS_TO_DELETE+=($REPOSITORY:$existing_tag)
-      elif [[ $V_SUB_EXISTING -lt $V_SUB ]]; then
-        echo "Found tag $existing_tag for $REPOSITORY, which is older than the current tag $TAG and should be deleted."
-        IMGS_TO_DELETE+=($REPOSITORY:$existing_tag)
+
+  declare -A IMAGES_INFO
+  COMPOSE_IMAGES=($(grep -oP "image: \Kmailcow.+" "${SCRIPT_DIR}/docker-compose.yml"))
+
+  for existing_image in $(docker images --format "{{.ID}}:{{.Repository}}:{{.Tag}}" | grep 'mailcow/'); do
+      ID=$(echo $existing_image | cut -d ':' -f 1)
+      REPOSITORY=$(echo $existing_image | cut -d ':' -f 2)
+      TAG=$(echo $existing_image | cut -d ':' -f 3)
+
+      if [[ " ${COMPOSE_IMAGES[@]} " =~ " ${REPOSITORY}:${TAG} " ]]; then
+          continue
+      else
+          IMGS_TO_DELETE+=("$ID")
+          IMAGES_INFO["$ID"]="$REPOSITORY:$TAG"
       fi
-    done
   done
 
   if [[ ! -z ${IMGS_TO_DELETE[*]} ]]; then
-    echo "Run the following command to delete unused image tags:"
-    echo
-    echo "    docker rmi ${IMGS_TO_DELETE[*]}"
-    echo
-    if [ ! $FORCE ]; then
-      read -r -p "Do you want to delete old image tags right now? [y/N] " response
-      if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
-        docker rmi ${IMGS_TO_DELETE[*]}
+      echo "The following unused mailcow images were found:"
+      for id in "${IMGS_TO_DELETE[@]}"; do
+          echo "    ${IMAGES_INFO[$id]} ($id)"
+      done
+
+      if [ ! $FORCE ]; then
+          read -r -p "Do you want to delete them to free up some space? [y/N] " response
+          if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+              docker rmi ${IMGS_TO_DELETE[*]}
+          else
+              echo "OK, skipped."
+          fi
       else
-        echo "OK, skipped."
+          echo "Running in forced mode! Force removing old mailcow images..."
+          docker rmi ${IMGS_TO_DELETE[*]}
       fi
-    else
-      echo "Running image removal without extra confirmation due to force mode."
-      docker rmi ${IMGS_TO_DELETE[*]}
-    fi
-    echo -e "\e[32mFurther cleanup...\e[0m"
-    echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
+      echo -e "\e[32mFurther cleanup...\e[0m"
+      echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
   fi
 }
 
@@ -178,11 +171,11 @@ remove_obsolete_nginx_ports() {
 detect_docker_compose_command(){
 if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
   if docker compose > /dev/null 2>&1; then
-      if docker compose version --short | grep "2." > /dev/null 2>&1; then
+      if docker compose version --short | grep -e "^2." -e "^v2." > /dev/null 2>&1; then
         DOCKER_COMPOSE_VERSION=native
         COMPOSE_COMMAND="docker compose"
-        echo -e "\e[31mFound Docker Compose Plugin (native).\e[0m"
-        echo -e "\e[31mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
+        echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
+        echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
         sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' $SCRIPT_DIR/mailcow.conf 
         sleep 2
         echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
@@ -196,8 +189,8 @@ if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
       if docker-compose version --short | grep "^2." > /dev/null 2>&1; then
         DOCKER_COMPOSE_VERSION=standalone
         COMPOSE_COMMAND="docker-compose"
-        echo -e "\e[31mFound Docker Compose Standalone.\e[0m"
-        echo -e "\e[31mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
+        echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
+        echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
         sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' $SCRIPT_DIR/mailcow.conf
         sleep 2
         echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
@@ -448,6 +441,8 @@ CONFIG_ARRAY=(
   "SKIP_SOGO"
   "USE_WATCHDOG"
   "WATCHDOG_NOTIFY_EMAIL"
+  "WATCHDOG_NOTIFY_WEBHOOK"
+  "WATCHDOG_NOTIFY_WEBHOOK_BODY"
   "WATCHDOG_NOTIFY_BAN"
   "WATCHDOG_NOTIFY_START"
   "WATCHDOG_EXTERNAL_CHECKS"
@@ -631,6 +626,21 @@ for option in ${CONFIG_ARRAY[@]}; do
       echo "#MAILDIR_SUB=Maildir" >> mailcow.conf
       echo "MAILDIR_SUB=" >> mailcow.conf
     fi
+  elif [[ ${option} == "WATCHDOG_NOTIFY_WEBHOOK" ]]; then
+    if ! grep -q ${option} mailcow.conf; then
+      echo "Adding new option \"${option}\" to mailcow.conf"
+      echo '# Send notifications to a webhook URL that receives a POST request with the content type "application/json".' >> mailcow.conf
+      echo '# You can use this to send notifications to services like Discord, Slack and others.' >> mailcow.conf
+      echo '#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' >> mailcow.conf
+    fi
+  elif [[ ${option} == "WATCHDOG_NOTIFY_WEBHOOK_BODY" ]]; then
+    if ! grep -q ${option} mailcow.conf; then
+      echo "Adding new option \"${option}\" to mailcow.conf"
+      echo '# JSON body included in the webhook POST request. Needs to be in single quotes.' >> mailcow.conf
+      echo '# Following variables are available: SUBJECT, BODY' >> mailcow.conf
+      WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
+      echo "#WATCHDOG_NOTIFY_WEBHOOK_BODY='${WEBHOOK_BODY}'" >> mailcow.conf
+    fi
   elif [[ ${option} == "WATCHDOG_NOTIFY_BAN" ]]; then
     if ! grep -q ${option} mailcow.conf; then
       echo "Adding new option \"${option}\" to mailcow.conf"
@@ -900,45 +910,54 @@ done
 # git remote set-url origin https://github.com/mailcow/mailcow-dockerized
 
 DEFAULT_REPO=https://github.com/mailcow/mailcow-dockerized
-CURRENT_REPO=$(git remote get-url origin)
+CURRENT_REPO=$(git config --get remote.origin.url)
 if [ "$CURRENT_REPO" != "$DEFAULT_REPO" ]; then 
   echo "The Repository currently used is not the default Mailcow Repository."
   echo "Currently Repository: $CURRENT_REPO"
   echo "Default Repository:   $DEFAULT_REPO"
-  read -r -p "Should it be changed back to default? [y/N] " repo_response
-  if [[ "$repo_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
-    git remote set-url origin $DEFAULT_REPO
+  if [ ! $FORCE ]; then
+    read -r -p "Should it be changed back to default? [y/N] " repo_response
+    if [[ "$repo_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+      git remote set-url origin $DEFAULT_REPO
+    fi
+  else
+      echo "Running in forced mode... setting Repo to default!"
+      git remote set-url origin $DEFAULT_REPO
   fi
 fi
 
-echo -e "\e[32mCommitting current status...\e[0m"
-[[ -z "$(git config user.name)" ]] && git config user.name moo
-[[ -z "$(git config user.email)" ]] && git config user.email moo@cow.moo
-[[ ! -z $(git ls-files data/conf/rspamd/override.d/worker-controller-password.inc) ]] && git rm data/conf/rspamd/override.d/worker-controller-password.inc
-git add -u
-git commit -am "Before update on ${DATE}" > /dev/null
-echo -e "\e[32mFetching updated code from remote...\e[0m"
-git fetch origin #${BRANCH}
-echo -e "\e[32mMerging local with remote code (recursive, strategy: \"${MERGE_STRATEGY:-theirs}\", options: \"patience\"...\e[0m"
-git config merge.defaultToUpstream true
-git merge -X${MERGE_STRATEGY:-theirs} -Xpatience -m "After update on ${DATE}"
-# Need to use a variable to not pass return codes of if checks
-MERGE_RETURN=$?
-if [[ ${MERGE_RETURN} == 128 ]]; then
-  echo -e "\e[31m\nOh no, what happened?\n=> You most likely added files to your local mailcow instance that were now added to the official mailcow repository. Please move them to another location before updating mailcow.\e[0m"
-  exit 1
-elif [[ ${MERGE_RETURN} == 1 ]]; then
-  echo -e "\e[93mPotenial conflict, trying to fix...\e[0m"
-  git status --porcelain | grep -E "UD|DU" | awk '{print $2}' | xargs rm -v
-  git add -A
-  git commit -m "After update on ${DATE}" > /dev/null
-  git checkout .
-  echo -e "\e[32mRemoved and recreated files if necessary.\e[0m"
-elif [[ ${MERGE_RETURN} != 0 ]]; then
-  echo -e "\e[31m\nOh no, something went wrong. Please check the error message above.\e[0m"
-  echo
-  echo "Run $COMPOSE_COMMAND up -d to restart your stack without updates or try again after fixing the mentioned errors."
-  exit 1
+if [ ! $DEV ]; then
+  echo -e "\e[32mCommitting current status...\e[0m"
+  [[ -z "$(git config user.name)" ]] && git config user.name moo
+  [[ -z "$(git config user.email)" ]] && git config user.email moo@cow.moo
+  [[ ! -z $(git ls-files data/conf/rspamd/override.d/worker-controller-password.inc) ]] && git rm data/conf/rspamd/override.d/worker-controller-password.inc
+  git add -u
+  git commit -am "Before update on ${DATE}" > /dev/null
+  echo -e "\e[32mFetching updated code from remote...\e[0m"
+  git fetch origin #${BRANCH}
+  echo -e "\e[32mMerging local with remote code (recursive, strategy: \"${MERGE_STRATEGY:-theirs}\", options: \"patience\"...\e[0m"
+  git config merge.defaultToUpstream true
+  git merge -X${MERGE_STRATEGY:-theirs} -Xpatience -m "After update on ${DATE}"
+  # Need to use a variable to not pass return codes of if checks
+  MERGE_RETURN=$?
+  if [[ ${MERGE_RETURN} == 128 ]]; then
+    echo -e "\e[31m\nOh no, what happened?\n=> You most likely added files to your local mailcow instance that were now added to the official mailcow repository. Please move them to another location before updating mailcow.\e[0m"
+    exit 1
+  elif [[ ${MERGE_RETURN} == 1 ]]; then
+    echo -e "\e[93mPotenial conflict, trying to fix...\e[0m"
+    git status --porcelain | grep -E "UD|DU" | awk '{print $2}' | xargs rm -v
+    git add -A
+    git commit -m "After update on ${DATE}" > /dev/null
+    git checkout .
+    echo -e "\e[32mRemoved and recreated files if necessary.\e[0m"
+  elif [[ ${MERGE_RETURN} != 0 ]]; then
+    echo -e "\e[31m\nOh no, something went wrong. Please check the error message above.\e[0m"
+    echo
+    echo "Run $COMPOSE_COMMAND up -d to restart your stack without updates or try again after fixing the mentioned errors."
+    exit 1
+  fi
+elif [ $DEV ]; then
+  echo -e "\e[33mDEVELOPER MODE: Not creating a git diff and commiting it to prevent development stuff within a backup diff...\e[0m"
 fi
 
 echo -e "\e[32mFetching new images, if any...\e[0m"