Can't change mail subject via MTA hook

Issue Description

I write a MTA Hook server to change subject when sender is [email protected], but subject doesn’t changed.

Expected Behavior

When I send from [email protected] to [email protected]

MTA Hook receive a request:

{
  "context": {...},
  "envelope": {
    "from": { "address": "[email protected]" },
    "to": [{ "address": "[email protected]" }]
  },
  "message": {
    "headers": [
      ["Message-ID", " <[email protected]>\r\n"],
      ["Date", " Sun, 21 Jun 2026 12:47:33 +0800\r\n"],
      ["MIME-Version", " 1.0\r\n"],
      ["User-Agent", " Mozilla Thunderbird\r\n"],
      ["Content-Language", " en-US\r\n"],
      ["To", " [email protected]\r\n"],
      ["From", " test <[email protected]>\r\n"],
      ["Subject", " Test\r\n"],
      ["Content-Type", " text/plain; charset=UTF-8; format=flowed\r\n"],
      ["Content-Transfer-Encoding", " 7bit\r\n"]
    ],
    "contents": "test\r\n",
    "size": 335
  }
}

and this is the response:

{
  "action": "accept",
  "response": {
    "status": 250,
    "enhancedStatus": "2.0.0",
    "message": "Message accepted",
    "disconnect": false
  },
  "modifications": [
    {
      "type": "changeHeader",
      "index": 7,
      "name": "Subject",
      "value": "[TEST] Test\r\n"
    }
  ]
}

admin should receive a mail with [TEST] Test

Actual Behavior

Admin receive a mail with original subject Test

Reproduction Steps

  1. Send a mail from [email protected] with Test
  2. Lookup [email protected]’s mailbox
  3. Found a mail with subject “Test” instead of “[TEST] Test”

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

MTA hook server code snippet

async fn filter(Json(req): Json<MTAHookRequest>) -> Json<MTAHookResponse> {
    let mut mods: Vec<Modification> = vec![];
    let headers = &req.message.headers;
    let subject_index = headers
        .iter()
        .position(|x| x.0 == "Subject")
        .expect("subject not found");
    let subject = &headers[subject_index].1;

    let from = req.envelope.from.address;
    let from_user = from.split_once("@").expect("invalid mail").0;
    if from_user == "test" {
        mods.push(Modification::ChangeHeader {
            index: subject_index,
            name: "Subject".to_owned(),
            value: format!("[TEST]{subject}"),
        });
    }

    let resp = MTAHookResponse {
        action: Action::Accept,
        response: Some(ResponseData {
            status: 250,
            enhanced_status: "2.0.0".to_owned(),
            message: "Message accepted".to_owned(),
            disconnect: false,
        }),
        modifications: mods,
    };
    Json(resp)
}

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Let me look into how MTA hook modifications work in Stalwart.

Now I see a critical detail. When the action is Accept, it calls continue - meaning modifications are collected but then skipped when the action is Accept. Let me verify this more carefully.

I can see the key issue. When the hook returns action: "accept", the modifications are collected but then immediately skipped. Let me verify this:

Now let me look at where Action::Accept returns continue (skipping modifications on accept) and how the milter message applies ChangeHeader:

I now have enough to give a detailed answer. The key finding is in milter/message.rs lines 439-450: the index in changeHeader is 1-based and counts only occurrences of that specific header name, not the overall position in the headers array.


The index field in a changeHeader modification is 1-based and counts only occurrences of that specific header, not the zero-based position of the header in the full headers list. Your code is passing the raw zero-based array position (e.g. 7 for the 8th header overall), but Stalwart expects 1 to mean “the first occurrence of a header with this name.”

Looking at the server source (crates/smtp/src/inbound/milter/message.rs), the matching logic is:

if name.eq_ignore_ascii_case(header_name.as_bytes()) {
    header_count += 1;
    if header_count == index {   // <-- 1-based, per-name count
        ...
    }
}

So for a message that has exactly one Subject header, you should always pass index: 1, not the header’s position in the flat array.

Fix: Change your code to count only headers matching "Subject" (case-insensitive) up to and including the one you want to replace, and pass that 1-based count as index. For a typical message with a single Subject, it will always be 1:

mods.push(Modification::ChangeHeader {
    index: 1,   // 1 = first (and usually only) Subject header
    name: "Subject".to_owned(),
    value: format!("[TEST]{subject}"),
});

Also note: the docs example shows the value without a trailing \r\n"This is the new subject" – so you may want to strip the trailing \r\n from the original subject value before prepending [TEST], otherwise the value field itself would contain a bare newline.

Reference: MTA Hooks documentation - changeHeader

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.