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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ jobs:

- name: Build solution
run: dotnet build calendar-mcp.slnx --configuration Release --no-restore

- name: Run tests
run: dotnet test calendar-mcp.slnx --configuration Release --no-build --verbosity normal
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<VersionPrefix>1.2.2</VersionPrefix>
<VersionPrefix>1.2.6</VersionPrefix>
</PropertyGroup>
</Project>
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The server exposes these tools to AI assistants:
- **get_emails** / **search_emails** — Read and search email across accounts
- **get_email_details** — Get full email content
- **get_contextual_email_summary** — AI-powered topic clustering and persona analysis
- **send_email** — Send email with smart domain-based routing
- **send_email** — Send email with smart domain-based routing; supports file attachments (base64-encoded, up to 25 MB total)
- **list_calendars** / **get_calendar_events** — View calendars and events
- **get_calendar_event_details** — Get full event details
- **find_available_times** — Find free time across all calendars
Expand Down
174 changes: 174 additions & 0 deletions changelogs/2026-05-02-email-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# 2026-05-02: Email Attachments

## Summary

Extended the `send_email` MCP tool to accept file attachments. Attachments
are passed inline as a JSON array of `{name, contentType, base64Content}`
objects, so they round-trip cleanly through any MCP client (Claude
Desktop, rockbot, etc.) without requiring filesystem access on the
server. Supported on Microsoft 365, Outlook.com, Google Workspace/Gmail,
and IMAP/SMTP accounts.

## Changes Made

### Core Library (CalendarMcp.Core)

#### New Model
- `Models/OutboundEmailAttachment.cs` — payload type for outbound
attachments. Distinct from the existing `EmailAttachment` (which
represents inbound attachment metadata on a received message).

#### Interface Update
- `Services/IProviderService.cs` — added an
`IReadOnlyList<OutboundEmailAttachment>?` parameter to
`SendEmailAsync`.

#### Provider Implementations
- `Providers/M365ProviderService.cs` — attaches via Microsoft Graph
`FileAttachment` on the `SendMail` request.
- `Providers/OutlookComProviderService.cs` — same as M365 (shared
Graph code path).
- `Providers/GraphAttachmentBuilder.cs` (new) — shared helper that
builds Graph `FileAttachment` instances and validates the
per-attachment 3 MB cap that the Graph SendMail endpoint enforces.
- `Providers/GoogleProviderService.cs` — switched from hand-rolled
RFC 2822 string assembly to MimeKit `BodyBuilder`, which handles
multipart/mixed correctly when attachments are present.
- `Providers/ImapProviderService.cs` — uses MimeKit `BodyBuilder`
when attachments are present; preserves the existing single-part
`TextPart` body when there are none.
- `Providers/MimeAttachmentBuilder.cs` (new) — shared helper used by
Google and IMAP/SMTP to add attachments to a MimeKit `BodyBuilder`.
- `Providers/IcsProviderService.cs` — stub continues to throw
`NotSupportedException` (read-only).
- `Providers/JsonCalendarProviderService.cs` — stub continues to
throw `NotSupportedException` (read-only).

#### MCP Tool
- `Tools/SendEmailTool.cs` — added the `attachments` parameter with
a description that fully specifies the JSON shape so MCP clients
surface it correctly. Validates name, content, and total payload
size (25 MB cap) before forwarding to the provider.

#### Misc
- `Tools/UnsubscribeFromEmailTool.cs` — updated to pass `null` for
the new attachments parameter on its internal `SendEmailAsync`
call.

### Tests
- `Tests/Tools/SendEmailToolTests.cs` — updated existing mocks for
the new signature, plus four new tests:
- `SendEmail_WithAttachment_PassesThroughToProvider`
- `SendEmail_AttachmentMissingName_ReturnsError`
- `SendEmail_AttachmentMissingContent_ReturnsError`
- `SendEmail_AttachmentExceedsTotalCap_ReturnsError`

### CI
- `.github/workflows/ci.yml` — gained a `dotnet test` step (added by
Copilot during this PR) so the new tests run on every push and PR.

## API Details

