Detecting French-Language Emails with a Custom Rspamd Plugin on MailCow

Detecting French-Language Emails with a Custom Rspamd Plugin on MailCow
Photo by Le Vu / Unsplash

One of the quieter joys of self-hosting your own mail server is the ability to reach deep into the stack and do things no SaaS provider would ever let you do. This post documents a small but satisfying customisation I made to my MailCow setup: a custom Rspamd Lua plugin that detects whether an incoming email is written in French — and acts on it.

The Problem

Some of the email accounts on my server belong to people who receive a lot of French-language spam and phishing attempts. French content alone isn't grounds for rejection, but it is a useful signal that I wanted to capture — both as a custom header (X-French-Mail) for downstream mail filtering rules, and as a weighted spam score contribution.

Rspamd has built-in language detection, but there's no out-of-the-box way to target specific recipients, attach a custom header, and bump the score — all in one place. A small Lua plugin turned out to be the cleanest solution.

Need help with Rspamd? The community Telegram group at t.me/rspamd is active and very helpful.

How It Works

The plugin registers a pre-filter that runs on every inbound message before scoring. It does three things in sequence:

  1. Checks the envelope recipients. If none of the SMTP RCPT TO addresses match a configured target list, the plugin returns immediately — no wasted work.
  2. Inspects the language of each text part. Rspamd's built-in language guesser annotates each MIME text part. The plugin looks for any part classified as fr.
  3. Acts on the result. If French is detected, it adds an X-French-Mail: Yes header via a milter reply and inserts a custom FRENCH_LANGUAGE symbol with a score of 15.0. If not, it adds X-French-Mail: No.

The score of 15.0 is deliberately high — my server's rejection threshold is 50.0 — so French alone won't block a legitimate email, but it contributes meaningfully when combined with other signals.

The Plugin

Save this as data/conf/rspamd/plugins.d/detect_french.lua inside your MailCow directory:

lua

-- /opt/mailcow-dockerized/data/conf/rspamd/local.d/lua/detect_french.lua
local rspamd_logger = require "rspamd_logger"

-- List of envelope recipients to target (lower-case!)
local target_recipients = {
  ["recepient1@domain.com"] = true,
  ["recepient2@domain.com"] = true,
  -- add more addresses here
}

rspamd_config:register_pre_filter(function(task)
  -- 1) Check envelope recipients
  local rcpts = task:get_recipients('smtp') or {}
  local interested = false

  for _, r in ipairs(rcpts) do
    local addr = (r.addr or ""):lower()
    if target_recipients[addr] then
      interested = true
      break
    end
  end

  if not interested then
    -- none of our target users is in To:, so skip
    return
  end

  local french_detected = false

  for _, part in ipairs(task:get_text_parts() or {}) do
    local lang = part:get_language() or ""
    local dash = lang:find('-')
    if dash then lang = lang:sub(1, dash-1) end
    if lang == "fr" then
      french_detected = true
    end
  end

  rspamd_logger.infox(task, "detect_french: part language = %s", lang)

  if french_detected then
    rspamd_logger.infox(task, "detect_french: inserting X-French-Mail header")
    task:set_milter_reply({
      add_headers = {['X-French-Mail'] = 'Yes'},
    })
    task:insert_result(true, "FRENCH_LANGUAGE", 15.0)
  else
    task:set_milter_reply({
      add_headers = {['X-French-Mail'] = 'No'},
    })
  end
end)

Enabling the Plugin

Tell Rspamd to load the plugin by adding the following to data/conf/rspamd/rspamd.conf.local:

detect_french { }

Then restart the Rspamd container:

sudo docker-compose restart rspamd-mailcow

Verifying It Works

Check that the module was picked up on startup:

sudo docker-compose logs rspamd-mailcow | grep -i french

A successful load looks like this:

rspamd-mailcow_1 | 2025-05-21 08:58:08 #1(main) <p38ikj>; cfg;
rspamd_init_lua_filters: init lua module detect_french
from /etc/rspamd/plugins.d//detect_french.lua; digest: 7007ceb456

Seeing It in Action

Once live mail starts flowing, you can see the plugin's output in the Rspamd task log. Here's a real example — an email from a Swisscom address to one of the target recipients, written in French:

(default: F (no action): [4.80/50.00]
[FRENCH_LANGUAGE(14.00){}, NEURAL_HAM_LONG(-4.00){-1.000;},
SIGNED_SMIME(-2.00){}, IP_REPUTATION_HAM(-1.09){...},
DWL_DNSWL_LOW(-1.00){swisscom.com:dkim;}, ...])

A few things worth noting here:

  • FRENCH_LANGUAGE fired and contributed 14.0 points (slightly less than the configured 15.0 due to Rspamd's internal weight normalisation).
  • The overall score was 4.80 / 50.00 — well below the rejection threshold. The email was legitimately from a known Swisscom address with valid DKIM, SPF and DMARC, so the other checks heavily counteracted the French boost.
  • The custom X-French-Mail: Yes header was injected via the milter reply, making it available for any downstream Sieve or client-side filter rules.

This is exactly the intended behaviour: French is a signal, not a verdict.

Customising for Your Setup

A few knobs you might want to adjust:

  • Target recipients — edit the target_recipients table at the top of the Lua file. Keys must be lowercase.
  • Score15.0 is high. If you just want the header without score impact, remove the task:insert_result(...) line entirely.
  • Language — replace "fr" with any BCP 47 language subtag to target a different language.
  • All recipients — remove the if not interested then return end block to apply the detection to every inbound message, regardless of recipient.

Wrapping Up

What started as a one-afternoon experiment turned into a genuinely useful addition to my mail stack. The combination of Rspamd's built-in language guesser, the Lua plugin API, and milter header injection makes for a surprisingly clean solution — and it slots naturally into MailCow's overlay config directory without touching any upstream files.

If you're running MailCow and want to experiment with your own Rspamd plugins, the official Rspamd Lua API docs and the Telegram community are good places to start.