Browse Source

Merge pull request #5227 from mailcow/feat/domain-wide-footer

[Rspamd] add domain wide footer
Patrick Schult 2 years ago
parent
commit
0303dbc1d2

+ 143 - 0
data/conf/rspamd/lua/rspamd.local.lua

@@ -522,3 +522,146 @@ rspamd_config:register_symbol({
     end
   end
 })
+
+rspamd_config:register_symbol({
+  name = 'MOO_FOOTER',
+  type = 'prefilter',
+  callback = function(task)
+    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 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 function newline(task)
+      local t = task:get_newlines_type()
+    
+      if t == 'cr' then
+        return '\r'
+      elseif t == 'lf' then
+        return '\n'
+      end
+    
+      return '\r\n'
+    end
+    local function redis_cb_footer(err, data)
+      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
+          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)
+
+            local envfrom_mime = task:get_from(2)
+            local from_name = ""
+            if envfrom_mime and envfrom_mime[1].name then
+              from_name = envfrom_mime[1].name
+            elseif envfrom and envfrom[1].name then
+              from_name = envfrom[1].name
+            end
+
+            local replacements = {
+              auth_user = uname,
+              from_user = envfrom[1].user,
+              from_name = from_name,
+              from_addr = envfrom[1].addr,
+              from_domain = envfrom[1].domain:lower()
+            }
+            if footer.html then
+              footer.html = lua_util.jinja_template(footer.html, replacements, true)
+            end
+            if footer.plain then
+              footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
+            end
+  
+            -- add footer
+            local out = {}
+            local rewrite = lua_mime.add_text_footer(task, footer.html, footer.plain) or {}
+        
+            local seen_cte
+            local newline_s = newline(task)
+        
+            local function rewrite_ct_cb(name, hdr)
+              if rewrite.need_rewrite_ct then
+                if name:lower() == 'content-type' then
+                  local nct = string.format('%s: %s/%s; charset=utf-8',
+                      'Content-Type', rewrite.new_ct.type, rewrite.new_ct.subtype)
+                  out[#out + 1] = nct
+                  return
+                elseif name:lower() == 'content-transfer-encoding' then
+                  out[#out + 1] = string.format('%s: %s',
+                      'Content-Transfer-Encoding', 'quoted-printable')
+                  seen_cte = true
+                  return
+                end
+              end
+              out[#out + 1] = hdr.raw:gsub('\r?\n?$', '')
+            end
+        
+            task:headers_foreach(rewrite_ct_cb, {full = true})
+        
+            if not seen_cte and rewrite.need_rewrite_ct then
+              out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable')
+            end
+        
+            -- End of headers
+            out[#out + 1] = newline_s
+        
+            if rewrite.out then
+              for _,o in ipairs(rewrite.out) do
+                out[#out + 1] = o
+              end
+            else
+              out[#out + 1] = task:get_rawbody()
+            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
+            end
+            task:set_message(out_parts)
+          else
+            rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\")", uname, data)
+          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
+
+    return true
+  end,
+  priority = 1
+})

+ 2 - 0
data/web/edit.php

@@ -47,6 +47,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
           $quota_notification_bcc = quota_notification_bcc('get', $domain);
           $rl = ratelimit('get', 'domain', $domain);
           $rlyhosts = relayhost('get');
+          $domain_footer = mailbox('get', 'domain_wide_footer', $domain);
           $template = 'edit/domain.twig';
           $template_data = [
             'acl' => $_SESSION['acl'],
@@ -56,6 +57,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
             'rlyhosts' => $rlyhosts,
             'dkim' => dkim('details', $domain),
             'domain_details' => $result,
+            'domain_footer' => $domain_footer,
           ];
       }
     }

+ 73 - 0
data/web/inc/functions.mailbox.inc.php

@@ -3320,6 +3320,45 @@ 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;
+          }
+          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;
+          }
+
+          $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));
+          }
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+          $_SESSION['return'][] = array(
+            'type' => 'success',
+            'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+            'msg' => array('domain_footer_modified', htmlspecialchars($domain))
+          );
+        break;
       }
     break;
     case 'get':
@@ -4432,6 +4471,40 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
           }
           return $resourcedata;
         break;
+        case 'domain_wide_footer':
+          $domain = idn_to_ascii(strtolower(trim($_data)), 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'], $_data)) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'msg' => 'access_denied'
+            );
+            return false;
+          }
+
+          try {
+            $footers = $redis->hGet('DOMAIN_WIDE_FOOTER', $domain);
+            $footers = json_decode($footers, true);
+          }
+          catch (RedisException $e) {
+            $_SESSION['return'][] = array(
+              'type' => 'danger',
+              'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+              'msg' => array('redis_error', $e)
+            );
+            return false;
+          }
+
+          return $footers;
+        break;
       }
     break;
     case 'delete':

