@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/alchemy.ts +123 -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,537 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MAX_COMPOSER_ATTACHMENT_BYTES } from "../../shared/mail";
|
|
3
|
+
import type { AppEnv } from "@open-workspace/core/worker/env.ts";
|
|
4
|
+
import { createId } from "@open-workspace/core/worker/lib/ids.ts";
|
|
5
|
+
import { signR2PutUrl } from "./r2-presigned-urls";
|
|
6
|
+
|
|
7
|
+
type Bindings = AppEnv["Bindings"];
|
|
8
|
+
|
|
9
|
+
export type UploadedAttachment = {
|
|
10
|
+
id: string;
|
|
11
|
+
r2Key: string;
|
|
12
|
+
filename: string;
|
|
13
|
+
contentType: string;
|
|
14
|
+
size: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ComposerUploadIntent = {
|
|
18
|
+
id: string;
|
|
19
|
+
uploadUrl: string;
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
filename: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
size: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type UploadMeta = {
|
|
27
|
+
filename: string;
|
|
28
|
+
contentType: string;
|
|
29
|
+
size: number;
|
|
30
|
+
uploadedBy: string;
|
|
31
|
+
mailboxId: string;
|
|
32
|
+
finalizedAt: number | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const uploadMetaSchema = z.object({
|
|
36
|
+
filename: z.string().min(1).max(255),
|
|
37
|
+
contentType: z.string().min(1).max(255),
|
|
38
|
+
size: z.number().int().positive().max(MAX_COMPOSER_ATTACHMENT_BYTES),
|
|
39
|
+
uploadedBy: z.string().min(1),
|
|
40
|
+
mailboxId: z.string().min(1),
|
|
41
|
+
finalizedAt: z.number().int().nonnegative().nullable(),
|
|
42
|
+
}).strict();
|
|
43
|
+
|
|
44
|
+
function composerUploadPrefix(
|
|
45
|
+
mailboxId: string,
|
|
46
|
+
userId: string,
|
|
47
|
+
uploadId: string,
|
|
48
|
+
) {
|
|
49
|
+
return `composer-uploads/${mailboxId}/${userId}/${uploadId}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function composerUploadKey(
|
|
53
|
+
mailboxId: string,
|
|
54
|
+
userId: string,
|
|
55
|
+
uploadId: string,
|
|
56
|
+
) {
|
|
57
|
+
return `${composerUploadPrefix(mailboxId, userId, uploadId)}/staging`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function composerUploadFinalKey(
|
|
61
|
+
mailboxId: string,
|
|
62
|
+
userId: string,
|
|
63
|
+
uploadId: string,
|
|
64
|
+
) {
|
|
65
|
+
return `${composerUploadPrefix(mailboxId, userId, uploadId)}/immutable`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function composerUploadMetaKey(
|
|
69
|
+
mailboxId: string,
|
|
70
|
+
userId: string,
|
|
71
|
+
uploadId: string,
|
|
72
|
+
) {
|
|
73
|
+
return `${composerUploadPrefix(mailboxId, userId, uploadId)}/metadata.json`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function composerUploadKeys(
|
|
77
|
+
mailboxId: string,
|
|
78
|
+
userId: string,
|
|
79
|
+
uploadId: string,
|
|
80
|
+
) {
|
|
81
|
+
return {
|
|
82
|
+
staging: composerUploadKey(mailboxId, userId, uploadId),
|
|
83
|
+
immutable: composerUploadFinalKey(mailboxId, userId, uploadId),
|
|
84
|
+
metadata: composerUploadMetaKey(mailboxId, userId, uploadId),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function putUploadMeta(input: {
|
|
89
|
+
env: Bindings;
|
|
90
|
+
metadataKey: string;
|
|
91
|
+
meta: UploadMeta;
|
|
92
|
+
}) {
|
|
93
|
+
await input.env.MAIL_STORAGE.put(
|
|
94
|
+
input.metadataKey,
|
|
95
|
+
JSON.stringify(input.meta),
|
|
96
|
+
{ httpMetadata: { contentType: "application/json" } },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readUploadMeta(env: Bindings, metadataKey: string) {
|
|
101
|
+
const object = await env.MAIL_STORAGE.get(metadataKey);
|
|
102
|
+
if (!object) return null;
|
|
103
|
+
try {
|
|
104
|
+
const parsed = uploadMetaSchema.safeParse(JSON.parse(await object.text()));
|
|
105
|
+
return parsed.success ? parsed.data : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function createComposerUploadIntent(input: {
|
|
112
|
+
env: Bindings;
|
|
113
|
+
requestOrigin: string;
|
|
114
|
+
mailboxId: string;
|
|
115
|
+
userId: string;
|
|
116
|
+
filename: string;
|
|
117
|
+
contentType: string;
|
|
118
|
+
size: number;
|
|
119
|
+
directOnly?: boolean;
|
|
120
|
+
}): Promise<ComposerUploadIntent> {
|
|
121
|
+
if (input.size <= 0) {
|
|
122
|
+
throw new UploadValidationError("Attachment file is empty");
|
|
123
|
+
}
|
|
124
|
+
if (input.size > MAX_COMPOSER_ATTACHMENT_BYTES) {
|
|
125
|
+
throw new UploadValidationError(
|
|
126
|
+
`Attachments are limited to ${Math.floor(MAX_COMPOSER_ATTACHMENT_BYTES / 1_000_000)} MB per message`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const id = createId("upl");
|
|
131
|
+
const keys = composerUploadKeys(input.mailboxId, input.userId, id);
|
|
132
|
+
const filename = input.filename.trim().slice(0, 255) || "attachment";
|
|
133
|
+
const contentType =
|
|
134
|
+
input.contentType.trim().slice(0, 255) || "application/octet-stream";
|
|
135
|
+
|
|
136
|
+
await putUploadMeta({
|
|
137
|
+
env: input.env,
|
|
138
|
+
metadataKey: keys.metadata,
|
|
139
|
+
meta: {
|
|
140
|
+
filename,
|
|
141
|
+
contentType,
|
|
142
|
+
size: input.size,
|
|
143
|
+
uploadedBy: input.userId,
|
|
144
|
+
mailboxId: input.mailboxId,
|
|
145
|
+
finalizedAt: null,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const headers = { "content-type": contentType };
|
|
150
|
+
const presignedUrl = await signR2PutUrl({
|
|
151
|
+
env: input.env,
|
|
152
|
+
r2Key: keys.staging,
|
|
153
|
+
contentType,
|
|
154
|
+
});
|
|
155
|
+
if (presignedUrl) {
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
uploadUrl: presignedUrl,
|
|
159
|
+
headers,
|
|
160
|
+
filename,
|
|
161
|
+
contentType,
|
|
162
|
+
size: input.size,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (input.directOnly) {
|
|
167
|
+
await input.env.MAIL_STORAGE.delete(keys.metadata);
|
|
168
|
+
throw new DirectUploadUnavailableError(
|
|
169
|
+
"Direct R2 attachment uploads are not configured",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Local / Deploy without R2 S3 API tokens: same client flow, Worker receives PUT.
|
|
174
|
+
const fallback = new URL("/api/mail/uploads/content", input.requestOrigin);
|
|
175
|
+
fallback.searchParams.set("mailboxId", input.mailboxId);
|
|
176
|
+
fallback.searchParams.set("uploadId", id);
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
uploadUrl: fallback.toString(),
|
|
180
|
+
headers,
|
|
181
|
+
filename,
|
|
182
|
+
contentType,
|
|
183
|
+
size: input.size,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function storeComposerUploadContent(input: {
|
|
188
|
+
env: Bindings;
|
|
189
|
+
mailboxId: string;
|
|
190
|
+
userId: string;
|
|
191
|
+
uploadId: string;
|
|
192
|
+
body: ReadableStream<Uint8Array> | ArrayBuffer | Blob | null;
|
|
193
|
+
contentType: string | undefined;
|
|
194
|
+
contentLength?: number;
|
|
195
|
+
}) {
|
|
196
|
+
if (!input.body) {
|
|
197
|
+
throw new UploadValidationError("Attachment body is required");
|
|
198
|
+
}
|
|
199
|
+
const keys = composerUploadKeys(
|
|
200
|
+
input.mailboxId,
|
|
201
|
+
input.userId,
|
|
202
|
+
input.uploadId,
|
|
203
|
+
);
|
|
204
|
+
const meta = assertUploadOwner(
|
|
205
|
+
await readUploadMeta(input.env, keys.metadata),
|
|
206
|
+
input.userId,
|
|
207
|
+
input.mailboxId,
|
|
208
|
+
);
|
|
209
|
+
if (meta.finalizedAt !== null) {
|
|
210
|
+
throw new UploadValidationError("Attachment upload is already complete");
|
|
211
|
+
}
|
|
212
|
+
const contentType = (input.contentType || meta.contentType)
|
|
213
|
+
.split(";", 1)[0]!
|
|
214
|
+
.trim()
|
|
215
|
+
.toLowerCase();
|
|
216
|
+
if (contentType !== meta.contentType.trim().toLowerCase()) {
|
|
217
|
+
throw new UploadValidationError("Attachment content type does not match");
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
input.contentLength !== undefined
|
|
221
|
+
&& (
|
|
222
|
+
!Number.isSafeInteger(input.contentLength)
|
|
223
|
+
|| input.contentLength !== meta.size
|
|
224
|
+
)
|
|
225
|
+
) {
|
|
226
|
+
throw new UploadValidationError("Attachment size does not match");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const sizeState = { mismatch: false };
|
|
230
|
+
const uploadBody = uploadBodyWithExactSize(input.body, meta.size, sizeState);
|
|
231
|
+
const write = input.env.MAIL_STORAGE.put(keys.staging, uploadBody.body, {
|
|
232
|
+
httpMetadata: { contentType: meta.contentType },
|
|
233
|
+
});
|
|
234
|
+
const results = await Promise.allSettled([
|
|
235
|
+
write,
|
|
236
|
+
...(uploadBody.completion ? [uploadBody.completion] : []),
|
|
237
|
+
]);
|
|
238
|
+
const failure = results.find(
|
|
239
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
240
|
+
);
|
|
241
|
+
if (failure || sizeState.mismatch) {
|
|
242
|
+
await deleteStagingObject(input.env, keys.staging);
|
|
243
|
+
if (sizeState.mismatch) {
|
|
244
|
+
throw new UploadValidationError("Attachment size does not match");
|
|
245
|
+
}
|
|
246
|
+
throw failure!.reason;
|
|
247
|
+
}
|
|
248
|
+
const stored = await input.env.MAIL_STORAGE.head(keys.staging);
|
|
249
|
+
if (!stored || stored.size !== meta.size) {
|
|
250
|
+
await deleteStagingObject(input.env, keys.staging);
|
|
251
|
+
throw new UploadValidationError("Attachment size does not match");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function uploadBodyWithExactSize(
|
|
256
|
+
body: ReadableStream<Uint8Array> | ArrayBuffer | Blob,
|
|
257
|
+
expectedSize: number,
|
|
258
|
+
state: { mismatch: boolean },
|
|
259
|
+
) {
|
|
260
|
+
if (body instanceof ArrayBuffer) {
|
|
261
|
+
if (body.byteLength !== expectedSize) {
|
|
262
|
+
throw new UploadValidationError("Attachment size does not match");
|
|
263
|
+
}
|
|
264
|
+
return { body, completion: null };
|
|
265
|
+
}
|
|
266
|
+
if (body instanceof Blob) {
|
|
267
|
+
if (body.size !== expectedSize) {
|
|
268
|
+
throw new UploadValidationError("Attachment size does not match");
|
|
269
|
+
}
|
|
270
|
+
return { body, completion: null };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const reader = body.getReader();
|
|
274
|
+
let received = 0;
|
|
275
|
+
const bounded = new ReadableStream<Uint8Array>({
|
|
276
|
+
async pull(controller) {
|
|
277
|
+
const chunk = await reader.read();
|
|
278
|
+
if (chunk.done) {
|
|
279
|
+
if (received !== expectedSize) state.mismatch = true;
|
|
280
|
+
controller.close();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const remaining = expectedSize - received;
|
|
284
|
+
if (chunk.value.byteLength > remaining) {
|
|
285
|
+
state.mismatch = true;
|
|
286
|
+
if (remaining > 0) {
|
|
287
|
+
controller.enqueue(chunk.value.subarray(0, remaining));
|
|
288
|
+
}
|
|
289
|
+
await reader.cancel();
|
|
290
|
+
controller.close();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
received += chunk.value.byteLength;
|
|
294
|
+
controller.enqueue(chunk.value);
|
|
295
|
+
},
|
|
296
|
+
cancel(reason) {
|
|
297
|
+
return reader.cancel(reason);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const fixedLength = new FixedLengthStream(expectedSize);
|
|
301
|
+
return {
|
|
302
|
+
body: fixedLength.readable,
|
|
303
|
+
completion: bounded.pipeTo(fixedLength.writable),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function assertUploadOwner(
|
|
308
|
+
meta: UploadMeta | null,
|
|
309
|
+
userId: string,
|
|
310
|
+
mailboxId: string,
|
|
311
|
+
) {
|
|
312
|
+
if (
|
|
313
|
+
!meta
|
|
314
|
+
|| meta.uploadedBy !== userId
|
|
315
|
+
|| meta.mailboxId !== mailboxId
|
|
316
|
+
) {
|
|
317
|
+
throw new UploadValidationError("Attachment upload was not found");
|
|
318
|
+
}
|
|
319
|
+
return meta;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function loadComposerUploadMetadata(input: {
|
|
323
|
+
env: Bindings;
|
|
324
|
+
mailboxId: string;
|
|
325
|
+
userId: string;
|
|
326
|
+
uploadId: string;
|
|
327
|
+
}): Promise<UploadedAttachment> {
|
|
328
|
+
const keys = composerUploadKeys(
|
|
329
|
+
input.mailboxId,
|
|
330
|
+
input.userId,
|
|
331
|
+
input.uploadId,
|
|
332
|
+
);
|
|
333
|
+
const meta = assertUploadOwner(
|
|
334
|
+
await readUploadMeta(input.env, keys.metadata),
|
|
335
|
+
input.userId,
|
|
336
|
+
input.mailboxId,
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
id: input.uploadId,
|
|
340
|
+
r2Key: meta.finalizedAt === null ? keys.staging : keys.immutable,
|
|
341
|
+
filename: meta.filename,
|
|
342
|
+
contentType: meta.contentType,
|
|
343
|
+
size: meta.size,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function finalizeComposerUpload(input: {
|
|
348
|
+
env: Bindings;
|
|
349
|
+
mailboxId: string;
|
|
350
|
+
userId: string;
|
|
351
|
+
uploadId: string;
|
|
352
|
+
defer?: (task: Promise<unknown>) => void;
|
|
353
|
+
}): Promise<UploadedAttachment> {
|
|
354
|
+
const keys = composerUploadKeys(
|
|
355
|
+
input.mailboxId,
|
|
356
|
+
input.userId,
|
|
357
|
+
input.uploadId,
|
|
358
|
+
);
|
|
359
|
+
const meta = assertUploadOwner(
|
|
360
|
+
await readUploadMeta(input.env, keys.metadata),
|
|
361
|
+
input.userId,
|
|
362
|
+
input.mailboxId,
|
|
363
|
+
);
|
|
364
|
+
if (meta.finalizedAt !== null) {
|
|
365
|
+
const upload = await loadComposerUpload(input);
|
|
366
|
+
await cleanupStagingAfterFinalization(input, keys.staging);
|
|
367
|
+
return upload;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const staging = await input.env.MAIL_STORAGE.get(keys.staging);
|
|
371
|
+
if (
|
|
372
|
+
!staging
|
|
373
|
+
|| staging.size <= 0
|
|
374
|
+
|| staging.size !== meta.size
|
|
375
|
+
|| staging.size > MAX_COMPOSER_ATTACHMENT_BYTES
|
|
376
|
+
) {
|
|
377
|
+
const immutable = await input.env.MAIL_STORAGE.head(keys.immutable);
|
|
378
|
+
if (immutable?.size === meta.size) {
|
|
379
|
+
return completeComposerUploadFinalization(input, keys, meta);
|
|
380
|
+
}
|
|
381
|
+
// Do not delete metadata or the immutable key here: another finalizer may
|
|
382
|
+
// already hold the valid staging body and be about to seal it.
|
|
383
|
+
await deleteStagingObject(input.env, keys.staging);
|
|
384
|
+
throw new UploadValidationError("Attachment upload is incomplete");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The client never receives this key. A still-valid presigned staging URL
|
|
388
|
+
// can be replayed, but it cannot mutate the immutable bytes used for send.
|
|
389
|
+
const created = await input.env.MAIL_STORAGE.put(keys.immutable, staging.body, {
|
|
390
|
+
httpMetadata: { contentType: meta.contentType },
|
|
391
|
+
onlyIf: new Headers({ "if-none-match": "*" }),
|
|
392
|
+
});
|
|
393
|
+
// A concurrent finalizer may win the conditional write. In that case its
|
|
394
|
+
// first sealed snapshot remains authoritative for this upload id.
|
|
395
|
+
const immutable = created
|
|
396
|
+
?? await input.env.MAIL_STORAGE.head(keys.immutable);
|
|
397
|
+
if (!immutable || immutable.size !== meta.size) {
|
|
398
|
+
await deleteStagingObject(input.env, keys.staging);
|
|
399
|
+
throw new UploadValidationError("Attachment upload is incomplete");
|
|
400
|
+
}
|
|
401
|
+
return completeComposerUploadFinalization(input, keys, meta);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function completeComposerUploadFinalization(
|
|
405
|
+
input: {
|
|
406
|
+
env: Bindings;
|
|
407
|
+
mailboxId: string;
|
|
408
|
+
userId: string;
|
|
409
|
+
uploadId: string;
|
|
410
|
+
defer?: (task: Promise<unknown>) => void;
|
|
411
|
+
},
|
|
412
|
+
keys: ReturnType<typeof composerUploadKeys>,
|
|
413
|
+
meta: UploadMeta,
|
|
414
|
+
): Promise<UploadedAttachment> {
|
|
415
|
+
await putUploadMeta({
|
|
416
|
+
env: input.env,
|
|
417
|
+
metadataKey: keys.metadata,
|
|
418
|
+
meta: {
|
|
419
|
+
...meta,
|
|
420
|
+
finalizedAt: Date.now(),
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
await cleanupStagingAfterFinalization(input, keys.staging);
|
|
424
|
+
return {
|
|
425
|
+
id: input.uploadId,
|
|
426
|
+
r2Key: keys.immutable,
|
|
427
|
+
filename: meta.filename,
|
|
428
|
+
contentType: meta.contentType,
|
|
429
|
+
size: meta.size,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function cleanupStagingAfterFinalization(
|
|
434
|
+
input: {
|
|
435
|
+
env: Bindings;
|
|
436
|
+
defer?: (task: Promise<unknown>) => void;
|
|
437
|
+
},
|
|
438
|
+
key: string,
|
|
439
|
+
) {
|
|
440
|
+
const cleanup = deleteStagingObject(input.env, key);
|
|
441
|
+
if (input.defer) input.defer(cleanup);
|
|
442
|
+
else return cleanup;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function deleteStagingObject(env: Bindings, key: string) {
|
|
446
|
+
try {
|
|
447
|
+
await env.MAIL_STORAGE.delete(key);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
// composer-uploads/ expires automatically, so cleanup must not fail upload.
|
|
450
|
+
console.error("Could not remove composer upload staging", error);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function loadComposerUpload(input: {
|
|
455
|
+
env: Bindings;
|
|
456
|
+
mailboxId: string;
|
|
457
|
+
userId: string;
|
|
458
|
+
uploadId: string;
|
|
459
|
+
}): Promise<UploadedAttachment> {
|
|
460
|
+
const keys = composerUploadKeys(
|
|
461
|
+
input.mailboxId,
|
|
462
|
+
input.userId,
|
|
463
|
+
input.uploadId,
|
|
464
|
+
);
|
|
465
|
+
const [object, meta] = await Promise.all([
|
|
466
|
+
input.env.MAIL_STORAGE.head(keys.immutable),
|
|
467
|
+
readUploadMeta(input.env, keys.metadata),
|
|
468
|
+
]);
|
|
469
|
+
if (!object || !meta || typeof meta.finalizedAt !== "number") {
|
|
470
|
+
throw new UploadValidationError("Attachment upload was not found");
|
|
471
|
+
}
|
|
472
|
+
if (meta.uploadedBy !== input.userId || meta.mailboxId !== input.mailboxId) {
|
|
473
|
+
throw new UploadValidationError("Attachment upload was not found");
|
|
474
|
+
}
|
|
475
|
+
if (object.size <= 0 || object.size !== meta.size) {
|
|
476
|
+
throw new UploadValidationError("Attachment upload is incomplete");
|
|
477
|
+
}
|
|
478
|
+
if (object.size > MAX_COMPOSER_ATTACHMENT_BYTES) {
|
|
479
|
+
throw new UploadValidationError("Attachment upload is invalid");
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
id: input.uploadId,
|
|
483
|
+
r2Key: keys.immutable,
|
|
484
|
+
filename: meta.filename,
|
|
485
|
+
contentType: meta.contentType,
|
|
486
|
+
size: object.size,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function copyStoredAttachmentToMessage(input: {
|
|
491
|
+
env: Bindings;
|
|
492
|
+
sourceKey: string;
|
|
493
|
+
destinationKey: string;
|
|
494
|
+
contentType: string;
|
|
495
|
+
}) {
|
|
496
|
+
const source = await input.env.MAIL_STORAGE.get(input.sourceKey);
|
|
497
|
+
if (!source?.body) {
|
|
498
|
+
throw new UploadValidationError("Attachment was not found");
|
|
499
|
+
}
|
|
500
|
+
await input.env.MAIL_STORAGE.put(input.destinationKey, source.body, {
|
|
501
|
+
httpMetadata: { contentType: input.contentType },
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function deleteComposerUploadObjects(
|
|
506
|
+
env: Bindings,
|
|
507
|
+
keys: ReturnType<typeof composerUploadKeys>,
|
|
508
|
+
) {
|
|
509
|
+
try {
|
|
510
|
+
await env.MAIL_STORAGE.delete([
|
|
511
|
+
keys.staging,
|
|
512
|
+
keys.immutable,
|
|
513
|
+
keys.metadata,
|
|
514
|
+
]);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
console.error("Could not remove composer upload objects", error);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function discardComposerUploads(input: {
|
|
521
|
+
env: Bindings;
|
|
522
|
+
mailboxId: string;
|
|
523
|
+
userId: string;
|
|
524
|
+
uploadIds: string[];
|
|
525
|
+
}) {
|
|
526
|
+
await Promise.all(
|
|
527
|
+
input.uploadIds.map((uploadId) =>
|
|
528
|
+
deleteComposerUploadObjects(
|
|
529
|
+
input.env,
|
|
530
|
+
composerUploadKeys(input.mailboxId, input.userId, uploadId),
|
|
531
|
+
)
|
|
532
|
+
),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export class UploadValidationError extends Error {}
|
|
537
|
+
export class DirectUploadUnavailableError extends Error {}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
3
|
+
import { mailboxAddressSql, mailboxKind } from "@open-workspace/core/worker/db/mailboxes.ts";
|
|
4
|
+
import { domains, mailboxes } from "@open-workspace/core/worker/db/schema.ts";
|
|
5
|
+
import { headerAuthentication } from "./header-metadata";
|
|
6
|
+
import { mailboxStub } from "../mailbox";
|
|
7
|
+
import type { Email } from "../mailbox/schema";
|
|
8
|
+
|
|
9
|
+
function publicEmail(email: Email, bodyHtml: string | null) {
|
|
10
|
+
return {
|
|
11
|
+
id: email.id,
|
|
12
|
+
conversationId: email.conversationId,
|
|
13
|
+
direction: email.direction,
|
|
14
|
+
from: email.fromJson,
|
|
15
|
+
to: email.toJson,
|
|
16
|
+
cc: email.ccJson,
|
|
17
|
+
bcc: email.bccJson,
|
|
18
|
+
replyTo: email.replyToJson,
|
|
19
|
+
subject: email.subject,
|
|
20
|
+
body: {
|
|
21
|
+
text: email.bodyText,
|
|
22
|
+
html: bodyHtml,
|
|
23
|
+
},
|
|
24
|
+
attachments: email.attachmentsJson.map((attachment) => ({
|
|
25
|
+
id: attachment.id,
|
|
26
|
+
filename: attachment.filename,
|
|
27
|
+
contentType: attachment.contentType,
|
|
28
|
+
size: attachment.size,
|
|
29
|
+
contentId: attachment.contentId,
|
|
30
|
+
disposition: attachment.disposition,
|
|
31
|
+
})),
|
|
32
|
+
authentication: headerAuthentication(email),
|
|
33
|
+
aiClassification: email.aiClassificationJson,
|
|
34
|
+
transportState: email.transportState,
|
|
35
|
+
transportError: email.transportError,
|
|
36
|
+
deliveryStatuses: email.deliveryStatusJson,
|
|
37
|
+
occurredAt: email.timelineAt.toISOString(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The payload of an email.* webhook event. */
|
|
42
|
+
export async function emailWebhookData(env: Env, source: Record<string, unknown>) {
|
|
43
|
+
if (typeof source.mailboxId !== "string" || typeof source.messageId !== "string") {
|
|
44
|
+
throw new Error("Email webhook source needs a mailbox and message");
|
|
45
|
+
}
|
|
46
|
+
const [mailbox, email] = await Promise.all([
|
|
47
|
+
createDb(env.DB)
|
|
48
|
+
.select({
|
|
49
|
+
id: mailboxes.id,
|
|
50
|
+
address: mailboxAddressSql,
|
|
51
|
+
displayName: mailboxes.displayName,
|
|
52
|
+
ownerUserId: mailboxes.ownerUserId,
|
|
53
|
+
})
|
|
54
|
+
.from(mailboxes)
|
|
55
|
+
.innerJoin(domains, eq(mailboxes.domainId, domains.id))
|
|
56
|
+
.where(eq(mailboxes.id, source.mailboxId))
|
|
57
|
+
.limit(1)
|
|
58
|
+
.then((rows) => rows[0]
|
|
59
|
+
? { ...rows[0], kind: mailboxKind(rows[0].ownerUserId) }
|
|
60
|
+
: null),
|
|
61
|
+
mailboxStub(env, source.mailboxId).getEmail(source.messageId),
|
|
62
|
+
]);
|
|
63
|
+
if (!mailbox || !email) throw new Error("Webhook email no longer exists");
|
|
64
|
+
const htmlObject = email.bodyHtmlR2Key
|
|
65
|
+
? await env.MAIL_STORAGE.get(email.bodyHtmlR2Key)
|
|
66
|
+
: null;
|
|
67
|
+
return {
|
|
68
|
+
mailbox,
|
|
69
|
+
email: publicEmail(email, htmlObject ? await htmlObject.text() : null),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { and, desc, eq, ne } from "drizzle-orm";
|
|
2
|
+
import type { FeedbackLabel } from "../../shared/mail-feedback";
|
|
3
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
4
|
+
import { blockedSenders, senderReports } from "../db/schema";
|
|
5
|
+
|
|
6
|
+
const MAX_WORKSPACE_BLOCKS = 5_000;
|
|
7
|
+
|
|
8
|
+
/** Workspace-wide block patterns with the time each was added. */
|
|
9
|
+
export async function workspaceBlockPatterns(binding: D1Database) {
|
|
10
|
+
const rows = await createDb(binding)
|
|
11
|
+
.select({ pattern: blockedSenders.pattern, createdAt: blockedSenders.createdAt })
|
|
12
|
+
.from(blockedSenders)
|
|
13
|
+
.orderBy(desc(blockedSenders.createdAt))
|
|
14
|
+
.limit(MAX_WORKSPACE_BLOCKS);
|
|
15
|
+
return new Map(rows.map((row) => [row.pattern, row.createdAt.getTime()]));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
function addressDomain(address: string) {
|
|
20
|
+
return address.slice(address.lastIndexOf("@") + 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Shares a spam, phishing or not-spam verdict with the rest of the workspace. */
|
|
24
|
+
export async function recordWorkspaceFeedback(binding: D1Database, input: {
|
|
25
|
+
mailboxId: string;
|
|
26
|
+
emailId: string;
|
|
27
|
+
address: string;
|
|
28
|
+
label: FeedbackLabel;
|
|
29
|
+
}) {
|
|
30
|
+
const address = input.address.toLocaleLowerCase();
|
|
31
|
+
if (!address.includes("@")) return;
|
|
32
|
+
const db = createDb(binding);
|
|
33
|
+
const row = {
|
|
34
|
+
mailboxId: input.mailboxId,
|
|
35
|
+
emailId: input.emailId,
|
|
36
|
+
address,
|
|
37
|
+
domain: addressDomain(address),
|
|
38
|
+
label: input.label,
|
|
39
|
+
createdAt: new Date(),
|
|
40
|
+
};
|
|
41
|
+
// A new stance replaces the opposite one this mailbox gave the sender before.
|
|
42
|
+
const opposite = input.label === "not_spam"
|
|
43
|
+
? ne(senderReports.label, "not_spam")
|
|
44
|
+
: eq(senderReports.label, "not_spam");
|
|
45
|
+
await db.batch([
|
|
46
|
+
db.delete(senderReports).where(and(
|
|
47
|
+
eq(senderReports.mailboxId, input.mailboxId),
|
|
48
|
+
eq(senderReports.address, address),
|
|
49
|
+
opposite,
|
|
50
|
+
)),
|
|
51
|
+
db.insert(senderReports).values(row).onConflictDoUpdate({
|
|
52
|
+
target: [senderReports.mailboxId, senderReports.emailId],
|
|
53
|
+
set: { label: row.label, createdAt: row.createdAt },
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Verdicts other mailboxes in the workspace gave this sender and its domain. */
|
|
59
|
+
export async function workspaceFeedbackSummary(binding: D1Database, mailboxId: string, address: string) {
|
|
60
|
+
const value = address.toLocaleLowerCase();
|
|
61
|
+
const [groups, reporters] = await binding.batch<{
|
|
62
|
+
label: FeedbackLabel;
|
|
63
|
+
same_address: number;
|
|
64
|
+
count: number;
|
|
65
|
+
} & { mailboxes: number }>([
|
|
66
|
+
binding.prepare(`
|
|
67
|
+
select label, address = ?1 as same_address, count(*) as count
|
|
68
|
+
from sender_reports
|
|
69
|
+
where domain = ?2 and mailbox_id <> ?3
|
|
70
|
+
group by label, address = ?1
|
|
71
|
+
`).bind(value, addressDomain(value), mailboxId),
|
|
72
|
+
binding.prepare(`
|
|
73
|
+
select count(distinct mailbox_id) as mailboxes
|
|
74
|
+
from sender_reports
|
|
75
|
+
where address = ?1 and label <> 'not_spam' and mailbox_id <> ?2
|
|
76
|
+
`).bind(value, mailboxId),
|
|
77
|
+
]);
|
|
78
|
+
const rows = groups?.results ?? [];
|
|
79
|
+
const count = (labels: FeedbackLabel[], sameAddress: boolean) => rows
|
|
80
|
+
.filter((row) => labels.includes(row.label) && Boolean(row.same_address) === sameAddress)
|
|
81
|
+
.reduce((total, row) => total + Number(row.count), 0);
|
|
82
|
+
return {
|
|
83
|
+
spamReports: count(["spam"], true),
|
|
84
|
+
phishingReports: count(["phishing"], true),
|
|
85
|
+
notSpam: count(["not_spam"], true),
|
|
86
|
+
domainReports: count(["spam", "phishing"], false),
|
|
87
|
+
/** How many other mailboxes reported this exact address. */
|
|
88
|
+
reportingMailboxes: Number(reporters?.results[0]?.mailboxes ?? 0),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type WorkspaceFeedbackSummary = Awaited<ReturnType<typeof workspaceFeedbackSummary>>;
|