Browse Source

Merge pull request #5578 from mailcow/staging

2023-11a
Niklas Meyer 1 year ago
parent
commit
2024cda560

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

@@ -79,6 +79,9 @@ EOF
   redis-cli -h redis-mailcow SLAVEOF NO ONE
   redis-cli -h redis-mailcow SLAVEOF NO ONE
 fi
 fi
 
 
+# Provide additional lua modules
+ln -s /usr/lib/$(uname -m)-linux-gnu/liblua5.1-cjson.so.0.0.0 /usr/lib/rspamd/cjson.so
+
 chown -R _rspamd:_rspamd /var/lib/rspamd \
 chown -R _rspamd:_rspamd /var/lib/rspamd \
   /etc/rspamd/local.d \
   /etc/rspamd/local.d \
   /etc/rspamd/override.d \
   /etc/rspamd/override.d \

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

@@ -1,6 +1,6 @@
-# Whitelist generated by Postwhite v3.4 on Wed Nov  1 00:14:24 UTC 2023
+# Whitelist generated by Postwhite v3.4 on Fri Dec  1 00:15:18 UTC 2023
 # https://github.com/stevejenkins/postwhite/
 # https://github.com/stevejenkins/postwhite/
-# 2030 total rules
+# 2038 total rules
 2a00:1450:4000::/36	permit
 2a00:1450:4000::/36	permit
 2a01:111:f400::/48	permit
 2a01:111:f400::/48	permit
 2a01:111:f403:8000::/50	permit
 2a01:111:f403:8000::/50	permit
@@ -10,10 +10,10 @@
 2a02:a60:0:5::/64	permit
 2a02:a60:0:5::/64	permit
 2c0f:fb50:4000::/36	permit
 2c0f:fb50:4000::/36	permit
 2.207.151.53	permit
 2.207.151.53	permit
-3.14.230.16	permit
 3.70.123.177	permit
 3.70.123.177	permit
 3.93.157.0/24	permit
 3.93.157.0/24	permit
 3.129.120.190	permit
 3.129.120.190	permit
+3.137.78.75	permit
 3.210.190.0/24	permit
 3.210.190.0/24	permit
 8.20.114.31	permit
 8.20.114.31	permit
 8.25.194.0/23	permit
 8.25.194.0/23	permit
@@ -183,8 +183,6 @@
 50.18.125.237	permit
 50.18.125.237	permit
 50.18.126.162	permit
 50.18.126.162	permit
 50.31.32.0/19	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.137.58.21	permit
 51.140.75.55	permit
 51.140.75.55	permit
 51.144.100.179	permit
 51.144.100.179	permit
@@ -304,22 +302,31 @@
 64.147.123.27	permit
 64.147.123.27	permit
 64.147.123.28	permit
 64.147.123.28	permit
 64.147.123.29	permit
 64.147.123.29	permit
+64.147.123.128/27	permit
 64.207.219.7	permit
 64.207.219.7	permit
 64.207.219.8	permit
 64.207.219.8	permit
 64.207.219.9	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.13	permit
 64.207.219.14	permit
 64.207.219.14	permit
 64.207.219.15	permit
 64.207.219.15	permit
 64.207.219.71	permit
 64.207.219.71	permit
 64.207.219.72	permit
 64.207.219.72	permit
 64.207.219.73	permit
 64.207.219.73	permit
+64.207.219.74	permit
 64.207.219.75	permit
 64.207.219.75	permit
+64.207.219.76	permit
 64.207.219.77	permit
 64.207.219.77	permit
 64.207.219.78	permit
 64.207.219.78	permit
 64.207.219.79	permit
 64.207.219.79	permit
 64.207.219.135	permit
 64.207.219.135	permit
 64.207.219.136	permit
 64.207.219.136	permit
 64.207.219.137	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.141	permit
 64.207.219.142	permit
 64.207.219.142	permit
 64.207.219.143	permit
 64.207.219.143	permit
