@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,350 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono, type Context } from "hono";
|
|
3
|
+
import { normalizeBlockPattern, type ReportKind } from "../../shared/mail-feedback";
|
|
4
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
5
|
+
import type { AppEnv } from "@open-workspace/core/worker/env.ts";
|
|
6
|
+
import { apiError } from "@open-workspace/core/worker/lib/http.ts";
|
|
7
|
+
import { createId } from "@open-workspace/core/worker/lib/ids.ts";
|
|
8
|
+
import { checkRateLimit } from "@open-workspace/core/worker/lib/rate-limit.ts";
|
|
9
|
+
import { mailboxStub } from "../mailbox";
|
|
10
|
+
import { MAIL_RULE_LIMIT_MESSAGE } from "../mailbox/rules";
|
|
11
|
+
import type { Email } from "../mailbox/schema";
|
|
12
|
+
import { getMailboxAccess } from "./access";
|
|
13
|
+
import { oneClickFetcher } from "./demo-routes";
|
|
14
|
+
import { withHeaderMetadata } from "./header-metadata";
|
|
15
|
+
import { emailListKey, emailSecurity } from "./message-view";
|
|
16
|
+
import { submitOutgoing } from "./outbound-service";
|
|
17
|
+
import { recordReportedMessage, reportSnapshot } from "./reported-messages";
|
|
18
|
+
import {
|
|
19
|
+
blockPatternQuerySchema,
|
|
20
|
+
blockSenderSchema,
|
|
21
|
+
bulkReportSchema,
|
|
22
|
+
mailboxQuerySchema,
|
|
23
|
+
mailRuleSchema,
|
|
24
|
+
reorderRulesSchema,
|
|
25
|
+
reportMessageSchema,
|
|
26
|
+
trustSenderSchema,
|
|
27
|
+
unsubscribeSchema,
|
|
28
|
+
} from "./schemas";
|
|
29
|
+
import { workspaceIdentity } from "./security-settings";
|
|
30
|
+
import { storedSenderIntel } from "./sender-intel";
|
|
31
|
+
import { oneClickHost, parseMailtoUnsubscribe, postOneClick } from "./unsubscribe";
|
|
32
|
+
import { recordWorkspaceFeedback, workspaceBlockPatterns } from "./workspace-safety";
|
|
33
|
+
|
|
34
|
+
async function mailboxAccess(env: Env, userId: string, mailboxId: string) {
|
|
35
|
+
return getMailboxAccess(createDb(env.DB), userId, mailboxId);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function forbidden(c: Context<AppEnv>) {
|
|
39
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Report spam or phishing for one message. Phishing also opens an admin report. */
|
|
43
|
+
async function reportOne(input: {
|
|
44
|
+
env: Env;
|
|
45
|
+
mailboxId: string;
|
|
46
|
+
userId: string;
|
|
47
|
+
email: Email;
|
|
48
|
+
kind: ReportKind;
|
|
49
|
+
}) {
|
|
50
|
+
const mailbox = mailboxStub(input.env, input.mailboxId);
|
|
51
|
+
const result = await mailbox.reportMessage({ emailId: input.email.id, kind: input.kind, userId: input.userId });
|
|
52
|
+
if (!result) return null;
|
|
53
|
+
const sender = result.email.fromJson[0]?.address;
|
|
54
|
+
if (sender) {
|
|
55
|
+
await recordWorkspaceFeedback(input.env.DB, {
|
|
56
|
+
mailboxId: input.mailboxId,
|
|
57
|
+
emailId: result.email.id,
|
|
58
|
+
address: sender,
|
|
59
|
+
label: input.kind,
|
|
60
|
+
}).catch((error) => console.error(`Could not share the report for ${result.email.id}`, error));
|
|
61
|
+
}
|
|
62
|
+
let reportId: string | null = null;
|
|
63
|
+
if (input.kind === "phishing") {
|
|
64
|
+
const email = await withHeaderMetadata(input.env, mailbox, result.email);
|
|
65
|
+
const address = email.fromJson[0]?.address ?? "";
|
|
66
|
+
const [identity, history, bodyHtml] = await Promise.all([
|
|
67
|
+
workspaceIdentity(input.env.DB),
|
|
68
|
+
mailbox.getSenderHistory(address ? [address] : []),
|
|
69
|
+
email.bodyHtmlR2Key
|
|
70
|
+
? input.env.MAIL_STORAGE.get(email.bodyHtmlR2Key).then((object) => object?.text() ?? null)
|
|
71
|
+
: Promise.resolve(null),
|
|
72
|
+
]);
|
|
73
|
+
reportId = await recordReportedMessage(input.env.DB, {
|
|
74
|
+
mailboxId: input.mailboxId,
|
|
75
|
+
email,
|
|
76
|
+
reporterUserId: input.userId,
|
|
77
|
+
snapshot: reportSnapshot({
|
|
78
|
+
email,
|
|
79
|
+
security: emailSecurity(email, identity, history),
|
|
80
|
+
intel: storedSenderIntel(email.senderIntelJson),
|
|
81
|
+
bodyHtml,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { moved: result.moved, reportId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const mailSafetyRoutes = new Hono<AppEnv>()
|
|
89
|
+
.post(
|
|
90
|
+
"/messages/:id/unsubscribe",
|
|
91
|
+
zValidator("query", mailboxQuerySchema),
|
|
92
|
+
zValidator("json", unsubscribeSchema),
|
|
93
|
+
async (c) => {
|
|
94
|
+
const { mailboxId } = c.req.valid("query");
|
|
95
|
+
const user = c.get("user");
|
|
96
|
+
const access = await mailboxAccess(c.env, user.id, mailboxId);
|
|
97
|
+
if (!access) return forbidden(c);
|
|
98
|
+
const mailbox = mailboxStub(c.env, mailboxId);
|
|
99
|
+
const stored = await mailbox.getEmail(c.req.param("id"));
|
|
100
|
+
if (!stored || stored.direction !== "incoming") return apiError(c, 404, "NOT_FOUND", "Message not found");
|
|
101
|
+
// Stored at ingestion from the original; never taken from the client.
|
|
102
|
+
const email = await withHeaderMetadata(c.env, mailbox, stored);
|
|
103
|
+
const methods = email.listUnsubscribeJson;
|
|
104
|
+
const listKey = emailListKey(email);
|
|
105
|
+
if (!methods || !listKey || (!methods.httpsUrl && !methods.mailto)) {
|
|
106
|
+
return apiError(c, 409, "CONFLICT", "This message has no unsubscribe option");
|
|
107
|
+
}
|
|
108
|
+
const limit = await checkRateLimit(c.env.DB, {
|
|
109
|
+
action: "mail-unsubscribe",
|
|
110
|
+
identifier: user.id,
|
|
111
|
+
limit: 20,
|
|
112
|
+
windowMs: 60_000,
|
|
113
|
+
});
|
|
114
|
+
if (!limit.allowed) {
|
|
115
|
+
c.header("retry-after", String(limit.retryAfterSeconds));
|
|
116
|
+
return apiError(c, 429, "RATE_LIMITED", "Too many unsubscribe requests");
|
|
117
|
+
}
|
|
118
|
+
const record = (
|
|
119
|
+
method: "one_click" | "mailto" | "link",
|
|
120
|
+
status: "done" | "requested" | "opened" | "failed",
|
|
121
|
+
target: string | null,
|
|
122
|
+
detail: string | null,
|
|
123
|
+
) => mailbox.recordUnsubscription({ listKey, emailId: email.id, method, status, target, detail, userId: user.id });
|
|
124
|
+
|
|
125
|
+
if (c.req.valid("json").method === "link") {
|
|
126
|
+
if (!methods.httpsUrl) return apiError(c, 409, "CONFLICT", "This message has no unsubscribe page");
|
|
127
|
+
const unsubscription = await record("link", "opened", oneClickHost(methods.httpsUrl), null);
|
|
128
|
+
return c.json({ ok: true as const, outcome: "opened" as const, unsubscription, url: methods.httpsUrl, detail: null });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let detail: string | null = null;
|
|
132
|
+
if (methods.oneClick && methods.httpsUrl) {
|
|
133
|
+
const result = await postOneClick(methods.httpsUrl, oneClickFetcher(c.env));
|
|
134
|
+
if (result.ok) {
|
|
135
|
+
const unsubscription = await record(
|
|
136
|
+
"one_click",
|
|
137
|
+
"done",
|
|
138
|
+
oneClickHost(methods.httpsUrl),
|
|
139
|
+
result.redirected ? `Answered ${result.status} instead of 2xx` : null,
|
|
140
|
+
);
|
|
141
|
+
return c.json({ ok: true as const, outcome: "done" as const, unsubscription, url: null, detail: null });
|
|
142
|
+
}
|
|
143
|
+
detail = result.detail;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mailto = methods.mailto ? parseMailtoUnsubscribe(methods.mailto) : null;
|
|
147
|
+
if (mailto && access.canSend) {
|
|
148
|
+
const submission = await submitOutgoing({
|
|
149
|
+
env: c.env,
|
|
150
|
+
requestUrl: c.req.url,
|
|
151
|
+
compose: {
|
|
152
|
+
requestId: crypto.randomUUID(),
|
|
153
|
+
mailboxId,
|
|
154
|
+
userId: user.id,
|
|
155
|
+
to: [mailto.to],
|
|
156
|
+
cc: [],
|
|
157
|
+
bcc: [],
|
|
158
|
+
subject: mailto.subject,
|
|
159
|
+
bodyText: mailto.body,
|
|
160
|
+
attachments: [],
|
|
161
|
+
},
|
|
162
|
+
conversationId: createId("conv"),
|
|
163
|
+
related: null,
|
|
164
|
+
forwarded: null,
|
|
165
|
+
includeRelatedContext: false,
|
|
166
|
+
fromAddress: access.address,
|
|
167
|
+
fromName: access.displayName,
|
|
168
|
+
defer: (task) => c.executionCtx.waitUntil(task),
|
|
169
|
+
});
|
|
170
|
+
const failed = submission.email.transportState === "failed";
|
|
171
|
+
const unsubscription = await record(
|
|
172
|
+
"mailto",
|
|
173
|
+
failed ? "failed" : "requested",
|
|
174
|
+
mailto.to,
|
|
175
|
+
failed ? submission.email.transportError : detail,
|
|
176
|
+
);
|
|
177
|
+
if (!failed) {
|
|
178
|
+
return c.json({ ok: true as const, outcome: "requested" as const, unsubscription, url: null, detail });
|
|
179
|
+
}
|
|
180
|
+
detail = submission.email.transportError ?? "The unsubscribe email could not be sent";
|
|
181
|
+
}
|
|
182
|
+
if (methods.httpsUrl) {
|
|
183
|
+
// Opening the page is left to the person, in a new tab, after this answer.
|
|
184
|
+
return c.json({ ok: true as const, outcome: "link" as const, unsubscription: null, url: methods.httpsUrl, detail });
|
|
185
|
+
}
|
|
186
|
+
if (mailto && !access.canSend) {
|
|
187
|
+
return apiError(c, 403, "FORBIDDEN", "Unsubscribing by email requires send access to this mailbox");
|
|
188
|
+
}
|
|
189
|
+
const unsubscription = await record(methods.oneClick ? "one_click" : "mailto", "failed", null, detail);
|
|
190
|
+
return c.json({ ok: true as const, outcome: "failed" as const, unsubscription, url: null, detail });
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
.post(
|
|
194
|
+
"/messages/:id/report",
|
|
195
|
+
zValidator("query", mailboxQuerySchema),
|
|
196
|
+
zValidator("json", reportMessageSchema),
|
|
197
|
+
async (c) => {
|
|
198
|
+
const { mailboxId } = c.req.valid("query");
|
|
199
|
+
const user = c.get("user");
|
|
200
|
+
const access = await mailboxAccess(c.env, user.id, mailboxId);
|
|
201
|
+
if (!access) return forbidden(c);
|
|
202
|
+
const input = c.req.valid("json");
|
|
203
|
+
const email = await mailboxStub(c.env, mailboxId).getEmail(c.req.param("id"));
|
|
204
|
+
if (!email || email.direction !== "incoming") return apiError(c, 404, "NOT_FOUND", "Message not found");
|
|
205
|
+
const result = await reportOne({
|
|
206
|
+
env: c.env,
|
|
207
|
+
mailboxId,
|
|
208
|
+
userId: user.id,
|
|
209
|
+
email,
|
|
210
|
+
kind: input.kind,
|
|
211
|
+
});
|
|
212
|
+
if (!result) return apiError(c, 404, "NOT_FOUND", "Message not found");
|
|
213
|
+
return c.json({ ok: true as const, ...result });
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
.post(
|
|
217
|
+
"/conversations/report",
|
|
218
|
+
zValidator("query", mailboxQuerySchema),
|
|
219
|
+
zValidator("json", bulkReportSchema),
|
|
220
|
+
async (c) => {
|
|
221
|
+
const { mailboxId } = c.req.valid("query");
|
|
222
|
+
const user = c.get("user");
|
|
223
|
+
const access = await mailboxAccess(c.env, user.id, mailboxId);
|
|
224
|
+
if (!access) return forbidden(c);
|
|
225
|
+
const input = c.req.valid("json");
|
|
226
|
+
const messages = await mailboxStub(c.env, mailboxId).latestIncomingEmails(input.conversationIds);
|
|
227
|
+
let reported = 0;
|
|
228
|
+
for (const email of messages) {
|
|
229
|
+
const result = await reportOne({
|
|
230
|
+
env: c.env,
|
|
231
|
+
mailboxId,
|
|
232
|
+
userId: user.id,
|
|
233
|
+
email,
|
|
234
|
+
kind: input.kind,
|
|
235
|
+
});
|
|
236
|
+
if (result) reported += 1;
|
|
237
|
+
}
|
|
238
|
+
return c.json({ ok: true as const, reportedCount: reported });
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
.post("/messages/:id/not-spam", zValidator("query", mailboxQuerySchema), async (c) => {
|
|
242
|
+
const { mailboxId } = c.req.valid("query");
|
|
243
|
+
const user = c.get("user");
|
|
244
|
+
if (!await mailboxAccess(c.env, user.id, mailboxId)) return forbidden(c);
|
|
245
|
+
const emailId = c.req.param("id");
|
|
246
|
+
const result = await mailboxStub(c.env, mailboxId).markNotSpam({ emailId, userId: user.id });
|
|
247
|
+
if (!result) return apiError(c, 404, "NOT_FOUND", "Message not found");
|
|
248
|
+
if (result.address) {
|
|
249
|
+
await recordWorkspaceFeedback(c.env.DB, { mailboxId, emailId, address: result.address, label: "not_spam" })
|
|
250
|
+
.catch((error) => console.error(`Could not share the not-spam verdict for ${emailId}`, error));
|
|
251
|
+
}
|
|
252
|
+
return c.json({ ok: true as const, restored: result.restored });
|
|
253
|
+
})
|
|
254
|
+
.get("/mailboxes/:id/blocked-senders", async (c) => {
|
|
255
|
+
const mailboxId = c.req.param("id");
|
|
256
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
257
|
+
const [blocked, workspace] = await Promise.all([
|
|
258
|
+
mailboxStub(c.env, mailboxId).listBlockedSenders(),
|
|
259
|
+
workspaceBlockPatterns(c.env.DB),
|
|
260
|
+
]);
|
|
261
|
+
return c.json({
|
|
262
|
+
ok: true as const,
|
|
263
|
+
blocked,
|
|
264
|
+
workspace: [...workspace].map(([pattern, at]) => ({ pattern, blockedAt: at })),
|
|
265
|
+
});
|
|
266
|
+
})
|
|
267
|
+
.post("/mailboxes/:id/blocked-senders", zValidator("json", blockSenderSchema), async (c) => {
|
|
268
|
+
const mailboxId = c.req.param("id");
|
|
269
|
+
const access = await mailboxAccess(c.env, c.get("user").id, mailboxId);
|
|
270
|
+
if (!access) return forbidden(c);
|
|
271
|
+
const input = c.req.valid("json");
|
|
272
|
+
const pattern = normalizeBlockPattern(input.pattern);
|
|
273
|
+
if (!pattern) return apiError(c, 400, "BAD_REQUEST", "Enter an email address or a domain");
|
|
274
|
+
const own = access.address.toLocaleLowerCase();
|
|
275
|
+
if (pattern === own || own.endsWith(pattern)) {
|
|
276
|
+
return apiError(c, 400, "BAD_REQUEST", "This mailbox cannot block its own address or domain");
|
|
277
|
+
}
|
|
278
|
+
const result = await mailboxStub(c.env, mailboxId).blockSender(pattern, input.moveExisting);
|
|
279
|
+
return c.json({ ok: true as const, ...result });
|
|
280
|
+
})
|
|
281
|
+
.delete(
|
|
282
|
+
"/mailboxes/:id/blocked-senders",
|
|
283
|
+
zValidator("query", blockPatternQuerySchema),
|
|
284
|
+
async (c) => {
|
|
285
|
+
const mailboxId = c.req.param("id");
|
|
286
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
287
|
+
const pattern = normalizeBlockPattern(c.req.valid("query").pattern);
|
|
288
|
+
if (!pattern || !await mailboxStub(c.env, mailboxId).unblockSender(pattern)) {
|
|
289
|
+
return apiError(c, 404, "NOT_FOUND", "Blocked sender not found");
|
|
290
|
+
}
|
|
291
|
+
return c.json({ ok: true as const });
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
.put("/mailboxes/:id/trusted-senders", zValidator("json", trustSenderSchema), async (c) => {
|
|
295
|
+
const mailboxId = c.req.param("id");
|
|
296
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
297
|
+
const input = c.req.valid("json");
|
|
298
|
+
await mailboxStub(c.env, mailboxId).setSenderTrusted(input.address, input.trusted);
|
|
299
|
+
return c.json({ ok: true as const });
|
|
300
|
+
})
|
|
301
|
+
.get("/mailboxes/:id/rules", async (c) => {
|
|
302
|
+
const mailboxId = c.req.param("id");
|
|
303
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
304
|
+
return c.json({ ok: true as const, rules: await mailboxStub(c.env, mailboxId).listRules() });
|
|
305
|
+
})
|
|
306
|
+
.post("/mailboxes/:id/rules", zValidator("json", mailRuleSchema), async (c) => {
|
|
307
|
+
const mailboxId = c.req.param("id");
|
|
308
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
309
|
+
try {
|
|
310
|
+
const rule = await mailboxStub(c.env, mailboxId).createRule(c.req.valid("json"));
|
|
311
|
+
return c.json({ ok: true as const, rule }, 201);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// Errors cross the Durable Object boundary without their class.
|
|
314
|
+
if (error instanceof Error && error.message.startsWith(MAIL_RULE_LIMIT_MESSAGE)) {
|
|
315
|
+
return apiError(c, 409, "CONFLICT", error.message);
|
|
316
|
+
}
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
.put("/mailboxes/:id/rules/:ruleId", zValidator("json", mailRuleSchema), async (c) => {
|
|
321
|
+
const mailboxId = c.req.param("id");
|
|
322
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
323
|
+
const rule = await mailboxStub(c.env, mailboxId).updateRule(c.req.param("ruleId"), c.req.valid("json"));
|
|
324
|
+
if (!rule) return apiError(c, 404, "NOT_FOUND", "Rule not found");
|
|
325
|
+
return c.json({ ok: true as const, rule });
|
|
326
|
+
})
|
|
327
|
+
.delete("/mailboxes/:id/rules/:ruleId", async (c) => {
|
|
328
|
+
const mailboxId = c.req.param("id");
|
|
329
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
330
|
+
if (!await mailboxStub(c.env, mailboxId).deleteRule(c.req.param("ruleId"))) {
|
|
331
|
+
return apiError(c, 404, "NOT_FOUND", "Rule not found");
|
|
332
|
+
}
|
|
333
|
+
return c.json({ ok: true as const });
|
|
334
|
+
})
|
|
335
|
+
.put("/mailboxes/:id/rules-order", zValidator("json", reorderRulesSchema), async (c) => {
|
|
336
|
+
const mailboxId = c.req.param("id");
|
|
337
|
+
if (!await mailboxAccess(c.env, c.get("user").id, mailboxId)) return forbidden(c);
|
|
338
|
+
if (!await mailboxStub(c.env, mailboxId).reorderRules(c.req.valid("json").ruleIds)) {
|
|
339
|
+
return apiError(c, 409, "CONFLICT", "Rules changed; refresh and try again");
|
|
340
|
+
}
|
|
341
|
+
return c.json({ ok: true as const });
|
|
342
|
+
})
|
|
343
|
+
.post("/mailboxes/:id/rules/:ruleId/apply", async (c) => {
|
|
344
|
+
const mailboxId = c.req.param("id");
|
|
345
|
+
const user = c.get("user");
|
|
346
|
+
if (!await mailboxAccess(c.env, user.id, mailboxId)) return forbidden(c);
|
|
347
|
+
const result = await mailboxStub(c.env, mailboxId).applyRuleToExisting(c.req.param("ruleId"), user.id);
|
|
348
|
+
if (!result) return apiError(c, 404, "NOT_FOUND", "Rule not found");
|
|
349
|
+
return c.json({ ok: true as const, ...result });
|
|
350
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
mailboxStates,
|
|
4
|
+
MAX_BULK_CONVERSATION_COUNT,
|
|
5
|
+
MAX_COMPOSER_ATTACHMENT_BYTES,
|
|
6
|
+
MAX_COMPOSER_ATTACHMENT_COUNT,
|
|
7
|
+
MAX_CUSTOM_FOLDER_COUNT,
|
|
8
|
+
MAX_MAIL_RECIPIENTS,
|
|
9
|
+
replyActionModes,
|
|
10
|
+
} from "../../shared/mail";
|
|
11
|
+
import {
|
|
12
|
+
MAX_TRANSLATION_CHARACTERS,
|
|
13
|
+
MAX_TRANSLATION_SEGMENTS,
|
|
14
|
+
} from "../../shared/mail-translation";
|
|
15
|
+
import { emailSchema } from "@open-workspace/core/worker/auth/schemas.ts";
|
|
16
|
+
import { hasActions, hasConditions, MAX_MAIL_RULES } from "../../shared/mail-rules";
|
|
17
|
+
import { dedupeRecipientFields, recipientCount } from "./recipients";
|
|
18
|
+
|
|
19
|
+
export const folderSchema = z.string().trim().min(1).max(80);
|
|
20
|
+
|
|
21
|
+
const customFolderNameSchema = z.string().trim().min(1).max(80);
|
|
22
|
+
|
|
23
|
+
export const createFolderSchema = z.object({
|
|
24
|
+
name: customFolderNameSchema,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const renameFolderSchema = createFolderSchema;
|
|
28
|
+
|
|
29
|
+
export const mailboxAiConfigurationSchema = z.object({
|
|
30
|
+
instructions: z.string().trim().max(4_000),
|
|
31
|
+
confidenceThreshold: z.number().int().min(50).max(100),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const reorderFoldersSchema = z.object({
|
|
35
|
+
folderIds: z
|
|
36
|
+
.array(folderSchema)
|
|
37
|
+
.max(MAX_CUSTOM_FOLDER_COUNT)
|
|
38
|
+
.refine((ids) => new Set(ids).size === ids.length, {
|
|
39
|
+
message: "Folder order contains duplicate ids",
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const mailboxQuerySchema = z.object({
|
|
44
|
+
mailboxId: z.string().min(1),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const recipientSuggestionQuerySchema = z.object({
|
|
48
|
+
q: z.string().trim().max(200).default(""),
|
|
49
|
+
limit: z.coerce.number().int().min(1).max(12).default(8),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const remoteProxyQuerySchema = mailboxQuerySchema.extend({
|
|
53
|
+
url: z.string().trim().min(1).max(4_096),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const conversationListQuerySchema = mailboxQuerySchema.extend({
|
|
57
|
+
folder: folderSchema.default("inbox"),
|
|
58
|
+
limit: z.coerce.number().int().min(1).max(50).default(25),
|
|
59
|
+
cursor: z.string().trim().min(1).max(512).optional(),
|
|
60
|
+
search: z.string().trim().max(200).optional(),
|
|
61
|
+
unreadOnly: z.literal("true").optional(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const conversationMessagesQuerySchema = mailboxQuerySchema.extend({
|
|
65
|
+
limit: z.coerce.number().int().min(1).max(25).default(25),
|
|
66
|
+
cursor: z.string().trim().min(1).max(512).optional(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const mailboxStateSchema = z.enum(mailboxStates);
|
|
70
|
+
|
|
71
|
+
export const messageReadSchema = z.object({
|
|
72
|
+
isRead: z.boolean(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const messageTranslationSchema = z.object({
|
|
76
|
+
language: z.string().trim().min(2).max(35).regex(/^[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*$/u),
|
|
77
|
+
segments: z.array(z.string().trim().min(1).max(MAX_TRANSLATION_CHARACTERS))
|
|
78
|
+
.min(1).max(MAX_TRANSLATION_SEGMENTS),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const createUploadSchema = z.object({
|
|
82
|
+
filename: z.string().trim().min(1).max(255),
|
|
83
|
+
contentType: z.string().trim().min(1).max(255),
|
|
84
|
+
size: z.number().int().positive().max(MAX_COMPOSER_ATTACHMENT_BYTES),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const uploadIdSchema = z
|
|
88
|
+
.string()
|
|
89
|
+
.trim()
|
|
90
|
+
.min(1)
|
|
91
|
+
.max(80)
|
|
92
|
+
.regex(/^upl_[a-f0-9]{32}$/u, "Attachment upload id is invalid");
|
|
93
|
+
|
|
94
|
+
export const outboundAttachmentSchema = z.object({
|
|
95
|
+
uploadId: uploadIdSchema,
|
|
96
|
+
disposition: z.enum(["attachment", "inline"]).default("attachment"),
|
|
97
|
+
contentId: z
|
|
98
|
+
.string()
|
|
99
|
+
.trim()
|
|
100
|
+
.min(1)
|
|
101
|
+
.max(255)
|
|
102
|
+
.regex(/^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+$/u, "Content ID is invalid")
|
|
103
|
+
.optional(),
|
|
104
|
+
}).superRefine((attachment, context) => {
|
|
105
|
+
if (attachment.disposition === "inline" && !attachment.contentId) {
|
|
106
|
+
context.addIssue({
|
|
107
|
+
code: "custom",
|
|
108
|
+
path: ["contentId"],
|
|
109
|
+
message: "Inline attachments require a content ID",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (attachment.disposition === "attachment" && attachment.contentId) {
|
|
113
|
+
context.addIssue({
|
|
114
|
+
code: "custom",
|
|
115
|
+
path: ["contentId"],
|
|
116
|
+
message: "Regular attachments cannot have a content ID",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const outboundMessageContentFields = {
|
|
122
|
+
bodyText: z.string().max(500_000).default(""),
|
|
123
|
+
bodyHtml: z.string().max(1_000_000).optional(),
|
|
124
|
+
attachments: z
|
|
125
|
+
.array(outboundAttachmentSchema)
|
|
126
|
+
.max(MAX_COMPOSER_ATTACHMENT_COUNT)
|
|
127
|
+
.default([])
|
|
128
|
+
.refine(
|
|
129
|
+
(attachments) =>
|
|
130
|
+
new Set(attachments.map((file) => file.uploadId)).size === attachments.length,
|
|
131
|
+
{ message: "Duplicate attachment uploads are not allowed" },
|
|
132
|
+
)
|
|
133
|
+
.refine(
|
|
134
|
+
(attachments) => {
|
|
135
|
+
const contentIds = attachments.flatMap((file) =>
|
|
136
|
+
file.contentId ? [file.contentId.toLocaleLowerCase("en-US")] : []
|
|
137
|
+
);
|
|
138
|
+
return new Set(contentIds).size === contentIds.length;
|
|
139
|
+
},
|
|
140
|
+
{ message: "Duplicate attachment content IDs are not allowed" },
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const hasMessageBody = (value: { bodyText: string; bodyHtml?: string }) =>
|
|
145
|
+
Boolean(value.bodyText.trim() || value.bodyHtml?.trim());
|
|
146
|
+
|
|
147
|
+
const missingMessageBody = {
|
|
148
|
+
path: ["bodyText"],
|
|
149
|
+
message: "Add text to the message before sending",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const outboundContentFields = {
|
|
153
|
+
mailboxId: z.string().min(1),
|
|
154
|
+
...outboundMessageContentFields,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const outboundRequestFields = {
|
|
158
|
+
requestId: z.uuid(),
|
|
159
|
+
...outboundContentFields,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const outboundRecipientFields = {
|
|
163
|
+
to: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).default([]),
|
|
164
|
+
cc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).default([]),
|
|
165
|
+
bcc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).default([]),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const composeSchema = z
|
|
169
|
+
.object({
|
|
170
|
+
...outboundRequestFields,
|
|
171
|
+
...outboundRecipientFields,
|
|
172
|
+
replyTo: emailSchema.optional(),
|
|
173
|
+
subject: z.string().trim().max(998).default(""),
|
|
174
|
+
})
|
|
175
|
+
.transform((value) => ({
|
|
176
|
+
...value,
|
|
177
|
+
...dedupeRecipientFields(value),
|
|
178
|
+
}))
|
|
179
|
+
.refine(hasMessageBody, missingMessageBody)
|
|
180
|
+
.refine(
|
|
181
|
+
(value) => recipientCount(value) > 0,
|
|
182
|
+
{ message: "Add at least one recipient in To, Cc, or Bcc" },
|
|
183
|
+
)
|
|
184
|
+
.refine(
|
|
185
|
+
(value) => recipientCount(value) <= MAX_MAIL_RECIPIENTS,
|
|
186
|
+
{ message: `An email can have at most ${MAX_MAIL_RECIPIENTS} recipients across To, Cc, and Bcc` },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
export const replySchema = z.object({
|
|
190
|
+
...outboundRequestFields,
|
|
191
|
+
mode: z.enum(replyActionModes),
|
|
192
|
+
cc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).optional(),
|
|
193
|
+
bcc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).default([]),
|
|
194
|
+
}).refine(hasMessageBody, missingMessageBody);
|
|
195
|
+
|
|
196
|
+
export const forwardSchema = z
|
|
197
|
+
.object({
|
|
198
|
+
...outboundRequestFields,
|
|
199
|
+
...outboundRecipientFields,
|
|
200
|
+
replyTo: emailSchema.optional(),
|
|
201
|
+
})
|
|
202
|
+
.transform((value) => ({
|
|
203
|
+
...value,
|
|
204
|
+
...dedupeRecipientFields(value),
|
|
205
|
+
}))
|
|
206
|
+
.refine(
|
|
207
|
+
(value) => recipientCount(value) > 0,
|
|
208
|
+
{ message: "Add at least one recipient in To, Cc, or Bcc" },
|
|
209
|
+
)
|
|
210
|
+
.refine(
|
|
211
|
+
(value) => recipientCount(value) <= MAX_MAIL_RECIPIENTS,
|
|
212
|
+
{ message: `An email can have at most ${MAX_MAIL_RECIPIENTS} recipients across To, Cc, and Bcc` },
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
export const attachmentPreflightSchema = z.discriminatedUnion("kind", [
|
|
216
|
+
z.object({
|
|
217
|
+
...outboundContentFields,
|
|
218
|
+
kind: z.literal("compose"),
|
|
219
|
+
subject: z.string().trim().max(998).default(""),
|
|
220
|
+
}),
|
|
221
|
+
z.object({
|
|
222
|
+
...outboundContentFields,
|
|
223
|
+
kind: z.literal("forward"),
|
|
224
|
+
sourceEmailId: z.string().trim().min(1).max(255),
|
|
225
|
+
}),
|
|
226
|
+
z.object({
|
|
227
|
+
...outboundContentFields,
|
|
228
|
+
kind: z.literal("reply"),
|
|
229
|
+
sourceEmailId: z.string().trim().min(1).max(255),
|
|
230
|
+
mode: z.enum(replyActionModes),
|
|
231
|
+
cc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).optional(),
|
|
232
|
+
bcc: z.array(emailSchema).max(MAX_MAIL_RECIPIENTS).default([]),
|
|
233
|
+
}),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
export const updateConversationSchema = z
|
|
237
|
+
.object({
|
|
238
|
+
mailboxState: mailboxStateSchema.optional(),
|
|
239
|
+
folderId: z.string().min(1).nullable().optional(),
|
|
240
|
+
})
|
|
241
|
+
.refine((value) => value.mailboxState !== undefined || value.folderId !== undefined, {
|
|
242
|
+
message: "No changes supplied",
|
|
243
|
+
})
|
|
244
|
+
.refine(
|
|
245
|
+
(value) =>
|
|
246
|
+
value.folderId === undefined
|
|
247
|
+
|| value.folderId === null
|
|
248
|
+
|| value.mailboxState === undefined
|
|
249
|
+
|| value.mailboxState === "active",
|
|
250
|
+
{
|
|
251
|
+
path: ["mailboxState"],
|
|
252
|
+
message: "Moving to a custom folder requires the active mailbox state",
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const bulkConversationIdsSchema = z
|
|
257
|
+
.array(z.string().trim().min(1).max(255))
|
|
258
|
+
.min(1)
|
|
259
|
+
.max(MAX_BULK_CONVERSATION_COUNT)
|
|
260
|
+
.transform((ids) => [...new Set(ids)]);
|
|
261
|
+
|
|
262
|
+
export const bulkConversationActionSchema = z.discriminatedUnion("type", [
|
|
263
|
+
z.object({
|
|
264
|
+
type: z.literal("read"),
|
|
265
|
+
conversationIds: bulkConversationIdsSchema,
|
|
266
|
+
isRead: z.boolean(),
|
|
267
|
+
}),
|
|
268
|
+
z.object({
|
|
269
|
+
type: z.literal("update"),
|
|
270
|
+
conversationIds: bulkConversationIdsSchema,
|
|
271
|
+
sourceFolderId: z.string().trim().min(1).max(255),
|
|
272
|
+
update: updateConversationSchema,
|
|
273
|
+
}),
|
|
274
|
+
z.object({
|
|
275
|
+
type: z.literal("delete_permanently"),
|
|
276
|
+
conversationIds: bulkConversationIdsSchema,
|
|
277
|
+
}),
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
const ruleText = z.string().trim().max(200).optional().transform((value) => value || undefined);
|
|
281
|
+
|
|
282
|
+
export const mailRuleSchema = z.object({
|
|
283
|
+
name: z.string().trim().min(1).max(80),
|
|
284
|
+
enabled: z.boolean().default(true),
|
|
285
|
+
conditions: z.object({
|
|
286
|
+
from: ruleText,
|
|
287
|
+
to: ruleText,
|
|
288
|
+
subjectContains: ruleText,
|
|
289
|
+
listId: ruleText,
|
|
290
|
+
hasAttachment: z.boolean().optional().transform((value) => value || undefined),
|
|
291
|
+
}).refine(hasConditions, { message: "Add at least one condition" }),
|
|
292
|
+
actions: z.object({
|
|
293
|
+
folderId: z.string().trim().min(1).max(255).optional(),
|
|
294
|
+
archive: z.boolean().optional().transform((value) => value || undefined),
|
|
295
|
+
spam: z.boolean().optional().transform((value) => value || undefined),
|
|
296
|
+
markRead: z.boolean().optional().transform((value) => value || undefined),
|
|
297
|
+
neverSpam: z.boolean().optional().transform((value) => value || undefined),
|
|
298
|
+
})
|
|
299
|
+
.refine(hasActions, { message: "Choose at least one action" })
|
|
300
|
+
.refine(
|
|
301
|
+
(actions) => [actions.folderId, actions.archive, actions.spam].filter(Boolean).length <= 1,
|
|
302
|
+
{ message: "Choose one destination: a folder, Archive, or Spam" },
|
|
303
|
+
)
|
|
304
|
+
.refine((actions) => !(actions.spam && actions.neverSpam), {
|
|
305
|
+
message: "A rule cannot both send to Spam and never send to Spam",
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
export const reorderRulesSchema = z.object({
|
|
310
|
+
ruleIds: z.array(z.string().min(1).max(255)).max(MAX_MAIL_RULES),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
export const blockSenderSchema = z.object({
|
|
314
|
+
pattern: z.string().trim().min(3).max(320),
|
|
315
|
+
moveExisting: z.boolean().default(false),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
export const blockPatternQuerySchema = z.object({
|
|
319
|
+
pattern: z.string().trim().min(3).max(320),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
export const trustSenderSchema = z.object({
|
|
323
|
+
address: emailSchema,
|
|
324
|
+
trusted: z.boolean(),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
export const unsubscribeSchema = z.object({
|
|
328
|
+
/** "link" records that the person opened the sender's page themselves. */
|
|
329
|
+
method: z.enum(["auto", "link"]).default("auto"),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
export const reportMessageSchema = z.object({
|
|
333
|
+
kind: z.enum(["spam", "phishing"]),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
export const bulkReportSchema = z.object({
|
|
337
|
+
kind: z.enum(["spam", "phishing"]),
|
|
338
|
+
conversationIds: z.array(z.string().trim().min(1).max(255)).min(1).max(100),
|
|
339
|
+
});
|