Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,53 @@ Agent Email ships with restrictive defaults that you loosen as needed:
2. the caller passes `user_explicitly_requested_deletion: true` on the tool call.
- **Error sanitization**: API keys, file paths, and stack traces are redacted from error responses
- **Body file sandboxing**: no `../` traversal, no symlinks, binary detection
- **Duplicate-delivery guard**: on by default -- an identical send, reply, or draft-send repeated within 15 minutes is refused instead of delivered twice

### Duplicate-delivery guard

Provider send endpoints (Graph `POST /sendMail`, Gmail `users.messages.send`) take no
idempotency key, so Agent Email dispatches each delivery exactly once and never retries
one automatically. That stops the *library* from duplicating a message. It does not stop
the *caller*: an agent whose tool result was lost -- a compacted context, a dropped
transport, a supervisor restarting the turn, a person retrying something that looked
stuck -- replays the same approved instruction, and the second call looks exactly like a
first one.

`send_email`, `reply_to_email`, and `send_draft` therefore record each dispatch in an
in-process ledger, keyed by a digest of the effective action inputs -- mailbox,
recipients, subject, rendered body, attachment contents, and the reply parent or draft
id. (Effective *inputs*, not final wire bytes: a provider may transform a message on
the way out, and Graph for instance truncates a subject at 255 characters, so two
different inputs can leave as identical mail. The guard catches a replayed tool call,
which is what actually happens.) A repeat within the window is refused before the
delivery is dispatched:

| Code | Meaning | What to do |
|------|---------|------------|
| `DUPLICATE_SEND_IN_FLIGHT` | An identical delivery has not returned yet | Wait for the first call |
| `DUPLICATE_SEND_BLOCKED` | An identical delivery already succeeded | Stop -- the response carries the original `messageId` |
| `DUPLICATE_SEND_UNRESOLVED` | An identical delivery ended ambiguously | Check Sent Items before resending |

A failure that *proves* nothing was delivered -- any 4xx-derived code, a 429, or a
connect failure -- releases the record immediately, so resending after a rejection is
never blocked. Anything else, including a code from a provider we have not seen, is held
as unresolved: a wrongly-held message costs one blocked resend, a wrongly-released one
costs a duplicate in somebody's inbox.

To send the same message again on purpose, pass `allow_duplicate: true`. It is an
unconditional per-request bypass -- a replay that also carries the flag dispatches
again, because the flag asserts that a person decided on this send. The forced attempt
is recorded alongside the earlier one rather than replacing it, so if the override is
rejected the original delivery still refuses an ordinary replay.

Records are scoped to a mailbox: `mailbox` when the caller supplies one, otherwise the
provider instance. Operators can change the window with
`AGENT_EMAIL_DUPLICATE_SEND_WINDOW_MS` (default `900000`); `0` disables the guard
entirely.

The ledger lives in the server process and in memory. It closes the replay window that
actually happens -- a retry inside a running server -- and does not survive restarting
that server.

## Packages

Expand Down
90 changes: 90 additions & 0 deletions openspec/changes/add-duplicate-send-guard/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
## Context

`isRetryable(err, 'delivery') === false` and the single-attempt dispatch in the three
delivery actions make the *library* safe. The residual risk is entirely above that
line: a caller that re-issues the same tool call.

This design adds the missing state — a record that a given delivery was attempted —
and makes the delivery actions consult it.

## Goals / Non-Goals

**Goals**
- A second identical delivery call within the window does not reach the provider.
- A resend after a *proven* rejection is not blocked.
- A human can override deliberately.
- On by default, no embedder wiring.

**Non-Goals**
- Surviving a process restart. (See the limitation in the proposal.)
- Cross-mailbox or cross-tenant deduplication.
- Detecting near-duplicates. The fingerprint is exact by construction; a body that
differs by one character is a different message and is allowed through.

## Decisions

### Decision: fingerprint the delivery, not the call

Keying on a caller-supplied idempotency key was rejected: it pushes the burden onto
the caller we are defending against, and an agent that replays a call replays the key
too — which would work — but an agent that *forgets* to send one gets no protection at
all. Fingerprinting the delivery content means protection is automatic and a replay is
detected whether or not the caller cooperates.

The fingerprint covers everything that determines what the recipient receives:
mailbox, action name, normalized `to`/`cc`, subject, the rendered body and bodyHtml
(post-truncation, so the fingerprint matches the bytes that would go out), attachment
name/size/content digests, `scheduled_send_at`, and the reply parent id / draft id.

