Email attachments: send, upload, fetch, store#52
Merged
Conversation
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
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>
Contributor
Added a |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_emailaccepts anattachmentsarray per item with eitherbase64Content(inline) orattachmentId(from upload).FileAttachment(M365/Outlook.com), MimeKitBodyBuilder+ base64urlraw send (Gmail), MimeKit + SMTP (IMAP). ICS and JSON providers
throw
NotSupportedException.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.IAttachmentStoreinterface +InMemoryAttachmentStore: 22-charbase64url IDs from
RandomNumberGenerator, single-use consume onsend_email, multi-read on GET, 60 s background eviction sweeper.only — no PVC required for the single-pod deployment.
Inbound attachment visibility + fetch:
get_email_detailspopulates anattachments[]array with name, size,contentType, and a provider-side
attachmentId. Implemented forM365, Outlook.com, Gmail, and IMAP. ICS/JSON return
null.get_email_attachmenttool fetches by ID. Two modes:stash(default): bytes go straight intoIAttachmentStore,returns the store ID; agent passes that ID to
send_emailtoforward without bytes ever transiting the LLM.
inline: returnsbase64Contentdirectly; capped at 1 MB.part-NIDs (handles small inline-dataattachments and large separate-fetch attachments uniformly). IMAP also
positional. Graph uses native opaque IDs.
What changed during review
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.
GET /attachments/{id}and burned tool calls guessing URL shapes.Added GET/DELETE for symmetry.
emailIdparameter explicitly says "NOTmessageId" after agents fumbled it;
get_email_attachmentdescriptionmentions the HTTP GET endpoint as the escape hatch when the file is
too large for the 1 MB inline cap.
body.attachmentId,which doesn't behave for small inline-data attachments. Switched to
positional
part-N.Testing
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): directinvocation and full
AIFunctionFactoryround-trip with attachmentJSON.
SendEmailToolAttachmentIdTests(5): ID resolution, name/contentTypeoverrides, unknown ID, both-shapes rejection, neither-shape
rejection.
GetEmailAttachmentToolTests(5): stash mode round-trips bytesthrough the store; inline under cap returns base64; inline over cap
rejects with guidance to use stash; provider-null bubbles as error;
invalid mode rejects.
via
POST /attachmentsupload +attachmentIdflow.message via both
inlineandstashmodes (after the Gmailinline-data fix in 1.2.5).
POST → GET → DELETE → GET-404 round trip.
Deployment
Image
rockylhotka/calendar-mcp-http:1.2.6already deployed and testedin the cluster.
Known limitations
ever scaled out, swap
InMemoryAttachmentStorefor a Redis-backedimplementation. Interface stays the same.
re-upload-on-retry. PVC would survive restart but adds operational
surface for marginal benefit at this scale.
ItemAttachment(forwarded-message attachments) andReferenceAttachment(links) are intentionally excluded from inboundvisibility — only
FileAttachmentis surfaced.part-N); fine in practicesince agents fetch right after listing details.
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
\$searchquote-handling bugnoticed in the rockbot logs.
🤖 Generated with Claude Code