Back to Blog
Overlapping white and cream envelopes spread across a flat surface

AI Email Automation Without Duplicate or Stale Replies

10 min read

AI email automation can fail even when the classifier is correct. A webhook arrives twice, a mailbox cursor expires, or a colleague replies while a model draft waits for approval. The workflow sends the response again or answers a conversation that has already changed. Prompt tuning cannot repair these state errors.

Treat email as a changing data source, not a stream of independent webhook tasks. The reliable design uses notifications to trigger synchronization, reconstructs the current thread before making a decision, binds every draft and approval to a specific thread version, and records one unique send intent. This article gives engineers an implementation sequence, decision rules, and failure tests for that design.

Why a correct classification can still produce a wrong action

Mailbox providers expose several identities because an email workflow has several kinds of state. A message is one immutable communication. A thread or conversation groups related messages. A mailbox history cursor records changes after a known point. A push notification usually says that something changed, but it does not replace the provider's message store.

Those identities are not interchangeable. The Gmail thread guide requires a reply to carry the target threadId and matching subject and reply headers if it should join an existing thread. Microsoft Graph exposes id, conversationId, conversationIndex, and internetMessageId on its message resource. A local record keyed only by subject text cannot preserve those relationships.

Notification delivery may repeat, arrive late, or say only that the mailbox changed. A Stack Overflow report about Gmail push processing describes duplicate message identifiers appearing during this path. It is one practitioner's case, not a provider guarantee. It still shows why receiving a webhook must not automatically create a reply job.

A useful mental model separates five events:

  1. The provider reports that mailbox state may have changed.
  2. The synchronizer fetches authoritative changes.
  3. The classifier proposes a disposition for a current message and thread.
  4. A policy or reviewer authorizes a versioned action.
  5. The sender records and executes one send intent.

If one request handler performs all five jobs, a retry can repeat any of them. Separate handlers give each boundary its own deduplication key and recovery rule.

Start with a mailbox state model

Store provider identifiers without rewriting them into one generic email ID. A minimal relational model can use these records:

Record Stable fields Mutable fields
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, version, latest activity
Decision thread ID, thread version, decision ID 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

The thread version can be a monotonic local number. Increase it whenever synchronization adds, removes, or materially changes a message in the thread. Keep the provider history cursor separately because it describes the mailbox, not one conversation.

Preserve the raw provider envelope or a lossless normalized subset alongside extracted text. Headers such as Message-ID, In-Reply-To, and References are needed for reply construction and investigations. Store enough source evidence to explain which message the classifier read. Avoid placing full email bodies in logs; reference the secured message record and log hashes or identifiers instead.

With this model, a draft is not merely "for customer@example.com about renewal." It is for mailbox A, thread T, version 17, in response to message M. The workflow can check that tuple before approval and again before sending.

Use notifications to drive synchronization

A notification handler should acknowledge valid provider delivery quickly and enqueue a mailbox synchronization request. It should not classify, draft, or send. If five notifications arrive for the same mailbox while a sync is pending, they can collapse into one synchronization run.

Gmail's push notification guide says that a successful watch call sends an immediate notification and that a mailbox watch must be renewed at least every seven days. The notification includes a history ID. The Gmail synchronization guide explains how to fetch history after a recent cursor and when an invalid cursor requires a full synchronization.

Use this control flow:

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()

The transaction must commit message changes and the new cursor together. Advancing the cursor before message records are durable can lose work after a crash. Writing messages but failing before cursor advancement is safer because the next sync repeats reads that the upsert keys can absorb.

A full sync needs an explicit operating policy. Bound concurrency and page through provider results. Mark the mailbox as recovering so an old queued classification cannot send during reconstruction. After the sync, compare local pending decisions with rebuilt thread versions and invalidate any that no longer match.

Reconstruct the thread before classification

Do not pass one new email to the model if the intended action depends on the conversation. Fetch the locally synchronized thread, order messages using provider sequence data and timestamps, identify the newest inbound and outbound messages, and compute the current thread version.

The classifier should return a proposal, not an action. Its output can include:

  • disposition, such as reply, route, archive, or ignore
  • reason code from a controlled set
  • whether human approval is required
  • the source message ID and thread version
  • extracted facts used by a deterministic policy
  • a draft body when reply is allowed

Keep deterministic checks outside the model. Examples include blocking auto reply when the newest message is outbound, when the sender is on a deny list, when the thread contains a legal hold marker, or when another active decision already owns the same source message. A low confidence model result should route to review, but confidence alone must not authorize a send.