### Tool Signature
```
send_email(
to: string[],
subject: string,
body: string,
accountId?: string,
bodyFormat?: "html" | "text",
cc?: string[],
attachments?: OutboundEmailAttachment[]
) -> JSON response
```

### Attachment Shape
```json
[
{
"name": "report.pdf",
"contentType": "application/pdf",
"base64Content": "<base64-encoded file bytes>"
}
]
```

- **name** (required): file name as it should appear on the email
- **contentType** (optional): MIME type; sniffed by the provider if
omitted
- **base64Content** (required): file bytes encoded as one base64
string

### Limits

| Scope | Limit | Source |
|-------|-------|--------|
| Per attachment, M365 / Outlook.com | 3 MB | Microsoft Graph SendMail endpoint cap |
| Per message, total decoded payload | 25 MB | Tool-level cap to keep MCP payloads tractable |
| Google / IMAP per-attachment | (no extra cap beyond the 25 MB total) | MimeKit handles arbitrary multipart sizes |

Larger files are rejected with an explicit error message rather than
attempted and partially uploaded.

## Provider Support

| Provider | Supported | Implementation |
|----------|-----------|----------------|
| Microsoft 365 | Yes | Graph `FileAttachment` on `Me.SendMail` |
| Outlook.com | Yes | Graph `FileAttachment` on `Me.SendMail` |
| Google Workspace / Gmail | Yes | MimeKit `BodyBuilder` → base64url MIME → Gmail `Users.Messages.Send` |
| IMAP / SMTP | Yes | MimeKit `BodyBuilder` → SMTP send + IMAP APPEND to Sent |
| ICS | No | Read-only provider |
| JSON file | No | Read-only provider |

## Usage Example

```
User: "Email the Q2 report to alice@example.com"
Assistant: [reads or generates the file]
Assistant: [calls send_email with attachments=[{
name: "Q2-report.pdf",
contentType: "application/pdf",
base64Content: "JVBERi0xLjQK..."
}]]
Assistant: "Sent — Q2-report.pdf (842 KB) is on its way to alice."
```

## Design Notes

- **Inline base64, not file paths.** Path-based attachments would require
the MCP server to share a filesystem with whatever produced the file,
which doesn't hold for hosted clients like Claude Desktop or rockbot.
Inline base64 keeps the tool call self-contained.
- **Type split.** `EmailAttachment` (existing, inbound) and
`OutboundEmailAttachment` (new) are kept distinct because they
describe different things — inbound carries metadata about an
attachment already on the server; outbound carries the actual bytes
to upload.
- **Why not Graph upload sessions for >3 MB on M365?** Implementing
the draft + upload-session + send flow is non-trivial and would be
deferred unless real usage shows demand. Today the tool returns a
clear error pointing at the 3 MB cap.

## Files Changed

- `src/CalendarMcp.Core/Models/OutboundEmailAttachment.cs` (new)
- `src/CalendarMcp.Core/Providers/GraphAttachmentBuilder.cs` (new)
- `src/CalendarMcp.Core/Providers/MimeAttachmentBuilder.cs` (new)
- `src/CalendarMcp.Core/Services/IProviderService.cs`
- `src/CalendarMcp.Core/Providers/M365ProviderService.cs`
- `src/CalendarMcp.Core/Providers/OutlookComProviderService.cs`
- `src/CalendarMcp.Core/Providers/GoogleProviderService.cs`
- `src/CalendarMcp.Core/Providers/ImapProviderService.cs`
- `src/CalendarMcp.Core/Providers/IcsProviderService.cs`
- `src/CalendarMcp.Core/Providers/JsonCalendarProviderService.cs`
- `src/CalendarMcp.Core/Tools/SendEmailTool.cs`
- `src/CalendarMcp.Core/Tools/UnsubscribeFromEmailTool.cs`
- `src/CalendarMcp.Tests/Tools/SendEmailToolTests.cs`
- `.github/workflows/ci.yml` (added `dotnet test` step)

## Related Documentation

