Browse Source

[Rspamd] add domain wide footer

FreddleSpl0it 2 years ago
parent
commit
f295b8cd91

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

@@ -499,3 +499,123 @@ 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 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)
+  
+            -- 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':
@@ -4399,6 +4438,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':

+ 15 - 12
data/web/json_api.php

@@ -288,18 +288,18 @@ if (isset($_GET['query'])) {
         case "domain-admin":
           process_add_return(domain_admin('add', $attr));
         break;
-        case "sso":
-          switch ($object) {
-            case "domain-admin":
-              $data = domain_admin_sso('issue', $attr);
-              if($data) {
-                echo json_encode($data);
-                exit(0);
-              }
-              process_add_return($data);
-            break;
-          }
-        break;
+        case "sso":
+          switch ($object) {
+            case "domain-admin":
+              $data = domain_admin_sso('issue', $attr);
+              if($data) {
+                echo json_encode($data);
+                exit(0);
+              }
+              process_add_return($data);
+            break;
+          }
+        break;
         case "admin":
           process_add_return(admin('add', $attr));
         break;
@@ -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

@@ -576,6 +576,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 E-Mails hinzugefügt, die von der angegebenen Domain gesendet 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",
@@ -1011,6 +1015,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",

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

@@ -576,6 +576,10 @@
         "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 will be added to all emails sent from the specified domain.",
+        "domain_footer_plain": "PLAIN footer",
         "domain_quota": "Domain quota",
         "domains": "Domains",
         "dont_check_sender_acl": "Disable sender check for domain %s (+ alias domains)",
@@ -1018,6 +1022,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",

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

@@ -7,6 +7,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>
 <div class="tab-content">
@@ -229,6 +230,33 @@
       </div>
     </div>
   </div>
+  <div id="dfooter" class="tab-pane fade" role="tabpanel" aria-labelledby="domain-footer">
+    <div class="row">
+      <div class="col-sm-12">
+        <h4>{{ lang.edit.domain_footer }}</h4>
+        <p>{{ lang.edit.domain_footer_info|raw }}</p>
+        <form class="form-horizontal" 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>
 {% else %}
   {{ parent() }}