@@ -397,7 +404,6 @@
 66.196.81.232/31	permit
 66.196.81.232/31	permit
 66.196.81.234	permit
 66.196.81.234	permit
 66.211.168.230/31	permit
 66.211.168.230/31	permit
-66.211.170.86/31	permit
 66.211.170.88/29	permit
 66.211.170.88/29	permit
 66.211.184.0/23	permit
 66.211.184.0/23	permit
 66.218.74.64/30	permit
 66.218.74.64/30	permit
@@ -621,7 +627,9 @@
 82.165.229.130	permit
 82.165.229.130	permit
 82.165.230.21	permit
 82.165.230.21	permit
 82.165.230.22	permit
 82.165.230.22	permit
+84.116.6.0/23	permit
 84.116.36.0/24	permit
 84.116.36.0/24	permit
+84.116.50.0/23	permit
 85.158.136.0/21	permit
 85.158.136.0/21	permit
 86.61.88.25	permit
 86.61.88.25	permit
 87.198.219.130	permit
 87.198.219.130	permit
@@ -1193,7 +1201,6 @@
 104.130.96.0/28	permit
 104.130.96.0/28	permit
 104.130.122.0/23	permit
 104.130.122.0/23	permit
 104.214.25.77	permit
 104.214.25.77	permit
-104.245.209.192/26	permit
 106.10.144.64/27	permit
 106.10.144.64/27	permit
 106.10.144.100/31	permit
 106.10.144.100/31	permit
 106.10.144.103	permit
 106.10.144.103	permit
@@ -1422,6 +1429,7 @@
 136.143.182.0/23	permit
 136.143.182.0/23	permit
 136.143.184.0/24	permit
 136.143.184.0/24	permit
 136.143.188.0/24	permit
 136.143.188.0/24	permit
+136.143.190.0/23	permit
 136.147.128.0/20	permit
 136.147.128.0/20	permit
 136.147.135.0/24	permit
 136.147.135.0/24	permit
 136.147.176.0/20	permit
 136.147.176.0/20	permit
@@ -1458,7 +1466,6 @@
 146.20.215.0/24	permit
 146.20.215.0/24	permit
 146.20.215.182	permit
 146.20.215.182	permit
 146.88.28.0/24	permit
 146.88.28.0/24	permit
-147.160.158.0/24	permit
 147.243.1.47	permit
 147.243.1.47	permit
 147.243.1.48	permit
 147.243.1.48	permit
 147.243.1.153	permit
 147.243.1.153	permit
@@ -1558,6 +1565,7 @@
 168.245.127.231	permit
 168.245.127.231	permit
 169.148.129.0/24	permit
 169.148.129.0/24	permit
 169.148.131.0/24	permit
 169.148.131.0/24	permit
+169.148.142.10	permit
 169.148.144.0/25	permit
 169.148.144.0/25	permit
 170.10.68.0/22	permit
 170.10.68.0/22	permit
 170.10.128.0/24	permit
 170.10.128.0/24	permit
@@ -1710,7 +1718,6 @@
 198.244.60.0/22	permit
 198.244.60.0/22	permit
 198.245.80.0/20	permit
 198.245.80.0/20	permit
 198.245.81.0/24	permit
 198.245.81.0/24	permit
-199.15.176.173	permit
 199.15.213.187	permit
 199.15.213.187	permit
 199.15.226.37	permit
 199.15.226.37	permit
 199.16.156.0/22	permit
 199.16.156.0/22	permit
@@ -1718,6 +1725,7 @@
 199.33.145.32	permit
 199.33.145.32	permit
 199.34.22.36	permit
 199.34.22.36	permit
 199.59.148.0/22	permit
 199.59.148.0/22	permit
+199.67.80.2	permit
 199.67.84.0/24	permit
 199.67.84.0/24	permit
 199.67.86.0/24	permit
 199.67.86.0/24	permit
 199.67.88.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";
+# }

+ 32 - 24
data/conf/rspamd/lua/rspamd.local.lua

@@ -527,20 +527,21 @@ rspamd_config:register_symbol({
   name = 'MOO_FOOTER',
   name = 'MOO_FOOTER',
   type = 'prefilter',
   type = 'prefilter',
   callback = function(task)
   callback = function(task)
+    local cjson = require "cjson"
     local lua_mime = require "lua_mime"
     local lua_mime = require "lua_mime"
     local lua_util = require "lua_util"
     local lua_util = require "lua_util"
     local rspamd_logger = require "rspamd_logger"
     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 envfrom = task:get_from(1)
     local uname = task:get_user()
     local uname = task:get_user()
     if not envfrom or not uname then
     if not envfrom or not uname then
       return false
       return false
     end
     end
     local uname = uname:lower()
     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 function newline(task)
       local t = task:get_newlines_type()
       local t = task:get_newlines_type()
     
     
@@ -552,20 +553,19 @@ rspamd_config:register_symbol({
     
     
       return '\r\n'
       return '\r\n'
     end
     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
       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)
         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
       else
+        
         -- parse json string
         -- 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)
           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
         else
-          local footer = parser:get_object()
-
           if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "")  then
           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", uname, footer.html, footer.plain)
+            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 envfrom_mime = task:get_from(2)
             local from_name = ""
             local from_name = ""
@@ -575,6 +575,7 @@ rspamd_config:register_symbol({
               from_name = envfrom[1].name
               from_name = envfrom[1].name
             end
             end
 
 
+            -- default replacements
             local replacements = {
             local replacements = {
               auth_user = uname,
               auth_user = uname,
               from_user = envfrom[1].user,
               from_user = envfrom[1].user,
@@ -582,10 +583,20 @@ rspamd_config:register_symbol({
               from_addr = envfrom[1].addr,
               from_addr = envfrom[1].addr,
               from_domain = envfrom[1].domain:lower()
               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)
               footer.html = lua_util.jinja_template(footer.html, replacements, true)
             end
             end
-            if footer.plain then
+            if footer.plain and footer.plain ~= "" then
               footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
               footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
             end
             end
   
   
@@ -653,17 +664,14 @@ rspamd_config:register_symbol({
       end
       end
     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
     return true
   end,
   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";
 whitelisted_rcpts = "postmaster,mailer-daemon";
 max_rcpt = 25;
 max_rcpt = 25;
 custom_keywords = "/etc/rspamd/lua/ratelimit.lua";
 custom_keywords = "/etc/rspamd/lua/ratelimit.lua";

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

@@ -3137,6 +3137,86 @@ paths:
                     type: string
                     type: string
               type: object
               type: object
       summary: Update domain
       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:
   /api/v1/edit/fail2ban:
     post:
     post:
       responses:
       responses:
@@ -3336,6 +3416,86 @@ paths:
                   type: object
                   type: object
               type: object
               type: object
       summary: Update mailbox
       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:
   /api/v1/edit/mailq:
     post:
     post:
       responses:
       responses:
@@ -5581,6 +5741,7 @@ paths:
                         sogo_access: "1"
                         sogo_access: "1"
                         tls_enforce_in: "0"
                         tls_enforce_in: "0"
                         tls_enforce_out: "0"
                         tls_enforce_out: "0"
+                      custom_attributes: {}
                       domain: domain3.tld
                       domain: domain3.tld
                       is_relayed: 0
                       is_relayed: 0
                       local_part: info
                       local_part: info
@@ -5646,6 +5807,40 @@ paths:
                       items:
                       items:
                         type: string
                         type: string
       summary: Edit Cross-Origin Resource Sharing (CORS) settings
       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:
 tags:
   - name: Domains
   - name: Domains

+ 3 - 0
data/web/edit.php

@@ -58,6 +58,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
             'dkim' => dkim('details', $domain),
             'dkim' => dkim('details', $domain),
             'domain_details' => $result,
             'domain_details' => $result,
             'domain_footer' => $domain_footer,
             '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['result'] = $result;
 $template_data['return_to'] = $_SESSION['return_to'];
 $template_data['return_to'] = $_SESSION['return_to'];
 $template_data['lang_user'] = json_encode($lang['user']);
 $template_data['lang_user'] = json_encode($lang['user']);
+$template_data['lang_admin'] = json_encode($lang['admin']);
 $template_data['lang_datatables'] = json_encode($lang['datatables']);
 $template_data['lang_datatables'] = json_encode($lang['datatables']);
 
 
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
 require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 148 - 36
data/web/inc/functions.mailbox.inc.php

@@ -3264,6 +3264,62 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           );
           );
           return true;
           return true;
         break;
         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':
         case 'resource':
           if (!is_array($_data['name'])) {
           if (!is_array($_data['name'])) {
             $names = array();
             $names = array();
@@ -3343,44 +3399,89 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             );
             );
           }
           }
         break;
         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 = 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(
             $_SESSION['return'][] = array(
-              'type' => 'danger',
+              'type' => 'success',
               'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
               '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;
       }
       }
     break;
     break;
@@ -3934,13 +4035,17 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
           if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
             return false;
             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(
           $stmt->execute(array(
             ':domain' => $_data,
             ':domain' => $_data,
           ));
           ));
           $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
           $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
           while($row = array_shift($rows)) {
           while($row = array_shift($rows)) {
-            $aliases[] = $row['id'];
+            if ($_extra == "address"){
+              $aliases[] = $row['address'];
+            } else {
+              $aliases[] = $row['id'];
+            }
           }
           }
           return $aliases;
           return $aliases;
         break;
         break;
@@ -4292,6 +4397,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               `mailbox`.`modified`,
               `mailbox`.`modified`,
               `quota2`.`bytes`,
               `quota2`.`bytes`,
               `attributes`,
               `attributes`,
+              `custom_attributes`,
               `quota2`.`messages`
               `quota2`.`messages`
                 FROM `mailbox`, `quota2`, `domain`
                 FROM `mailbox`, `quota2`, `domain`
                   WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
                   WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
@@ -4312,6 +4418,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               `mailbox`.`modified`,
               `mailbox`.`modified`,
               `quota2replica`.`bytes`,
               `quota2replica`.`bytes`,
               `attributes`,
               `attributes`,
+              `custom_attributes`,
               `quota2replica`.`messages`
               `quota2replica`.`messages`
                 FROM `mailbox`, `quota2replica`, `domain`
                 FROM `mailbox`, `quota2replica`, `domain`
                   WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
                   WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
@@ -4334,6 +4441,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           $mailboxdata['quota'] = $row['quota'];
           $mailboxdata['quota'] = $row['quota'];
           $mailboxdata['messages'] = $row['messages'];
           $mailboxdata['messages'] = $row['messages'];
           $mailboxdata['attributes'] = json_decode($row['attributes'], true);
           $mailboxdata['attributes'] = json_decode($row['attributes'], true);
+          $mailboxdata['custom_attributes'] = json_decode($row['custom_attributes'], true);
           $mailboxdata['quota_used'] = intval($row['bytes']);
           $mailboxdata['quota_used'] = intval($row['bytes']);
           $mailboxdata['percent_in_use'] = ($row['quota'] == 0) ? '- ' : round((intval($row['bytes']) / intval($row['quota'])) * 100);
           $mailboxdata['percent_in_use'] = ($row['quota'] == 0) ? '- ' : round((intval($row['bytes']) / intval($row['quota'])) * 100);
           $mailboxdata['created'] = $row['created'];
           $mailboxdata['created'] = $row['created'];
@@ -4514,19 +4622,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           }
           }
 
 
           try {
           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(
             $_SESSION['return'][] = array(
               'type' => 'danger',
               'type' => 'danger',
               'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
               'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
-              'msg' => array('redis_error', $e)
+              'msg' => $e->getMessage()
             );
             );
             return false;
             return false;
           }
           }
 
 
