I gave an AI agent a mailbox and one rule, enforced in 18 lines of plain code: nothing leaves this machine unless every fact in the reply exists in a file I wrote.
The gate worked. 18 messages in, 4 dropped as notices before they cost a model call, 6 drafted, all 6 cleared honestly with every fact cited.
Then I read the sent message back out of Sent Mail and diffed it against the body the gate had approved.
Approved body bytes 182
Landed body bytes 251
Added after body 69
Removed bytes 0
Sixty-nine bytes the gate never saw:
This email was sent automatically with n8n.
https://n8n.io
Not malicious. Not hidden. It is the Send Email node's appendAttribution option, on by default, and it is applied after the node's parameters are resolved — which is after any gate you build, because your gate runs upstream as part of the workflow.
The same feature on an earlier build using the HTML path added 312 bytes, including a tracked anchor carrying a campaign parameter.
Why this is worth more than a config flag
Turning the option off is a one-line fix, and that is not the interesting part.
The interesting part is the shape of the mistake. I had built a gate that proves a property about a string in memory, and then I described that property as if it held for the thing the recipient receives. Those are different objects, and everything between them — the node, the transport, the provider — is free to modify the second one.
Once you see it, you see it everywhere:
- A Slack node that renders your text as mrkdwn and eats characters
- A provider that rewrites links for click tracking
- An HTML wrapper that appends a footer
- A gateway that appends an unsubscribe block
Every one of those is downstream of your validation. If the guarantee you make out loud is "the recipient only ever receives text supported by the source file", none of the checks in your workflow can establish that on their own.
The only check that actually establishes it
Read it back from the far end and diff.
// Fetch the message from Sent Mail by Message-ID and compare
// the delivered body against the approved bytes.
const landed = extractPlainPart(raw); // what the recipient got
const approved = fs.readFileSync(approvedPath); // what the gate cleared
const added = diffBytes(approved, landed);
if (added.length) throw new Error(`transport added ${added.length} bytes`);
That is the check that found the 69 bytes, and it is the check that keeps working when a provider quietly changes behaviour next quarter. Everything else is a proxy.
The other thing that broke, which cost me the whole morning
Before any of that, the trigger returned 1 message out of 17 I had appended to the mailbox. No error. No warning. An empty-looking mailbox and a green run.
"Fetch Only New Emails" is on by default. On this node version it pushes SINCE <today> onto the IMAP search — date granularity — so backdated test messages simply do not match. My corpus was backdated, which made it invisible.
Reading the shipped source, the search is built from two mutually exclusive branches:
if (staticData.lastMessageUid !== undefined) {
searchCriteria.push(['UID', `${staticData.lastMessageUid}:*`]);
} else if (node.typeVersion > 2 && options.trackLastMessageId !== false) {
searchCriteria.push(['SINCE', activatedAt.toFormat('dd-LLL-yyyy')]);
}
Two things follow. The stored UID watermark beats everything — once set, nothing else is consulted, and the code's own comment notes that UIDs change if a mailbox is recreated and that UIDVALIDITY would catch it, which it does not check. And the SINCE branch only exists above typeVersion 2, so which of the two is biting you depends on the version of the node on your canvas.
The fix for my case was the toggle plus clearing the stored watermark. After that: 18 of 18.
If you are testing an email trigger with messages you appended yourself, check this before you debug anything else. A default that filters is indistinguishable from an empty mailbox.
What the gate still cannot do
It cannot verify a sender is who they claim to be. It cannot tell a plausible request from a real one. It cannot judge whether a correctly cited reply is the right thing to say. And it could not see 69 bytes of the message it approved until I went and looked at the far end.
Build files: https://github.com/Ships-Itself/builds
Every number here came off one recorded run.
Top comments (0)