Attachment *content* is digested rather than compared, so the ledger never holds
message bytes.

### Decision: three states, not a boolean

`in-flight` / `delivered` / `unresolved` are distinguished because the caller's next
move differs: wait, stop (here is the id), or escalate to a human who can look in
Sent Items. Collapsing them to one "duplicate" code would force the agent to guess.

### Decision: fail closed on unknown outcome codes

Only an explicit allowlist of codes releases the fingerprint —
`RATE_LIMITED`, `INVALID_REQUEST`, `AUTH_REQUIRED`, `PERMISSION_DENIED`, `NOT_FOUND`,
`CONFLICT`, `PAYLOAD_TOO_LARGE`, `PROVIDER_REJECTED`, `PROVIDER_UNREACHABLE`,
`SCHEDULE_SEND_FAILED`, `NOT_SUPPORTED`. These are exactly the outcomes the existing
`classifyHttpStatus` reasoning already proves did not deliver: a 4xx proves receipt
and rejection; a connect-failure proves no bytes were written.

Every other code, including one this version has never seen from a third-party
provider, holds the fingerprint as `unresolved`. The asymmetry is intentional. A false
hold costs friction and has an override; a false release costs a duplicate in
someone's inbox, which is the bug being fixed.

### Decision: default-on singleton, injectable for tests

`ActionContext.sendLedger` is optional, but when absent the actions use a module-level
default rather than skipping the guard. `ActionContext.rateLimiter` is the cautionary
example: it is an optional interface that no shipped adapter constructs, so the rate
limit it describes does not exist in the product. An optional guard is not a guard.

Tests inject a fresh `SendLedger` (and `resetDefaultSendLedger()` exists for suites
that exercise the default path) so cases stay independent.

### Decision: claim before dispatch, settle after

The claim is written *before* the provider call, not after, so the in-flight window —
the exact window in which the acknowledgement gets lost — is covered. A settle
records the terminal state; a release removes the record.

If the action throws between claim and settle, the record stays `in-flight` until its
TTL expires and then becomes claimable again. Holding rather than releasing is the
fail-closed direction: a throw after dispatch is precisely the ambiguous case.

## Risks / Trade-offs

- **Legitimate identical resends inside the window are blocked.** Mitigated by
`allow_duplicate`, by the 15-minute default, and by the error message naming the
flag. Repeated identical automated mail (a status ping) is normally further apart
than the window; an operator who disagrees can set the window to `0`.
- **Memory growth.** Bounded: entries are pruned by TTL on every access and the map is
capped at 512 records, evicting oldest-first.
- **A restart clears the ledger.** Documented rather than papered over.
80 changes: 80 additions & 0 deletions openspec/changes/add-duplicate-send-guard/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
## Why

`email-write` already forbids automatic retry of a delivery: the provider endpoints
carry no idempotency key, so the action layer dispatches exactly once and reports an
ambiguous outcome as `SEND_STATUS_UNKNOWN`. That closes the machine-retry hole. It
does not close the caller-replay hole.

The guarantee today is "the library will not retry," not "a replay cannot duplicate."
Nothing at the action layer stops the *caller* from re-issuing the identical send.
The only defence is a sentence of English in each tool description — "do not resend
without checking Sent Items" — and an LLM under retry pressure, or a supervisor loop
that lost the tool result mid-flight, is exactly the caller that skips it. The
failure is invisible when it happens: the second call succeeds, and the recipient
gets the message twice.

The concrete sequence: an agent calls `send_email`; the provider accepts; the
acknowledgement is lost (context compaction, transport drop, process supervisor
restart of the agent loop, a human retrying a "stuck" turn); the agent replays the
same approved instruction; a second message is delivered. No component in the
current design has the state to notice.

## What Changes

