From 57198a08eed8635e814a488cd6a3b29b42555b54 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 16 Sep 2026 19:55:14 +0200 Subject: [PATCH] Add Microsoft Graph draft, reply, and file attachment flows --- README.md | 9 + apps/web/app/docs/microsoft/page.mdx | 19 + packages/@emulators/microsoft/README.md | 19 + .../microsoft/src/__tests__/mail.test.ts | 292 ++++++++++++++ packages/@emulators/microsoft/src/entities.ts | 11 + packages/@emulators/microsoft/src/helpers.ts | 3 +- packages/@emulators/microsoft/src/index.ts | 2 + packages/@emulators/microsoft/src/manifest.ts | 55 +++ .../@emulators/microsoft/src/routes/mail.ts | 360 ++++++++++++++++++ .../microsoft/src/routes/openapi.ts | 96 +++++ packages/@emulators/microsoft/src/store.ts | 3 + packages/emulate/src/index.ts | 2 + skills/microsoft/SKILL.md | 14 + 13 files changed, 884 insertions(+), 1 deletion(-) create mode 100644 packages/@emulators/microsoft/src/__tests__/mail.test.ts create mode 100644 packages/@emulators/microsoft/src/routes/mail.ts diff --git a/README.md b/README.md index f496d6bfa..41f9cc92b 100644 --- a/README.md +++ b/README.md @@ -962,6 +962,13 @@ Microsoft Entra ID (Azure AD) v2.0 OAuth 2.0 and OpenID Connect emulation with a - `GET /v1.0/me` - Microsoft Graph user profile - `GET /v1.0/users` - Microsoft Graph users - `GET /v1.0/me/messages` - Outlook mail messages +- `POST /v1.0/me/messages` - create a JSON draft with optional file attachments +- `PATCH /v1.0/me/messages/:id` - update a draft's subject, body, sender or recipients +- `POST /v1.0/me/messages/:id/createReply` - create a reply draft in the original conversation +- `POST /v1.0/me/messages/:id/send` - send a draft with an empty 202 response +- `GET/POST /v1.0/me/messages/:id/attachments` - list or add file attachments +- `GET /v1.0/me/messages/:id/attachments/:attachmentId` - fetch a file attachment +- `GET /v1.0/me/messages/:id/attachments/:attachmentId/$value` - download raw file bytes - `POST /v1.0/me/sendMail` - send mail - `GET /v1.0/me/events` - calendar events - `GET /v1.0/me/drive/root/children` - OneDrive root children @@ -974,6 +981,8 @@ Microsoft Entra ID (Azure AD) v2.0 OAuth 2.0 and OpenID Connect emulation with a - `GET /oauth2/v2.0/logout` - end session / logout - `POST /oauth2/v2.0/revoke` - token revocation +Microsoft draft mail supports JSON compose/reply flows and file attachments smaller than 3 MB. Draft writes require `Mail.ReadWrite`; sending requires `Mail.Send`. IDs stay stable on send. MIME, large-upload sessions, and external delivery are not implemented. See [Microsoft mail coverage](packages/@emulators/microsoft/README.md#drafts-replies-and-file-attachments). + ## AWS S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths and query-style SQS/IAM/STS endpoints. All responses use AWS-compatible XML. diff --git a/apps/web/app/docs/microsoft/page.mdx b/apps/web/app/docs/microsoft/page.mdx index a5c6bbf9e..185cef9c4 100644 --- a/apps/web/app/docs/microsoft/page.mdx +++ b/apps/web/app/docs/microsoft/page.mdx @@ -14,6 +14,13 @@ Microsoft Entra ID (Azure AD) v2.0 OAuth 2.0 and OpenID Connect emulation with a - `GET /v1.0/users` - Microsoft Graph users - `GET /v1.0/users/:id` - Microsoft Graph user by ID - `GET /v1.0/me/messages` - Outlook mail messages +- `POST /v1.0/me/messages` - create a JSON draft with optional file attachments +- `PATCH /v1.0/me/messages/:id` - update a draft's subject, body, sender or recipients +- `POST /v1.0/me/messages/:id/createReply` - create a reply draft in the original conversation +- `POST /v1.0/me/messages/:id/send` - send a draft with an empty 202 response +- `GET/POST /v1.0/me/messages/:id/attachments` - list or add file attachments +- `GET /v1.0/me/messages/:id/attachments/:attachmentId` - fetch a file attachment +- `GET /v1.0/me/messages/:id/attachments/:attachmentId/$value` - download raw file bytes - `POST /v1.0/me/sendMail` - send mail - `GET /v1.0/me/calendars` - calendars - `GET /v1.0/me/events` - calendar events @@ -81,3 +88,15 @@ curl -X PUT "$MICROSOFT_EMULATOR_URL/v1.0/me/drive/root:/notes.txt:/content" \ -H "Content-Type: text/plain" \ --data-binary "hello" ``` + +## Drafts, replies, and file attachments + +Use a delegated token with `Mail.ReadWrite` to create and update drafts or attach files, `Mail.Send` to send, and `Mail.Read` or `Mail.ReadWrite` to read messages and attachments. Every route is scoped to the signed-in mailbox. + +A compose flow is `POST /v1.0/me/messages` followed by `POST /v1.0/me/messages/:id/send`. A reply flow is `POST /v1.0/me/messages/:id/createReply`, `PATCH /v1.0/me/messages/:draftId`, optional attachment POSTs, then send. Draft and attachment creation return 201; PATCH returns 200; send returns 202 with no body. Sending moves the existing draft to `sentitems`, preserving its ID, conversation and attachments. Repeated sends of the same draft fail. No mail leaves the emulator. + +JSON drafts support `subject`, `body`, `from`, `sender`, `toRecipients`, `ccRecipients`, `bccRecipients`, `replyTo`, and `attachments`. PATCH supports the same fields except attachments, which must use the attachment endpoint. Reply drafts retain the original `conversationId` and use the original `replyTo`, falling back to `from`. Supply either `comment` or `message.body`, or omit the reply request body and PATCH later. Original attachments and quoted original body are not copied. + +Attachments must have `@odata.type: "#microsoft.graph.fileAttachment"`, `name`, and base64 `contentBytes`. Optional fields are `contentType`, `isInline`, and `contentId`. Decoded files must be smaller than 3 MB (3,145,728 bytes). Attachment GET returns base64; `/$value` returns the original bytes. Inline-only attachments do not set `hasAttachments`. + +This is a curated JSON subset. MIME, item/reference attachments, upload sessions, `/users` and mail-folder mail routes, attachment OData query options, and provider delivery are not implemented. IDs stay stable even without `Prefer: IdType="ImmutableId"`; the emulator does not model default Outlook ID changes. Use `/_emulate/coverage` to inspect these limits and `/_emulate/ledger` to inspect mutations. A fault armed for `message_Send` fails before the draft changes; it does not simulate delivery followed by a lost response. diff --git a/packages/@emulators/microsoft/README.md b/packages/@emulators/microsoft/README.md index d841d185c..44e3fc535 100644 --- a/packages/@emulators/microsoft/README.md +++ b/packages/@emulators/microsoft/README.md @@ -22,6 +22,13 @@ npm install @emulators/microsoft - `GET /v1.0/users` — Microsoft Graph users - `GET /v1.0/users/:id` — Microsoft Graph user by ID - `GET /v1.0/me/messages` — Outlook mail messages +- `POST /v1.0/me/messages` - create a JSON draft with optional file attachments +- `PATCH /v1.0/me/messages/:id` - update a draft's subject, body, sender or recipients +- `POST /v1.0/me/messages/:id/createReply` - create a reply draft in the original conversation +- `POST /v1.0/me/messages/:id/send` - send a draft with an empty 202 response +- `GET/POST /v1.0/me/messages/:id/attachments` - list or add file attachments +- `GET /v1.0/me/messages/:id/attachments/:attachmentId` - fetch a file attachment +- `GET /v1.0/me/messages/:id/attachments/:attachmentId/$value` - download raw file bytes - `POST /v1.0/me/sendMail` — send mail and optionally save to sent items - `GET /v1.0/me/calendars` — calendars - `GET /v1.0/me/events` — calendar events @@ -74,3 +81,15 @@ Seeded `drive_items[].content` is a plain UTF-8 string. The emulator stores file - [Full documentation](https://emulate.dev/microsoft) - [GitHub](https://github.com/vercel-labs/emulate) + +## Drafts, replies, and file attachments + +Use a delegated token with `Mail.ReadWrite` to create and update drafts or attach files, `Mail.Send` to send, and `Mail.Read` or `Mail.ReadWrite` to read messages and attachments. Every route is scoped to the signed-in mailbox. + +A compose flow is `POST /v1.0/me/messages` followed by `POST /v1.0/me/messages/:id/send`. A reply flow is `POST /v1.0/me/messages/:id/createReply`, `PATCH /v1.0/me/messages/:draftId`, optional attachment POSTs, then send. Draft and attachment creation return 201; PATCH returns 200; send returns 202 with no body. Sending moves the existing draft to `sentitems`, preserving its ID, conversation and attachments. Repeated sends of the same draft fail. No mail leaves the emulator. + +JSON drafts support `subject`, `body`, `from`, `sender`, `toRecipients`, `ccRecipients`, `bccRecipients`, `replyTo`, and `attachments`. PATCH supports the same fields except attachments, which must use the attachment endpoint. Reply drafts retain the original `conversationId` and use the original `replyTo`, falling back to `from`. Supply either `comment` or `message.body`, or omit the reply request body and PATCH later. Original attachments and quoted original body are not copied. + +Attachments must have `@odata.type: "#microsoft.graph.fileAttachment"`, `name`, and base64 `contentBytes`. Optional fields are `contentType`, `isInline`, and `contentId`. Decoded files must be smaller than 3 MB (3,145,728 bytes). Attachment GET returns base64; `/$value` returns the original bytes. Inline-only attachments do not set `hasAttachments`. + +This is a curated JSON subset. MIME, item/reference attachments, upload sessions, `/users` and mail-folder mail routes, attachment OData query options, and provider delivery are not implemented. IDs stay stable even without `Prefer: IdType="ImmutableId"`; the emulator does not model default Outlook ID changes. Use `/_emulate/coverage` to inspect these limits and `/_emulate/ledger` to inspect mutations. A fault armed for `message_Send` fails before the draft changes; it does not simulate delivery followed by a lost response. diff --git a/packages/@emulators/microsoft/src/__tests__/mail.test.ts b/packages/@emulators/microsoft/src/__tests__/mail.test.ts new file mode 100644 index 000000000..0d0b4498f --- /dev/null +++ b/packages/@emulators/microsoft/src/__tests__/mail.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createServer } from "@emulators/core"; +import { manifest, microsoftPlugin, seedFromConfig } from "../index.js"; + +const base = "http://localhost:4000"; +type Message = { + id: string; + conversationId: string; + body: { content: string }; + toRecipients: unknown[]; + [key: string]: unknown; +}; +type AttachmentCollection = { value: Array<{ id: string }> }; + +async function json(response: Response | Promise): Promise { + return (await response).json() as Promise; +} +const address = (email: string) => ({ emailAddress: { address: email } }); +const file = { + "@odata.type": "#microsoft.graph.fileAttachment", + name: "proposal.bin", + contentType: "application/octet-stream", + contentBytes: "AP9iaW5hcnkNCg==", +}; + +describe("Microsoft draft mail", () => { + let server: ReturnType; + + beforeEach(() => { + server = createServer(microsoftPlugin, { + baseUrl: base, + manifest, + tokens: { + writer: { login: "alice@example.com", id: 1, scopes: ["Mail.ReadWrite", "Mail.Send"] }, + reader: { login: "alice@example.com", id: 1, scopes: ["Mail.Read"] }, + editor: { login: "alice@example.com", id: 1, scopes: ["Mail.ReadWrite"] }, + sender: { login: "alice@example.com", id: 1, scopes: ["Mail.Send"] }, + other: { login: "bob@example.com", id: 2, scopes: ["Mail.ReadWrite", "Mail.Send"] }, + app: { login: "application", id: 0, scopes: ["https://graph.microsoft.com/.default"] }, + }, + }); + seedFromConfig(server.store, base, { + users: [{ email: "alice@example.com" }, { email: "bob@example.com" }], + messages: [ + { id: "original", user_email: "alice@example.com", subject: "Proposal", from: "customer@example.com" }, + ], + }); + }); + + function request(path: string, method = "GET", body?: unknown, token = "writer") { + return server.app.request(`${base}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Prefer: 'IdType="ImmutableId"', + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + } + + async function draft(extra: Record = {}) { + const res = await request("/v1.0/me/messages", "POST", { + subject: "Proposal", + body: { contentType: "HTML", content: "

Hello

" }, + toRecipients: [address("customer@example.com")], + ccRecipients: [address("colleague@example.com")], + bccRecipients: [address("archive@example.com")], + ...extra, + }); + expect(res.status).toBe(201); + return res.json() as Promise<{ id: string; conversationId: string; [key: string]: unknown }>; + } + + it("creates a draft with binary attachments, sends it once, and retains its immutable identity", async () => { + const message = await draft({ attachments: [file] }); + expect(message).toMatchObject({ + isDraft: true, + parentFolderId: "drafts", + hasAttachments: true, + body: { contentType: "html", content: "

Hello

" }, + bccRecipients: [address("archive@example.com")], + }); + const path = `/v1.0/me/messages/${message.id}`; + const attachments = await json(request(`${path}/attachments`)); + expect(attachments.value).toHaveLength(1); + expect(attachments.value[0]).toMatchObject({ ...file, size: 10, isInline: false }); + const attachmentPath = `${path}/attachments/${attachments.value[0].id}`; + expect(await (await request(attachmentPath)).json()).toMatchObject(file); + const raw = await request(`${attachmentPath}/$value`); + expect(raw.status).toBe(200); + expect(raw.headers.get("Content-Type")).toBe("application/octet-stream"); + expect(new Uint8Array(await raw.arrayBuffer())).toEqual( + new Uint8Array([0, 255, 98, 105, 110, 97, 114, 121, 13, 10]), + ); + + const sent = await request(`${path}/send`, "POST"); + expect(sent.status).toBe(202); + expect(await sent.text()).toBe(""); + expect(await (await request(path)).json()).toMatchObject({ + id: message.id, + conversationId: message.conversationId, + isDraft: false, + parentFolderId: "sentitems", + hasAttachments: true, + bccRecipients: [address("archive@example.com")], + }); + expect((await request(`${path}/send`, "POST")).status).toBe(400); + expect((await request(`${path}/attachments`, "POST", file)).status).toBe(400); + const ledger = await json<{ entries: unknown[] }>(request("/_emulate/ledger")); + expect(ledger.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ operationId: "message_Create", response: expect.objectContaining({ status: 201 }) }), + expect.objectContaining({ + operationId: "message_Send", + response: expect.objectContaining({ status: 202 }), + sideEffects: expect.arrayContaining([ + expect.objectContaining({ collection: "microsoft.messages", id: message.id }), + ]), + }), + ]), + ); + }); + + it("creates a reply, patches it without replacing identity or recipients, attaches a file, and sends", async () => { + const original = await json(request("/v1.0/me/messages/original")); + const res = await request("/v1.0/me/messages/original/createReply", "POST", {}); + expect(res.status).toBe(201); + const reply = await json(res); + expect(reply).toMatchObject({ + isDraft: true, + subject: "RE: Proposal", + conversationId: original.conversationId, + toRecipients: [address("customer@example.com")], + hasAttachments: false, + }); + expect(reply.id).not.toBe(original.id); + const path = `/v1.0/me/messages/${reply.id}`; + const patch = await request(path, "PATCH", { + body: { contentType: "html", content: "

Reply

" }, + ccRecipients: [], + }); + expect(patch.status).toBe(200); + expect(await patch.json()).toMatchObject({ + id: reply.id, + conversationId: original.conversationId, + subject: "RE: Proposal", + toRecipients: reply.toRecipients, + ccRecipients: [], + body: { contentType: "html", content: "

Reply

" }, + bodyPreview: "Reply", + }); + const attached = await request(`${path}/attachments`, "POST", file); + expect(attached.status).toBe(201); + expect(await attached.json()).toMatchObject(file); + expect((await request(`${path}/send`, "POST")).status).toBe(202); + expect(await (await request(path)).json()).toMatchObject({ isDraft: false, hasAttachments: true }); + expect(await (await request("/v1.0/me/messages/original")).json()).toEqual(original); + }); + + it("uses replyTo, accepts comment or message.body, and does not copy original attachments", async () => { + const original = await draft({ replyTo: [address("support@example.com")], attachments: [file] }); + await request(`/v1.0/me/messages/${original.id}/send`, "POST"); + const path = `/v1.0/me/messages/${original.id}/createReply`; + const res = await request(path, "POST", { comment: "Thanks" }); + expect(res.status).toBe(201); + const reply = await json(res); + expect(reply).toMatchObject({ toRecipients: [address("support@example.com")], hasAttachments: false }); + expect(reply.body.content).toContain("Thanks"); + const bodyReply = await request(path, "POST", { + message: { body: { contentType: "text", content: "Different reply" } }, + }); + expect(bodyReply.status).toBe(201); + expect((await json(bodyReply)).body.content).toBe("Different reply"); + expect( + (await request(path, "POST", { comment: "One", message: { body: { contentType: "text", content: "Two" } } })) + .status, + ).toBe(400); + expect((await request(path, "POST")).status).toBe(201); + }); + + it("keeps the draft and attachments after an injected send failure", async () => { + const message = await draft({ attachments: [file] }); + const path = `/v1.0/me/messages/${message.id}`; + const fault = await request("/_emulate/faults", "POST", { + match: { operationId: "message_Send" }, + response: { status: 503, body: { error: "temporary" } }, + }); + expect(fault.status).toBe(200); + expect((await request(`${path}/send`, "POST")).status).toBe(503); + expect(await (await request(path)).json()).toMatchObject({ isDraft: true, hasAttachments: true }); + expect((await json(request(`${path}/attachments`))).value).toHaveLength(1); + expect((await request(`${path}/send`, "POST")).status).toBe(202); + }); + + it("checks delegated scopes and isolates messages and attachment bytes by mailbox", async () => { + const message = await draft({ attachments: [file] }); + const path = `/v1.0/me/messages/${message.id}`; + const { + value: [attachment], + } = await json(request(`${path}/attachments`)); + const operations: Array<[string, string, unknown?]> = [ + [path, "GET"], + [path, "PATCH", { subject: "Changed" }], + [`${path}/createReply`, "POST", {}], + [`${path}/send`, "POST"], + [`${path}/attachments`, "GET"], + [`${path}/attachments`, "POST", file], + [`${path}/attachments/${attachment.id}`, "GET"], + [`${path}/attachments/${attachment.id}/$value`, "GET"], + ]; + for (const [url, method, body] of operations) { + expect((await request(url, method, body, "other")).status).toBe(404); + expect((await request(url, method, body, "app")).status).toBe(403); + expect((await request(url, method, body, "invalid")).status).toBe(401); + } + for (const token of ["reader", "sender", "app"]) { + expect((await request("/v1.0/me/messages", "POST", {}, token)).status).toBe(403); + } + expect((await request(path, "PATCH", { subject: "Changed" }, "reader")).status).toBe(403); + expect((await request(`${path}/attachments`, "POST", file, "reader")).status).toBe(403); + expect((await request(`${path}/createReply`, "POST", {}, "reader")).status).toBe(403); + expect((await request(`${path}/send`, "POST", undefined, "editor")).status).toBe(403); + expect((await request(`${path}/send`, "POST", undefined, "sender")).status).toBe(202); + const second = await draft(); + expect((await request(`/v1.0/me/messages/${second.id}/attachments/${attachment.id}/$value`)).status).toBe(404); + }); + + it("rejects invalid attachments atomically and requires the attachment endpoint for PATCH", async () => { + const before = (await json<{ value: Message[] }>(request("/v1.0/me/messages"))).value.length; + for (const attachments of [ + [file, { ...file, contentBytes: "not base64!" }], + [{ ...file, "@odata.type": "#microsoft.graph.itemAttachment" }], + ]) { + expect((await request("/v1.0/me/messages", "POST", { attachments })).status).toBe(400); + } + expect((await json<{ value: Message[] }>(request("/v1.0/me/messages"))).value).toHaveLength(before); + const message = await draft(); + const path = `/v1.0/me/messages/${message.id}`; + expect((await request(path, "PATCH", { subject: "Should not change", attachments: [file] })).status).toBe(400); + expect( + ( + await request(`${path}/attachments`, "POST", { + ...file, + contentBytes: Buffer.alloc(3 * 1024 * 1024).toString("base64"), + }) + ).status, + ).toBe(400); + expect((await json(request(`${path}/attachments`))).value).toEqual([]); + expect(await (await request(path)).json()).toMatchObject({ subject: "Proposal", hasAttachments: false }); + const malformed = await server.app.request(`${base}/v1.0/me/messages`, { + method: "POST", + headers: { Authorization: "Bearer writer", "Content-Type": "application/json" }, + body: "{", + }); + expect(malformed.status).toBe(400); + }); + + it("does not count inline-only attachments and rejects sending without recipients", async () => { + const message = await draft({ + toRecipients: [], + ccRecipients: [], + bccRecipients: [], + attachments: [{ ...file, isInline: true, contentId: "logo" }], + }); + expect(message.hasAttachments).toBe(false); + expect((await request(`/v1.0/me/messages/${message.id}/send`, "POST")).status).toBe(400); + expect(await (await request(`/v1.0/me/messages/${message.id}`)).json()).toMatchObject({ isDraft: true }); + }); + + it("accepts file bytes immediately below the simple attachment size limit", async () => { + const message = await draft(); + const contentBytes = Buffer.alloc(3 * 1024 * 1024 - 1, 255).toString("base64"); + const res = await request(`/v1.0/me/messages/${message.id}/attachments`, "POST", { ...file, contentBytes }); + expect(res.status).toBe(201); + expect(await res.json()).toMatchObject({ size: 3 * 1024 * 1024 - 1, contentBytes }); + }); + + it("preserves disjoint draft edits when requests arrive together", async () => { + const message = await draft(); + const path = `/v1.0/me/messages/${message.id}`; + const results = await Promise.all([ + request(path, "PATCH", { subject: "Updated subject" }), + request(path, "PATCH", { body: { contentType: "text", content: "Updated body" } }), + ]); + expect(results.map((response) => response.status)).toEqual([200, 200]); + expect(await (await request(path)).json()).toMatchObject({ + subject: "Updated subject", + body: { contentType: "text", content: "Updated body" }, + }); + }); +}); diff --git a/packages/@emulators/microsoft/src/entities.ts b/packages/@emulators/microsoft/src/entities.ts index 9a783bace..30f0899bc 100644 --- a/packages/@emulators/microsoft/src/entities.ts +++ b/packages/@emulators/microsoft/src/entities.ts @@ -60,6 +60,17 @@ export interface MicrosoftMessage extends Entity { has_attachments: boolean; } +export interface MicrosoftFileAttachment extends Entity { + graph_id: string; + message_id: string; + name: string; + content_type: string; + content_bytes: string; + size: number; + is_inline: boolean; + content_id: string | null; +} + export interface MicrosoftCalendar extends Entity { graph_id: string; user_email: string; diff --git a/packages/@emulators/microsoft/src/helpers.ts b/packages/@emulators/microsoft/src/helpers.ts index 871ef5c3d..af8ccff7e 100644 --- a/packages/@emulators/microsoft/src/helpers.ts +++ b/packages/@emulators/microsoft/src/helpers.ts @@ -336,7 +336,8 @@ export function parseMessageInput( const bodyValue = message.body && typeof message.body === "object" ? (message.body as Record) : {}; const from = parseEmailAddress(message.from) ?? emailAddress(fallbackFrom.email, fallbackFrom.name); const sender = parseEmailAddress(message.sender) ?? from; - const contentType = bodyValue.contentType === "html" ? "html" : "text"; + const contentType = + typeof bodyValue.contentType === "string" && bodyValue.contentType.toLowerCase() === "html" ? "html" : "text"; const content = typeof bodyValue.content === "string" ? bodyValue.content : ""; return { user_email: fallbackFrom.email, diff --git a/packages/@emulators/microsoft/src/index.ts b/packages/@emulators/microsoft/src/index.ts index 25ef10a82..2d0b52a95 100644 --- a/packages/@emulators/microsoft/src/index.ts +++ b/packages/@emulators/microsoft/src/index.ts @@ -12,6 +12,7 @@ import { emailAddress, } from "./helpers.js"; import { graphRoutes } from "./routes/graph.js"; +import { draftMailRoutes } from "./routes/mail.js"; import { oauthRoutes } from "./routes/oauth.js"; import { openapiRoutes } from "./routes/openapi.js"; @@ -349,6 +350,7 @@ export const microsoftPlugin: ServicePlugin = { register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; oauthRoutes(ctx); + draftMailRoutes(ctx); graphRoutes(ctx); openapiRoutes(ctx); }, diff --git a/packages/@emulators/microsoft/src/manifest.ts b/packages/@emulators/microsoft/src/manifest.ts index 226e2d42b..207a7c207 100644 --- a/packages/@emulators/microsoft/src/manifest.ts +++ b/packages/@emulators/microsoft/src/manifest.ts @@ -6,6 +6,60 @@ const graphOperations: OperationCoverage[] = [ { operationId: "graphUser_GetById", method: "GET", path: "/v1.0/users/:id", status: "hand-authored" }, { operationId: "message_List", method: "GET", path: "/v1.0/me/messages", status: "hand-authored" }, { operationId: "message_Get", method: "GET", path: "/v1.0/me/messages/:id", status: "hand-authored" }, + { + operationId: "message_Create", + method: "POST", + path: "/v1.0/me/messages", + status: "partial", + summary: "JSON drafts with file attachments under 3 MB.", + }, + { + operationId: "message_Update", + method: "PATCH", + path: "/v1.0/me/messages/:id", + status: "partial", + summary: "Draft subject, body, sender and recipients; attachments use their own endpoint.", + }, + { + operationId: "message_CreateReply", + method: "POST", + path: "/v1.0/me/messages/:id/createReply", + status: "partial", + summary: "JSON reply drafts retain conversationId and use replyTo; quoted original body is not generated.", + }, + { + operationId: "message_Send", + method: "POST", + path: "/v1.0/me/messages/:id/send", + status: "partial", + summary: "Move draft to sent items with a stable ID and an empty 202 response; no external delivery.", + }, + { + operationId: "attachment_Create", + method: "POST", + path: "/v1.0/me/messages/:id/attachments", + status: "partial", + summary: "File attachments under 3 MB on drafts; no upload sessions or item/reference attachments.", + }, + { + operationId: "attachment_List", + method: "GET", + path: "/v1.0/me/messages/:id/attachments", + status: "partial", + summary: "Stored file attachments; OData query options are not implemented.", + }, + { + operationId: "attachment_Get", + method: "GET", + path: "/v1.0/me/messages/:id/attachments/:attachmentId", + status: "hand-authored", + }, + { + operationId: "attachment_GetContent", + method: "GET", + path: "/v1.0/me/messages/:id/attachments/:attachmentId/$value", + status: "hand-authored", + }, { operationId: "message_SendMail", method: "POST", path: "/v1.0/me/sendMail", status: "hand-authored" }, { operationId: "calendar_GetDefaultCalendar", method: "GET", path: "/v1.0/me/calendar", status: "hand-authored" }, { operationId: "calendar_List", method: "GET", path: "/v1.0/me/calendars", status: "hand-authored" }, @@ -274,6 +328,7 @@ export const manifest: ServiceManifest = { { name: "microsoft.users" }, { name: "microsoft.oauth_clients" }, { name: "microsoft.messages" }, + { name: "microsoft.attachments" }, { name: "microsoft.calendars" }, { name: "microsoft.events" }, { name: "microsoft.drives" }, diff --git a/packages/@emulators/microsoft/src/routes/mail.ts b/packages/@emulators/microsoft/src/routes/mail.ts new file mode 100644 index 000000000..5f4ebee07 --- /dev/null +++ b/packages/@emulators/microsoft/src/routes/mail.ts @@ -0,0 +1,360 @@ +import type { Context, RouteContext } from "@emulators/core"; +import { recordSideEffect } from "@emulators/core"; +import type { MicrosoftFileAttachment, MicrosoftMessage, MicrosoftUser } from "../entities.js"; +import { + createMessageRecord, + emailAddress, + formatMessage, + generateGraphId, + graphError, + parseEmailAddress, + parseMessageInput, + requireDelegatedUser, + requireGraphScope, +} from "../helpers.js"; +import { getMicrosoftStore, type MicrosoftStore } from "../store.js"; + +type AttachmentInput = Omit; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function badRequest(c: Context, message: string): Response { + return graphError(c, 400, "ErrorInvalidRequest", message); +} + +async function jsonBody(c: Context, allowEmpty = false): Promise | Response> { + const text = await c.req.text(); + if (!text && allowEmpty) return {}; + if (!c.req.header("Content-Type")?.toLowerCase().startsWith("application/json")) { + return badRequest(c, "This emulator supports JSON mail requests only."); + } + try { + const body: unknown = JSON.parse(text); + return isRecord(body) ? body : badRequest(c, "A JSON object is required."); + } catch { + return badRequest(c, "Invalid JSON request body."); + } +} + +function validateMessage(c: Context, body: Record): Response | undefined { + if (body.subject !== undefined && typeof body.subject !== "string") return badRequest(c, "subject must be a string."); + if (body.body !== undefined) { + if ( + !isRecord(body.body) || + typeof body.body.content !== "string" || + (body.body.contentType !== undefined && + (typeof body.body.contentType !== "string" || !["text", "html"].includes(body.body.contentType.toLowerCase()))) + ) { + return badRequest(c, "body must contain content and a text or html contentType."); + } + } + for (const field of ["toRecipients", "ccRecipients", "bccRecipients", "replyTo"]) { + if ( + body[field] !== undefined && + (!Array.isArray(body[field]) || !body[field].every((value) => parseEmailAddress(value))) + ) { + return badRequest(c, `${field} must be an array of email recipients.`); + } + } + for (const field of ["from", "sender"]) { + if (body[field] !== undefined && !parseEmailAddress(body[field])) + return badRequest(c, `${field} must be an email recipient.`); + } + const supported = [ + "subject", + "body", + "toRecipients", + "ccRecipients", + "bccRecipients", + "replyTo", + "from", + "sender", + "attachments", + "@odata.type", + ]; + const unsupported = Object.keys(body).find((field) => !supported.includes(field)); + if (unsupported) return badRequest(c, `Draft property '${unsupported}' is not supported by this emulator.`); +} + +function parseAttachment(c: Context, value: unknown): AttachmentInput | Response { + if (!isRecord(value) || value["@odata.type"] !== "#microsoft.graph.fileAttachment") { + return badRequest(c, "Only microsoft.graph.fileAttachment is supported."); + } + if (typeof value.contentBytes === "string" && value.contentBytes.length > 4 * 1024 * 1024) { + return badRequest(c, "File attachments must be smaller than 3 MB; upload sessions are not supported."); + } + if ( + typeof value.name !== "string" || + typeof value.contentBytes !== "string" || + !/^[A-Za-z0-9+/]*={0,2}$/.test(value.contentBytes) || + Buffer.from(value.contentBytes, "base64").toString("base64") !== value.contentBytes + ) { + return badRequest(c, "File attachments require a name and valid base64 contentBytes."); + } + const size = Buffer.from(value.contentBytes, "base64").byteLength; + if (size >= 3 * 1024 * 1024) + return badRequest(c, "File attachments must be smaller than 3 MB; upload sessions are not supported."); + if ( + (value.contentType !== undefined && typeof value.contentType !== "string") || + (value.isInline !== undefined && typeof value.isInline !== "boolean") || + (value.contentId !== undefined && value.contentId !== null && typeof value.contentId !== "string") + ) { + return badRequest(c, "Invalid file attachment metadata."); + } + return { + name: value.name, + content_type: typeof value.contentType === "string" ? value.contentType : "application/octet-stream", + content_bytes: value.contentBytes, + size, + is_inline: value.isInline === true, + content_id: typeof value.contentId === "string" ? value.contentId : null, + }; +} + +function parseAttachments(c: Context, value: unknown): AttachmentInput[] | Response { + if (value === undefined) return []; + if (!Array.isArray(value)) return badRequest(c, "attachments must be an array."); + const attachments: AttachmentInput[] = []; + for (const item of value) { + const attachment = parseAttachment(c, item); + if (attachment instanceof Response) return attachment; + attachments.push(attachment); + } + return attachments; +} + +function formatAttachment(attachment: MicrosoftFileAttachment): Record { + return { + "@odata.type": "#microsoft.graph.fileAttachment", + id: attachment.graph_id, + lastModifiedDateTime: attachment.updated_at, + name: attachment.name, + contentType: attachment.content_type, + size: attachment.size, + isInline: attachment.is_inline, + contentId: attachment.content_id, + contentBytes: attachment.content_bytes, + }; +} + +function saveAttachment( + c: Context, + ms: MicrosoftStore, + messageId: string, + input: AttachmentInput, +): MicrosoftFileAttachment { + const attachment = ms.attachments.insert({ ...input, graph_id: generateGraphId("att"), message_id: messageId }); + recordSideEffect(c, { + type: "create", + collection: "microsoft.attachments", + id: attachment.graph_id, + summary: `Attached '${attachment.name}'`, + }); + return attachment; +} + +function mailbox(c: Context, ms: MicrosoftStore, scopes: string[]): MicrosoftUser | Response { + return requireGraphScope(c, scopes) ?? requireDelegatedUser(c, ms); +} + +function ownedMessage(c: Context, ms: MicrosoftStore, user: MicrosoftUser): MicrosoftMessage | Response { + const message = ms.messages.findOneBy("graph_id", c.req.param("id")); + return message?.user_email === user.email + ? message + : graphError(c, 404, "ErrorItemNotFound", "The specified object was not found in the store."); +} + +function requireDraft(c: Context, message: MicrosoftMessage): Response | undefined { + if (!message.is_draft) return graphError(c, 400, "ErrorInvalidOperation", "This operation requires a draft message."); +} + +export function draftMailRoutes({ app, store, baseUrl }: RouteContext): void { + const ms = getMicrosoftStore(store); + const entity = (message: MicrosoftMessage) => ({ + "@odata.context": `${baseUrl}/v1.0/$metadata#me/messages/$entity`, + ...formatMessage(baseUrl, message), + }); + + const createDraft = (c: Context, user: MicrosoftUser, body: Record, conversationId?: string) => { + const invalid = validateMessage(c, body); + if (invalid) return invalid; + const attachments = parseAttachments(c, body.attachments); + if (attachments instanceof Response) return attachments; + const message = createMessageRecord(ms, { + ...parseMessageInput(body, user), + parent_folder_id: "drafts", + is_draft: true, + conversation_id: conversationId, + has_attachments: attachments.some((attachment) => !attachment.is_inline), + }); + recordSideEffect(c, { + type: "create", + collection: "microsoft.messages", + id: message.graph_id, + summary: `Created draft '${message.subject}'`, + }); + for (const attachment of attachments) saveAttachment(c, ms, message.graph_id, attachment); + c.header("Location", `${baseUrl}/v1.0/me/messages/${message.graph_id}`); + return c.json(entity(message), 201); + }; + + app.post("/v1.0/me/messages", async (c) => { + c.set("operationId", "message_Create"); + const user = mailbox(c, ms, ["Mail.ReadWrite"]); + if (user instanceof Response) return user; + const body = await jsonBody(c); + if (body instanceof Response) return body; + return createDraft(c, user, body); + }); + + app.post("/v1.0/me/messages/:id/createReply", async (c) => { + c.set("operationId", "message_CreateReply"); + const user = mailbox(c, ms, ["Mail.ReadWrite"]); + if (user instanceof Response) return user; + const original = ownedMessage(c, ms, user); + if (original instanceof Response) return original; + const body = await jsonBody(c, true); + if (body instanceof Response) return body; + if ( + (body.comment !== undefined && typeof body.comment !== "string") || + (body.message !== undefined && !isRecord(body.message)) + ) { + return badRequest(c, "createReply accepts a comment string or a message object."); + } + const message = isRecord(body.message) ? body.message : {}; + if (body.comment !== undefined && message.body !== undefined) + return badRequest(c, "Specify comment or message.body, not both."); + if (Object.keys(body).some((key) => !["comment", "message"].includes(key))) + return badRequest(c, "Unsupported createReply property."); + return createDraft( + c, + user, + { + subject: /^re:/i.test(original.subject) ? original.subject : `RE: ${original.subject}`, + toRecipients: original.reply_to.length + ? original.reply_to + : [emailAddress(original.from_address, original.from_name)], + body: { contentType: "text", content: body.comment ?? "" }, + ...message, + }, + original.conversation_id, + ); + }); + + app.patch("/v1.0/me/messages/:id", async (c) => { + c.set("operationId", "message_Update"); + const user = mailbox(c, ms, ["Mail.ReadWrite"]); + if (user instanceof Response) return user; + const body = await jsonBody(c); + if (body instanceof Response) return body; + const message = ownedMessage(c, ms, user); + if (message instanceof Response) return message; + const notDraft = requireDraft(c, message); + if (notDraft) return notDraft; + if (body.attachments !== undefined) return badRequest(c, "Use the attachments endpoint to add attachments."); + const invalid = validateMessage(c, body); + if (invalid) return invalid; + const { + user_email: _userEmail, + parent_folder_id: _folder, + ...changes + } = parseMessageInput({ ...formatMessage(baseUrl, message), ...body }, user); + const updated = ms.messages.update(message.id, changes)!; + recordSideEffect(c, { + type: "update", + collection: "microsoft.messages", + id: message.graph_id, + summary: `Updated draft '${updated.subject}'`, + }); + return c.json(entity(updated)); + }); + + app.post("/v1.0/me/messages/:id/send", (c) => { + c.set("operationId", "message_Send"); + const user = mailbox(c, ms, ["Mail.Send"]); + if (user instanceof Response) return user; + const message = ownedMessage(c, ms, user); + if (message instanceof Response) return message; + const notDraft = requireDraft(c, message); + if (notDraft) return notDraft; + if (!message.to_recipients.length && !message.cc_recipients.length && !message.bcc_recipients.length) { + return graphError(c, 400, "ErrorInvalidRecipients", "At least one recipient is required."); + } + ms.messages.update(message.id, { + is_draft: false, + parent_folder_id: "sentitems", + sent_date_time: new Date().toISOString(), + }); + recordSideEffect(c, { + type: "update", + collection: "microsoft.messages", + id: message.graph_id, + summary: `Sent draft '${message.subject}'`, + }); + return c.body(null, 202); + }); + + app.post("/v1.0/me/messages/:id/attachments", async (c) => { + c.set("operationId", "attachment_Create"); + const user = mailbox(c, ms, ["Mail.ReadWrite"]); + if (user instanceof Response) return user; + const body = await jsonBody(c); + if (body instanceof Response) return body; + const message = ownedMessage(c, ms, user); + if (message instanceof Response) return message; + const notDraft = requireDraft(c, message); + if (notDraft) return notDraft; + const input = parseAttachment(c, body); + if (input instanceof Response) return input; + const attachment = saveAttachment(c, ms, message.graph_id, input); + ms.messages.update(message.id, { has_attachments: message.has_attachments || !attachment.is_inline }); + recordSideEffect(c, { + type: "update", + collection: "microsoft.messages", + id: message.graph_id, + summary: "Updated draft attachments", + }); + return c.json( + { + "@odata.context": `${baseUrl}/v1.0/$metadata#me/messages('${message.graph_id}')/attachments/$entity`, + ...formatAttachment(attachment), + }, + 201, + ); + }); + + app.get("/v1.0/me/messages/:id/attachments", (c) => { + c.set("operationId", "attachment_List"); + const user = mailbox(c, ms, ["Mail.Read", "Mail.ReadWrite"]); + if (user instanceof Response) return user; + const message = ownedMessage(c, ms, user); + if (message instanceof Response) return message; + return c.json({ + "@odata.context": `${baseUrl}/v1.0/$metadata#me/messages('${message.graph_id}')/attachments`, + value: ms.attachments.findBy("message_id", message.graph_id).map(formatAttachment), + }); + }); + + const getAttachment = (c: Context, raw: boolean) => { + c.set("operationId", raw ? "attachment_GetContent" : "attachment_Get"); + const user = mailbox(c, ms, ["Mail.Read", "Mail.ReadWrite"]); + if (user instanceof Response) return user; + const message = ownedMessage(c, ms, user); + if (message instanceof Response) return message; + const attachment = ms.attachments.findOneBy("graph_id", c.req.param("attachmentId")); + if (!attachment || attachment.message_id !== message.graph_id) + return graphError(c, 404, "ErrorItemNotFound", "Attachment not found."); + if (raw) + return new Response(Buffer.from(attachment.content_bytes, "base64"), { + headers: { "Content-Type": "application/octet-stream" }, + }); + return c.json({ + "@odata.context": `${baseUrl}/v1.0/$metadata#me/messages('${message.graph_id}')/attachments/$entity`, + ...formatAttachment(attachment), + }); + }; + app.get("/v1.0/me/messages/:id/attachments/:attachmentId", (c) => getAttachment(c, false)); + app.get("/v1.0/me/messages/:id/attachments/:attachmentId/$value", (c) => getAttachment(c, true)); +} diff --git a/packages/@emulators/microsoft/src/routes/openapi.ts b/packages/@emulators/microsoft/src/routes/openapi.ts index 3e59ec32d..97d18cc6a 100644 --- a/packages/@emulators/microsoft/src/routes/openapi.ts +++ b/packages/@emulators/microsoft/src/routes/openapi.ts @@ -17,6 +17,11 @@ const bearerErrors = { }; const idPathParameter = { name: "id", in: "path", required: true, schema: { type: "string" } }; +const attachmentIdPathParameter = { name: "attachmentId", in: "path", required: true, schema: { type: "string" } }; +const mailJsonBody = { + required: true, + content: { "application/json": { schema: { type: "object" } } }, +}; const driveIdPathParameter = { name: "driveId", in: "path", required: true, schema: { type: "string" } }; const itemIdPathParameter = { name: "itemId", in: "path", required: true, schema: { type: "string" } }; const drivePathParameter = { name: "path", in: "path", required: true, schema: { type: "string" } }; @@ -110,6 +115,18 @@ function buildSpec(baseUrl: string): Record { get: getOperation("message_List", "List messages", ["Mail.Read"], { "200": jsonResponse("Mail message collection."), }), + post: { + ...getOperation( + "message_Create", + "Create a JSON draft with optional file attachments under 3 MB", + ["Mail.ReadWrite"], + { + "201": jsonResponse("Created draft message."), + "400": jsonResponse("Invalid or unsupported draft or attachment input."), + }, + ), + requestBody: mailJsonBody, + }, }, "/v1.0/me/messages/{id}": { get: { @@ -119,6 +136,85 @@ function buildSpec(baseUrl: string): Record { }), parameters: [idPathParameter], }, + patch: { + ...getOperation( + "message_Update", + "Update a draft's subject, body, sender or recipients", + ["Mail.ReadWrite"], + { + "200": jsonResponse("Updated draft message."), + "400": jsonResponse("Invalid draft update or message is not a draft."), + "404": jsonResponse("Message not found."), + }, + ), + parameters: [idPathParameter], + requestBody: mailJsonBody, + }, + }, + "/v1.0/me/messages/{id}/createReply": { + post: { + ...getOperation( + "message_CreateReply", + "Create a JSON reply draft in the original conversation", + ["Mail.ReadWrite"], + { + "201": jsonResponse("Created reply draft. Original quoted body is not generated."), + "400": jsonResponse("Invalid reply input."), + "404": jsonResponse("Message not found."), + }, + ), + parameters: [idPathParameter], + requestBody: { ...mailJsonBody, required: false }, + }, + }, + "/v1.0/me/messages/{id}/send": { + post: { + ...getOperation("message_Send", "Send a draft to sent items, retaining its ID", ["Mail.Send"], { + "202": emptyResponse("Draft accepted; no external delivery is performed."), + "400": jsonResponse("Message is not a draft or has no recipients."), + "404": jsonResponse("Message not found."), + }), + parameters: [idPathParameter], + }, + }, + "/v1.0/me/messages/{id}/attachments": { + get: { + ...getOperation("attachment_List", "List file attachments", ["Mail.Read"], { + "200": jsonResponse("File attachment collection. OData query options are not implemented."), + "404": jsonResponse("Message not found."), + }), + parameters: [idPathParameter], + }, + post: { + ...getOperation("attachment_Create", "Add a file attachment under 3 MB to a draft", ["Mail.ReadWrite"], { + "201": jsonResponse("Created file attachment."), + "400": jsonResponse("Invalid attachment or message is not a draft."), + "404": jsonResponse("Message not found."), + }), + parameters: [idPathParameter], + requestBody: mailJsonBody, + }, + }, + "/v1.0/me/messages/{id}/attachments/{attachmentId}": { + get: { + ...getOperation("attachment_Get", "Get a file attachment including base64 contentBytes", ["Mail.Read"], { + "200": jsonResponse("File attachment."), + "404": jsonResponse("Message or attachment not found."), + }), + parameters: [idPathParameter, attachmentIdPathParameter], + }, + }, + "/v1.0/me/messages/{id}/attachments/{attachmentId}/$value": { + get: { + ...getOperation("attachment_GetContent", "Get raw file attachment bytes", ["Mail.Read"], { + "200": { + description: "File bytes.", + content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, + }, + "404": jsonResponse("Message or attachment not found."), + }), + parameters: [idPathParameter, attachmentIdPathParameter], + }, }, "/v1.0/me/sendMail": { post: getOperation("message_SendMail", "Send mail", ["Mail.Send"], { diff --git a/packages/@emulators/microsoft/src/store.ts b/packages/@emulators/microsoft/src/store.ts index 76d15fd00..2c7f81b5b 100644 --- a/packages/@emulators/microsoft/src/store.ts +++ b/packages/@emulators/microsoft/src/store.ts @@ -4,6 +4,7 @@ import type { MicrosoftDrive, MicrosoftDriveItem, MicrosoftEvent, + MicrosoftFileAttachment, MicrosoftMessage, MicrosoftOAuthClient, MicrosoftUser, @@ -13,6 +14,7 @@ export interface MicrosoftStore { users: Collection; oauthClients: Collection; messages: Collection; + attachments: Collection; calendars: Collection; events: Collection; drives: Collection; @@ -24,6 +26,7 @@ export function getMicrosoftStore(store: Store): MicrosoftStore { users: store.collection("microsoft.users", ["oid", "email"]), oauthClients: store.collection("microsoft.oauth_clients", ["client_id"]), messages: store.collection("microsoft.messages", ["graph_id", "user_email"]), + attachments: store.collection("microsoft.attachments", ["graph_id", "message_id"]), calendars: store.collection("microsoft.calendars", ["graph_id", "user_email"]), events: store.collection("microsoft.events", ["graph_id", "calendar_id", "user_email"]), drives: store.collection("microsoft.drives", ["graph_id", "user_email"]), diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index f97c8b69b..05c2d3a51 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -66,6 +66,8 @@ Global catalog: autumn, posthog, and mcp. MCP OAuth compliance scenarios are configured under mcp.oauth in seed data; see the MCP manifest seed schema for issuer, resource, DCR, and token-auth knobs. + Microsoft Graph supports JSON mail drafts, replies, sends, and file attachments + under /v1.0/me/messages (files smaller than 3 MB; no MIME or upload sessions). Microsoft Graph includes OneDrive file content upload/download routes under /v1.0/me/drive and /v1.0/drives/:driveId. Service host: .emulators.dev (control plane only, no shared diff --git a/skills/microsoft/SKILL.md b/skills/microsoft/SKILL.md index 46e7614d1..26a359114 100644 --- a/skills/microsoft/SKILL.md +++ b/skills/microsoft/SKILL.md @@ -343,6 +343,20 @@ curl -X PUT "$MICROSOFT_URL/v1.0/me/drive/root:/notes.txt:/content" \ --data-binary "hello" ``` +### Draft mail and attachments + +- `POST /v1.0/me/messages` - create a JSON draft with optional file attachments +- `PATCH /v1.0/me/messages/:id` - update a draft's subject, body, sender or recipients +- `POST /v1.0/me/messages/:id/createReply` - create a reply draft in the original conversation +- `POST /v1.0/me/messages/:id/send` - send a draft with an empty 202 response +- `GET/POST /v1.0/me/messages/:id/attachments` - list or add file attachments +- `GET /v1.0/me/messages/:id/attachments/:attachmentId` - fetch a file attachment +- `GET /v1.0/me/messages/:id/attachments/:attachmentId/$value` - download raw file bytes + +Use `Mail.ReadWrite` for draft creation, PATCH, reply creation and attachment POST; `Mail.Send` for sending; and `Mail.Read` or `Mail.ReadWrite` for GET. Send returns an empty 202 and moves the same ID to sent items. Reply drafts keep the original conversation and target `replyTo` or `from`. Add attachments through POST, not PATCH. File attachments use `#microsoft.graph.fileAttachment` and base64 `contentBytes`, with decoded bytes smaller than 3 MB. + +The supported subset is JSON and delegated `/me` routes. MIME, upload sessions, item/reference attachments, quoted reply bodies, mail-folder routes, default Outlook ID changes, and real delivery are not emulated. Inspect `/_emulate/coverage` before depending on these details. + ### Logout ```bash