- [Microsoft Graph: Send mail with attachments](https://learn.microsoft.com/en-us/graph/api/user-sendmail)
- [Gmail API: Users.messages.send](https://developers.google.com/gmail/api/reference/rest/v1/users.messages/send)
- [MimeKit BodyBuilder](http://www.mimekit.net/docs/html/T_MimeKit_BodyBuilder.htm)
93 changes: 93 additions & 0 deletions changelogs/2026-05-03-attachment-get-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 2026-05-03: Attachment GET / DELETE + Param-Name Hint

## Summary

Adds two HTTP endpoints on the attachment store and tightens the
`emailId` parameter description so agents stop guessing `messageId`.

- `GET /attachments/{id}` — read the bytes of a stored attachment
without consuming it. Returns raw bytes with `Content-Type` and
`Content-Disposition`. Useful when the agent has HTTP access and the
attachment exceeds the 1 MB inline cap on `get_email_attachment`.
- `DELETE /attachments/{id}` — explicit cleanup before TTL.
- Tool descriptions for `get_email_details` and `get_email_attachment`
now explicitly call out the parameter name (`emailId`, not `messageId`).

## Why

Observed during rockbot integration: after `get_email_attachment(mode=stash)`
returned an `attachmentId`, the agent burned 4 tool calls and a web
search trying to download it via various GET URL shapes
(`/attachments/{id}`, `/attachment/{id}`, `/attachments/download/{id}`,
`/api/attachments/{id}` — all 404). The original design intentionally
omitted GET to keep "bytes never round-trip through the agent" as a
guarantee. But that guarantee was already softened by
`get_email_attachment(mode=inline)`, which returns base64 in the JSON
tool result — so adding a real GET is the same capability over a better
transport (no JSON envelope, no base64 inflation, no 1 MB cap).

The agent also fumbled `messageId` vs `emailId` once. Description fix is
free.

## Design

### Single-use vs. peek

`send_email`'s consume semantics are unchanged — the ID is removed when
send succeeds, preventing replay confusion. The new GET is **non-consuming**:
it reads without removing, so the same ID can be GET'd multiple times and
then still passed to `send_email`. The entry stays in the store until
`send_email` consumes it, `DELETE` removes it, or TTL fires.

### Auth

Same as the existing POST: network-level only (Tailscale ACL on the
public ingress; pod-internal `http://calendar-mcp.calendar-mcp:8080/`
inside the cluster). IDs are 128 random bits, unguessable in practice.

### Caps

No new caps. Per-attachment size and global store size are unchanged from
the upload endpoint (10 MB / 100 MB / 15 min TTL).

## Changes Made

### Core (CalendarMcp.Core)

- `Services/IAttachmentStore.cs` — added `TryRead(id)` and `TryDelete(id)`
alongside the existing `TryConsume(id)`.
- `Services/InMemoryAttachmentStore.cs` — implemented both. `TryRead`
also evicts lazily on expiry while it has the lock.

### HTTP Server (CalendarMcp.HttpServer)

- `Endpoints/AttachmentEndpoints.cs` —
- `GET /attachments/{id}` returns `Results.File` with the entry's
`Bytes`, `ContentType`, and `Name` for `Content-Disposition`.
- `DELETE /attachments/{id}` returns 204 on success, 404 if already
gone.

### Tools (description tightening)

- `Tools/GetEmailDetailsTool.cs`, `Tools/GetEmailAttachmentTool.cs` —
explicit "pass as parameter name 'emailId' (NOT 'messageId')" hint
on the email parameter description.
- `Tools/GetEmailAttachmentTool.cs` — top-level description now mentions
`GET /attachments/{id}` as an HTTP-only escape hatch when the file is
too large for inline mode.

### Tests (CalendarMcp.Tests)

- `Services/InMemoryAttachmentStoreTests.cs` (3 new) —
`TryRead` is repeatable and doesn't block consume; `TryDelete` removes
+ frees quota; `TryRead` evicts on expiry.
- `Helpers/TestAttachmentStore.cs` — implements the new interface
methods.

### Docs

- `docs/mcp-tools.md` — new sections for `GET /attachments/{id}` and
`DELETE /attachments/{id}` with curl recipes; param-name reminder
callout under `get_email_attachment`.

## Bumps `VersionPrefix` to 1.2.6.
Loading
Loading