The workflow should also distinguish a new message from a message-state update. A label or folder change can appear in history without creating new content. Reclassifying every update wastes model calls and can create a second decision for the same source message. Key classification work by mailbox and provider message ID, with a version field if the provider allows mutable content that matters to the decision.

Bind approval to the state a reviewer saw

Human approval becomes unsafe when the approval record says only "approved." The record must include the decision ID, source message ID, thread ID, reviewed thread version, exact approved body or its content hash, recipients, and requested action.

Immediately before showing the review screen, compare the decision's thread version with the current version. Repeat the comparison when the reviewer submits. If a new message arrived, present the changed messages and require a fresh decision. Do not silently carry approval forward.

Use these decision rules:

  • If only a read flag or nonsemantic label changed, retain the decision but record the ignored change type.
  • If a new inbound or outbound message arrived, invalidate the draft.
  • If recipients, subject, body, attachments, or policy labels changed, require review again.
  • If the reviewer edits the draft, treat the edited content as the approved artifact.
  • If an approval expires before sending, return it to review rather than extending it automatically.

Provider thread APIs explain how messages relate. Approval guidance explains oversight. The application still has to define which mailbox changes make an approval stale.

Make sending a recoverable transaction

Email providers may accept a send even when your worker loses the response. A simple retry can then send the message twice. Create a durable send intent before calling the provider. Its unique key should represent the business action, for example:

sha256(mailbox_id + thread_id + approved_thread_version + decision_id)

The hash is an example of a local uniqueness key, not a provider idempotency guarantee. Enforce a unique database constraint on the underlying fields as well. A worker claims a pending intent, builds the reply with provider thread identifiers and required headers, and records the attempt time before the external call.

After a confirmed send, store the provider response ID and mark the intent sent. After a confirmed rejection, classify the error as retryable or terminal. After a timeout or connection loss, mark the outcome unknown. Do not immediately replay an unknown send.

Reconcile unknown outcomes by synchronizing the Sent folder and matching strong evidence: the provider send ID if one was returned, a custom header if the provider preserves it, the approved recipient set, the thread, and a content hash. If evidence confirms the message exists, mark the intent sent. If the provider offers no reliable lookup and evidence remains ambiguous, route the intent to an operator. One delayed reply is usually less harmful than two contradictory replies.

Reply construction must also preserve conversation identity. Gmail documents the thread ID and compliant reply headers needed to add a message to a thread. Microsoft Graph exposes reply operations and conversation fields on the message resource. Put provider-specific construction behind an adapter, but keep version checks and send-intent uniqueness in the shared workflow layer.

Test failures instead of only testing examples

A happy-path test proves very little because production faults happen between boundaries. Build fixtures that inject a failure after every durable write and every provider call.

Start with duplicate delivery. Send the same notification twice, then send two different notifications carrying the same mailbox history hint. Both cases should produce one synchronized message record and at most one active decision.

Test cursor recovery by expiring or invalidating the stored history cursor. The workflow should enter recovery, complete a full sync, advance the cursor only after data is durable, and invalidate stale decisions. No reply should leave while mailbox reconstruction is incomplete.

Test approval races by inserting an inbound response after the review page loads and before approval submission. The submission should fail with a state-changed result. Repeat with a human sent message from the same mailbox; the old model draft should not send after a colleague has already answered.

Test uncertain sends by making the provider accept a message while the client receives a timeout. The retry worker must reconcile before another call. Assert that the final thread contains one sent reply and that the intent retains evidence of the uncertain attempt.

Finally, verify thread placement. Generate replies with missing or mismatched headers in a provider test account and confirm that your adapter rejects them before sending. Check that accepted replies appear in the intended thread, not merely in Sent mail.

Operational metrics should follow the same state model. Track notification age, cursor age, full-sync count, duplicate message upserts, stale decisions, approval invalidations, pending unknown sends, reconciled sends, and duplicate-send incidents. Alert on cursor age and unknown send backlog before users report contradictory emails.

Put the controls in place in this order

Begin by adding provider message, thread, and cursor identities to storage. Make mailbox synchronization idempotent and prove cursor recovery. Next, key classification by source message and bind each decision to a thread version. Add approval version checks before any automated reply path. Last, introduce durable send intents and uncertain-outcome reconciliation.

Do not start by tuning the classifier. Run one mailbox through the duplicate-notification, late-reply, expired-cursor, and accepted-but-timed-out send tests first. Once those pass, model quality errors remain reviewable content problems instead of uncontrolled duplicate actions.

References


About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation with the mailbox state models, version-bound approvals, and durable send intents described above, at published fixed prices. Schedule a call to discuss your next project.