-          return $footers;
+          return $footer;
         break;
         break;
       }
       }
     break;
     break;

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

@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
   try {
     global $pdo;
     global $pdo;
 
 
-    $db_version = "15112023_1536";
+    $db_version = "21112023_1644";
 
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
     $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"
         "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(
       "tags_domain" => array(
         "cols" => array(
         "cols" => array(
           "tag_name" => "VARCHAR(255) NOT NULL",
           "tag_name" => "VARCHAR(255) NOT NULL",
@@ -344,6 +358,7 @@ function init_db_schema() {
           "local_part" => "VARCHAR(255) NOT NULL",
           "local_part" => "VARCHAR(255) NOT NULL",
           "domain" => "VARCHAR(255) NOT NULL",
           "domain" => "VARCHAR(255) NOT NULL",
           "attributes" => "JSON",
           "attributes" => "JSON",
+          "custom_attributes" => "JSON NOT NULL DEFAULT ('{}')",
           "kind" => "VARCHAR(100) NOT NULL DEFAULT ''",
           "kind" => "VARCHAR(100) NOT NULL DEFAULT ''",
           "multiple_bookings" => "INT NOT NULL DEFAULT -1",
           "multiple_bookings" => "INT NOT NULL DEFAULT -1",
           "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
           "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",

+ 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
   // detect element visibility changes
   function onVisible(element, callback) {
   function onVisible(element, callback) {

+ 12 - 2
data/web/json_api.php

@@ -1591,6 +1591,12 @@ if (isset($_GET['query'])) {
               }
               }
             }
             }
           break;
           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;
         break;
         // return no route found if no case is matched
         // return no route found if no case is matched
         default:
         default:
@@ -1867,8 +1873,6 @@ if (isset($_GET['query'])) {
         case "quota_notification_bcc":
         case "quota_notification_bcc":
           process_edit_return(quota_notification_bcc('edit', $attr));
           process_edit_return(quota_notification_bcc('edit', $attr));
         break;
         break;
-        case "domain-wide-footer":
-          process_edit_return(mailbox('edit', 'domain_wide_footer', $attr));
         break;
         break;
         case "mailq":
         case "mailq":
           process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));
           process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));
@@ -1881,6 +1885,9 @@ if (isset($_GET['query'])) {
             case "template":
             case "template":
               process_edit_return(mailbox('edit', 'mailbox_templates', array_merge(array('ids' => $items), $attr)));
               process_edit_return(mailbox('edit', 'mailbox_templates', array_merge(array('ids' => $items), $attr)));
             break;
             break;
+            case "custom-attribute":
+              process_edit_return(mailbox('edit', 'mailbox_custom_attribute', array_merge(array('mailboxes' => $items), $attr)));
+            break;
             default:
             default:
               process_edit_return(mailbox('edit', 'mailbox', array_merge(array('username' => $items), $attr)));
               process_edit_return(mailbox('edit', 'mailbox', array_merge(array('username' => $items), $attr)));
             break;
             break;
@@ -1900,6 +1907,9 @@ if (isset($_GET['query'])) {
             case "template":
             case "template":
               process_edit_return(mailbox('edit', 'domain_templates', array_merge(array('ids' => $items), $attr)));
               process_edit_return(mailbox('edit', 'domain_templates', array_merge(array('ids' => $items), $attr)));
             break;
             break;
+            case "footer":
+              process_edit_return(mailbox('edit', 'domain_wide_footer', array_merge(array('domains' => $items), $attr)));
+            break;
             default:
             default:
               process_edit_return(mailbox('edit', 'domain', array_merge(array('domain' => $items), $attr)));
               process_edit_return(mailbox('edit', 'domain', array_merge(array('domain' => $items), $attr)));
             break;
             break;

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

@@ -209,7 +209,7 @@
         "include_exclude_info": "Ve výchozím nastavení (bez výběru), jsou adresovány <b>všechny mailové schránky</b>",
         "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",
         "includes": "Zahrnout tyto přijemce",
         "ip_check": "Kontrola IP",
         "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>",
+        "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.",
         "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",
         "is_mx_based": "Na základě MX",
         "last_applied": "Naposledy použité",
         "last_applied": "Naposledy použité",

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

@@ -574,6 +574,7 @@
         "client_secret": "Client-Secret",
         "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.",
         "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",
         "created_on": "Erstellt am",
+        "custom_attributes": "benutzerdefinierte Attribute",
         "delete1": "Lösche Nachricht nach Übertragung vom Quell-Server",
         "delete1": "Lösche Nachricht nach Übertragung vom Quell-Server",
         "delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind",
         "delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind",
         "delete2duplicates": "Lösche Duplikate im Ziel",
         "delete2duplicates": "Lösche Duplikate im Ziel",
@@ -614,6 +615,7 @@
         "max_quota": "Max. Größe per Mailbox (MiB)",
         "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>",
         "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)",
         "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.",
         "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)",
         "mins_interval": "Intervall (min)",
         "multiple_bookings": "Mehrfaches Buchen",
         "multiple_bookings": "Mehrfaches Buchen",
@@ -1125,6 +1127,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_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_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.",
         "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": "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.",
         "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",
         "clear_recent_successful_connections": "Alle erfolgreichen Verbindungen bereinigen",
@@ -1244,6 +1247,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.",
         "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",
         "user_settings": "Benutzereinstellungen",
         "username": "Benutzername",
         "username": "Benutzername",
+        "value": "Wert",
         "verify": "Verifizieren",
         "verify": "Verifizieren",
         "waiting": "Warte auf Ausführung",
         "waiting": "Warte auf Ausführung",
         "week": "Woche",
         "week": "Woche",

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

@@ -576,6 +576,7 @@
         "client_secret": "Client secret",
         "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",
         "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",
         "created_on": "Created on",
+        "custom_attributes": "Custom attributes",
         "delete1": "Delete from source when completed",
         "delete1": "Delete from source when completed",
         "delete2": "Delete messages on destination that are not on source",
         "delete2": "Delete messages on destination that are not on source",
         "delete2duplicates": "Delete duplicates on destination",
         "delete2duplicates": "Delete duplicates on destination",
@@ -592,7 +593,8 @@
             "from_user": "{= from_user =}   - From user part of envelope, e.g for \"moo@mailcow.tld\" it returns \"moo\"",
             "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_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_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_footer_plain": "PLAIN footer",
         "domain_quota": "Domain quota",
         "domain_quota": "Domain quota",
@@ -623,6 +625,7 @@
         "max_quota": "Max. quota per mailbox (MiB)",
         "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>",
         "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>",
         "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.",
         "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)",
         "mins_interval": "Interval (min)",
         "multiple_bookings": "Multiple bookings",
         "multiple_bookings": "Multiple bookings",
@@ -1141,6 +1144,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_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_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.",
         "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": "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.",
         "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",
         "clear_recent_successful_connections": "Clear seen successful connections",
@@ -1271,6 +1275,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.",
         "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",
         "user_settings": "User settings",
         "username": "Username",
         "username": "Username",
+        "value": "Value",
         "verify": "Verify",
         "verify": "Verify",
         "waiting": "Waiting",
         "waiting": "Waiting",
         "week": "week",
         "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ä",
         "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_token": "Lomakkeen tunnus sanoma ei kelpaa: tunnus sanoman risti riita",
         "session_ua": "Lomakkeen tunnus sanoma ei kelpaa: käyttäjä agentin tarkistus virhe"
         "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"
+        }
     }
     }
 }
 }

