0005 — Outbound send-lock for the inbox_email path
- Status: Proposed
- Date: 2026-04-17
- Deciders: Fede
Context
On April 15, a prospect (Perrin Contrivo) received an email from Clara that read:
This is a duplicate of the original lead email — the tour request has already been processed and I've already sent Perrin a reply with available Monday April 13th time slots. No further action needed on this one.
Root cause:
- Original lead came in on April 10 through the Outlook inbox (inbox_email channel). Handled correctly.
- On April 15 the same lead reappeared in the mailbox — different Microsoft Graph message ID, likely a re-forward or Outlook rule re-delivery.
- The inbox-poller enqueued it (the poller has since been retired — see ADR-0011). SQS FIFO let it through (different dedup ID).
- Clara's agent semantically detected the duplicate but that reasoning text became the email body — because
graphDeliverinlambda/inbound-processor/handler.ts:261-303has no outbound send-lock.
The SES path has one (acquireSendLock/markResponseSent at src/lib/email/process-inbound-email.ts:300-325). The Graph path was never ported.
Decision
Port the send-lock pattern to graphDeliver.
Before calling client.sendReply():
- Call
acquireSendLock(dedupKey, { ... })wherededupKeyis derived from the RFC 5322internetMessageId(preserved acrossFwd:of the same original message), falling back tos3Key. - On success, call
markResponseSent(dedupKey, { ... }). - If
acquireSendLockfails (another process already sending or sent), log and skip — do not throw.
Also suppress outbound when the agent's response matches a duplicate-detection pattern. If the reply body includes phrases like "duplicate of the original", "already processed", or "no further action needed", drop the outbound and log. Clara should not be the last line of defense, but if she correctly detects a duplicate, we should not email the prospect our reasoning.
Consequences
Easier
- Inbox re-delivery of the same lead no longer produces duplicate outbound. Works across different Graph message IDs because
internetMessageIdis stable. - Graph
delta-linkreset (410 Gone) no longer risks a burst of re-deliveries.
Harder
- Confirm
internetMessageIdis reliably populated on every Graph message we receive. - Duplicate-detection pattern suppression is heuristic. Better long-term fix is deterministic dedup at the queue layer.
Follow-up work
- Review SMS and Telegram paths — also lack outbound send-locks. Port the same pattern.
- Audit
/api/voice/call-endedfor similar exposure.
Alternatives considered
Dedup at the SQS publish layer by hashing internetMessageId. Would prevent the duplicate from ever reaching Clara. Considered for a follow-up; send-lock is the immediate fix.
Rely on the agent not producing duplicate-detection text. Fragile. The agent got it right most of the time, but "most of the time" once cost a prospect a confusing email.