- Add a **send ledger**: an in-process, TTL-bounded record of delivery attempts keyed
by a stable fingerprint of what would be delivered (mailbox, action, recipients,
subject, rendered body, attachment digests, and for replies/drafts the parent
message or draft id).
- Delivery actions (`send_email`, `reply_to_email`, `send_draft`) **claim** the
fingerprint before dispatch and settle it after. A claim that collides with a live
record short-circuits before the provider call and returns a structured error
instead of delivering.
- Three outcomes are distinguished, because the recovery differs:
`DUPLICATE_SEND_IN_FLIGHT` (a prior identical attempt has not returned),
`DUPLICATE_SEND_BLOCKED` (a prior identical attempt was delivered — the prior
`messageId` is returned), and `DUPLICATE_SEND_UNRESOLVED` (a prior identical
attempt ended ambiguously, so Sent Items must be checked by a human).
- A failure that **proves** nothing was delivered (any 4xx-derived code, a 429, or a
connect-failure) releases the fingerprint immediately: resending after a rejection
is safe and must not be blocked. Anything else — including an unrecognised code —
is held as unresolved. The guard fails closed.
- Add `allow_duplicate: true` to the three delivery actions as the deliberate escape
hatch. It is an unconditional per-request bypass: a replay that itself carries the
flag dispatches again, because the flag says a person decided on this send. It
admits an attempt *alongside* the existing records rather than replacing them, so a
rejected override cannot clear the delivery it was overriding.
- Each attempt carries its own identity, and a claim's settle/release act only on the
attempt that produced them. Addressing the fingerprint alone let a slow attempt's
late rejection delete a newer attempt's successful record.
- The guard is **on by default**, with no wiring required by an embedder. This is
deliberate: `ActionContext.rateLimiter` is an optional injected interface that no
shipped adapter ever constructs, so it is inert in production. A duplicate-send
guard that has to be opted into is a guard that is off.
- `AGENT_EMAIL_DUPLICATE_SEND_WINDOW_MS` tunes the retention window (default 15
minutes); `0` disables the guard for operators who want the old behavior.

## Impact

- Affected specs: `email-write`
- Affected code: new `email-core/src/security/send-ledger.ts`; claim/settle wiring in
`actions/send.ts`, `actions/reply.ts`, `actions/draft.ts`; `ActionContext` gains an
optional `sendLedger`; new input field and error codes on three action schemas;
`index.ts` exports; tool descriptions.
- Compatibility: additive. The new input field is optional and the new error codes are
only reachable on a second identical call within the window, which previously
delivered a duplicate.
- Security: the ledger stores a SHA-256 digest of canonical message content, never
the content itself, and never leaves the process. The digest is unsalted — it is a
collision key, not a privacy control. (An earlier draft of this proposal said
"salted digest"; no salt was ever implemented, and the claim was wrong when
written. Adding one is a separate privacy decision, not a duplicate-detection
requirement.)
- Namespace: the fingerprint is scoped by `ctx.mailboxName`, falling back to a
per-provider-instance id when the caller supplies none. Two mailboxes in one
process previously shared the empty key, so the second mailbox's first-ever send
was refused and handed the first mailbox's message id.
- **Documented limitation:** the ledger is per-process and in-memory, so it does not
survive an MCP server restart. It closes the replay window that actually occurs —
the agent loop retrying inside a live server — and does not claim to close a
crash-and-restart window. Cross-process durability would need a persisted store and
is deliberately out of scope here.
101 changes: 101 additions & 0 deletions openspec/changes/add-duplicate-send-guard/specs/email-write/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## ADDED Requirements

### Requirement: Duplicate Delivery Guard

Delivery operations (`send_email`, `reply_to_email`, `send_draft`) SHALL record each
dispatch attempt in a send ledger keyed by a stable fingerprint of the delivery
(mailbox, action, normalized recipients, subject, rendered body and bodyHtml,
attachment digests, `scheduled_send_at`, and the reply parent id or draft id). The
record SHALL be written BEFORE the provider is invoked and settled after it returns.

When a delivery request's fingerprint matches a live ledger record, the system SHALL
NOT dispatch the delivery and SHALL return a structured error naming the prior
attempt's state. (Provider *reads* may still occur: `reply_to_email` fetches the
parent message for its allowlist check before the ledger is consulted, and
`send_draft` reads the draft. Only the delivery dispatch is suppressed.) The states
are: `DUPLICATE_SEND_IN_FLIGHT` when the prior attempt has not returned,
`DUPLICATE_SEND_BLOCKED` when it was delivered (carrying the prior `messageId`), and
`DUPLICATE_SEND_UNRESOLVED` when its outcome was ambiguous.

A delivery outcome that PROVES nothing was delivered — any 4xx-derived code, a 429, a
connect-failure, or an unsupported-capability rejection — SHALL release the
fingerprint, so a resend after a rejection is permitted. Any other outcome, including
an unrecognized provider code, SHALL be held as unresolved.