+ 10 - 5
data/web/lang/lang.pt-br.json

@@ -1,6 +1,6 @@
 {
 {
     "acl": {
     "acl": {
-        "alias_domains": "Adicionar domínios de alias",
+        "alias_domains": "Adicionar domínios alternativos",
         "app_passwds": "Gerenciar senhas de aplicativos",
         "app_passwds": "Gerenciar senhas de aplicativos",
         "bcc_maps": "Mapas BCC",
         "bcc_maps": "Mapas BCC",
         "delimiter_action": "Ação delimitadora",
         "delimiter_action": "Ação delimitadora",
@@ -107,7 +107,8 @@
         "timeout2": "Tempo limite para conexão com o host local",
         "timeout2": "Tempo limite para conexão com o host local",
         "username": "Nome de usuário",
         "username": "Nome de usuário",
         "validate": "Validar",
         "validate": "Validar",
-        "validation_success": "Validado com sucesso"
+        "validation_success": "Validado com sucesso",
+        "dry": "Simular sincronização"
     },
     },
     "admin": {
     "admin": {
         "access": "Acesso",
         "access": "Acesso",
@@ -681,7 +682,9 @@
         "title": "Editar objeto",
         "title": "Editar objeto",
         "unchanged_if_empty": "Se inalterado, deixe em branco",
         "unchanged_if_empty": "Se inalterado, deixe em branco",
         "username": "Nome de usuário",
         "username": "Nome de usuário",
-        "validate_save": "Valide e salve"
+        "validate_save": "Valide e salve",
+        "custom_attributes": "Atributos personalizados",
+        "mbox_exclude": "Excluir caixas de email"
     },
     },
     "fido2": {
     "fido2": {
         "confirm": "Confirme",
         "confirm": "Confirme",
@@ -873,7 +876,7 @@
         "sieve_preset_5": "Resposta automática (férias)",
         "sieve_preset_5": "Resposta automática (férias)",
         "sieve_preset_6": "Rejeitar e-mail com resposta",
         "sieve_preset_6": "Rejeitar e-mail com resposta",
         "sieve_preset_7": "Redirecionar e manter/soltar",
         "sieve_preset_7": "Redirecionar e manter/soltar",
-        "sieve_preset_8": "Descartar mensagem enviada para um endereço de alias do qual o remetente faz parte",
+        "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>.",
         "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": "O alias é visível no SoGo",
         "sogo_visible_n": "Ocultar alias no SoGo",
         "sogo_visible_n": "Ocultar alias no SoGo",
@@ -1277,7 +1280,9 @@
         "weeks": "semanas",
         "weeks": "semanas",
         "with_app_password": "com senha do aplicativo",
         "with_app_password": "com senha do aplicativo",
         "year": "ano",
         "year": "ano",
-        "years": "anos"
+        "years": "anos",
+        "attribute": "Atributo",
+        "value": "Valor"
     },
     },
     "warning": {
     "warning": {
         "cannot_delete_self": "Não é possível excluir o usuário conectado",
         "cannot_delete_self": "Não é possível excluir o usuário conectado",

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

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

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

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

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

@@ -24,6 +24,7 @@
 
 
 <script type='text/javascript'>
 <script type='text/javascript'>
   var lang_user = {{ lang_user|raw }};
   var lang_user = {{ lang_user|raw }};
+  var lang_admin = {{ lang_admin|raw }};
   var lang_datatables = {{ lang_datatables|raw }};
   var lang_datatables = {{ lang_datatables|raw }};
   var csrf_token = '{{ csrf_token }}';
   var csrf_token = '{{ csrf_token }}';
   var pagination_size = Math.trunc('{{ pagination_size }}');
   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>
                     <label class="control-label col-sm-2">{{ lang.edit.ratelimit }}</label>
                     <div class="col-sm-10">
                     <div class="col-sm-10">
                       <div class="input-group">
                       <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">
                         <select name="rl_frame" class="form-control">
                         {% include 'mailbox/rl-frame.twig' %}
                         {% include 'mailbox/rl-frame.twig' %}
                         </select>
                         </select>
@@ -285,23 +285,41 @@
 {{ lang.edit.domain_footer_info_vars.from_user }}
 {{ lang.edit.domain_footer_info_vars.from_user }}
 {{ lang.edit.domain_footer_info_vars.from_name }}
 {{ lang.edit.domain_footer_info_vars.from_name }}
 {{ lang.edit.domain_footer_info_vars.from_addr }}
 {{ 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">
                     <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">
                       <div class="row mb-2">
                         <label class="control-label col-sm-2" for="domain_footer_html">{{ lang.edit.domain_footer_html }}:</label>
                         <label class="control-label col-sm-2" for="domain_footer_html">{{ lang.edit.domain_footer_html }}:</label>
                         <div class="col-sm-10">
                         <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>
                       </div>
                       <div class="row mb-4">
                       <div class="row mb-4">
                         <label class="control-label col-sm-2" for="domain_footer_plain">{{ lang.edit.domain_footer_plain }}:</label>
                         <label class="control-label col-sm-2" for="domain_footer_plain">{{ lang.edit.domain_footer_plain }}:</label>
                         <div class="col-sm-10">
                         <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>
                       </div>
                       <div class="row">
                       <div class="row">
                         <div class="offset-sm-2 col-sm-10">
                         <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>
                       </div>
                       </div>
                     </form>
                     </form>

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

@@ -5,6 +5,7 @@
 <div id="mailbox-content" class="responsive-tabs">
 <div id="mailbox-content" class="responsive-tabs">
     <ul class="nav nav-tabs" role="tablist">
     <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 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="#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="#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>
       <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>
       </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 id="mpushover" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-pushover">
         <div class="card mb-4">
         <div class="card mb-4">
             <div class="card-header d-flex d-md-none fs-5">
             <div class="card-header d-flex d-md-none fs-5">

+ 1 - 1
docker-compose.yml

@@ -77,7 +77,7 @@ services:
             - clamd
             - clamd
 
 
     rspamd-mailcow:
     rspamd-mailcow:
-      image: mailcow/rspamd:1.93
+      image: mailcow/rspamd:1.94
       stop_grace_period: 30s
       stop_grace_period: 30s
       depends_on:
       depends_on:
         - dovecot-mailcow
         - dovecot-mailcow

+ 2 - 2
generate_config.sh

@@ -26,7 +26,7 @@ for bin in openssl curl docker git awk sha1sum grep cut; do
 done
 done
 
 
 if docker compose > /dev/null 2>&1; 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
       COMPOSE_VERSION=native
       COMPOSE_VERSION=native
       echo -e "\e[33mFound Docker Compose Plugin (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"
       echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
@@ -553,4 +553,4 @@ else
   echo -e "\e[33mCannot determine current git repository version...\e[0m"
   echo -e "\e[33mCannot determine current git repository version...\e[0m"
 fi
 fi
 
 
-detect_bad_asn
+detect_bad_asn

+ 1 - 1
helper-scripts/nextcloud.sh

@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 # renovate: datasource=github-releases depName=nextcloud/server versioning=semver extractVersion=^v(?<version>.*)$
 # renovate: datasource=github-releases depName=nextcloud/server versioning=semver extractVersion=^v(?<version>.*)$
-NEXTCLOUD_VERSION=27.1.3
+NEXTCLOUD_VERSION=27.1.4
 
 
 echo -ne "Checking prerequisites..."
 echo -ne "Checking prerequisites..."
 sleep 1
 sleep 1

+ 1 - 1
update.sh

@@ -171,7 +171,7 @@ remove_obsolete_nginx_ports() {
 detect_docker_compose_command(){
 detect_docker_compose_command(){
 if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
 if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
   if docker compose > /dev/null 2>&1; 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
         DOCKER_COMPOSE_VERSION=native
         COMPOSE_COMMAND="docker compose"
         COMPOSE_COMMAND="docker compose"
         echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
         echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"