@open-workspace/email 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/alchemy.ts +126 -0
- package/containers/office-preview/Dockerfile +10 -0
- package/index.ts +3 -0
- package/migrations/0000_email.sql +53 -0
- package/migrations/meta/0000_snapshot.json +408 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +91 -4
- package/shared/attachment-preview.ts +67 -0
- package/shared/mail-authentication.ts +145 -0
- package/shared/mail-feedback.ts +90 -0
- package/shared/mail-headers.ts +250 -0
- package/shared/mail-received.ts +298 -0
- package/shared/mail-remote.ts +17 -0
- package/shared/mail-rules.ts +156 -0
- package/shared/mail-sender-intel.ts +125 -0
- package/shared/mail-translation.ts +2 -0
- package/shared/mail.ts +179 -0
- package/shared/office-preview.ts +26 -0
- package/worker/admin/report-routes.ts +164 -0
- package/worker/bindings.d.ts +11 -0
- package/worker/db/schema.ts +97 -0
- package/worker/mail/access.ts +30 -0
- package/worker/mail/ai-classification.ts +119 -0
- package/worker/mail/ai-translation.ts +31 -0
- package/worker/mail/auth-headers.ts +557 -0
- package/worker/mail/bimi.ts +172 -0
- package/worker/mail/classification-signals.ts +116 -0
- package/worker/mail/conversation-cursor.ts +43 -0
- package/worker/mail/delivery-events.ts +99 -0
- package/worker/mail/delivery-facts.ts +122 -0
- package/worker/mail/demo-routes.ts +33 -0
- package/worker/mail/doh.ts +74 -0
- package/worker/mail/downloads.ts +47 -0
- package/worker/mail/header-metadata.ts +77 -0
- package/worker/mail/impersonation.ts +175 -0
- package/worker/mail/inbound-delivery.ts +22 -0
- package/worker/mail/inbound-threading.ts +48 -0
- package/worker/mail/inbound.ts +255 -0
- package/worker/mail/ip-intel.ts +135 -0
- package/worker/mail/link-domains.ts +57 -0
- package/worker/mail/mailbox-deletion.ts +18 -0
- package/worker/mail/mailbox-directory.ts +73 -0
- package/worker/mail/mailing-list.ts +120 -0
- package/worker/mail/message-security.ts +190 -0
- package/worker/mail/message-view.ts +63 -0
- package/worker/mail/office-conversion.ts +38 -0
- package/worker/mail/office-preview-container.ts +57 -0
- package/worker/mail/office-preview-queue.ts +49 -0
- package/worker/mail/org-domain.ts +15 -0
- package/worker/mail/outbound-content.ts +208 -0
- package/worker/mail/outbound-delivery.ts +83 -0
- package/worker/mail/outbound-service.ts +145 -0
- package/worker/mail/outbound-threading.ts +70 -0
- package/worker/mail/outbound.ts +446 -0
- package/worker/mail/outgoing-context.ts +122 -0
- package/worker/mail/participants.ts +115 -0
- package/worker/mail/push-notifications.ts +484 -0
- package/worker/mail/quote-parser.ts +50 -0
- package/worker/mail/r2-presigned-urls.ts +69 -0
- package/worker/mail/receive.ts +59 -0
- package/worker/mail/recipients.ts +34 -0
- package/worker/mail/remote-proxy.ts +140 -0
- package/worker/mail/reply-plan.ts +136 -0
- package/worker/mail/reported-messages.ts +68 -0
- package/worker/mail/rfc.ts +73 -0
- package/worker/mail/routes.ts +1434 -0
- package/worker/mail/safety-routes.ts +350 -0
- package/worker/mail/schemas.ts +339 -0
- package/worker/mail/search.ts +14 -0
- package/worker/mail/security-settings.ts +38 -0
- package/worker/mail/sender-intel.ts +189 -0
- package/worker/mail/spamhaus.ts +97 -0
- package/worker/mail/unsubscribe.ts +118 -0
- package/worker/mail/uploads.ts +537 -0
- package/worker/mail/webhook-source.ts +71 -0
- package/worker/mail/workspace-safety.ts +92 -0
- package/worker/mailbox/demo-html.ts +136 -0
- package/worker/mailbox/demo-seed.ts +79 -0
- package/worker/mailbox/demo.ts +978 -0
- package/worker/mailbox/folder-model.ts +72 -0
- package/worker/mailbox/index.ts +2508 -0
- package/worker/mailbox/legacy-migrations.ts +41 -0
- package/worker/mailbox/migrations.ts +10 -0
- package/worker/mailbox/model.ts +65 -0
- package/worker/mailbox/office-preview-jobs.ts +73 -0
- package/worker/mailbox/rules.ts +110 -0
- package/worker/mailbox/schema.ts +353 -0
- package/worker/mailbox/senders.ts +240 -0
- package/worker/mcp/mail-action-tools.ts +210 -0
- package/worker/mcp/mail-compose-tools.ts +196 -0
- package/worker/mcp/mail-query-tools.ts +196 -0
- package/worker/mcp/mail-safety-tools.ts +452 -0
- package/worker/mcp/tools.ts +15 -0
- package/worker/module.ts +59 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { AccountApiClient, apiPath } from "@open-workspace/core/worker/mcp/account-client.ts";
|
|
4
|
+
import { runTool } from "@open-workspace/core/worker/mcp/results.ts";
|
|
5
|
+
import {
|
|
6
|
+
conversationIdsSchema,
|
|
7
|
+
entityIdSchema,
|
|
8
|
+
mailboxIdSchema,
|
|
9
|
+
mailboxStateSchema,
|
|
10
|
+
} from "@open-workspace/core/worker/mcp/schemas.ts";
|
|
11
|
+
|
|
12
|
+
const write = {
|
|
13
|
+
readOnlyHint: false,
|
|
14
|
+
destructiveHint: false,
|
|
15
|
+
idempotentHint: true,
|
|
16
|
+
openWorldHint: false,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export function registerMailActionTools(server: McpServer, api: AccountApiClient) {
|
|
20
|
+
server.registerTool(
|
|
21
|
+
"set_message_read",
|
|
22
|
+
{
|
|
23
|
+
title: "Mark message read or unread",
|
|
24
|
+
description: "Set the current account's personal read state for one message.",
|
|
25
|
+
inputSchema: z.object({
|
|
26
|
+
mailboxId: mailboxIdSchema,
|
|
27
|
+
messageId: entityIdSchema,
|
|
28
|
+
isRead: z.boolean(),
|
|
29
|
+
}),
|
|
30
|
+
annotations: write,
|
|
31
|
+
},
|
|
32
|
+
({ mailboxId, messageId, isRead }) => runTool(() => api.json(apiPath(
|
|
33
|
+
`/mail/messages/${encodeURIComponent(messageId)}/read`,
|
|
34
|
+
{ mailboxId },
|
|
35
|
+
), { method: "PATCH", json: { isRead } })),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
server.registerTool(
|
|
39
|
+
"set_conversations_read",
|
|
40
|
+
{
|
|
41
|
+
title: "Mark conversations read or unread",
|
|
42
|
+
description: "Set the current account's personal read state for all messages in up to 500 conversations.",
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
mailboxId: mailboxIdSchema,
|
|
45
|
+
conversationIds: conversationIdsSchema,
|
|
46
|
+
isRead: z.boolean(),
|
|
47
|
+
}),
|
|
48
|
+
annotations: write,
|
|
49
|
+
},
|
|
50
|
+
({ mailboxId, conversationIds, isRead }) => runTool(() => api.json(apiPath(
|
|
51
|
+
"/mail/conversations/bulk",
|
|
52
|
+
{ mailboxId },
|
|
53
|
+
), {
|
|
54
|
+
method: "PATCH",
|
|
55
|
+
json: { type: "read", conversationIds, isRead },
|
|
56
|
+
})),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
server.registerTool(
|
|
60
|
+
"move_conversations",
|
|
61
|
+
{
|
|
62
|
+
title: "Move conversations",
|
|
63
|
+
description: "Move up to 500 conversations between Inbox, Archive, Trash, or a custom folder, or out of Spam. To put mail in Spam use report_conversations (or report_message), which also records the report for the spam classifier.",
|
|
64
|
+
inputSchema: z.object({
|
|
65
|
+
mailboxId: mailboxIdSchema,
|
|
66
|
+
conversationIds: conversationIdsSchema,
|
|
67
|
+
sourceFolderId: entityIdSchema,
|
|
68
|
+
// Spam goes through reporting so the classifier learns from it.
|
|
69
|
+
mailboxState: mailboxStateSchema.exclude(["spam"]).optional(),
|
|
70
|
+
folderId: entityIdSchema.nullable().optional(),
|
|
71
|
+
}).refine(
|
|
72
|
+
(value) => value.mailboxState !== undefined || value.folderId !== undefined,
|
|
73
|
+
"Provide mailboxState, folderId, or both",
|
|
74
|
+
),
|
|
75
|
+
annotations: write,
|
|
76
|
+
},
|
|
77
|
+
({ mailboxId, conversationIds, sourceFolderId, mailboxState, folderId }) =>
|
|
78
|
+
runTool(() => api.json(apiPath("/mail/conversations/bulk", { mailboxId }), {
|
|
79
|
+
method: "PATCH",
|
|
80
|
+
json: {
|
|
81
|
+
type: "update",
|
|
82
|
+
conversationIds,
|
|
83
|
+
sourceFolderId,
|
|
84
|
+
update: { mailboxState, folderId },
|
|
85
|
+
},
|
|
86
|
+
})),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
server.registerTool(
|
|
90
|
+
"delete_conversations_permanently",
|
|
91
|
+
{
|
|
92
|
+
title: "Permanently delete conversations",
|
|
93
|
+
description: "Permanently delete up to 500 conversations. Every conversation must already be in Trash.",
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
mailboxId: mailboxIdSchema,
|
|
96
|
+
conversationIds: conversationIdsSchema,
|
|
97
|
+
}),
|
|
98
|
+
annotations: {
|
|
99
|
+
...write,
|
|
100
|
+
destructiveHint: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
({ mailboxId, conversationIds }) => runTool(() => api.json(apiPath(
|
|
104
|
+
"/mail/conversations/bulk",
|
|
105
|
+
{ mailboxId },
|
|
106
|
+
), {
|
|
107
|
+
method: "PATCH",
|
|
108
|
+
json: { type: "delete_permanently", conversationIds },
|
|
109
|
+
})),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.registerTool(
|
|
113
|
+
"create_folder",
|
|
114
|
+
{
|
|
115
|
+
title: "Create custom folder",
|
|
116
|
+
description: "Create a custom folder shared by everyone with access to the mailbox.",
|
|
117
|
+
inputSchema: z.object({
|
|
118
|
+
mailboxId: mailboxIdSchema,
|
|
119
|
+
name: z.string().trim().min(1).max(80),
|
|
120
|
+
}),
|
|
121
|
+
annotations: { ...write, idempotentHint: false },
|
|
122
|
+
},
|
|
123
|
+
({ mailboxId, name }) => runTool(() => api.json(
|
|
124
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/folders`,
|
|
125
|
+
{ method: "POST", json: { name } },
|
|
126
|
+
)),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
server.registerTool(
|
|
130
|
+
"rename_folder",
|
|
131
|
+
{
|
|
132
|
+
title: "Rename custom folder",
|
|
133
|
+
inputSchema: z.object({
|
|
134
|
+
mailboxId: mailboxIdSchema,
|
|
135
|
+
folderId: entityIdSchema,
|
|
136
|
+
name: z.string().trim().min(1).max(80),
|
|
137
|
+
}),
|
|
138
|
+
annotations: write,
|
|
139
|
+
},
|
|
140
|
+
({ mailboxId, folderId, name }) => runTool(() => api.json(
|
|
141
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/folders/${encodeURIComponent(folderId)}`,
|
|
142
|
+
{ method: "PATCH", json: { name } },
|
|
143
|
+
)),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
server.registerTool(
|
|
147
|
+
"delete_folder",
|
|
148
|
+
{
|
|
149
|
+
title: "Delete custom folder",
|
|
150
|
+
description: "Delete a custom folder. Conversations remain in Inbox.",
|
|
151
|
+
inputSchema: z.object({
|
|
152
|
+
mailboxId: mailboxIdSchema,
|
|
153
|
+
folderId: entityIdSchema,
|
|
154
|
+
}),
|
|
155
|
+
annotations: { ...write, destructiveHint: true },
|
|
156
|
+
},
|
|
157
|
+
({ mailboxId, folderId }) => runTool(() => api.json(
|
|
158
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/folders/${encodeURIComponent(folderId)}`,
|
|
159
|
+
{ method: "DELETE" },
|
|
160
|
+
)),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"reorder_folders",
|
|
165
|
+
{
|
|
166
|
+
title: "Reorder custom folders",
|
|
167
|
+
description: "Set the complete order of custom folders in a mailbox.",
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
mailboxId: mailboxIdSchema,
|
|
170
|
+
folderIds: z.array(entityIdSchema).max(100),
|
|
171
|
+
}),
|
|
172
|
+
annotations: write,
|
|
173
|
+
},
|
|
174
|
+
({ mailboxId, folderIds }) => runTool(() => api.json(
|
|
175
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/folders/order`,
|
|
176
|
+
{ method: "PUT", json: { folderIds } },
|
|
177
|
+
)),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
server.registerTool(
|
|
181
|
+
"get_mailbox_ai_settings",
|
|
182
|
+
{
|
|
183
|
+
title: "Get mailbox AI rules",
|
|
184
|
+
description: "Read mailbox-level classification instructions and confidence threshold.",
|
|
185
|
+
inputSchema: z.object({ mailboxId: mailboxIdSchema }),
|
|
186
|
+
annotations: { ...write, readOnlyHint: true },
|
|
187
|
+
},
|
|
188
|
+
({ mailboxId }) => runTool(() => api.json(
|
|
189
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/ai`,
|
|
190
|
+
)),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
server.registerTool(
|
|
194
|
+
"update_mailbox_ai_settings",
|
|
195
|
+
{
|
|
196
|
+
title: "Update mailbox AI rules",
|
|
197
|
+
description: "Update mailbox-level classification instructions and confidence threshold.",
|
|
198
|
+
inputSchema: z.object({
|
|
199
|
+
mailboxId: mailboxIdSchema,
|
|
200
|
+
instructions: z.string().trim().max(4_000),
|
|
201
|
+
confidenceThreshold: z.number().int().min(50).max(100),
|
|
202
|
+
}),
|
|
203
|
+
annotations: write,
|
|
204
|
+
},
|
|
205
|
+
({ mailboxId, instructions, confidenceThreshold }) => runTool(() => api.json(
|
|
206
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/ai`,
|
|
207
|
+
{ method: "PUT", json: { instructions, confidenceThreshold } },
|
|
208
|
+
)),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
MAX_COMPOSER_ATTACHMENT_BYTES,
|
|
5
|
+
MAX_MAIL_RECIPIENTS,
|
|
6
|
+
} from "../../shared/mail";
|
|
7
|
+
import { emailSchema } from "@open-workspace/core/worker/auth/schemas.ts";
|
|
8
|
+
import { dedupeRecipientFields, recipientCount } from "../mail/recipients";
|
|
9
|
+
import { AccountApiClient, apiPath } from "@open-workspace/core/worker/mcp/account-client.ts";
|
|
10
|
+
import { runTool } from "@open-workspace/core/worker/mcp/results.ts";
|
|
11
|
+
import {
|
|
12
|
+
entityIdSchema,
|
|
13
|
+
mailboxIdSchema,
|
|
14
|
+
messageContentFields,
|
|
15
|
+
recipientFields,
|
|
16
|
+
replyModeSchema,
|
|
17
|
+
requestId,
|
|
18
|
+
} from "@open-workspace/core/worker/mcp/schemas.ts";
|
|
19
|
+
|
|
20
|
+
const sendAnnotations = {
|
|
21
|
+
readOnlyHint: false,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
idempotentHint: false,
|
|
24
|
+
openWorldHint: true,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const composeInputSchema = z.object({
|
|
28
|
+
mailboxId: mailboxIdSchema,
|
|
29
|
+
...recipientFields,
|
|
30
|
+
replyTo: emailSchema.optional(),
|
|
31
|
+
subject: z.string().trim().max(998).default(""),
|
|
32
|
+
...messageContentFields,
|
|
33
|
+
}).refine(
|
|
34
|
+
(value) => recipientCount(dedupeRecipientFields(value)) > 0,
|
|
35
|
+
"Add at least one recipient",
|
|
36
|
+
).refine(
|
|
37
|
+
(value) => recipientCount(dedupeRecipientFields(value)) <= MAX_MAIL_RECIPIENTS,
|
|
38
|
+
`An email can have at most ${MAX_MAIL_RECIPIENTS} recipients`,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const forwardInputSchema = z.object({
|
|
42
|
+
mailboxId: mailboxIdSchema,
|
|
43
|
+
sourceMessageId: entityIdSchema,
|
|
44
|
+
...recipientFields,
|
|
45
|
+
replyTo: emailSchema.optional(),
|
|
46
|
+
...messageContentFields,
|
|
47
|
+
}).refine(
|
|
48
|
+
(value) => recipientCount(dedupeRecipientFields(value)) > 0,
|
|
49
|
+
"Add at least one recipient",
|
|
50
|
+
).refine(
|
|
51
|
+
(value) => recipientCount(dedupeRecipientFields(value)) <= MAX_MAIL_RECIPIENTS,
|
|
52
|
+
`An email can have at most ${MAX_MAIL_RECIPIENTS} recipients`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export function registerMailComposeTools(server: McpServer, api: AccountApiClient) {
|
|
56
|
+
server.registerTool(
|
|
57
|
+
"create_attachment_upload",
|
|
58
|
+
{
|
|
59
|
+
title: "Create attachment upload",
|
|
60
|
+
description: "Create a 15-minute presigned R2 PUT URL. Upload exactly the declared number of bytes to upload.uploadUrl using upload.headers, then call complete_attachment_upload with upload.id.",
|
|
61
|
+
inputSchema: z.object({
|
|
62
|
+
mailboxId: mailboxIdSchema,
|
|
63
|
+
filename: z.string().trim().min(1).max(255),
|
|
64
|
+
contentType: z.string().trim().min(1).max(255).default("application/octet-stream"),
|
|
65
|
+
size: z.number().int().positive().max(MAX_COMPOSER_ATTACHMENT_BYTES),
|
|
66
|
+
}),
|
|
67
|
+
annotations: {
|
|
68
|
+
readOnlyHint: false,
|
|
69
|
+
destructiveHint: false,
|
|
70
|
+
idempotentHint: false,
|
|
71
|
+
openWorldHint: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
({ mailboxId, filename, contentType, size }) => runTool(() =>
|
|
75
|
+
api.json(apiPath("/mail/uploads", { mailboxId, directOnly: true }), {
|
|
76
|
+
method: "POST",
|
|
77
|
+
json: { filename, contentType, size },
|
|
78
|
+
})
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
server.registerTool(
|
|
83
|
+
"complete_attachment_upload",
|
|
84
|
+
{
|
|
85
|
+
title: "Complete attachment upload",
|
|
86
|
+
description: "Verify and seal a file uploaded with create_attachment_upload. Use the returned upload id in send_email, reply_to_email, or forward_email attachments.",
|
|
87
|
+
inputSchema: z.object({
|
|
88
|
+
mailboxId: mailboxIdSchema,
|
|
89
|
+
uploadId: entityIdSchema,
|
|
90
|
+
}),
|
|
91
|
+
annotations: {
|
|
92
|
+
readOnlyHint: false,
|
|
93
|
+
destructiveHint: false,
|
|
94
|
+
idempotentHint: true,
|
|
95
|
+
openWorldHint: false,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
({ mailboxId, uploadId }) => runTool(() => api.json(apiPath(
|
|
99
|
+
`/mail/uploads/${encodeURIComponent(uploadId)}/complete`,
|
|
100
|
+
{ mailboxId },
|
|
101
|
+
), { method: "POST" })),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
server.registerTool(
|
|
105
|
+
"discard_attachment_upload",
|
|
106
|
+
{
|
|
107
|
+
title: "Discard attachment upload",
|
|
108
|
+
inputSchema: z.object({
|
|
109
|
+
mailboxId: mailboxIdSchema,
|
|
110
|
+
uploadId: entityIdSchema,
|
|
111
|
+
}),
|
|
112
|
+
annotations: {
|
|
113
|
+
readOnlyHint: false,
|
|
114
|
+
destructiveHint: true,
|
|
115
|
+
idempotentHint: true,
|
|
116
|
+
openWorldHint: false,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
({ mailboxId, uploadId }) => runTool(() => api.json(apiPath(
|
|
120
|
+
`/mail/uploads/${encodeURIComponent(uploadId)}`,
|
|
121
|
+
{ mailboxId },
|
|
122
|
+
), { method: "DELETE" })),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.registerTool(
|
|
126
|
+
"send_email",
|
|
127
|
+
{
|
|
128
|
+
title: "Send email",
|
|
129
|
+
description: "Compose and send a new email from a mailbox where the current account has send access.",
|
|
130
|
+
inputSchema: composeInputSchema,
|
|
131
|
+
annotations: sendAnnotations,
|
|
132
|
+
},
|
|
133
|
+
(input) => runTool(() => api.json("/mail/messages", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
json: { ...input, requestId: requestId(input.requestId) },
|
|
136
|
+
})),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
server.registerTool(
|
|
140
|
+
"reply_to_email",
|
|
141
|
+
{
|
|
142
|
+
title: "Reply to email",
|
|
143
|
+
description: "Reply, reply all, continue a draft-like outgoing thread, or reply to a mailing list.",
|
|
144
|
+
inputSchema: z.object({
|
|
145
|
+
mailboxId: mailboxIdSchema,
|
|
146
|
+
sourceMessageId: entityIdSchema,
|
|
147
|
+
mode: replyModeSchema.default("reply"),
|
|
148
|
+
cc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).optional(),
|
|
149
|
+
bcc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).default([]),
|
|
150
|
+
...messageContentFields,
|
|
151
|
+
}),
|
|
152
|
+
annotations: sendAnnotations,
|
|
153
|
+
},
|
|
154
|
+
({ sourceMessageId, ...input }) => runTool(() => api.json(
|
|
155
|
+
`/mail/messages/${encodeURIComponent(sourceMessageId)}/replies`,
|
|
156
|
+
{
|
|
157
|
+
method: "POST",
|
|
158
|
+
json: { ...input, requestId: requestId(input.requestId) },
|
|
159
|
+
},
|
|
160
|
+
)),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"forward_email",
|
|
165
|
+
{
|
|
166
|
+
title: "Forward email",
|
|
167
|
+
description: "Forward a message, including its original attachments, to new recipients.",
|
|
168
|
+
inputSchema: forwardInputSchema,
|
|
169
|
+
annotations: sendAnnotations,
|
|
170
|
+
},
|
|
171
|
+
({ sourceMessageId, ...input }) => runTool(() => api.json(
|
|
172
|
+
`/mail/messages/${encodeURIComponent(sourceMessageId)}/forward`,
|
|
173
|
+
{
|
|
174
|
+
method: "POST",
|
|
175
|
+
json: { ...input, requestId: requestId(input.requestId) },
|
|
176
|
+
},
|
|
177
|
+
)),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
server.registerTool(
|
|
181
|
+
"send_email_again",
|
|
182
|
+
{
|
|
183
|
+
title: "Retry failed email",
|
|
184
|
+
description: "Retry an outgoing message whose delivery is failed or unconfirmed.",
|
|
185
|
+
inputSchema: z.object({
|
|
186
|
+
mailboxId: mailboxIdSchema,
|
|
187
|
+
messageId: entityIdSchema,
|
|
188
|
+
}),
|
|
189
|
+
annotations: sendAnnotations,
|
|
190
|
+
},
|
|
191
|
+
({ mailboxId, messageId }) => runTool(() => api.json(apiPath(
|
|
192
|
+
`/mail/messages/${encodeURIComponent(messageId)}/send-again`,
|
|
193
|
+
{ mailboxId },
|
|
194
|
+
), { method: "POST" })),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { AccountApiClient, apiPath } from "@open-workspace/core/worker/mcp/account-client.ts";
|
|
4
|
+
import {
|
|
5
|
+
runBinaryResourceTool,
|
|
6
|
+
runResourceLinkTool,
|
|
7
|
+
runTool,
|
|
8
|
+
} from "@open-workspace/core/worker/mcp/results.ts";
|
|
9
|
+
import { entityIdSchema, mailboxIdSchema, mailboxInputSchema } from "@open-workspace/core/worker/mcp/schemas.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_RESOURCE_CHUNK_BYTES = 4_000_000;
|
|
12
|
+
|
|
13
|
+
const binaryResourceInputSchema = z.object({
|
|
14
|
+
mailboxId: mailboxIdSchema,
|
|
15
|
+
messageId: entityIdSchema,
|
|
16
|
+
offsetBytes: z.number().int().min(0).default(0),
|
|
17
|
+
maxBytes: z.number().int().min(1).max(MAX_RESOURCE_CHUNK_BYTES)
|
|
18
|
+
.default(MAX_RESOURCE_CHUNK_BYTES),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const attachmentLinkResponseSchema = z.object({
|
|
22
|
+
ok: z.literal(true),
|
|
23
|
+
attachment: z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
filename: z.string(),
|
|
26
|
+
contentType: z.string(),
|
|
27
|
+
size: z.number().int().nonnegative(),
|
|
28
|
+
url: z.url(),
|
|
29
|
+
expiresAt: z.number().int().positive(),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const readOnly = {
|
|
34
|
+
readOnlyHint: true,
|
|
35
|
+
destructiveHint: false,
|
|
36
|
+
idempotentHint: true,
|
|
37
|
+
openWorldHint: false,
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export function registerMailQueryTools(server: McpServer, api: AccountApiClient) {
|
|
41
|
+
server.registerTool(
|
|
42
|
+
"list_mailboxes",
|
|
43
|
+
{
|
|
44
|
+
title: "List mailboxes",
|
|
45
|
+
description: "List mailboxes available to the current account, including send access and unread counts.",
|
|
46
|
+
annotations: readOnly,
|
|
47
|
+
},
|
|
48
|
+
() => runTool(() => api.json("/mail/mailboxes")),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
server.registerTool(
|
|
52
|
+
"list_folders",
|
|
53
|
+
{
|
|
54
|
+
title: "List mailbox folders",
|
|
55
|
+
description: "List system and custom folders with total and unread counts.",
|
|
56
|
+
inputSchema: mailboxInputSchema,
|
|
57
|
+
annotations: readOnly,
|
|
58
|
+
},
|
|
59
|
+
({ mailboxId }) => runTool(() =>
|
|
60
|
+
api.json(`/mail/mailboxes/${encodeURIComponent(mailboxId)}/folders`)
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
server.registerTool(
|
|
65
|
+
"list_conversations",
|
|
66
|
+
{
|
|
67
|
+
title: "List or search conversations",
|
|
68
|
+
description: "List a cursor-paginated mailbox folder. Supports search and unread-only filtering.",
|
|
69
|
+
inputSchema: z.object({
|
|
70
|
+
mailboxId: mailboxIdSchema,
|
|
71
|
+
folder: z.string().trim().min(1).max(80).default("inbox"),
|
|
72
|
+
limit: z.number().int().min(1).max(50).default(25),
|
|
73
|
+
cursor: z.string().trim().min(1).max(512).optional(),
|
|
74
|
+
search: z.string().trim().max(200).optional(),
|
|
75
|
+
unreadOnly: z.boolean().default(false),
|
|
76
|
+
}),
|
|
77
|
+
annotations: readOnly,
|
|
78
|
+
},
|
|
79
|
+
(input) => runTool(() => api.json(apiPath("/mail/conversations", {
|
|
80
|
+
mailboxId: input.mailboxId,
|
|
81
|
+
folder: input.folder,
|
|
82
|
+
limit: input.limit,
|
|
83
|
+
cursor: input.cursor,
|
|
84
|
+
search: input.search,
|
|
85
|
+
unreadOnly: input.unreadOnly ? "true" : undefined,
|
|
86
|
+
}))),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
server.registerTool(
|
|
90
|
+
"get_conversation",
|
|
91
|
+
{
|
|
92
|
+
title: "Get conversation",
|
|
93
|
+
description: "Read a cursor-paginated conversation page, including bodies, attachment metadata, delivery state, and read receipts. The first page contains the newest messages; continue with nextCursor to load older messages.",
|
|
94
|
+
inputSchema: z.object({
|
|
95
|
+
mailboxId: mailboxIdSchema,
|
|
96
|
+
conversationId: entityIdSchema,
|
|
97
|
+
limit: z.number().int().min(1).max(25).default(25),
|
|
98
|
+
cursor: z.string().trim().min(1).max(512).optional(),
|
|
99
|
+
}),
|
|
100
|
+
annotations: readOnly,
|
|
101
|
+
},
|
|
102
|
+
({ mailboxId, conversationId, limit, cursor }) => runTool(() => api.json(apiPath(
|
|
103
|
+
`/mail/conversations/${encodeURIComponent(conversationId)}`,
|
|
104
|
+
{ mailboxId, limit, cursor },
|
|
105
|
+
))),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
server.registerTool(
|
|
109
|
+
"suggest_recipients",
|
|
110
|
+
{
|
|
111
|
+
title: "Suggest recipients",
|
|
112
|
+
description: "Find recent recipients known to a mailbox.",
|
|
113
|
+
inputSchema: z.object({
|
|
114
|
+
mailboxId: mailboxIdSchema,
|
|
115
|
+
query: z.string().trim().max(200).default(""),
|
|
116
|
+
limit: z.number().int().min(1).max(12).default(8),
|
|
117
|
+
}),
|
|
118
|
+
annotations: readOnly,
|
|
119
|
+
},
|
|
120
|
+
({ mailboxId, query, limit }) => runTool(() => api.json(apiPath(
|
|
121
|
+
`/mail/mailboxes/${encodeURIComponent(mailboxId)}/recipient-suggestions`,
|
|
122
|
+
{ q: query, limit },
|
|
123
|
+
))),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.registerTool(
|
|
127
|
+
"get_message_authentication",
|
|
128
|
+
{
|
|
129
|
+
title: "Get message authentication",
|
|
130
|
+
description: "Get SPF, DKIM, DMARC, and ARC results, the sending IP, the experimental Cloudflare spam score, and the sender verdict for an incoming message, as recorded by Cloudflare Email Routing in its headers.",
|
|
131
|
+
inputSchema: z.object({
|
|
132
|
+
mailboxId: mailboxIdSchema,
|
|
133
|
+
messageId: entityIdSchema,
|
|
134
|
+
}),
|
|
135
|
+
annotations: readOnly,
|
|
136
|
+
},
|
|
137
|
+
({ mailboxId, messageId }) => runTool(() => api.json(apiPath(
|
|
138
|
+
`/mail/messages/${encodeURIComponent(messageId)}/authentication`,
|
|
139
|
+
{ mailboxId },
|
|
140
|
+
))),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.registerTool(
|
|
144
|
+
"get_original_message",
|
|
145
|
+
{
|
|
146
|
+
title: "Read original message",
|
|
147
|
+
description: "Read the raw RFC 822 message as an embedded .eml resource. Large messages are returned in chunks; continue with nextOffsetBytes until it is null.",
|
|
148
|
+
inputSchema: binaryResourceInputSchema,
|
|
149
|
+
annotations: readOnly,
|
|
150
|
+
},
|
|
151
|
+
({ mailboxId, messageId, offsetBytes, maxBytes }) =>
|
|
152
|
+
runBinaryResourceTool({
|
|
153
|
+
action: () => api.binary(apiPath(
|
|
154
|
+
`/mail/messages/${encodeURIComponent(messageId)}/original`,
|
|
155
|
+
{ mailboxId },
|
|
156
|
+
), { offsetBytes, maxBytes }),
|
|
157
|
+
uri: `openworkspace://mail/${encodeURIComponent(mailboxId)}/messages/${encodeURIComponent(messageId)}/original.eml#bytes=${offsetBytes}`,
|
|
158
|
+
mimeType: "message/rfc822",
|
|
159
|
+
fallbackFilename: `${messageId}.eml`,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"get_attachment_download_url",
|
|
165
|
+
{
|
|
166
|
+
title: "Get attachment download URL",
|
|
167
|
+
description: "Return a private R2 download link that expires after 15 minutes. Treat the URL as a temporary bearer credential.",
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
mailboxId: mailboxIdSchema,
|
|
170
|
+
messageId: entityIdSchema,
|
|
171
|
+
attachmentId: entityIdSchema,
|
|
172
|
+
}),
|
|
173
|
+
annotations: readOnly,
|
|
174
|
+
},
|
|
175
|
+
({ mailboxId, messageId, attachmentId }) => runResourceLinkTool(async () => {
|
|
176
|
+
const response = attachmentLinkResponseSchema.parse(await api.json(
|
|
177
|
+
apiPath(
|
|
178
|
+
`/mail/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}/download-url`,
|
|
179
|
+
{ mailboxId },
|
|
180
|
+
),
|
|
181
|
+
));
|
|
182
|
+
return {
|
|
183
|
+
structuredContent: response,
|
|
184
|
+
resource: {
|
|
185
|
+
type: "resource_link",
|
|
186
|
+
uri: response.attachment.url,
|
|
187
|
+
name: response.attachment.filename,
|
|
188
|
+
title: response.attachment.filename,
|
|
189
|
+
description: "Temporary private attachment download",
|
|
190
|
+
mimeType: response.attachment.contentType,
|
|
191
|
+
size: response.attachment.size,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
}
|