Each attempt SHALL carry an identity, and a settlement or release SHALL apply only to
the attempt that produced it. A fingerprint MAY carry more than one live attempt; a
colliding claim SHALL be told about the strongest evidence available, preferring a
delivered attempt over an unresolved one over one still in flight. The ledger SHALL
NOT discard an in-flight reservation to stay within its size bound, and SHALL retain a
successful outcome that settles after its reservation was pruned.

The fingerprint SHALL be scoped to a mailbox namespace: the caller-supplied mailbox
name, or a per-provider-instance identity when none is supplied.

The guard SHALL be active without embedder configuration. Callers MAY bypass it for a
single request with `allow_duplicate: true`. Operators MAY tune the retention window
with `AGENT_EMAIL_DUPLICATE_SEND_WINDOW_MS`, where `0` disables the guard.

The ledger is per-process and in-memory; it SHALL NOT be presented as surviving a
restart of the server.

#### Scenario: Replay after successful delivery is blocked
- **WHEN** `send_email` succeeds and an identical `send_email` is called again within the window
- **THEN** the delivery is not dispatched a second time
- **AND** the system returns `{success: false, error: {code: "DUPLICATE_SEND_BLOCKED", recoverable: false}}` carrying the `messageId` of the first delivery

#### Scenario: Replay after ambiguous outcome is blocked
- **WHEN** a send fails with `SEND_STATUS_UNKNOWN` and an identical send is called again within the window
- **THEN** the delivery is not dispatched a second time
- **AND** the system returns `DUPLICATE_SEND_UNRESOLVED` with guidance to check Sent Items before resending

#### Scenario: Concurrent identical send is blocked while in flight
- **WHEN** an identical send is issued while a prior attempt with the same fingerprint has not yet returned
- **THEN** the second call returns `DUPLICATE_SEND_IN_FLIGHT` without dispatching

#### Scenario: Resend after a proven rejection is allowed
- **WHEN** a send fails with a code that proves the provider rejected it (for example `INVALID_REQUEST` or `RATE_LIMITED`)
- **AND** the same send is issued again
- **THEN** the fingerprint has been released and the provider is invoked

#### Scenario: Unrecognized failure code is held as unresolved
- **WHEN** a send fails with a provider code that is not on the proven-unsent list
- **AND** the same send is issued again within the window
- **THEN** the system returns `DUPLICATE_SEND_UNRESOLVED` rather than delivering again

#### Scenario: A different message is not blocked
- **WHEN** a send succeeds and a send differing in any fingerprinted field is issued
- **THEN** the provider is invoked normally

#### Scenario: Explicit duplicate override delivers
- **WHEN** an identical send is issued with `allow_duplicate: true` after a prior delivery
- **THEN** the delivery is dispatched and the new attempt is recorded ALONGSIDE the prior one
- **AND** if the forced attempt is rejected, the prior delivery still refuses an ordinary replay

#### Scenario: Guard is active without embedder wiring
- **WHEN** a delivery action runs with an ActionContext that supplies no send ledger
- **THEN** the process-default ledger is used and an identical replay is still refused

#### Scenario: Replayed draft send reports the duplicate, not a stale draft lookup
- **WHEN** `send_draft` succeeds and the same `draft_id` is sent again
- **THEN** the system returns `DUPLICATE_SEND_BLOCKED`
- **AND** it does not report a draft-lookup failure caused by the provider having consumed the draft on the first send

#### Scenario: A bail-out before dispatch does not block the corrected call
- **WHEN** a delivery is refused after the ledger record is written but before the provider is invoked (draft lookup failure, empty recipients, allowlist refusal, rate limit)
- **THEN** the record is released
- **AND** a corrected delivery with the same fingerprint is dispatched normally

#### Scenario: Separate mailboxes do not share a duplicate record
- **WHEN** two mailboxes in one process send an identical message and neither context supplies a mailbox name
- **THEN** each delivery is dispatched once
- **AND** neither response carries the other mailbox's `messageId`

#### Scenario: A stale settlement cannot overwrite a newer attempt
- **WHEN** two attempts are live on one fingerprint (an ordinary one and an `allow_duplicate` one) and the older settles after the newer
- **THEN** each settlement applies only to the attempt that produced it
- **AND** a subsequent ordinary replay is still refused

#### Scenario: Guard disabled by window of zero
- **WHEN** `AGENT_EMAIL_DUPLICATE_SEND_WINDOW_MS` is `0`
- **THEN** identical repeated sends are dispatched without duplicate checking
Loading
Loading