AI Email Automation Without Duplicate or Stale Replies
AI email automation can fail with a correct classifier. A webhook arrives twice, a mailbox cursor expires, or a colleague answers while a model draft sits waiting for approval. The workflow then sends a reply twice or answers a conversation that has already moved on. Prompt tuning cannot repair state errors, so treat email as a changing data source: use notifications only to trigger synchronization, rebuild the current thread before deciding, bind every draft and approval to a thread version, and record one unique send intent.
Why a correct classification can still produce a wrong action
A mailbox exposes several identities because it holds several kinds of state: an immutable message, a thread that groups messages, a history cursor for changes since a known point, and a push notification that usually says only that something changed. They are not interchangeable. The Gmail thread guide requires a reply to carry the target threadId plus matching subject and reply headers to join an existing thread, and the Microsoft Graph message resource separates id, conversationId, conversationIndex and internetMessageId. A Stack Overflow report describes duplicate message identifiers during Gmail push processing. It is one practitioner's case, not a provider guarantee, and it shows why receiving a webhook must not equal sending a reply.
Keep five steps in separate handlers, each with its own dedup key: the provider reports a possible change, the synchronizer fetches authoritative changes, the classifier proposes a disposition, a policy or reviewer authorizes a versioned action, and the sender records and executes one intent. If one handler does all five, a retry can repeat any of them.
Store a mailbox state model
Keep provider identifiers as they are instead of folding them into one generic email ID. You need:
- Mailbox: provider, account ID, history cursor, watch expiry, sync status.
- Message: provider message ID, Internet message ID, labels, folder, read state.
- Thread: provider thread or conversation ID, ordered message IDs, a local monotonic version, latest activity.
- Decision: thread ID, thread version, class, confidence, proposed action.
- Approval: decision ID, reviewed thread version, reviewer, edits, outcome.
- Send intent: mailbox ID, intent key, provider send ID, state, attempt evidence.
Increase the thread version whenever sync adds, removes or materially changes a message. Keep the cursor on the mailbox, since it describes the mailbox and not one conversation. Preserve the raw headers (Message-ID, In-Reply-To, References) because reply construction and investigations need them. A draft is then "for mailbox A, thread T, version 17, in response to message M", a tuple you can check before approval and again before sending.
Notifications drive synchronization, nothing else
The handler authenticates the delivery, enqueues a unique sync job for the mailbox and acknowledges. Five notifications during one pending sync collapse into one run. Gmail's push guide says a watch must be renewed at least every seven days, and a notification carries a history ID.
on_notification(mailbox, provider_hint):
authenticate_notification()
record_delivery_id_if_present()
enqueue_unique(sync, mailbox)
acknowledge()
sync_mailbox(mailbox):
lock_one_synchronizer(mailbox)
changes = fetch_changes_after(mailbox.history_cursor)
if cursor_is_invalid(changes):
changes = run_bounded_full_sync(mailbox)
upsert_messages_by_provider_id(changes)
rebuild_affected_threads()
advance_cursor_only_after_commit()
enqueue_current_unhandled_messages()
Commit the messages and the new cursor in one transaction. Advancing the cursor first loses work after a crash, while writing messages and failing before the cursor moves only repeats reads that your upsert keys absorb.
Treat the full sync as routine. Gmail's synchronization guide says history records are typically available for at least a week, and that the period might be significantly shorter, even unavailable in rare cases. An out-of-range startHistoryId returns HTTP 404 and the client must do a full sync. A mailbox that was paused for a weekend or whose worker was down can hit this, so rehearse it. During recovery, mark the mailbox as recovering so a classification queued before the gap cannot send, and compare pending decisions with the rebuilt thread versions afterward.
Rebuild the thread before classifying
Do not hand the model one new message when the right action depends on the conversation. Load the synchronized thread, find the newest inbound and outbound messages, and compute the current version. The classifier returns a proposal: a disposition, a reason code, whether approval is required, the source message ID and thread version, and a draft if replying is allowed. Keep hard rules outside the model: no auto reply when the newest message is outbound, when the sender is on a deny list, when the thread has a legal hold, or when another active decision already owns that source message. Key classification by mailbox and provider message ID, so a label or folder change that appears in history does not trigger a second decision.
Do not answer other robots
A fully automatic reply can start a loop with a vacation responder or another agent, and the model will happily write a polite answer to a bounce. RFC 3834 covers this. It defines an Auto-Submitted header whose value is no, auto-generated, auto-replied or an extension keyword, and says responders should not reply to any message carrying that header with a value other than no. It also says personal responders should not send the same response to the same sender more than once within a period of several days, recommends 7 days as the default, and directs automatic responses to the Return-Path address instead of From or Reply-To. A human-approved draft is not an automatic response in the RFC's sense, but a reply sent without review is, so check inbound headers in your deterministic rules.
Self-triggering is common enough that Microsoft built a warning for it into Power Automate. When you save a cloud flow that updates the record it is triggered by, the designer warns that the flow "may result in an infinite trigger loop", and the official anti-pattern guidance gives two fixes: a trigger condition that skips runs where the state is already the desired one, and a Terminate step when the flow sees it has processed the record. A reply agent gets no such warning, because the write that re-triggers it, such as a label, a note or a status change, is chosen by a model at run time. Put the same guard in code. Skip events your own service account caused, and add a fires-per-hour cap per trigger as the backstop for loops your tests did not imagine.
Bind approval to what the reviewer saw
"Approved" is not a record. Store the decision ID, source message, thread ID, reviewed thread version, the exact body or its hash, recipients and action. Compare versions when the review screen opens and again on submit. A new inbound or outbound message invalidates the draft. A changed recipient, subject, body, attachment or policy label requires review again. An edit by the reviewer makes the edited text the approved artifact, and an expired approval returns to review instead of being extended. A read flag or label change keeps the decision, with the ignored change type logged.
Make sending recoverable
A provider can accept a send while your worker loses the response. Create a durable send intent before calling the provider, keyed on the business action, for example sha256(mailbox_id + thread_id + approved_thread_version + decision_id), and enforce a unique constraint on the underlying fields. That key is local uniqueness, not a provider idempotency guarantee. After a confirmed send, store the provider ID. After a timeout, mark the outcome unknown and do not replay. Reconcile by syncing the Sent folder and matching strong evidence: the provider send ID, a custom header the provider preserves, the approved recipients, the thread and a content hash. If the message exists, mark the intent sent. If not, a retry is safe.
Webhook-driven triggers need the same discipline, and need it for longer than you expect. Stripe's webhook documentation says it retries live-mode deliveries for up to three days, that the dashboard can resend an event for 15 days and the CLI for 30, and that resending does not cancel the automatic retries. It also warns that two distinct event objects can describe the same change, so deduplicating on event ID alone is not enough: match on the ID of the object in data.object plus the event type as well. Keep the dedup record at least as long as the longest redelivery path you have, which here is 30 days, and write the number down.
Test failures, not examples
Inject a failure after every durable write and every provider call. Send the same notification twice and two different ones with the same history hint, and expect one message record and at most one active decision. Invalidate the stored cursor and expect recovery with no reply leaving mid-rebuild. Insert an inbound message after the review page loads and before submit, and expect a state-changed rejection, then repeat with a reply sent by a colleague. Make the provider accept a message while the client sees a timeout, and expect the retry to reconcile first and the thread to end with one reply. Send an inbound message with Auto-Submitted: auto-replied and expect no reply. Check that replies with missing or mismatched headers are rejected before sending, and that accepted replies land in the intended thread, not only in Sent mail.
Track notification age, cursor age, full-sync count, duplicate upserts, invalidated approvals, unknown sends pending and reconciled, and duplicate-send incidents. Alert on cursor age and the unknown-send backlog before users complain. Build in this order: identities and idempotent sync, then version-bound decisions, then approval checks, then send intents. Run one mailbox through duplicate notifications, a late reply, an expired cursor and an accepted-but-timed-out send before you tune the classifier.
References
- Gmail thread management for thread membership and reply requirements.
- Gmail client synchronization for full and partial sync, history retention and the 404 recovery rule.
- Gmail push notifications for watch setup, payloads and renewal.
- Microsoft Graph message resource for message and conversation identities and reply operations.
- RFC 3834, Recommendations for Automatic Responses to Electronic Mail for the Auto-Submitted header and response-suppression rules.
- Stack Overflow report on duplicate Gmail push message IDs for a practitioner report of duplicate identifiers in push processing.
- Microsoft Power Automate, avoid anti-patterns for the infinite trigger loop warning and its two mitigations.
- Stripe webhooks documentation for the three-day retry window, 15 and 30 day manual resends, and duplicate event objects.