chore(ai-builder): Move polling duplicate rules into the Gmail and Outlook trigger hints (no-changelog)

The trigger builder hints now carry the full rule: why polled emails come
back, why an unread filter alone does nothing, and that the mark step
runs after the record is created. The workflow-builder skill keeps one
generic sentence for other polling triggers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour
2026-10-23 18:28:24 +02:00
co-authored by Claude Opus 5.5
parent 992b63f48f
commit 17db677cf4
5 changed files with 37 additions and 17 deletions
@@ -742,21 +742,11 @@ Follow these rules strictly when generating workflows:
upstream of the data producer, or have B reference `$('Data Node')`
explicitly.
8. A polling trigger (Gmail Trigger, Outlook Trigger, or similar) feeding an
action that creates or writes records must ensure each polled item is
processed once — poll cursors are best-effort bookkeeping (they reset when
the trigger node is recreated or renamed) and every still-matching item is
then re-delivered as a duplicate. Either restrict the trigger to
unread/unprocessed items AND mark each item handled once its record exists,
in a way the trigger's own filter excludes — mark as read when filtering
unread, move out of the watched folder, or apply a label only if the
trigger's query also excludes that label (a label does not mark a message
read) — or record handled ids in a Data Table: look the id up before
creating the record, skip ids already seen, and insert it only after the
create succeeds. An unread filter alone is not enough: if no step ever
marks the item read, it never excludes anything. Wire the mark-as-handled
step AFTER the record-creating node, so a mid-run failure cannot consume an
item without producing its output — this trades a rare duplicate (create
succeeded, marking failed) for never losing an item; do not invert it.
action that creates or writes records must process each polled item once:
poll cursors reset, and still-matching items come back as duplicates. Mark
each item handled in a way the trigger's own filter excludes, or record
handled ids in a Data Table, and do it after the record-creating node. The
trigger's `@builderHint` has the details for that service.
## Tool Naming Rules
@@ -63,7 +63,7 @@ export class GmailTrigger implements INodeType {
},
builderHint: {
searchHint:
'When downstream nodes create records (tasks, rows, tickets) per email, guarantee each email is processed exactly once: filter to unread AND mark each email read/labelled after its record is created, or track handled message ids in a Data Table. Otherwise the same email can be reprocessed into duplicates.',
"When downstream nodes create records (tasks, rows, tickets) per email, guarantee each email is processed exactly once. Poll cursors reset when the trigger is recreated or renamed, and every still-matching email is then delivered again. Either filter to unread AND mark each email read, or apply a label only if the trigger's search query excludes that label (a label does not mark an email read); or track handled message ids in a Data Table: skip ids already seen and insert an id only after its record is created. An unread filter alone is not enough: if no step marks the email read, it excludes nothing. Wire the mark-as-handled step after the record-creating node, so a failed run never consumes an email without its record.",
relatedNodes: [
{
nodeType: 'n8n-nodes-base.gmail',
@@ -0,0 +1,17 @@
import { GmailTrigger } from '../GmailTrigger.node';
const searchHint = new GmailTrigger().description.builderHint?.searchHint ?? '';
describe('Gmail Trigger duplicate-processing hint', () => {
it('marks an email handled only after its record exists', () => {
expect(searchHint).toContain('after the record-creating node');
});
it('says an unread filter alone excludes nothing', () => {
expect(searchHint).toContain('An unread filter alone is not enough');
});
it('says a label only works when the trigger query excludes it', () => {
expect(searchHint).toContain("only if the trigger's search query excludes that label");
});
});
@@ -27,7 +27,7 @@ export class MicrosoftOutlookTrigger implements INodeType {
},
builderHint: {
searchHint:
'When downstream nodes create records (tasks, rows, tickets) per email, guarantee each email is processed exactly once: filter to unread AND mark each email read or move it to a folder after its record is created, or track handled message ids in a Data Table. Otherwise the same email can be reprocessed into duplicates.',
'When downstream nodes create records (tasks, rows, tickets) per email, guarantee each email is processed exactly once. Poll cursors reset when the trigger is recreated or renamed, and every still-matching email is then delivered again. Either filter to unread AND mark each email read or move it out of the watched folder; or track handled message ids in a Data Table: skip ids already seen and insert an id only after its record is created. An unread filter alone is not enough: if no step marks the email read, it excludes nothing. Wire the mark-as-handled step after the record-creating node, so a failed run never consumes an email without its record.',
relatedNodes: [
{
nodeType: 'n8n-nodes-base.microsoftOutlook',
@@ -0,0 +1,13 @@
import { MicrosoftOutlookTrigger } from '../../MicrosoftOutlookTrigger.node';
const searchHint = new MicrosoftOutlookTrigger().description.builderHint?.searchHint ?? '';
describe('Microsoft Outlook Trigger duplicate-processing hint', () => {
it('marks an email handled only after its record exists', () => {
expect(searchHint).toContain('after the record-creating node');
});
it('says an unread filter alone excludes nothing', () => {
expect(searchHint).toContain('An unread filter alone is not enough');
});
});