Skip to content

send_email: a retried call is indistinguishable from a new email — nothing in the path can dedupe it #62

Description

@aurumflux20

First: this is a genuinely well-built server — the append-only audit log, the rate limiter, and input validation put it ahead of most MCP servers I've read. This issue is about the one gap that safety net doesn't cover.

The scenario. An agent calls send_email. The SMTP conversation is slow; the MCP client times out and retries — or the agent itself decides the call failed and tries again. The recipient gets two identical emails. With bulk or scheduled sends, the same shape amplifies.

Why nothing catches it today (all from the current code):

  1. send_email's schema (src/tools/send.tool.ts) has no parameter a caller could use to mark two attempts as the same send — no message id, no client token.
  2. smtp.service.ts calls transport.sendMail({...}) without a messageId, so nodemailer generates a fresh random Message-ID per attempt (its documented default). Two attempts at the same email are, on the wire, two unrelated messages.
  3. The audit log (src/safety/audit.ts) is append-only and written after the send — it records the duplicate faithfully but can't prevent it. (And it redacts body, correctly, which means it also can't be used for exact-payload matching after the fact.)

SMTP itself has no idempotency mechanism, so there's no server on the other side that can rescue this — the guard has to live client-side, which is exactly where this repo already keeps its other safety machinery.

Two fixes that fit the existing design, smaller first:

  • Accept an optional messageId param on send_email/reply_email/forward_email and pass it through to sendMail. A caller that supplies a stable id gets: major receivers (Gmail-class) deduplicating an identical Message-ID arriving twice, and at minimum a traceable identity across attempts in your audit log. No behavior change for callers who omit it.
  • (Larger, optional) a pre-send check against the audit log — same account + recipients + subject within a short window → refuse without an explicit override. Your log already captures everything needed except the intent to consult it before sending.

I'm happy to send a PR for the first one — it's small and I'd keep it to exactly that scope.

Disclosure: I work on open-source retry-safety tooling (once-kernel, effectfence), which is how I came to be reading send paths in MCP servers. This issue stands on its own either way — the fix above needs none of my code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions