Skip to content

Email attachments: send, upload, fetch, store#52

Merged
rockfordlhotka merged 8 commits into
mainfrom
claude/add-email-attachments-86N6l
May 4, 2026
Merged

Email attachments: send, upload, fetch, store#52
rockfordlhotka merged 8 commits into
mainfrom
claude/add-email-attachments-86N6l

Conversation

@rockfordlhotka

@rockfordlhotka rockfordlhotka commented May 2, 2026

Copy link
Copy Markdown
Member

End-to-end email attachment support across all real email providers (M365,
Outlook.com, Gmail, IMAP/SMTP). Started as outbound-only; grew through
several review iterations into a complete inbound + outbound + temp-store
solution that's been validated against rockbot in the live k8s deployment.

What ships

Outbound (the original PR):

  • send_email accepts an attachments array per item with either
    base64Content (inline) or attachmentId (from upload).
  • All four real providers attach via the right API: Graph
    FileAttachment (M365/Outlook.com), MimeKit BodyBuilder + base64url
    raw send (Gmail), MimeKit + SMTP (IMAP). ICS and JSON providers
    throw NotSupportedException.
  • Per-message cap 25 MB; per-attachment 3 MB on Graph.

Upload endpoint:

  • POST /attachments (multipart) returns
    { attachmentId, name, contentType, size, expiresAt }.
  • GET /attachments/{id} non-consuming read (raw bytes,
    Content-Disposition).
  • DELETE /attachments/{id} explicit cleanup.
  • IAttachmentStore interface + InMemoryAttachmentStore: 22-char
    base64url IDs from RandomNumberGenerator, single-use consume on
    send_email, multi-read on GET, 60 s background eviction sweeper.
  • Limits: 10 MB per upload, 100 MB global, 15 min TTL. In-process memory
    only — no PVC required for the single-pod deployment.

Inbound attachment visibility + fetch:

  • get_email_details populates an attachments[] array with name, size,
    contentType, and a provider-side attachmentId. Implemented for
    M365, Outlook.com, Gmail, and IMAP. ICS/JSON return null.
  • New get_email_attachment tool fetches by ID. Two modes:
    • stash (default): bytes go straight into IAttachmentStore,
      returns the store ID; agent passes that ID to send_email to
      forward without bytes ever transiting the LLM.
    • inline: returns base64Content directly; capped at 1 MB.
  • Gmail uses positional part-N IDs (handles small inline-data
    attachments and large separate-fetch attachments uniformly). IMAP also
    positional. Graph uses native opaque IDs.

What changed during review

  • Original design assumed agents could either base64-encode files or
    share a filesystem with the server. Neither is true for hosted agents
    (M365 Copilot) or k8s-deployed servers without PVCs. The HTTP upload
    endpoint addresses this.
  • Initial agent self-test (rockbot) tripped over the missing
    GET /attachments/{id} and burned tool calls guessing URL shapes.
    Added GET/DELETE for symmetry.
  • Tool descriptions tightened: emailId parameter explicitly says "NOT
    messageId" after agents fumbled it; get_email_attachment description
    mentions the HTTP GET endpoint as the escape hatch when the file is
    too large for the 1 MB inline cap.
  • Gmail attachment fetch initially used Gmail's body.attachmentId,
    which doesn't behave for small inline-data attachments. Switched to
    positional part-N.

Testing

  • 243/243 unit tests pass. New coverage:
    • InMemoryAttachmentStoreTests (8): put/consume, oversized rejection,
      global cap, expiry on consume, expiry on read, eviction frees quota,
      peek-twice, delete frees quota.
    • SendEmailToolTests + SendEmailToolMcpInvocationTests (9): direct
      invocation and full AIFunctionFactory round-trip with attachment
      JSON.
    • SendEmailToolAttachmentIdTests (5): ID resolution, name/contentType
      overrides, unknown ID, both-shapes rejection, neither-shape
      rejection.
    • GetEmailAttachmentToolTests (5): stash mode round-trips bytes
      through the store; inline under cap returns base64; inline over cap
      rejects with guidance to use stash; provider-null bubbles as error;
      invalid mode rejects.
  • End-to-end manual validation in the live k8s deployment:
    • Outbound: rockbot sent attachments via base64 path (succeeded) and
      via POST /attachments upload + attachmentId flow.
    • Inbound: rockbot fetched a 58-byte text attachment from a Gmail
      message via both inline and stash modes (after the Gmail
      inline-data fix in 1.2.5).
    • HTTP store lifecycle: rockbot's Python script verified
      POST → GET → DELETE → GET-404 round trip.

Deployment

Image rockylhotka/calendar-mcp-http:1.2.6 already deployed and tested
in the cluster.

Known limitations

  • Single-replica only. The in-memory store is per-pod; if HttpServer is
    ever scaled out, swap InMemoryAttachmentStore for a Redis-backed
    implementation. Interface stays the same.
  • Pod restart drops in-flight uploads. Acceptable given 15 min TTL and
    re-upload-on-retry. PVC would survive restart but adds operational
    surface for marginal benefit at this scale.
  • Graph ItemAttachment (forwarded-message attachments) and
    ReferenceAttachment (links) are intentionally excluded from inbound
    visibility — only FileAttachment is surfaced.
  • IMAP/Gmail attachment IDs are positional (part-N); fine in practice
    since agents fetch right after listing details.
  • No agent-facing streaming. MCP protocol delivers tool results as a
    single JSON-RPC message even over SSE. Server-internal forward
    streaming is a future option (would skip the store entirely for
    forward workflows; requires Graph upload sessions for outbound > 3 MB).

Discoverability follow-up

Filed #53 for the unrelated M365/Outlook \$search quote-handling bug
noticed in the rockbot logs.

🤖 Generated with Claude Code

Attachments are passed as a JSON array of objects with name, contentType,
and base64Content fields so they round-trip cleanly through any MCP client
(Claude Desktop, rockbot, etc.) without filesystem access on the server.

Adds an EmailAttachment model and threads it through IProviderService and
all six provider implementations: M365 and Outlook.com use Graph
FileAttachment (capped at 3 MB per attachment by the SendMail endpoint);
Google and IMAP/SMTP build MIME via MimeKit BodyBuilder; ICS and JSON
file providers continue to throw NotSupportedException. The tool itself
caps total payload at 25 MB and validates name/content/size before
forwarding.

https://claude.ai/code/session_01ETP8QfdTvDo9xBaDz5mbP8
@rockfordlhotka

Copy link
Copy Markdown
Member Author

@copilot add a ci build to the repo to build and run tests

Agent-Logs-Url: https://github.com/MarimerLLC/calendar-mcp/sessions/64a0c95e-a90b-485d-b6f2-b2e5cca43d3e

Co-authored-by: rockfordlhotka <2333134+rockfordlhotka@users.noreply.github.com>

Copilot AI commented May 2, 2026

Copy link
Copy Markdown
Contributor

@copilot add a ci build to the repo to build and run tests

Added a dotnet test step to the existing CI workflow in commit 2a80027. The CI now restores, builds, and runs all tests on every PR and push to main.

claude and others added 6 commits May 2, 2026 23:45
The previous commit introduced a second EmailAttachment type that
collided with the existing one in EmailMessage.cs (used for received-
message attachment metadata). Renaming to OutboundEmailAttachment makes
the inbound vs outbound distinction explicit and resolves the duplicate
definition error.

Adding using MimeKit; to GoogleProviderService also pulled in
MimeKit.MessagePart, which conflicted with Google.Apis.Gmail.v1.Data.MessagePart.
Added a using alias to bind the unqualified name to the Google type.

https://claude.ai/code/session_01ETP8QfdTvDo9xBaDz5mbP8
Fleshes out the send_email attachments parameter shape, adds it to the
README tool list, and drops a changelog entry following the existing
format under changelogs/.

The MCP tool's [Description] attribute already describes the JSON shape
inline, so it is left unchanged.

https://claude.ai/code/session_01ETP8QfdTvDo9xBaDz5mbP8
Lets agents POST a file once and pass back a short-lived attachmentId on
send_email instead of inlining base64 in the JSON tool call. Cuts token
overhead for Claude Code and rockbot, and avoids the LLM having to emit
megabytes of base64 inline.

- POST /attachments (multipart) on the HTTP server, returns an ID with a
  15-min absolute TTL.
- In-memory store (no PVC); per-upload cap 10 MB, global cap 100 MB,
  60 s background eviction sweeper.
- send_email now accepts either base64Content (existing inline path,
  required for stdio + Copilot) or attachmentId per attachment.
- Single-use IDs; shape errors don't consume entries.

Bumps VersionPrefix to 1.2.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
get_email_details now populates attachments[] with name, size,
contentType, and a provider-side attachmentId for each file. New
get_email_attachment tool fetches the bytes by ID:

- stash mode (default): server downloads and stores in IAttachmentStore;
  returns a store ID the agent passes to send_email. Bytes never round
  trip through the LLM. Right shape for the forward use case.
- inline mode: returns base64 directly, capped at 1 MB so the JSON tool
  result stays small. For when the agent itself needs to read the file.

Provider implementations:
- M365 / Outlook.com: Graph attachments collection (metadata-only $select
  on details, full FileAttachment fetch for content).
- Gmail: walks message.payload.parts for filename-bearing parts; uses
  body.attachmentId for the fetch.
- IMAP: positional synthetic IDs (part-N) backed by MailKit re-fetch and
  decode.
- ICS / JSON: return null (no email attachments).

Bumps VersionPrefix to 1.2.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Gmail get_email_attachment surfaced Gmail's body.attachmentId
to the agent, then re-fetched it via messages.attachments.get. That path
fails for small attachments (< 5 KB), where Gmail returns the bytes
inline in body.data on the original message and the separate attachmentId
either isn't populated or doesn't behave as expected.

Switch to positional ids (part-0, part-1, ...) — same scheme as IMAP — and
handle both cases on fetch:
  - inline data in body.data: base64url-decode in place
  - separate fetch via body.attachmentId: only when body.data is empty

Bumps VersionPrefix to 1.2.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /attachments/{id} reads the bytes without consuming the entry —
useful when the agent has HTTP access and the file is too large for the
1 MB inline cap on get_email_attachment. DELETE /attachments/{id} lets
agents free memory before TTL.

Single-use semantics on send_email are unchanged. GET is non-consuming;
the entry stays until send_email consumes it, DELETE removes it, or the
15 min TTL fires. Same network-level auth as POST.

Also tightens the emailId parameter description on get_email_details
and get_email_attachment ('NOT messageId') after observing rockbot
fumble it once.

Bumps VersionPrefix to 1.2.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rockfordlhotka rockfordlhotka changed the title Add file attachment support to send_email tool Email attachments: send, upload, fetch, store May 4, 2026
@rockfordlhotka
rockfordlhotka merged commit 3f24f8e into main May 4, 2026
1 check passed
@rockfordlhotka
rockfordlhotka deleted the claude/add-email-attachments-86N6l branch May 4, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants