@open-workspace/email 0.0.0-stage → 0.1.0

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.
Files changed (96) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -2
  3. package/alchemy.ts +123 -0
  4. package/containers/office-preview/Dockerfile +10 -0
  5. package/index.ts +3 -0
  6. package/migrations/0000_email.sql +53 -0
  7. package/migrations/meta/0000_snapshot.json +408 -0
  8. package/migrations/meta/_journal.json +13 -0
  9. package/package.json +91 -4
  10. package/shared/attachment-preview.ts +67 -0
  11. package/shared/mail-authentication.ts +145 -0
  12. package/shared/mail-feedback.ts +90 -0
  13. package/shared/mail-headers.ts +250 -0
  14. package/shared/mail-received.ts +298 -0
  15. package/shared/mail-remote.ts +17 -0
  16. package/shared/mail-rules.ts +156 -0
  17. package/shared/mail-sender-intel.ts +125 -0
  18. package/shared/mail-translation.ts +2 -0
  19. package/shared/mail.ts +179 -0
  20. package/shared/office-preview.ts +26 -0
  21. package/worker/admin/report-routes.ts +164 -0
  22. package/worker/bindings.d.ts +11 -0
  23. package/worker/db/schema.ts +97 -0
  24. package/worker/mail/access.ts +30 -0
  25. package/worker/mail/ai-classification.ts +119 -0
  26. package/worker/mail/ai-translation.ts +31 -0
  27. package/worker/mail/auth-headers.ts +557 -0
  28. package/worker/mail/bimi.ts +172 -0
  29. package/worker/mail/classification-signals.ts +116 -0
  30. package/worker/mail/conversation-cursor.ts +43 -0
  31. package/worker/mail/delivery-events.ts +99 -0
  32. package/worker/mail/delivery-facts.ts +122 -0
  33. package/worker/mail/demo-routes.ts +33 -0
  34. package/worker/mail/doh.ts +74 -0
  35. package/worker/mail/downloads.ts +47 -0
  36. package/worker/mail/header-metadata.ts +77 -0
  37. package/worker/mail/impersonation.ts +175 -0
  38. package/worker/mail/inbound-delivery.ts +22 -0
  39. package/worker/mail/inbound-threading.ts +48 -0
  40. package/worker/mail/inbound.ts +255 -0
  41. package/worker/mail/ip-intel.ts +135 -0
  42. package/worker/mail/link-domains.ts +57 -0
  43. package/worker/mail/mailbox-deletion.ts +18 -0
  44. package/worker/mail/mailbox-directory.ts +73 -0
  45. package/worker/mail/mailing-list.ts +120 -0
  46. package/worker/mail/message-security.ts +190 -0
  47. package/worker/mail/message-view.ts +63 -0
  48. package/worker/mail/office-conversion.ts +38 -0
  49. package/worker/mail/office-preview-container.ts +57 -0
  50. package/worker/mail/office-preview-queue.ts +49 -0
  51. package/worker/mail/org-domain.ts +15 -0
  52. package/worker/mail/outbound-content.ts +208 -0
  53. package/worker/mail/outbound-delivery.ts +83 -0
  54. package/worker/mail/outbound-service.ts +145 -0
  55. package/worker/mail/outbound-threading.ts +70 -0
  56. package/worker/mail/outbound.ts +446 -0
  57. package/worker/mail/outgoing-context.ts +122 -0
  58. package/worker/mail/participants.ts +115 -0
  59. package/worker/mail/push-notifications.ts +484 -0
  60. package/worker/mail/quote-parser.ts +50 -0
  61. package/worker/mail/r2-presigned-urls.ts +69 -0
  62. package/worker/mail/receive.ts +59 -0
  63. package/worker/mail/recipients.ts +34 -0
  64. package/worker/mail/remote-proxy.ts +140 -0
  65. package/worker/mail/reply-plan.ts +136 -0
  66. package/worker/mail/reported-messages.ts +68 -0
  67. package/worker/mail/rfc.ts +73 -0
  68. package/worker/mail/routes.ts +1434 -0
  69. package/worker/mail/safety-routes.ts +350 -0
  70. package/worker/mail/schemas.ts +339 -0
  71. package/worker/mail/search.ts +14 -0
  72. package/worker/mail/security-settings.ts +38 -0
  73. package/worker/mail/sender-intel.ts +189 -0
  74. package/worker/mail/spamhaus.ts +97 -0
  75. package/worker/mail/unsubscribe.ts +118 -0
  76. package/worker/mail/uploads.ts +537 -0
  77. package/worker/mail/webhook-source.ts +71 -0
  78. package/worker/mail/workspace-safety.ts +92 -0
  79. package/worker/mailbox/demo-html.ts +136 -0
  80. package/worker/mailbox/demo-seed.ts +79 -0
  81. package/worker/mailbox/demo.ts +978 -0
  82. package/worker/mailbox/folder-model.ts +72 -0
  83. package/worker/mailbox/index.ts +2508 -0
  84. package/worker/mailbox/legacy-migrations.ts +41 -0
  85. package/worker/mailbox/migrations.ts +10 -0
  86. package/worker/mailbox/model.ts +65 -0
  87. package/worker/mailbox/office-preview-jobs.ts +73 -0
  88. package/worker/mailbox/rules.ts +110 -0
  89. package/worker/mailbox/schema.ts +353 -0
  90. package/worker/mailbox/senders.ts +240 -0
  91. package/worker/mcp/mail-action-tools.ts +210 -0
  92. package/worker/mcp/mail-compose-tools.ts +196 -0
  93. package/worker/mcp/mail-query-tools.ts +196 -0
  94. package/worker/mcp/mail-safety-tools.ts +452 -0
  95. package/worker/mcp/tools.ts +15 -0
  96. package/worker/module.ts +59 -0
@@ -0,0 +1,452 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ import type { InboundAuthentication, MessageSecurity } from "../../shared/mail-authentication";
4
+ import type { SenderIntel } from "../../shared/mail-sender-intel";
5
+ import { reportStatusSchema, workspaceBlockSchema } from "@open-workspace/core/worker/admin/schemas.ts";
6
+ import { mailRuleSchema } from "../mail/schemas";
7
+ import { AccountApiClient, apiPath } from "@open-workspace/core/worker/mcp/account-client.ts";
8
+ import { runTool } from "@open-workspace/core/worker/mcp/results.ts";
9
+ import { entityIdSchema, mailboxIdSchema } from "@open-workspace/core/worker/mcp/schemas.ts";
10
+
11
+ const readOnly = {
12
+ readOnlyHint: true,
13
+ destructiveHint: false,
14
+ idempotentHint: true,
15
+ openWorldHint: false,
16
+ } as const;
17
+
18
+ const write = {
19
+ readOnlyHint: false,
20
+ destructiveHint: false,
21
+ idempotentHint: true,
22
+ openWorldHint: false,
23
+ } as const;
24
+
25
+ const messageInputSchema = z.object({
26
+ mailboxId: mailboxIdSchema,
27
+ messageId: entityIdSchema,
28
+ });
29
+
30
+ const blockPatternSchema = z.string().trim().min(3).max(320).describe(
31
+ "An email address (alex@example.com) or a domain (example.com or @example.com; also matches its subdomains).",
32
+ );
33
+
34
+ const reportKindSchema = z.enum(["spam", "phishing"]);
35
+
36
+ const RULE_SEMANTICS = "Rules run in list order on new incoming mail. Every condition present must match: from/to take an address (exact) or a domain (matches subdomains); subjectContains is a case-insensitive substring; listId is the List-Id. Actions: at most one destination (folderId of a custom folder, archive, or spam), plus optional markRead and neverSpam (overrides the AI spam verdict; cannot combine with spam).";
37
+
38
+ const mailRuleInputSchema = z.object({
39
+ mailboxId: mailboxIdSchema,
40
+ ...mailRuleSchema.shape,
41
+ });
42
+
43
+ type AuthenticationResponse = {
44
+ original: {
45
+ subject: string;
46
+ from: string;
47
+ to: string[];
48
+ messageId: string | null;
49
+ receivedAt: string;
50
+ envelopeFrom: string | null;
51
+ envelopeTo: string | null;
52
+ };
53
+ authentication: InboundAuthentication | null;
54
+ security: MessageSecurity | null;
55
+ senderIntel: SenderIntel | null;
56
+ };
57
+
58
+ function authLine(result: string, details: Array<string | null | false | undefined>) {
59
+ const extra = details.filter(Boolean).join(", ");
60
+ return extra ? `${result} (${extra})` : result;
61
+ }
62
+
63
+ function compactAuthentication(auth: InboundAuthentication | null) {
64
+ if (!auth) return null;
65
+ return {
66
+ spf: auth.spf
67
+ ? authLine(auth.spf.result, [auth.spf.domain, auth.spf.aligned && "aligned"])
68
+ : null,
69
+ dkim: auth.dkim.map((dkim) => authLine(dkim.result, [
70
+ dkim.domain,
71
+ dkim.selector && `s=${dkim.selector}`,
72
+ dkim.aligned && "aligned",
73
+ ])),
74
+ dmarc: auth.dmarc
75
+ ? authLine(auth.dmarc.result, [
76
+ auth.dmarc.headerFrom,
77
+ auth.dmarc.policy && `p=${auth.dmarc.policy}`,
78
+ auth.dmarc.alignedVia.length > 0 && `via ${auth.dmarc.alignedVia.join("+")}`,
79
+ ])
80
+ : null,
81
+ arc: auth.arc
82
+ ? authLine(auth.arc.result, [
83
+ auth.arc.chainLength > 1 && `forwarded through ${auth.arc.sealers
84
+ .map((sealer) => sealer.domain ?? "unknown").join(", ")}`,
85
+ ])
86
+ : null,
87
+ remoteIp: auth.remoteIp,
88
+ helo: auth.helo,
89
+ envelopeFrom: auth.envelopeFrom,
90
+ spamScore: auth.spamScore,
91
+ };
92
+ }
93
+
94
+ function compactSenderIntel(intel: SenderIntel | null) {
95
+ if (!intel) return null;
96
+ const spamhausHits = intel.spamhaus
97
+ ? [...intel.spamhaus.zen.listings, ...intel.spamhaus.dbl.listings].map((listing) => ({
98
+ zone: listing.zone,
99
+ target: listing.target,
100
+ list: listing.list,
101
+ severity: listing.severity,
102
+ }))
103
+ : null;
104
+ return {
105
+ checkedAt: new Date(intel.checkedAt).toISOString(),
106
+ ip: intel.ip,
107
+ ptr: intel.ptr.confirmed ?? intel.ptr.names[0] ?? null,
108
+ forwardConfirmedPtr: intel.ptr.fcrdns,
109
+ genericPtr: intel.ptr.generic,
110
+ heloMatchesPtr: intel.heloMatchesPtr,
111
+ asn: intel.network.asn,
112
+ asName: intel.network.asName,
113
+ country: intel.network.country,
114
+ /** null means Spamhaus is not configured for this workspace. */
115
+ spamhausHits,
116
+ };
117
+ }
118
+
119
+ function compactSecurity(messageId: string, response: AuthenticationResponse) {
120
+ const security = response.security;
121
+ const unsubscribe = security?.listUnsubscribe ?? null;
122
+ return {
123
+ ok: true,
124
+ messageId,
125
+ from: response.original.from,
126
+ subject: response.original.subject,
127
+ receivedAt: response.original.receivedAt,
128
+ verdict: security?.verdict ?? response.authentication?.verdict ?? "unavailable",
129
+ verdictReason: security?.verdictReason ?? response.authentication?.verdictReason ?? null,
130
+ fromDomain: security?.fromDomain ?? null,
131
+ mailedBy: security?.mailedBy ?? null,
132
+ signedBy: security?.signedBy ?? null,
133
+ via: security?.via ?? null,
134
+ external: security?.external ?? null,
135
+ firstTimeSender: security?.firstTimeSender ?? false,
136
+ replyToMismatch: security?.replyToMismatch ?? null,
137
+ warnings: security?.warnings ?? [],
138
+ authentication: compactAuthentication(response.authentication),
139
+ senderIntel: compactSenderIntel(response.senderIntel),
140
+ unsubscribe: {
141
+ available: Boolean(unsubscribe?.httpsUrl || unsubscribe?.mailto),
142
+ oneClick: unsubscribe?.oneClick ?? false,
143
+ byEmail: Boolean(unsubscribe?.mailto),
144
+ pageUrl: unsubscribe?.httpsUrl ?? null,
145
+ },
146
+ };
147
+ }
148
+
149
+ export function registerMailSafetyTools(server: McpServer, api: AccountApiClient) {
150
+ server.registerTool(
151
+ "get_message_security",
152
+ {
153
+ title: "Get message security assessment",
154
+ description: "Summarize whether an incoming message is safe to trust: sender verdict (verified/unverified/suspicious/unavailable), one-line SPF/DKIM/DMARC/ARC results, mailed-by/signed-by/via, whether the sender is external, security warnings (lookalike domain, impersonation, Spamhaus listings, reply-to mismatch), sender server intel (IP, PTR, ASN, country, Spamhaus hits), and whether the message offers an unsubscribe option. Use this before acting on a suspicious message; use get_message_authentication only when the full parsed header data is needed. Blocked/trusted sender state is in get_conversation's per-message `sender` field.",
155
+ inputSchema: messageInputSchema,
156
+ annotations: readOnly,
157
+ },
158
+ ({ mailboxId, messageId }) => runTool(async () => compactSecurity(
159
+ messageId,
160
+ await api.json(apiPath(
161
+ `/mail/messages/${encodeURIComponent(messageId)}/authentication`,
162
+ { mailboxId },
163
+ )) as unknown as AuthenticationResponse,
164
+ )),
165
+ );
166
+
167
+ server.registerTool(
168
+ "unsubscribe_from_mailing_list",
169
+ {
170
+ title: "Unsubscribe from mailing list",
171
+ description: "OUTBOUND ACTION: unsubscribe this mailbox from the mailing list that sent an incoming message. With method \"auto\" the server tries, in order: an RFC 8058 one-click POST to the sender's URL (outcome \"done\"); an unsubscribe email sent from this mailbox, which requires send access (outcome \"requested\"); otherwise it returns the sender's unsubscribe page URL for the user to open themselves (outcome \"link\"). Method \"link\" only records that the user is opening the page and returns its URL; it contacts no one. Confirm with the user before calling. Rate-limited to 20 requests per minute. Check get_message_security's unsubscribe field first.",
172
+ inputSchema: messageInputSchema.extend({
173
+ method: z.enum(["auto", "link"]).default("auto"),
174
+ }),
175
+ annotations: {
176
+ readOnlyHint: false,
177
+ destructiveHint: false,
178
+ idempotentHint: false,
179
+ openWorldHint: true,
180
+ },
181
+ },
182
+ ({ mailboxId, messageId, method }) => runTool(() => api.json(apiPath(
183
+ `/mail/messages/${encodeURIComponent(messageId)}/unsubscribe`,
184
+ { mailboxId },
185
+ ), { method: "POST", json: { method } })),
186
+ );
187
+
188
+ server.registerTool(
189
+ "report_message",
190
+ {
191
+ title: "Report spam or phishing",
192
+ description: "Report an incoming message as spam or phishing: labels it, trains the sender's reputation, and moves its conversation to Spam. Phishing also opens a report for workspace administrators. Nothing is sent outside the workspace. Confirm with the user before reporting.",
193
+ inputSchema: messageInputSchema.extend({ kind: reportKindSchema }),
194
+ annotations: { ...write, idempotentHint: false },
195
+ },
196
+ ({ mailboxId, messageId, kind }) => runTool(() => api.json(apiPath(
197
+ `/mail/messages/${encodeURIComponent(messageId)}/report`,
198
+ { mailboxId },
199
+ ), { method: "POST", json: { kind } })),
200
+ );
201
+
202
+ server.registerTool(
203
+ "report_conversations",
204
+ {
205
+ title: "Report conversations as spam or phishing",
206
+ description: "Report the newest incoming message of up to 100 conversations as spam or phishing and move them to Spam. Phishing also opens administrator reports. Confirm with the user first.",
207
+ inputSchema: z.object({
208
+ mailboxId: mailboxIdSchema,
209
+ kind: reportKindSchema,
210
+ conversationIds: z.array(entityIdSchema).min(1).max(100)
211
+ .transform((ids) => [...new Set(ids)]),
212
+ }),
213
+ annotations: { ...write, idempotentHint: false },
214
+ },
215
+ ({ mailboxId, kind, conversationIds }) => runTool(() => api.json(apiPath(
216
+ "/mail/conversations/report",
217
+ { mailboxId },
218
+ ), { method: "POST", json: { kind, conversationIds } })),
219
+ );
220
+
221
+ server.registerTool(
222
+ "mark_message_not_spam",
223
+ {
224
+ title: "Mark message not spam",
225
+ description: "Move an incoming message's conversation from Spam back to Inbox, clear its spam/phishing label, record the feedback, and mark the sender as trusted in this mailbox.",
226
+ inputSchema: messageInputSchema,
227
+ annotations: write,
228
+ },
229
+ ({ mailboxId, messageId }) => runTool(() => api.json(apiPath(
230
+ `/mail/messages/${encodeURIComponent(messageId)}/not-spam`,
231
+ { mailboxId },
232
+ ), { method: "POST" })),
233
+ );
234
+
235
+ server.registerTool(
236
+ "set_sender_trusted",
237
+ {
238
+ title: "Trust or untrust sender",
239
+ description: "Mark a sender address as trusted (or not) for everyone using this mailbox. Trusted senders have caution warnings hidden; danger warnings still show.",
240
+ inputSchema: z.object({
241
+ mailboxId: mailboxIdSchema,
242
+ address: z.email(),
243
+ trusted: z.boolean(),
244
+ }),
245
+ annotations: write,
246
+ },
247
+ ({ mailboxId, address, trusted }) => runTool(() => api.json(
248
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/trusted-senders`,
249
+ { method: "PUT", json: { address, trusted } },
250
+ )),
251
+ );
252
+
253
+ server.registerTool(
254
+ "list_blocked_senders",
255
+ {
256
+ title: "List blocked senders",
257
+ description: "List addresses and domains blocked in this mailbox, plus workspace-wide blocks set by administrators (which the mailbox cannot remove).",
258
+ inputSchema: z.object({ mailboxId: mailboxIdSchema }),
259
+ annotations: readOnly,
260
+ },
261
+ ({ mailboxId }) => runTool(() => api.json(
262
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/blocked-senders`,
263
+ )),
264
+ );
265
+
266
+ server.registerTool(
267
+ "block_sender",
268
+ {
269
+ title: "Block sender or domain",
270
+ description: "Block an address or domain for everyone using this mailbox: future mail from it goes to Spam. With moveExisting, existing conversations from it are also moved to Spam. The mailbox's own address or domain cannot be blocked.",
271
+ inputSchema: z.object({
272
+ mailboxId: mailboxIdSchema,
273
+ pattern: blockPatternSchema,
274
+ moveExisting: z.boolean().default(false),
275
+ }),
276
+ annotations: write,
277
+ },
278
+ ({ mailboxId, pattern, moveExisting }) => runTool(() => api.json(
279
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/blocked-senders`,
280
+ { method: "POST", json: { pattern, moveExisting } },
281
+ )),
282
+ );
283
+
284
+ server.registerTool(
285
+ "unblock_sender",
286
+ {
287
+ title: "Unblock sender or domain",
288
+ description: "Remove a mailbox block. Mail already moved to Spam stays there. Workspace-wide blocks can only be removed by an administrator.",
289
+ inputSchema: z.object({
290
+ mailboxId: mailboxIdSchema,
291
+ pattern: blockPatternSchema,
292
+ }),
293
+ annotations: write,
294
+ },
295
+ ({ mailboxId, pattern }) => runTool(() => api.json(apiPath(
296
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/blocked-senders`,
297
+ { pattern },
298
+ ), { method: "DELETE" })),
299
+ );
300
+
301
+ server.registerTool(
302
+ "list_mail_rules",
303
+ {
304
+ title: "List mail rules",
305
+ description: `List the mailbox's filter rules in the order they run. ${RULE_SEMANTICS}`,
306
+ inputSchema: z.object({ mailboxId: mailboxIdSchema }),
307
+ annotations: readOnly,
308
+ },
309
+ ({ mailboxId }) => runTool(() => api.json(
310
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/rules`,
311
+ )),
312
+ );
313
+
314
+ server.registerTool(
315
+ "create_mail_rule",
316
+ {
317
+ title: "Create mail rule",
318
+ description: `Create a filter rule shared by everyone using the mailbox, appended to the end of the list. It applies to new mail only; call apply_mail_rule to run it on existing mail. ${RULE_SEMANTICS}`,
319
+ inputSchema: mailRuleInputSchema,
320
+ annotations: { ...write, idempotentHint: false },
321
+ },
322
+ ({ mailboxId, ...rule }) => runTool(() => api.json(
323
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/rules`,
324
+ { method: "POST", json: rule },
325
+ )),
326
+ );
327
+
328
+ server.registerTool(
329
+ "update_mail_rule",
330
+ {
331
+ title: "Update mail rule",
332
+ description: `Replace a rule's name, enabled state, conditions, and actions. ${RULE_SEMANTICS}`,
333
+ inputSchema: mailRuleInputSchema.extend({ ruleId: entityIdSchema }),
334
+ annotations: write,
335
+ },
336
+ ({ mailboxId, ruleId, ...rule }) => runTool(() => api.json(
337
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/rules/${encodeURIComponent(ruleId)}`,
338
+ { method: "PUT", json: rule },
339
+ )),
340
+ );
341
+
342
+ server.registerTool(
343
+ "delete_mail_rule",
344
+ {
345
+ title: "Delete mail rule",
346
+ description: "Delete a mail rule. Mail it already filed stays where it is.",
347
+ inputSchema: z.object({ mailboxId: mailboxIdSchema, ruleId: entityIdSchema }),
348
+ annotations: { ...write, destructiveHint: true },
349
+ },
350
+ ({ mailboxId, ruleId }) => runTool(() => api.json(
351
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/rules/${encodeURIComponent(ruleId)}`,
352
+ { method: "DELETE" },
353
+ )),
354
+ );
355
+
356
+ server.registerTool(
357
+ "reorder_mail_rules",
358
+ {
359
+ title: "Reorder mail rules",
360
+ description: "Set the complete run order of the mailbox's rules. ruleIds must list every existing rule exactly once; otherwise the call fails and the rules should be listed again.",
361
+ inputSchema: z.object({
362
+ mailboxId: mailboxIdSchema,
363
+ ruleIds: z.array(entityIdSchema).max(100),
364
+ }),
365
+ annotations: write,
366
+ },
367
+ ({ mailboxId, ruleIds }) => runTool(() => api.json(
368
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/rules-order`,
369
+ { method: "PUT", json: { ruleIds } },
370
+ )),
371
+ );
372
+
373
+ server.registerTool(
374
+ "apply_mail_rule",
375
+ {
376
+ title: "Apply mail rule to existing mail",
377
+ description: "Run one rule over the newest 5,000 incoming messages already in the mailbox. Matching Inbox conversations move to the rule's destination (a Spam rule also takes archived ones); markRead applies only for the current account. This moves many conversations at once; confirm with the user first.",
378
+ inputSchema: z.object({ mailboxId: mailboxIdSchema, ruleId: entityIdSchema }),
379
+ annotations: { ...write, idempotentHint: false },
380
+ },
381
+ ({ mailboxId, ruleId }) => runTool(() => api.json(
382
+ `/mail/mailboxes/${encodeURIComponent(mailboxId)}/rules/${encodeURIComponent(ruleId)}/apply`,
383
+ { method: "POST" },
384
+ )),
385
+ );
386
+ }
387
+
388
+ export function registerAdminSafetyTools(server: McpServer, api: AccountApiClient) {
389
+ server.registerTool(
390
+ "list_reported_messages",
391
+ {
392
+ title: "List reported phishing",
393
+ description: "Administrator: list the 200 most recent phishing reports across the workspace (sender, subject, reporter, security snapshot, status, whether the sender or domain is blocked workspace-wide), plus every workspace-wide block. Reported messages can only be inspected through their report.",
394
+ inputSchema: z.object({
395
+ status: z.enum(["open", "resolved", "dismissed"]).optional()
396
+ .describe("Return only reports with this status."),
397
+ }),
398
+ annotations: readOnly,
399
+ },
400
+ ({ status }) => runTool(async () => {
401
+ const result = await api.json("/admin/reported-messages");
402
+ if (!status || !Array.isArray(result.reports)) return result;
403
+ return {
404
+ ...result,
405
+ reports: (result.reports as Array<{ status: string }>)
406
+ .filter((report) => report.status === status),
407
+ };
408
+ }),
409
+ );
410
+
411
+ server.registerTool(
412
+ "set_report_status",
413
+ {
414
+ title: "Resolve or dismiss phishing report",
415
+ description: "Administrator: set a phishing report to open, resolved, or dismissed.",
416
+ inputSchema: reportStatusSchema.safeExtend({ reportId: entityIdSchema }),
417
+ annotations: write,
418
+ },
419
+ ({ reportId, status }) => runTool(() => api.json(
420
+ `/admin/reported-messages/${encodeURIComponent(reportId)}`,
421
+ { method: "PATCH", json: { status } },
422
+ )),
423
+ );
424
+
425
+ server.registerTool(
426
+ "block_sender_for_workspace",
427
+ {
428
+ title: "Block sender for everyone",
429
+ description: "Administrator: block an address or domain for every mailbox in the workspace; its future mail goes to Spam everywhere, and by default (moveExisting) mail already delivered from it is moved to Spam in every mailbox too. Workspace domains cannot be blocked. Pass reportId to also resolve the phishing report that prompted the block. Confirm with the user first.",
430
+ inputSchema: workspaceBlockSchema.safeExtend({ pattern: blockPatternSchema }),
431
+ annotations: write,
432
+ },
433
+ (input) => runTool(() => api.json("/admin/blocked-senders", {
434
+ method: "POST",
435
+ json: input,
436
+ })),
437
+ );
438
+
439
+ server.registerTool(
440
+ "unblock_sender_for_workspace",
441
+ {
442
+ title: "Remove workspace-wide block",
443
+ description: "Administrator: remove a workspace-wide block. Mailbox-level blocks are unaffected.",
444
+ inputSchema: z.object({ pattern: blockPatternSchema }),
445
+ annotations: write,
446
+ },
447
+ ({ pattern }) => runTool(() => api.json(
448
+ apiPath("/admin/blocked-senders", { pattern }),
449
+ { method: "DELETE" },
450
+ )),
451
+ );
452
+ }
@@ -0,0 +1,15 @@
1
+ import type { WorkspaceModule } from "@open-workspace/core/worker/modules.ts";
2
+ import { registerMailActionTools } from "./mail-action-tools";
3
+ import { registerMailComposeTools } from "./mail-compose-tools";
4
+ import { registerMailQueryTools } from "./mail-query-tools";
5
+ import { registerAdminSafetyTools, registerMailSafetyTools } from "./mail-safety-tools";
6
+
7
+ export const registerMailTools: NonNullable<WorkspaceModule["mcpTools"]> = (server, input) => {
8
+ registerMailQueryTools(server, input.api);
9
+ registerMailActionTools(server, input.api);
10
+ registerMailComposeTools(server, input.api);
11
+ registerMailSafetyTools(server, input.api);
12
+ if (input.permissions.includes("review_reports")) {
13
+ registerAdminSafetyTools(server, input.api);
14
+ }
15
+ };
@@ -0,0 +1,59 @@
1
+ import { Hono } from "hono";
2
+ import type { AppEnv } from "@open-workspace/core/worker/env.ts";
3
+ import type { WorkspaceModule } from "@open-workspace/core/worker/modules.ts";
4
+ import { reportAdminRoutes } from "./admin/report-routes";
5
+ import { consumeDeliveryEvents } from "./mail/delivery-events";
6
+ import { demoRoutes } from "./mail/demo-routes";
7
+ import { mailDownloadRoutes } from "./mail/downloads";
8
+ import { receiveEmail } from "./mail/receive";
9
+ import { deleteMailboxStorage } from "./mail/mailbox-deletion";
10
+ import { consumeOfficePreviews } from "./mail/office-preview-queue";
11
+ import {
12
+ consumePushNotifications,
13
+ pushNotificationRoutes,
14
+ } from "./mail/push-notifications";
15
+ import { mailRoutes } from "./mail/routes";
16
+ import { emailWebhookData } from "./mail/webhook-source";
17
+ import { seedEmailDemo } from "./mailbox/demo-seed";
18
+ import { registerMailTools } from "./mcp/tools";
19
+
20
+ /** Routes the browser and MCP use under /api. */
21
+ export const mailAccountApi = new Hono<AppEnv>()
22
+ .route("/mail", mailRoutes)
23
+ .route("/admin", reportAdminRoutes);
24
+
25
+ const mailPublicRoutes = new Hono<AppEnv>()
26
+ .route("/api/downloads", mailDownloadRoutes)
27
+ .route("/api/demo", demoRoutes)
28
+ .route("/api/notifications", pushNotificationRoutes);
29
+
30
+ /** The routes this module adds, for the browser's typed client. */
31
+ export const mailBrowserApi = new Hono<AppEnv>()
32
+ .route("/api", mailAccountApi)
33
+ .route("/", mailPublicRoutes);
34
+ export type MailAppType = typeof mailBrowserApi;
35
+
36
+ /**
37
+ * Team mail: personal and shared inboxes, mailing lists, previews, safety,
38
+ * and push. Pass `email()` to `createWorkspace({ modules })`, and export
39
+ * `MailboxDO` and `OfficePreviewContainer` from the Worker entry.
40
+ */
41
+ export function email(): WorkspaceModule {
42
+ return {
43
+ name: "email",
44
+ accountApi: mailAccountApi,
45
+ routes: mailPublicRoutes,
46
+ email: (message, env) => receiveEmail(message, env),
47
+ queues: {
48
+ "office-previews": (batch, env) => consumeOfficePreviews(batch, env),
49
+ "push-notifications": (batch, env) => consumePushNotifications(batch, env),
50
+ "delivery-events": (batch, env) => consumeDeliveryEvents(batch, env),
51
+ },
52
+ mcpTools: registerMailTools,
53
+ webhookSources: { email: emailWebhookData },
54
+ hooks: {
55
+ mailboxDeleted: deleteMailboxStorage,
56
+ seedDemo: (env, input) => seedEmailDemo(env, input),
57
+ },
58
+ };
59
+ }