+ 3 - 0
data/web/json_api.php

@@ -1867,6 +1867,9 @@ 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)));
         break;

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

@@ -581,6 +581,10 @@
         "disable_login": "Login verbieten (Mails werden weiterhin angenommen)",
         "domain": "Domain bearbeiten",
         "domain_admin": "Domain-Administrator bearbeiten",
+        "domain_footer": "Domain wide footer",
+        "domain_footer_html": "HTML footer",
+        "domain_footer_info": "Domain wide footer werden allen ausgehenden E-Mails hinzugefügt, die einer Adresse innerhalb dieser Domain gehört.<br>Die folgenden Variablen können für den Footer benutzt werden:",
+        "domain_footer_plain": "PLAIN footer",
         "domain_quota": "Domain Speicherplatz gesamt (MiB)",
         "domains": "Domains",
         "dont_check_sender_acl": "Absender für Domain %s u. Alias-Domain nicht prüfen",
@@ -1017,6 +1021,7 @@
         "domain_admin_added": "Domain-Administrator %s wurde angelegt",
         "domain_admin_modified": "Änderungen an Domain-Administrator %s wurden gespeichert",
         "domain_admin_removed": "Domain-Administrator %s wurde entfernt",
+        "domain_footer_modified": "Änderungen an Domain Footer %s wurden gespeichert",
         "domain_modified": "Änderungen an Domain %s wurden gespeichert",
         "domain_removed": "Domain %s wurde entfernt",
         "dovecot_restart_success": "Dovecot wurde erfolgreich neu gestartet",

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

@@ -583,6 +583,17 @@
         "disable_login": "Disallow login (incoming mail is still accepted)",
         "domain": "Edit domain",
         "domain_admin": "Edit domain administrator",
+        "domain_footer": "Domain wide footer",
+        "domain_footer_html": "HTML footer",
+        "domain_footer_info": "Domain-wide footers are added to all outgoing emails associated with an address within this domain. <br> The following variables can be used for the footer:",
+        "domain_footer_info_vars": {
+            "auth_user": "{= auth_user =}   - Authenticated Username specified by an MTA",
+            "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"
+        },
+        "domain_footer_plain": "PLAIN footer",
         "domain_quota": "Domain quota",
         "domains": "Domains",
         "dont_check_sender_acl": "Disable sender check for domain %s (+ alias domains)",
@@ -1026,6 +1037,7 @@
         "domain_admin_added": "Domain administrator %s has been added",
         "domain_admin_modified": "Changes to domain administrator %s have been saved",
         "domain_admin_removed": "Domain administrator %s has been removed",
+        "domain_footer_modified": "Changes to domain footer %s have been saved",
         "domain_modified": "Changes to domain %s have been saved",
         "domain_removed": "Domain %s has been removed",
         "dovecot_restart_success": "Dovecot was restarted successfully",

+ 42 - 0
data/web/templates/edit/domain.twig

@@ -8,6 +8,7 @@
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dratelimit">{{ lang.edit.ratelimit }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dspamfilter">{{ lang.edit.spam_filter }}</button></li>
       <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dqwbcc">{{ lang.edit.quota_warning_bcc }}</button></li>
+      <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#dfooter">{{ lang.edit.domain_footer }}</button></li>
     </ul>
     <hr class="d-none d-md-block">
     <div class="tab-content">
@@ -268,6 +269,47 @@
             </div>
         </div>
       </div>
+      <div id="dfooter" class="tab-pane fade" role="tabpanel" aria-labelledby="domain-footer">
+        <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-footer" data-bs-toggle="collapse" aria-controls="collapse-tab-footer">
+                {{ lang.edit.domain_footer }} <span class="badge bg-info table-lines"></span>
+              </button>
+            </div>
+            <div id="collapse-tab-footer" class="card-body collapse" data-bs-parent="#domain-content">
+                <div class="row">
+                  <div class="col-sm-12">
+                    <h4>{{ lang.edit.domain_footer }}</h4>
+                    <p>{{ lang.edit.domain_footer_info|raw }}</p>
+                    <pre>{{ lang.edit.domain_footer_info_vars.auth_user }}
+{{ 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>
+                    <form class="form-horizontal mt-4" data-id="domain_footer">
+                      <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>
+                        </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>
+                        </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>
+                        </div>
+                      </div>
+                    </form>
+                  </div>
+                </div>
+            </div>
+        </div>
+      </div>
     </div>
 </div>
 {% else %}