@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,2508 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { and, asc, desc, eq, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
3
+ import { drizzle, type DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
4
+ import { migrate } from "drizzle-orm/durable-sqlite/migrator";
5
+ import migrations from "./migrations";
6
+ import { upgradeLegacyMailbox } from "./legacy-migrations";
7
+ import { officePreviewExtension, officePreviewKey } from "../../shared/office-preview";
8
+ import {
9
+ dispatchOfficePreviewJobs,
10
+ hasPendingOfficePreviews,
11
+ insertOfficePreviewJobs,
12
+ nextOfficePreviewAttempt,
13
+ prepareOfficePreviews,
14
+ } from "./office-preview-jobs";
15
+ import {
16
+ MAX_CUSTOM_FOLDER_COUNT,
17
+ MAILBOX_REALTIME_UPDATE,
18
+ type MailboxPushJob,
19
+ type MailboxRealtimeClientMessage,
20
+ } from "../../shared/mail";
21
+ import {
22
+ classifyInboundEmail,
23
+ EmailAiClassificationError,
24
+ MAILBOX_AI_MAX_ATTEMPTS,
25
+ } from "../mail/ai-classification";
26
+ import { aiObjectRunner } from "@open-workspace/core/worker/ai/generate.ts";
27
+ import type { MailSafetyBindings } from "@open-workspace/core/worker/env.ts";
28
+ import { aiFeatureEnabled, aiModel, type AiModel } from "@open-workspace/core/worker/ai/models.ts";
29
+ import {
30
+ inboundMessageId,
31
+ MissingRawMimeError,
32
+ prepareInboundEmail,
33
+ prepareUnprocessableInboundEmail,
34
+ rawMessageId,
35
+ readInboundRawMime,
36
+ UnprocessableInboundEmailError,
37
+ } from "../mail/inbound";
38
+ import { prepareOutboundDelivery } from "../mail/outbound-delivery";
39
+ import {
40
+ blockFromPattern,
41
+ listBlockedSenders,
42
+ recordCorrespondents,
43
+ recordSenderFeedback,
44
+ senderFeedbackSummary,
45
+ senderHistory,
46
+ senderStatuses,
47
+ setSenderBlocked,
48
+ setSenderTrusted,
49
+ workspaceBlockFor,
50
+ } from "./senders";
51
+ import {
52
+ deleteMailRule,
53
+ emailRuleSubject,
54
+ insertMailRule,
55
+ listMailRules,
56
+ reorderMailRules,
57
+ updateMailRule,
58
+ type MailRuleInput,
59
+ } from "./rules";
60
+ import { evaluateRules, ruleDestination, ruleMatches, type RuleOutcome } from "../../shared/mail-rules";
61
+ import {
62
+ blockPatternsFor,
63
+ type FeedbackLabel,
64
+ type ReportKind,
65
+ type SenderBlock,
66
+ type SenderStatus,
67
+ type Unsubscription,
68
+ } from "../../shared/mail-feedback";
69
+ import { workspaceBlockPatterns, workspaceFeedbackSummary } from "../mail/workspace-safety";
70
+ import { deliveryFacts } from "../mail/delivery-facts";
71
+ import { createId } from "@open-workspace/core/worker/lib/ids.ts";
72
+ import { classificationSignals, type ClassificationSignals } from "../mail/classification-signals";
73
+ import { messageSecurity } from "../mail/message-security";
74
+ import { collectSenderIntel } from "../mail/sender-intel";
75
+ import {
76
+ spamhausDqsKey,
77
+ workspaceIdentity,
78
+ type WorkspaceIdentity,
79
+ } from "../mail/security-settings";
80
+ import {
81
+ suggestParticipants,
82
+ type ParticipantSource,
83
+ } from "../mail/participants";
84
+ import { listActiveMailboxSessions } from "../mail/mailbox-directory";
85
+ import { buildEmailSearchQuery } from "../mail/search";
86
+ import {
87
+ deferWebhookTask,
88
+ emailWebhookEventId,
89
+ queueWebhookEvent,
90
+ } from "@open-workspace/core/worker/webhooks/service.ts";
91
+ import {
92
+ customFolderPredicate,
93
+ folderAggregateJoinPredicate,
94
+ systemFolderDefinitions,
95
+ systemFolderPredicate,
96
+ type MailboxState,
97
+ type SystemFolderType,
98
+ } from "./folder-model";
99
+ import {
100
+ conversations,
101
+ emailReadStates,
102
+ emails,
103
+ folders,
104
+ mailboxAiConfiguration,
105
+ pendingInbound,
106
+ pendingObjectDeletions,
107
+ senders,
108
+ unsubscriptions,
109
+ type Email,
110
+ type FolderRecord,
111
+ type NewEmail,
112
+ type NewFolder,
113
+ type PendingInbound,
114
+ } from "./schema";
115
+ import type {
116
+ EmailAiClassification,
117
+ EmailScreening,
118
+ InboundAuthentication,
119
+ ListUnsubscribe,
120
+ MailboxAiConfiguration,
121
+ RecipientDeliveryStatus,
122
+ SenderIntel,
123
+ } from "./model";
124
+
125
+ const schema = {
126
+ conversations,
127
+ emailReadStates,
128
+ emails,
129
+ folders,
130
+ mailboxAiConfiguration,
131
+ pendingInbound,
132
+ pendingObjectDeletions,
133
+ senders,
134
+ unsubscriptions,
135
+ };
136
+ type MailboxDatabase = DrizzleSqliteDODatabase<typeof schema>;
137
+ const INBOUND_ALARM_BATCH_SIZE = 1;
138
+ const OBJECT_DELETION_ALARM_BATCH_SIZE = 100;
139
+ const MAX_RETRY_DELAY_MS = 60 * 60 * 1000;
140
+ const MAX_INBOUND_RETRY_AGE_MS = 24 * 60 * 60 * 1000;
141
+ const ALARM_WAKE_DELAY_MS = 1_000;
142
+ const PRESENCE_TTL_MS = 60_000;
143
+ const AI_CONFIGURATION_ID = "default";
144
+
145
+ type MailboxSocketAttachment = {
146
+ userId: string;
147
+ sessionId: string;
148
+ sessionTokenHash: string;
149
+ visibility: "visible" | "hidden";
150
+ presenceUpdatedAt: number;
151
+ };
152
+
153
+ function socketAttachment(socket: WebSocket): MailboxSocketAttachment | null {
154
+ const value = socket.deserializeAttachment();
155
+ if (!value || typeof value !== "object") return null;
156
+ const attachment = value as Partial<MailboxSocketAttachment>;
157
+ if (
158
+ typeof attachment.userId !== "string"
159
+ || typeof attachment.sessionId !== "string"
160
+ || typeof attachment.sessionTokenHash !== "string"
161
+ || !["visible", "hidden"].includes(attachment.visibility ?? "")
162
+ || typeof attachment.presenceUpdatedAt !== "number"
163
+ ) {
164
+ return null;
165
+ }
166
+ return attachment as MailboxSocketAttachment;
167
+ }
168
+
169
+ function errorMessage(error: unknown) {
170
+ return error instanceof Error ? error.message : String(error);
171
+ }
172
+
173
+ function retryDelay(attempts: number) {
174
+ return Math.min(
175
+ MAX_RETRY_DELAY_MS,
176
+ 5_000 * 2 ** Math.min(Math.max(attempts - 1, 0), 10),
177
+ );
178
+ }
179
+
180
+ function stringListJson(values: string[]) {
181
+ return JSON.stringify([...new Set(values)]);
182
+ }
183
+
184
+ export type ConversationUpdate = {
185
+ mailboxState?: MailboxState;
186
+ folderId?: string | null;
187
+ };
188
+
189
+ export type ConversationCursorPosition = {
190
+ timelineAt: number;
191
+ emailId: string;
192
+ };
193
+
194
+ export type ConversationListItem = {
195
+ email: Email;
196
+ messageCount: number;
197
+ unreadCount: number;
198
+ hasIncoming: boolean;
199
+ };
200
+
201
+ export type SubmitOutgoingResult = {
202
+ outcome: "inserted" | "existing" | "conflict";
203
+ email: Email;
204
+ };
205
+
206
+ export type InboundDeliveryInput = {
207
+ id: string;
208
+ mailboxId: string;
209
+ rawObjectKey: string;
210
+ envelopeFrom: string;
211
+ envelopeTo: string;
212
+ /** Set when this is a member's copy of mail to a workspace mailing list. */
213
+ listAddress?: string;
214
+ receivedAt: number;
215
+ };
216
+
217
+ export type FolderListItem = {
218
+ id: string;
219
+ name: string;
220
+ kind: "system" | "custom";
221
+ systemType: SystemFolderType | null;
222
+ sortOrder: number;
223
+ totalCount: number;
224
+ unreadCount: number;
225
+ };
226
+
227
+ type MailFolder =
228
+ | (FolderRecord & { kind: "custom"; systemType: null })
229
+ | {
230
+ id: string;
231
+ name: string;
232
+ kind: "system";
233
+ systemType: SystemFolderType;
234
+ sortOrder: number;
235
+ };
236
+
237
+ type ConversationRow = {
238
+ email_id: string;
239
+ timeline_at: number;
240
+ message_count: number;
241
+ unread_count: number;
242
+ has_incoming: number;
243
+ };
244
+
245
+ type ConversationSnapshotCountRow = {
246
+ message_count: number;
247
+ unread_count: number;
248
+ };
249
+
250
+ type FolderCountRow = {
251
+ total_count: number;
252
+ unread_count: number;
253
+ };
254
+
255
+ type FolderListRow = {
256
+ id: string;
257
+ name: string;
258
+ kind: "system" | "custom";
259
+ system_type: SystemFolderType | null;
260
+ sort_order: number;
261
+ total_count: number;
262
+ unread_count: number;
263
+ };
264
+
265
+ export type FolderCounts = {
266
+ totalCount: number;
267
+ unreadCount: number;
268
+ };
269
+
270
+ function folderMembership(folder: MailFolder) {
271
+ if (folder.kind === "custom") {
272
+ return {
273
+ predicate: `
274
+ c.folder_id = ?
275
+ and ${customFolderPredicate("c")}
276
+ `,
277
+ bindings: [folder.id],
278
+ };
279
+ }
280
+ return {
281
+ predicate: systemFolderPredicate(folder.systemType, "c"),
282
+ bindings: [],
283
+ };
284
+ }
285
+
286
+ const SYSTEM_FOLDER_DEFINITIONS_JSON = JSON.stringify(systemFolderDefinitions);
287
+ const FOLDER_AGGREGATE_JOIN_PREDICATE = folderAggregateJoinPredicate(
288
+ "folder",
289
+ "conversation",
290
+ );
291
+
292
+ export type TransportUpdate = {
293
+ state: Email["transportState"];
294
+ messageIdHeader?: string;
295
+ error?: string | null;
296
+ };
297
+
298
+ /** Where an incoming message lands, decided before it is stored. */
299
+ type InboundPlacement = {
300
+ state: "spam" | "archive" | null;
301
+ folderId: string | null;
302
+ /** Rule folders apply even when the conversation already has one; AI folders do not. */
303
+ overrideFolder: boolean;
304
+ markReadUserIds: string[];
305
+ };
306
+
307
+ type InboundScreening = {
308
+ block: SenderBlock | null;
309
+ trusted: boolean;
310
+ rules: RuleOutcome;
311
+ markReadUserIds: string[];
312
+ };
313
+
314
+ const LIKE_ESCAPE = (value: string) => value.replace(/[\\%_]/gu, (char) => `\\${char}`);
315
+
316
+ /** SQL predicate on a lowercased address expression for a block pattern. */
317
+ function blockPatternPredicate(expression: string, pattern: string) {
318
+ if (!pattern.startsWith("@")) return { sql: `${expression} = ?`, bindings: [pattern] };
319
+ const domain = LIKE_ESCAPE(pattern.slice(1));
320
+ return {
321
+ sql: `(${expression} like ? escape '\\' or ${expression} like ? escape '\\')`,
322
+ bindings: [`%@${domain}`, `%.${domain}`],
323
+ };
324
+ }
325
+
326
+ export class MailboxDO extends DurableObject<Env> {
327
+ private readonly db: MailboxDatabase;
328
+ private readonly state: DurableObjectState;
329
+ private readonly bindings: Env;
330
+ private recentParticipantSources: ParticipantSource[] | null = null;
331
+
332
+ constructor(ctx: DurableObjectState, env: Env) {
333
+ super(ctx, env);
334
+ this.state = ctx;
335
+ this.bindings = env;
336
+ this.db = drizzle(ctx.storage, { schema });
337
+ ctx.blockConcurrencyWhile(async () => {
338
+ ctx.storage.transactionSync(() =>
339
+ upgradeLegacyMailbox(ctx.storage.sql, migrations.journal.entries[0]!.when));
340
+ await migrate(this.db, migrations);
341
+ });
342
+ }
343
+
344
+ fetch(request: Request) {
345
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
346
+ return new Response("WebSocket upgrade required", { status: 426 });
347
+ }
348
+ const userId = request.headers.get("x-openworkspace-user-id");
349
+ const sessionId = request.headers.get("x-openworkspace-session-id");
350
+ const sessionTokenHash = request.headers.get("x-openworkspace-session-token-hash");
351
+ if (!userId || !sessionId || !sessionTokenHash) {
352
+ return new Response("Authentication required", { status: 401 });
353
+ }
354
+ const requestedVisibility = request.headers.get("x-openworkspace-visibility");
355
+ const visibility = requestedVisibility === "hidden" ? "hidden" : "visible";
356
+ const pair = new WebSocketPair();
357
+ const client = pair[0];
358
+ const server = pair[1];
359
+ server.serializeAttachment({
360
+ userId,
361
+ sessionId,
362
+ sessionTokenHash,
363
+ visibility,
364
+ presenceUpdatedAt: Date.now(),
365
+ } satisfies MailboxSocketAttachment);
366
+ this.state.acceptWebSocket(server);
367
+ return new Response(null, { status: 101, webSocket: client });
368
+ }
369
+
370
+ webSocketMessage(socket: WebSocket, message: string | ArrayBuffer) {
371
+ if (typeof message !== "string") return;
372
+ if (message !== "visible" && message !== "hidden") return;
373
+ const visibility: MailboxRealtimeClientMessage = message;
374
+ const attachment = socketAttachment(socket);
375
+ if (!attachment) return;
376
+ socket.serializeAttachment({
377
+ ...attachment,
378
+ visibility,
379
+ presenceUpdatedAt: Date.now(),
380
+ } satisfies MailboxSocketAttachment);
381
+ }
382
+
383
+ webSocketClose(socket: WebSocket, code: number, reason: string) {
384
+ socket.close(code, reason);
385
+ }
386
+
387
+ webSocketError(socket: WebSocket) {
388
+ socket.close(1011, "Realtime connection failed");
389
+ }
390
+
391
+ async deleteMailboxData() {
392
+ for (const socket of this.state.getWebSockets()) {
393
+ socket.close(1001, "Mailbox deleted");
394
+ }
395
+ this.recentParticipantSources = null;
396
+ await this.state.storage.deleteAll();
397
+ }
398
+
399
+ async shouldSuppressPush(messageId: string, userId: string) {
400
+ const messageState = this.db
401
+ .select({
402
+ messageId: emails.id,
403
+ readByUserId: emailReadStates.userId,
404
+ })
405
+ .from(emails)
406
+ .leftJoin(
407
+ emailReadStates,
408
+ and(
409
+ eq(emailReadStates.emailId, emails.id),
410
+ eq(emailReadStates.userId, userId),
411
+ ),
412
+ )
413
+ .where(eq(emails.id, messageId))
414
+ .get();
415
+ if (!messageState || messageState.readByUserId) return true;
416
+ const visibleAfter = Date.now() - PRESENCE_TTL_MS;
417
+ return (await this.authorizedSockets({ userId, visibleAfter })).length > 0;
418
+ }
419
+
420
+ private async authorizedSockets(filter?: {
421
+ userId: string;
422
+ visibleAfter: number;
423
+ }) {
424
+ const sockets = this.state.getWebSockets().flatMap((socket) => {
425
+ const attachment = socketAttachment(socket);
426
+ if (attachment) {
427
+ if (
428
+ filter
429
+ && (
430
+ attachment.userId !== filter.userId
431
+ || attachment.visibility !== "visible"
432
+ || attachment.presenceUpdatedAt < filter.visibleAfter
433
+ )
434
+ ) {
435
+ return [];
436
+ }
437
+ return [{ socket, attachment }];
438
+ }
439
+ socket.close(1008, "Realtime authorization expired");
440
+ return [];
441
+ });
442
+ if (!sockets.length) return sockets;
443
+
444
+ const mailboxId = this.state.id.name;
445
+ if (!mailboxId) {
446
+ for (const { socket } of sockets) socket.close(1008, "Mailbox identity is unavailable");
447
+ return [];
448
+ }
449
+ const sessionIds = [...new Set(
450
+ sockets.map(({ attachment }) => attachment.sessionId),
451
+ )];
452
+ const validSessions = await listActiveMailboxSessions(
453
+ this.bindings.DB,
454
+ mailboxId,
455
+ sessionIds,
456
+ Date.now(),
457
+ );
458
+ const validSessionsById = new Map(
459
+ validSessions.map((session) => [session.id, session]),
460
+ );
461
+ return sockets.flatMap(({ socket, attachment }) => {
462
+ const session = validSessionsById.get(attachment.sessionId);
463
+ if (
464
+ session?.userId === attachment.userId
465
+ && session.tokenHash === attachment.sessionTokenHash
466
+ ) {
467
+ return [{ socket, attachment }];
468
+ }
469
+ socket.close(1008, "Realtime authorization expired");
470
+ return [];
471
+ });
472
+ }
473
+
474
+ private async broadcastUpdate() {
475
+ let sockets: Awaited<ReturnType<MailboxDO["authorizedSockets"]>>;
476
+ try {
477
+ sockets = await this.authorizedSockets();
478
+ } catch {
479
+ try {
480
+ sockets = await this.authorizedSockets();
481
+ } catch (error) {
482
+ // Closing forces clients to reconnect and refresh instead of silently
483
+ // losing the only invalidation now that there is no polling fallback.
484
+ for (const socket of this.state.getWebSockets()) {
485
+ socket.close(1011, "Realtime authorization is temporarily unavailable");
486
+ }
487
+ throw error;
488
+ }
489
+ }
490
+ for (const { socket } of sockets) {
491
+ try {
492
+ socket.send(MAILBOX_REALTIME_UPDATE);
493
+ } catch {
494
+ socket.close(1011, "Could not deliver realtime event");
495
+ }
496
+ }
497
+ }
498
+
499
+ private publishUpdate() {
500
+ this.state.waitUntil(this.broadcastUpdate().catch((error) => {
501
+ console.error("Could not authorize realtime delivery", error);
502
+ }));
503
+ }
504
+
505
+ private queueIncomingNotification(email: Email) {
506
+ if (email.direction !== "incoming") return;
507
+ const sender = email.fromJson[0];
508
+ const job: MailboxPushJob = {
509
+ type: "dispatch",
510
+ mailboxId: this.state.id.name ?? "unknown-mailbox",
511
+ conversationId: email.conversationId,
512
+ messageId: email.id,
513
+ occurredAt: Date.now(),
514
+ sender: sender?.name || sender?.address || "Unknown sender",
515
+ subject: email.subject,
516
+ };
517
+ return this.bindings.PUSH_NOTIFICATIONS.send(job);
518
+ }
519
+
520
+ private retryStoredInboundNotification(
521
+ job: PendingInbound,
522
+ now: number,
523
+ error: unknown,
524
+ ) {
525
+ if (now - job.receivedAt.getTime() >= MAX_INBOUND_RETRY_AGE_MS) {
526
+ this.db.delete(pendingInbound).where(eq(pendingInbound.id, job.id)).run();
527
+ console.error(`Stopped retrying notification for inbound delivery ${job.id}`, error);
528
+ return;
529
+ }
530
+ const attempts = job.attempts + 1;
531
+ this.db
532
+ .update(pendingInbound)
533
+ .set({
534
+ attempts,
535
+ nextAttemptAt: new Date(now + retryDelay(attempts)),
536
+ })
537
+ .where(eq(pendingInbound.id, job.id))
538
+ .run();
539
+ console.error(`Could not queue notification for inbound delivery ${job.id}`, error);
540
+ }
541
+
542
+ private async finishStoredInbound(
543
+ job: PendingInbound,
544
+ email: Email,
545
+ now: number,
546
+ announce: boolean,
547
+ ) {
548
+ try {
549
+ const mailboxId = this.state.id.name ?? "unknown-mailbox";
550
+ deferWebhookTask(
551
+ (task) => this.state.waitUntil(task),
552
+ () => queueWebhookEvent(this.bindings, {
553
+ eventId: emailWebhookEventId("email.received", mailboxId, email.id),
554
+ eventType: "email.received",
555
+ occurredAt: email.timelineAt.getTime(),
556
+ source: { kind: "email", mailboxId, messageId: email.id },
557
+ }),
558
+ );
559
+ if (announce) this.publishUpdate();
560
+ const conversation = this.db
561
+ .select({ mailboxState: conversations.mailboxState })
562
+ .from(conversations)
563
+ .where(eq(conversations.id, email.conversationId))
564
+ .all()[0];
565
+ if (conversation?.mailboxState !== "spam") {
566
+ await this.queueIncomingNotification(email);
567
+ }
568
+ this.db.delete(pendingInbound).where(eq(pendingInbound.id, job.id)).run();
569
+ } catch (error) {
570
+ this.retryStoredInboundNotification(job, now, error);
571
+ }
572
+ }
573
+
574
+ listConversations(
575
+ userId: string,
576
+ folderId: string,
577
+ limit: number,
578
+ cursor: ConversationCursorPosition | null,
579
+ search: string | undefined,
580
+ unreadOnly = false,
581
+ ) {
582
+ const folder = this.getFolder(folderId);
583
+ if (!folder) return null;
584
+ const membership = folderMembership(folder);
585
+ const searchQuery = search ? buildEmailSearchQuery(search) : undefined;
586
+ if (search && !searchQuery) return { items: [], next: null };
587
+
588
+ const predicates = [membership.predicate];
589
+ const bindings: Array<string | number> = [...membership.bindings];
590
+ if (unreadOnly) {
591
+ predicates.push(`exists (
592
+ select 1
593
+ from emails unread_message
594
+ where unread_message.conversation_id = c.id
595
+ and unread_message.direction = 'incoming'
596
+ and not exists (
597
+ select 1
598
+ from email_read_states unread_state
599
+ where unread_state.user_id = ?
600
+ and unread_state.email_id = unread_message.id
601
+ )
602
+ )`);
603
+ bindings.push(userId);
604
+ }
605
+ if (searchQuery) {
606
+ // Folder membership belongs to the conversation, while a match can be in
607
+ // any message in that conversation.
608
+ predicates.push(`c.id in (
609
+ select conversation_id
610
+ from email_search
611
+ where email_search match ?
612
+ )`);
613
+ bindings.push(searchQuery);
614
+ }
615
+ if (cursor) {
616
+ predicates.push(
617
+ "(c.timeline_at < ? or (c.timeline_at = ? and c.latest_email_id < ?))",
618
+ );
619
+ bindings.push(cursor.timelineAt, cursor.timelineAt, cursor.emailId);
620
+ }
621
+ bindings.push(limit + 1);
622
+
623
+ const rows = this.state.storage.sql.exec<ConversationRow>(`
624
+ select
625
+ c.latest_email_id as email_id,
626
+ c.timeline_at,
627
+ c.has_incoming,
628
+ (
629
+ select count(*)
630
+ from emails message
631
+ where message.conversation_id = c.id
632
+ ) as message_count,
633
+ (
634
+ select count(*)
635
+ from emails message
636
+ where message.conversation_id = c.id
637
+ and message.direction = 'incoming'
638
+ and not exists (
639
+ select 1
640
+ from email_read_states read_state
641
+ where read_state.user_id = ?
642
+ and read_state.email_id = message.id
643
+ )
644
+ ) as unread_count
645
+ from conversations c
646
+ where ${predicates.join(" and ")}
647
+ order by c.timeline_at desc, c.latest_email_id desc
648
+ limit ?
649
+ `, userId, ...bindings).toArray();
650
+
651
+ const pageRows = rows.slice(0, limit);
652
+ const emailIds = pageRows.map((row) => row.email_id);
653
+ const emailRows = emailIds.length
654
+ ? this.db.select().from(emails).where(inArray(emails.id, emailIds)).all()
655
+ : [];
656
+ const emailsById = new Map(emailRows.map((email) => [email.id, email]));
657
+ const items = pageRows.flatMap((row): ConversationListItem[] => {
658
+ const email = emailsById.get(row.email_id);
659
+ return email
660
+ ? [{
661
+ email,
662
+ messageCount: Number(row.message_count),
663
+ unreadCount: Number(row.unread_count),
664
+ hasIncoming: Boolean(row.has_incoming),
665
+ }]
666
+ : [];
667
+ });
668
+ const last = pageRows.at(-1);
669
+ return {
670
+ items,
671
+ next: rows.length > limit && last
672
+ ? { timelineAt: Number(last.timeline_at), emailId: last.email_id }
673
+ : null,
674
+ };
675
+ }
676
+
677
+ getFolder(id: string): MailFolder | null {
678
+ const system = systemFolderDefinitions.find((folder) => folder.id === id);
679
+ if (system) {
680
+ return {
681
+ ...system,
682
+ kind: "system",
683
+ };
684
+ }
685
+ const custom = this.db
686
+ .select()
687
+ .from(folders)
688
+ .where(eq(folders.id, id))
689
+ .all()[0];
690
+ return custom ? { ...custom, kind: "custom", systemType: null } : null;
691
+ }
692
+
693
+ private folderNameTaken(name: string, excludingId?: string) {
694
+ const normalized = name.toLocaleLowerCase("en-US");
695
+ return systemFolderDefinitions.some(
696
+ (folder) => folder.name.toLocaleLowerCase("en-US") === normalized,
697
+ ) || this.db
698
+ .select({ id: folders.id, name: folders.name })
699
+ .from(folders)
700
+ .all()
701
+ .some((folder) =>
702
+ folder.id !== excludingId
703
+ && folder.name.toLocaleLowerCase("en-US") === normalized
704
+ );
705
+ }
706
+
707
+ createFolder(id: string, name: string) {
708
+ const normalizedName = name.trim();
709
+ if (!normalizedName || normalizedName.length > 80) {
710
+ return { status: "invalid" as const };
711
+ }
712
+ const existing = this.db
713
+ .select({ id: folders.id, sortOrder: folders.sortOrder })
714
+ .from(folders)
715
+ .orderBy(asc(folders.sortOrder), asc(folders.name))
716
+ .all();
717
+ if (existing.length >= MAX_CUSTOM_FOLDER_COUNT) {
718
+ return { status: "limit" as const };
719
+ }
720
+ if (this.folderNameTaken(normalizedName)) {
721
+ return { status: "conflict" as const };
722
+ }
723
+ const folder = this.db.transaction((tx) => {
724
+ const inserted = tx
725
+ .insert(folders)
726
+ .values({ id, name: normalizedName, sortOrder: 100 + existing.length })
727
+ .returning()
728
+ .all()[0]!;
729
+ [...existing.map((item) => item.id), id].forEach((folderId, index) => {
730
+ tx.update(folders)
731
+ .set({ sortOrder: 100 + index })
732
+ .where(eq(folders.id, folderId))
733
+ .run();
734
+ });
735
+ return inserted;
736
+ });
737
+ this.publishUpdate();
738
+ return { status: "ok" as const, folder };
739
+ }
740
+
741
+ renameFolder(id: string, name: string) {
742
+ const normalizedName = name.trim();
743
+ if (!normalizedName || normalizedName.length > 80) {
744
+ return { status: "invalid" as const };
745
+ }
746
+ if (!this.getFolder(id) || systemFolderDefinitions.some((item) => item.id === id)) {
747
+ return { status: "not_found" as const };
748
+ }
749
+ if (this.folderNameTaken(normalizedName, id)) {
750
+ return { status: "conflict" as const };
751
+ }
752
+ const folder = this.db
753
+ .update(folders)
754
+ .set({ name: normalizedName })
755
+ .where(eq(folders.id, id))
756
+ .returning()
757
+ .all()[0]!;
758
+ this.publishUpdate();
759
+ return { status: "ok" as const, folder };
760
+ }
761
+
762
+ deleteFolder(id: string) {
763
+ const deleted = this.db
764
+ .delete(folders)
765
+ .where(eq(folders.id, id))
766
+ .returning({ id: folders.id })
767
+ .all()[0];
768
+ if (!deleted) return { status: "not_found" as const };
769
+ // Rules that filed into this folder keep their other actions.
770
+ this.state.storage.sql.exec(
771
+ "update mail_rules set actions_json = json_remove(actions_json, '$.folderId') where json_extract(actions_json, '$.folderId') = ?",
772
+ id,
773
+ );
774
+ this.publishUpdate();
775
+ return { status: "ok" as const };
776
+ }
777
+
778
+ reorderFolders(folderIds: string[]) {
779
+ const existingIds = this.db
780
+ .select({ id: folders.id })
781
+ .from(folders)
782
+ .all()
783
+ .map((folder) => folder.id);
784
+ if (
785
+ folderIds.length !== existingIds.length
786
+ || new Set(folderIds).size !== folderIds.length
787
+ || existingIds.some((id) => !folderIds.includes(id))
788
+ ) {
789
+ return { status: "conflict" as const };
790
+ }
791
+ this.db.transaction((tx) => {
792
+ folderIds.forEach((id, index) => {
793
+ tx.update(folders)
794
+ .set({ sortOrder: 100 + index })
795
+ .where(eq(folders.id, id))
796
+ .run();
797
+ });
798
+ });
799
+ this.publishUpdate();
800
+ return { status: "ok" as const };
801
+ }
802
+
803
+ private aiConfiguration(): MailboxAiConfiguration {
804
+ const stored = this.db
805
+ .select()
806
+ .from(mailboxAiConfiguration)
807
+ .where(eq(mailboxAiConfiguration.id, AI_CONFIGURATION_ID))
808
+ .all()[0];
809
+ return stored
810
+ ? {
811
+ instructions: stored.instructions,
812
+ confidenceThreshold: stored.confidenceThreshold,
813
+ }
814
+ : {
815
+ instructions: "",
816
+ confidenceThreshold: 75,
817
+ };
818
+ }
819
+
820
+ async getMailboxAiSettings() {
821
+ return {
822
+ configuration: this.aiConfiguration(),
823
+ globalEnabled: aiFeatureEnabled("classification"),
824
+ };
825
+ }
826
+
827
+ async setMailboxAiConfiguration(configuration: MailboxAiConfiguration) {
828
+ if (
829
+ configuration.confidenceThreshold < 50
830
+ || configuration.confidenceThreshold > 100
831
+ || configuration.instructions.length > 4_000
832
+ ) {
833
+ throw new Error("Invalid mailbox AI configuration");
834
+ }
835
+ this.db
836
+ .insert(mailboxAiConfiguration)
837
+ .values({
838
+ id: AI_CONFIGURATION_ID,
839
+ instructions: configuration.instructions.trim(),
840
+ confidenceThreshold: configuration.confidenceThreshold,
841
+ updatedAt: new Date(),
842
+ })
843
+ .onConflictDoUpdate({
844
+ target: mailboxAiConfiguration.id,
845
+ set: {
846
+ instructions: configuration.instructions.trim(),
847
+ confidenceThreshold: configuration.confidenceThreshold,
848
+ updatedAt: new Date(),
849
+ },
850
+ })
851
+ .run();
852
+ return this.getMailboxAiSettings();
853
+ }
854
+
855
+ getEmail(id: string) {
856
+ return this.db.select().from(emails).where(eq(emails.id, id)).all()[0] ?? null;
857
+ }
858
+
859
+ getEmailByMessageId(messageIdHeader: string) {
860
+ return this.db
861
+ .select()
862
+ .from(emails)
863
+ .where(eq(emails.messageIdHeader, messageIdHeader))
864
+ .orderBy(desc(emails.timelineAt))
865
+ .all()[0] ?? null;
866
+ }
867
+
868
+ getConversation(conversationId: string) {
869
+ return this.db
870
+ .select()
871
+ .from(emails)
872
+ .where(eq(emails.conversationId, conversationId))
873
+ .orderBy(asc(emails.timelineAt), asc(emails.id))
874
+ .all();
875
+ }
876
+
877
+ getConversationSnapshot(conversationId: string) {
878
+ const conversation = this.db
879
+ .select({
880
+ mailboxState: conversations.mailboxState,
881
+ folderId: conversations.folderId,
882
+ })
883
+ .from(conversations)
884
+ .where(eq(conversations.id, conversationId))
885
+ .all()[0];
886
+ if (!conversation) return null;
887
+ return {
888
+ mailboxState: conversation.mailboxState,
889
+ folderId: conversation.folderId,
890
+ messages: this.getConversation(conversationId),
891
+ readStates: this.db
892
+ .select({
893
+ emailId: emailReadStates.emailId,
894
+ userId: emailReadStates.userId,
895
+ readAt: emailReadStates.readAt,
896
+ })
897
+ .from(emailReadStates)
898
+ .innerJoin(emails, eq(emailReadStates.emailId, emails.id))
899
+ .where(eq(emails.conversationId, conversationId))
900
+ .orderBy(asc(emailReadStates.readAt))
901
+ .all(),
902
+ };
903
+ }
904
+
905
+ getConversationPageSnapshot(
906
+ conversationId: string,
907
+ userId: string,
908
+ limit: number,
909
+ cursor: ConversationCursorPosition | null,
910
+ ) {
911
+ const conversation = this.db
912
+ .select({
913
+ mailboxState: conversations.mailboxState,
914
+ folderId: conversations.folderId,
915
+ hasIncoming: conversations.hasIncoming,
916
+ })
917
+ .from(conversations)
918
+ .where(eq(conversations.id, conversationId))
919
+ .all()[0];
920
+ if (!conversation) return null;
921
+
922
+ const pageLimit = Math.max(1, Math.min(limit, 25));
923
+ const cursorDate = cursor ? new Date(cursor.timelineAt) : null;
924
+ const predicate = cursor && cursorDate
925
+ ? and(
926
+ eq(emails.conversationId, conversationId),
927
+ or(
928
+ lt(emails.timelineAt, cursorDate),
929
+ and(
930
+ eq(emails.timelineAt, cursorDate),
931
+ lt(emails.id, cursor.emailId),
932
+ ),
933
+ ),
934
+ )
935
+ : eq(emails.conversationId, conversationId);
936
+ const descending = this.db
937
+ .select()
938
+ .from(emails)
939
+ .where(predicate)
940
+ .orderBy(desc(emails.timelineAt), desc(emails.id))
941
+ .limit(pageLimit + 1)
942
+ .all();
943
+ const page = descending.slice(0, pageLimit);
944
+ const oldest = page.at(-1);
945
+ const messageIds = page.map((email) => email.id);
946
+ const counts = this.state.storage.sql.exec<ConversationSnapshotCountRow>(`
947
+ select
948
+ count(*) as message_count,
949
+ coalesce(sum(
950
+ case
951
+ when message.direction = 'incoming' and not exists (
952
+ select 1
953
+ from email_read_states read_state
954
+ where read_state.user_id = ?
955
+ and read_state.email_id = message.id
956
+ ) then 1
957
+ else 0
958
+ end
959
+ ), 0) as unread_count
960
+ from emails message
961
+ where message.conversation_id = ?
962
+ `, userId, conversationId).one();
963
+ return {
964
+ mailboxState: conversation.mailboxState,
965
+ folderId: conversation.folderId,
966
+ hasIncoming: conversation.hasIncoming,
967
+ messageCount: Number(counts.message_count),
968
+ unreadCount: Number(counts.unread_count),
969
+ messages: page.reverse(),
970
+ next: descending.length > pageLimit && oldest
971
+ ? { timelineAt: oldest.timelineAt.getTime(), emailId: oldest.id }
972
+ : null,
973
+ readStates: messageIds.length
974
+ ? this.db
975
+ .select({
976
+ emailId: emailReadStates.emailId,
977
+ userId: emailReadStates.userId,
978
+ readAt: emailReadStates.readAt,
979
+ })
980
+ .from(emailReadStates)
981
+ .where(inArray(emailReadStates.emailId, messageIds))
982
+ .orderBy(asc(emailReadStates.readAt))
983
+ .all()
984
+ : [],
985
+ };
986
+ }
987
+
988
+ getAttachment(emailId: string, attachmentId: string) {
989
+ const email = this.getEmail(emailId);
990
+ return email?.attachmentsJson.find((file) => file.id === attachmentId) ?? null;
991
+ }
992
+
993
+ async completeOfficePreview(emailId: string, attachmentId: string, sourceKey: string, status: "ready" | "failed") {
994
+ const email = this.getEmail(emailId);
995
+ const file = email?.attachmentsJson.find((file) => file.id === attachmentId && file.r2Key === sourceKey);
996
+ if (!email || !file) {
997
+ // Conversion may finish after permanent deletion already removed its objects.
998
+ // Persist cleanup before the queue acknowledges, so an R2 outage cannot orphan the PDF.
999
+ const cleanupAt = new Date();
1000
+ await this.scheduleAlarm(cleanupAt.getTime() + ALARM_WAKE_DELAY_MS);
1001
+ this.db.insert(pendingObjectDeletions).values({
1002
+ objectKey: officePreviewKey(sourceKey), nextAttemptAt: cleanupAt,
1003
+ }).onConflictDoNothing().run();
1004
+ return false;
1005
+ }
1006
+ if (file.pdfPreviewStatus !== "pending") return true;
1007
+ this.db.update(emails).set({
1008
+ attachmentsJson: email.attachmentsJson.map((attachment) =>
1009
+ attachment.id === attachmentId ? { ...attachment, pdfPreviewStatus: status } : attachment
1010
+ ),
1011
+ }).where(eq(emails.id, emailId)).run();
1012
+ this.publishUpdate();
1013
+ return true;
1014
+ }
1015
+
1016
+ suggestRecipients(ownAddress: string, query: string, limit: number) {
1017
+ this.recentParticipantSources ??= this.db
1018
+ .select({
1019
+ fromJson: emails.fromJson,
1020
+ toJson: emails.toJson,
1021
+ ccJson: emails.ccJson,
1022
+ bccJson: emails.bccJson,
1023
+ })
1024
+ .from(emails)
1025
+ .orderBy(desc(emails.timelineAt), desc(emails.id))
1026
+ .limit(500)
1027
+ .all();
1028
+ const flagged = this.state.storage.sql.exec<{ address: string }>(
1029
+ "select address from senders where blocked_at is not null or reported_at is not null",
1030
+ ).toArray().map((row) => row.address);
1031
+ const excluded = new Set(flagged);
1032
+ return suggestParticipants(
1033
+ this.recentParticipantSources,
1034
+ ownAddress,
1035
+ query,
1036
+ limit,
1037
+ (address) => blockPatternsFor(address).some((pattern) => excluded.has(pattern)),
1038
+ );
1039
+ }
1040
+
1041
+ listFolders(userId: string): FolderListItem[] {
1042
+ const rows = this.state.storage.sql.exec<FolderListRow>(`
1043
+ with folder_rows(id, name, kind, system_type, sort_order) as (
1044
+ select
1045
+ json_extract(value, '$.id'),
1046
+ json_extract(value, '$.name'),
1047
+ 'system',
1048
+ json_extract(value, '$.systemType'),
1049
+ json_extract(value, '$.sortOrder')
1050
+ from json_each(?)
1051
+ union all
1052
+ select id, name, 'custom', null, sort_order
1053
+ from folders
1054
+ ),
1055
+ conversation_unread as (
1056
+ select
1057
+ c.id,
1058
+ c.mailbox_state,
1059
+ c.folder_id,
1060
+ c.has_incoming,
1061
+ c.has_outgoing,
1062
+ coalesce(sum(case
1063
+ when message.id is not null and read_state.email_id is null then 1
1064
+ else 0
1065
+ end), 0) as unread_count
1066
+ from conversations c
1067
+ left join emails message
1068
+ on message.conversation_id = c.id
1069
+ and message.direction = 'incoming'
1070
+ left join email_read_states read_state
1071
+ on read_state.user_id = ?
1072
+ and read_state.email_id = message.id
1073
+ group by
1074
+ c.id,
1075
+ c.mailbox_state,
1076
+ c.folder_id,
1077
+ c.has_incoming,
1078
+ c.has_outgoing
1079
+ )
1080
+ select
1081
+ folder.id,
1082
+ folder.name,
1083
+ folder.kind,
1084
+ folder.system_type,
1085
+ folder.sort_order,
1086
+ count(conversation.id) as total_count,
1087
+ coalesce(sum(conversation.unread_count), 0) as unread_count
1088
+ from folder_rows folder
1089
+ left join conversation_unread conversation on
1090
+ ${FOLDER_AGGREGATE_JOIN_PREDICATE}
1091
+ group by
1092
+ folder.id,
1093
+ folder.name,
1094
+ folder.kind,
1095
+ folder.system_type,
1096
+ folder.sort_order
1097
+ `, SYSTEM_FOLDER_DEFINITIONS_JSON, userId).toArray();
1098
+
1099
+ return rows.map((row): FolderListItem => ({
1100
+ id: row.id,
1101
+ name: row.name,
1102
+ kind: row.kind,
1103
+ systemType: row.system_type,
1104
+ sortOrder: Number(row.sort_order),
1105
+ totalCount: Number(row.total_count),
1106
+ unreadCount: Number(row.unread_count),
1107
+ })).sort(
1108
+ (left, right) =>
1109
+ left.sortOrder - right.sortOrder || left.name.localeCompare(right.name),
1110
+ );
1111
+ }
1112
+
1113
+ getFolderCounts(userId: string, folderId: string): FolderCounts | null {
1114
+ const folder = this.getFolder(folderId);
1115
+ return folder ? this.countFolder(userId, folder) : null;
1116
+ }
1117
+
1118
+ private countFolder(userId: string, folder: MailFolder): FolderCounts {
1119
+ const membership = folderMembership(folder);
1120
+ const counts = this.state.storage.sql.exec<FolderCountRow>(`
1121
+ select
1122
+ count(*) as total_count,
1123
+ coalesce(sum((
1124
+ select count(*)
1125
+ from emails message
1126
+ where message.conversation_id = c.id
1127
+ and message.direction = 'incoming'
1128
+ and not exists (
1129
+ select 1
1130
+ from email_read_states read_state
1131
+ where read_state.user_id = ?
1132
+ and read_state.email_id = message.id
1133
+ )
1134
+ )), 0) as unread_count
1135
+ from conversations c
1136
+ where ${membership.predicate}
1137
+ `, userId, ...membership.bindings).one();
1138
+ return {
1139
+ totalCount: Number(counts.total_count),
1140
+ unreadCount: Number(counts.unread_count),
1141
+ };
1142
+ }
1143
+
1144
+ setMessageRead(userId: string, emailId: string, isRead: boolean) {
1145
+ const email = this.getEmail(emailId);
1146
+ if (!email) return false;
1147
+ if (isRead) {
1148
+ const readAt = new Date();
1149
+ this.db
1150
+ .insert(emailReadStates)
1151
+ .values({ userId, emailId, readAt })
1152
+ .onConflictDoUpdate({
1153
+ target: [emailReadStates.userId, emailReadStates.emailId],
1154
+ set: { readAt },
1155
+ })
1156
+ .run();
1157
+ } else {
1158
+ this.db
1159
+ .delete(emailReadStates)
1160
+ .where(and(
1161
+ eq(emailReadStates.userId, userId),
1162
+ eq(emailReadStates.emailId, emailId),
1163
+ ))
1164
+ .run();
1165
+ }
1166
+ this.publishUpdate();
1167
+ return true;
1168
+ }
1169
+
1170
+ bulkSetConversationRead(
1171
+ userId: string,
1172
+ conversationIds: string[],
1173
+ isRead: boolean,
1174
+ ) {
1175
+ const candidates = [...new Set(conversationIds)];
1176
+ if (!candidates.length) return 0;
1177
+ const candidatesJson = stringListJson(candidates);
1178
+ const existingIds = this.db
1179
+ .select({ id: conversations.id })
1180
+ .from(conversations)
1181
+ .where(and(
1182
+ sql`${conversations.id} in (
1183
+ select cast(value as text) from json_each(${candidatesJson})
1184
+ )`,
1185
+ eq(conversations.hasIncoming, true),
1186
+ ))
1187
+ .all()
1188
+ .map((conversation) => conversation.id);
1189
+ if (!existingIds.length) return 0;
1190
+ const existingIdsJson = stringListJson(existingIds);
1191
+
1192
+ if (isRead) {
1193
+ this.db.run(sql`
1194
+ insert into email_read_states (user_id, email_id, read_at)
1195
+ select ${userId}, id, ${Date.now()}
1196
+ from emails
1197
+ where conversation_id in (
1198
+ select cast(value as text) from json_each(${existingIdsJson})
1199
+ ) and direction = 'incoming'
1200
+ on conflict (user_id, email_id) do update set read_at = excluded.read_at
1201
+ `);
1202
+ } else {
1203
+ this.db.run(sql`
1204
+ delete from email_read_states
1205
+ where user_id = ${userId}
1206
+ and email_id in (
1207
+ select message.id
1208
+ from emails message
1209
+ where message.conversation_id in (
1210
+ select cast(value as text) from json_each(${existingIdsJson})
1211
+ )
1212
+ and message.direction = 'incoming'
1213
+ and not exists (
1214
+ select 1
1215
+ from emails newer
1216
+ where newer.conversation_id = message.conversation_id
1217
+ and newer.direction = 'incoming'
1218
+ and (
1219
+ newer.timeline_at > message.timeline_at
1220
+ or (
1221
+ newer.timeline_at = message.timeline_at
1222
+ and newer.id > message.id
1223
+ )
1224
+ )
1225
+ )
1226
+ )
1227
+ `);
1228
+ }
1229
+ this.publishUpdate();
1230
+ return existingIds.length;
1231
+ }
1232
+
1233
+ async enqueueInbound(input: InboundDeliveryInput) {
1234
+ const receivedAt = new Date(input.receivedAt);
1235
+ if (Number.isNaN(receivedAt.getTime())) {
1236
+ throw new Error("Inbound delivery has an invalid receipt time");
1237
+ }
1238
+ if (this.state.id.name && this.state.id.name !== input.mailboxId) {
1239
+ throw new Error("Inbound delivery targeted the wrong mailbox object");
1240
+ }
1241
+
1242
+ const queuedAt = new Date();
1243
+ // Keep the alarm just ahead of the due time. Scheduling it for "now" can
1244
+ // wake it on the setAlarm await before the synchronous insert below runs.
1245
+ const alarmAt = queuedAt.getTime() + ALARM_WAKE_DELAY_MS;
1246
+ await this.scheduleAlarm(alarmAt);
1247
+ const messageId = inboundMessageId(input.id);
1248
+ const queued = this.db.transaction((tx) => {
1249
+ const existing = tx
1250
+ .select({ id: emails.id })
1251
+ .from(emails)
1252
+ .where(eq(emails.id, messageId))
1253
+ .all()[0];
1254
+ if (existing) return false;
1255
+
1256
+ const result = tx
1257
+ .insert(pendingInbound)
1258
+ .values({
1259
+ id: input.id,
1260
+ rawObjectKey: input.rawObjectKey,
1261
+ envelopeFrom: input.envelopeFrom,
1262
+ envelopeTo: input.envelopeTo,
1263
+ listAddress: input.listAddress ?? null,
1264
+ receivedAt,
1265
+ nextAttemptAt: queuedAt,
1266
+ })
1267
+ .onConflictDoNothing()
1268
+ .returning({ id: pendingInbound.id })
1269
+ .all();
1270
+ return result.length > 0;
1271
+ });
1272
+ return { messageId, queued };
1273
+ }
1274
+
1275
+ async insertEmail(email: NewEmail) {
1276
+ email = prepareOfficePreviews(email);
1277
+ if (hasPendingOfficePreviews(email)) await this.scheduleAlarm(Date.now() + ALARM_WAKE_DELAY_MS);
1278
+ this.db.transaction((tx) => {
1279
+ const inserted = tx.insert(emails).values(email).onConflictDoNothing().returning({ id: emails.id }).all();
1280
+ if (inserted.length) {
1281
+ insertOfficePreviewJobs(this.state.storage.sql, email);
1282
+ recordCorrespondents(this.state.storage.sql, email);
1283
+ }
1284
+ });
1285
+ this.recentParticipantSources = null;
1286
+ return this.getEmail(email.id);
1287
+ }
1288
+
1289
+ /**
1290
+ * DNS intelligence and receiver signals for a parsed delivery, gathered
1291
+ * before AI classification so the model can use them. Fails open.
1292
+ */
1293
+ private async inboundSenderSignals(email: NewEmail, links: string[]) {
1294
+ let identity: WorkspaceIdentity = { domains: [], names: new Map() };
1295
+ let dqsKey: string | null = null;
1296
+ let workspaceBlocks = new Map<string, number>();
1297
+ try {
1298
+ [identity, dqsKey, workspaceBlocks] = await Promise.all([
1299
+ workspaceIdentity(this.bindings.DB),
1300
+ spamhausDqsKey(this.bindings as Env & MailSafetyBindings),
1301
+ workspaceBlockPatterns(this.bindings.DB),
1302
+ ]);
1303
+ } catch (error) {
1304
+ console.error("Could not read workspace identity for sender checks", error);
1305
+ }
1306
+ const stored = email.authenticationResultsJson;
1307
+ const authentication = stored?.source === "headers" ? stored : null;
1308
+ const from = email.fromJson[0] ?? { address: "", name: null };
1309
+ const sql = this.state.storage.sql;
1310
+ const status = from.address ? senderStatuses(sql, [from.address]).get(from.address.toLocaleLowerCase()) : undefined;
1311
+ const block = workspaceBlockFor(from.address, workspaceBlocks) ?? status?.block ?? null;
1312
+ const trusted = Boolean(status?.trustedAt) && !block;
1313
+ const rules = evaluateRules(listMailRules(sql), emailRuleSubject(email));
1314
+ let intel: SenderIntel | null = null;
1315
+ try {
1316
+ intel = await collectSenderIntel({
1317
+ authentication,
1318
+ fromAddress: from.address,
1319
+ linkDomains: links,
1320
+ workspaceDomains: identity.domains,
1321
+ dqsKey,
1322
+ bucket: this.bindings.MAIL_STORAGE,
1323
+ });
1324
+ } catch (error) {
1325
+ console.error(`Could not collect sender intelligence for ${email.id}`, error);
1326
+ }
1327
+ const history = senderHistory(sql, [from.address]).get(from.address.toLocaleLowerCase());
1328
+ const delivery = deliveryFacts({
1329
+ authentication,
1330
+ deliveredTo: email.envelopeTo ?? null,
1331
+ to: (email.toJson ?? []).map((item) => item.address),
1332
+ cc: (email.ccJson ?? []).map((item) => item.address),
1333
+ listId: email.listId ?? null,
1334
+ workspaceDomains: identity.domains,
1335
+ isKnownAddressee: (address) => this.isKnownAddressee(address),
1336
+ });
1337
+ const security = messageSecurity({
1338
+ fromAddress: from.address,
1339
+ fromName: from.name,
1340
+ authentication,
1341
+ listUnsubscribe: null,
1342
+ workspaceDomains: identity.domains,
1343
+ internalNames: identity.names,
1344
+ intel,
1345
+ // Counted as if this message were already stored.
1346
+ history: { inboundCount: (history?.inboundCount ?? 0) + 1, outboundCount: history?.outboundCount ?? 0 },
1347
+ replyTo: (email.replyToJson ?? []).map((item) => item.address),
1348
+ listId: email.listId ?? null,
1349
+ delivery,
1350
+ trusted,
1351
+ });
1352
+ let markReadUserIds: string[] = [];
1353
+ if (rules.markRead && !block) {
1354
+ try {
1355
+ markReadUserIds = await this.mailboxMemberIds();
1356
+ } catch (error) {
1357
+ console.error(`Could not read mailbox members to mark ${email.id} read`, error);
1358
+ }
1359
+ }
1360
+ const screening: InboundScreening = { block, trusted, rules, markReadUserIds };
1361
+ let workspaceFeedback: Awaited<ReturnType<typeof workspaceFeedbackSummary>> | null = null;
1362
+ const mailboxId = this.state.id.name;
1363
+ if (from.address && mailboxId && !block && !rules.destination) {
1364
+ try {
1365
+ workspaceFeedback = await workspaceFeedbackSummary(this.bindings.DB, mailboxId, from.address);
1366
+ } catch (error) {
1367
+ console.error(`Could not read workspace feedback for ${email.id}`, error);
1368
+ }
1369
+ }
1370
+ return {
1371
+ intel,
1372
+ delivery,
1373
+ screening,
1374
+ signals: classificationSignals({
1375
+ authentication,
1376
+ intel,
1377
+ firstTimeSender: security.firstTimeSender,
1378
+ warnings: security.warnings,
1379
+ feedback: from.address ? senderFeedbackSummary(sql, from.address) : null,
1380
+ workspaceFeedback,
1381
+ delivery,
1382
+ }),
1383
+ };
1384
+ }
1385
+
1386
+ /** Mail written to this address already reached the mailbox from at least two senders. */
1387
+ private isKnownAddressee(address: string) {
1388
+ const [row] = this.state.storage.sql.exec<{ senders: number }>(`
1389
+ select count(distinct lower(json_extract(message.from_json, '$[0].address'))) as senders
1390
+ from emails message
1391
+ where message.direction = 'incoming'
1392
+ and exists (
1393
+ select 1 from json_each(message.to_json) recipient
1394
+ where lower(json_extract(recipient.value, '$.address')) = ?1
1395
+ union all
1396
+ select 1 from json_each(message.cc_json) recipient
1397
+ where lower(json_extract(recipient.value, '$.address')) = ?1
1398
+ )
1399
+ `, address.toLocaleLowerCase()).toArray();
1400
+ return Number(row?.senders ?? 0) >= 2;
1401
+ }
1402
+
1403
+ private async mailboxMemberIds() {
1404
+ const mailboxId = this.state.id.name;
1405
+ if (!mailboxId) return [];
1406
+ const result = await this.bindings.DB
1407
+ .prepare("select user_id from mailbox_access where mailbox_id = ?")
1408
+ .bind(mailboxId)
1409
+ .all<{ user_id: string }>();
1410
+ return result.results.map((row) => row.user_id);
1411
+ }
1412
+
1413
+ /** Blocks win over rules, rules over AI; trusted senders and "never spam" rules override an AI spam verdict. */
1414
+ private inboundPlacement(
1415
+ screening: InboundScreening,
1416
+ classification: EmailAiClassification | null,
1417
+ ): InboundPlacement {
1418
+ const markReadUserIds = screening.markReadUserIds;
1419
+ if (screening.block) return { state: "spam", folderId: null, overrideFolder: false, markReadUserIds: [] };
1420
+ const destination = screening.rules.destination;
1421
+ if (destination?.kind === "spam") return { state: "spam", folderId: null, overrideFolder: false, markReadUserIds };
1422
+ if (destination?.kind === "archive") return { state: "archive", folderId: null, overrideFolder: false, markReadUserIds };
1423
+ if (destination?.kind === "folder") {
1424
+ return { state: null, folderId: destination.folderId, overrideFolder: true, markReadUserIds };
1425
+ }
1426
+ const spamAllowed = !screening.trusted && !screening.rules.neverSpam;
1427
+ if (classification?.spam && spamAllowed) {
1428
+ return { state: "spam", folderId: null, overrideFolder: false, markReadUserIds };
1429
+ }
1430
+ return { state: null, folderId: classification?.folderId ?? null, overrideFolder: false, markReadUserIds };
1431
+ }
1432
+
1433
+ /** The model from openworkspace.json; tests substitute one on the instance. */
1434
+ protected classificationModel(): AiModel | null {
1435
+ return aiModel(this.bindings, "classification");
1436
+ }
1437
+
1438
+ private async classifyInbound(rawMime: ArrayBuffer, signals: ClassificationSignals | null) {
1439
+ let ai: AiModel | null;
1440
+ try {
1441
+ ai = this.classificationModel();
1442
+ } catch (error) {
1443
+ throw new EmailAiClassificationError("The classification model is misconfigured", { cause: error });
1444
+ }
1445
+ if (!ai) return null;
1446
+ const configuration = this.aiConfiguration();
1447
+ const customFolders = this.db
1448
+ .select({ id: folders.id, name: folders.name })
1449
+ .from(folders)
1450
+ .orderBy(asc(folders.sortOrder), asc(folders.name))
1451
+ .all();
1452
+ return classifyInboundEmail({
1453
+ rawMime,
1454
+ folders: customFolders,
1455
+ configuration,
1456
+ signals,
1457
+ model: ai.id,
1458
+ run: aiObjectRunner(ai),
1459
+ });
1460
+ }
1461
+
1462
+ private insertClassifiedInboundEmail(
1463
+ email: NewEmail,
1464
+ classification: EmailAiClassification | null,
1465
+ placement: InboundPlacement,
1466
+ ) {
1467
+ email = prepareOfficePreviews(email);
1468
+ const stored = this.db.transaction((tx) => {
1469
+ const trustedFolder = placement.folderId
1470
+ ? tx
1471
+ .select({ id: folders.id })
1472
+ .from(folders)
1473
+ .where(eq(folders.id, placement.folderId))
1474
+ .all()[0]
1475
+ : null;
1476
+ const normalizedClassification = classification?.folderId
1477
+ && !tx.select({ id: folders.id }).from(folders).where(eq(folders.id, classification.folderId)).all()[0]
1478
+ ? { ...classification, folderId: null }
1479
+ : classification;
1480
+ const inserted = tx
1481
+ .insert(emails)
1482
+ .values({
1483
+ ...email,
1484
+ aiClassificationJson: normalizedClassification,
1485
+ })
1486
+ .onConflictDoNothing()
1487
+ .returning()
1488
+ .all()[0];
1489
+ if (!inserted) return null;
1490
+ insertOfficePreviewJobs(this.state.storage.sql, email);
1491
+ recordCorrespondents(this.state.storage.sql, email);
1492
+
1493
+ const activeConversation = and(
1494
+ eq(conversations.id, email.conversationId),
1495
+ eq(conversations.mailboxState, "active"),
1496
+ );
1497
+ if (placement.state) {
1498
+ tx.update(conversations)
1499
+ .set({ mailboxState: placement.state, folderId: null })
1500
+ .where(activeConversation)
1501
+ .run();
1502
+ } else if (trustedFolder) {
1503
+ tx.update(conversations)
1504
+ .set({ folderId: trustedFolder.id })
1505
+ .where(placement.overrideFolder ? activeConversation : and(activeConversation, isNull(conversations.folderId)))
1506
+ .run();
1507
+ }
1508
+ const readAt = new Date();
1509
+ for (const userId of placement.markReadUserIds) {
1510
+ tx.insert(emailReadStates).values({ userId, emailId: inserted.id, readAt }).onConflictDoNothing().run();
1511
+ }
1512
+ return tx
1513
+ .select()
1514
+ .from(emails)
1515
+ .where(eq(emails.id, inserted.id))
1516
+ .all()[0] ?? null;
1517
+ });
1518
+ this.recentParticipantSources = null;
1519
+ return stored;
1520
+ }
1521
+
1522
+ /** Lazy fill for messages stored before sender intelligence existed. */
1523
+ setEmailSenderIntel(emailId: string, intel: SenderIntel) {
1524
+ return this.db
1525
+ .update(emails)
1526
+ .set({ senderIntelJson: intel })
1527
+ .where(and(eq(emails.id, emailId), eq(emails.direction, "incoming"), isNull(emails.senderIntelJson)))
1528
+ .returning({ id: emails.id })
1529
+ .all().length > 0;
1530
+ }
1531
+
1532
+ /** Inbound/outbound counters for the given addresses (lowercased keys). */
1533
+ getSenderHistory(addresses: string[]) {
1534
+ return Object.fromEntries(senderHistory(this.state.storage.sql, addresses));
1535
+ }
1536
+
1537
+ /** Lazy backfill for messages stored before header parsing existed. */
1538
+ setEmailHeaderMetadata(
1539
+ emailId: string,
1540
+ metadata: {
1541
+ authenticationResultsJson: InboundAuthentication;
1542
+ listUnsubscribeJson: ListUnsubscribe | null;
1543
+ },
1544
+ ) {
1545
+ return this.db
1546
+ .update(emails)
1547
+ .set(metadata)
1548
+ .where(and(eq(emails.id, emailId), eq(emails.direction, "incoming")))
1549
+ .returning({ id: emails.id })
1550
+ .all().length > 0;
1551
+ }
1552
+
1553
+ /** Block, trust and report state for senders, and unsubscribe records for lists. */
1554
+ getMessageSafetyContext(addresses: string[], listKeys: string[]) {
1555
+ const keys = [...new Set(listKeys)].slice(0, 100);
1556
+ const rows = keys.length
1557
+ ? this.db.select().from(unsubscriptions).where(inArray(unsubscriptions.listKey, keys)).all()
1558
+ : [];
1559
+ return {
1560
+ senders: Object.fromEntries(senderStatuses(this.state.storage.sql, addresses)) as Record<string, SenderStatus>,
1561
+ unsubscriptions: Object.fromEntries(rows.map((row): [string, Unsubscription] => [row.listKey, {
1562
+ listKey: row.listKey,
1563
+ method: row.method,
1564
+ status: row.status,
1565
+ target: row.target,
1566
+ at: row.createdAt.getTime(),
1567
+ }])),
1568
+ };
1569
+ }
1570
+
1571
+ recordUnsubscription(input: {
1572
+ listKey: string;
1573
+ emailId: string;
1574
+ method: Unsubscription["method"];
1575
+ status: Unsubscription["status"];
1576
+ target: string | null;
1577
+ detail: string | null;
1578
+ userId: string;
1579
+ }): Unsubscription {
1580
+ const createdAt = new Date();
1581
+ const values = { ...input, createdAt };
1582
+ this.db.insert(unsubscriptions).values(values)
1583
+ .onConflictDoUpdate({ target: unsubscriptions.listKey, set: values })
1584
+ .run();
1585
+ this.publishUpdate();
1586
+ return {
1587
+ listKey: input.listKey,
1588
+ method: input.method,
1589
+ status: input.status,
1590
+ target: input.target,
1591
+ at: createdAt.getTime(),
1592
+ };
1593
+ }
1594
+
1595
+ /** Moves every Inbox or Archive conversation with mail from the pattern to Spam. */
1596
+ private moveSenderConversationsToSpam(pattern: string) {
1597
+ const predicate = blockPatternPredicate("lower(json_extract(message.from_json, '$[0].address'))", pattern);
1598
+ return this.state.storage.sql.exec<{ id: string }>(`
1599
+ update conversations
1600
+ set mailbox_state = 'spam', folder_id = null
1601
+ where mailbox_state in ('active', 'archive')
1602
+ and id in (
1603
+ select message.conversation_id from emails message
1604
+ where message.direction = 'incoming' and ${predicate.sql}
1605
+ )
1606
+ returning id
1607
+ `, ...predicate.bindings).toArray().length;
1608
+ }
1609
+
1610
+ blockSender(pattern: string, moveExisting: boolean) {
1611
+ const moved = this.db.transaction(() => {
1612
+ setSenderBlocked(this.state.storage.sql, pattern, true, Date.now());
1613
+ return moveExisting ? this.moveSenderConversationsToSpam(pattern) : 0;
1614
+ });
1615
+ this.recentParticipantSources = null;
1616
+ this.publishUpdate();
1617
+ return { block: blockFromPattern(pattern, Date.now(), false), moved };
1618
+ }
1619
+
1620
+ /** For a workspace-wide block: existing mail goes to Spam; the block itself lives in D1. */
1621
+ moveSenderToSpam(pattern: string) {
1622
+ const moved = this.moveSenderConversationsToSpam(pattern);
1623
+ if (moved) {
1624
+ this.recentParticipantSources = null;
1625
+ this.publishUpdate();
1626
+ }
1627
+ return moved;
1628
+ }
1629
+
1630
+ unblockSender(pattern: string) {
1631
+ const existed = this.state.storage.sql
1632
+ .exec("select 1 from senders where address = ? and blocked_at is not null", pattern)
1633
+ .toArray().length > 0;
1634
+ setSenderBlocked(this.state.storage.sql, pattern, false, Date.now());
1635
+ this.recentParticipantSources = null;
1636
+ this.publishUpdate();
1637
+ return existed;
1638
+ }
1639
+
1640
+ listBlockedSenders() {
1641
+ return listBlockedSenders(this.state.storage.sql);
1642
+ }
1643
+
1644
+ setSenderTrusted(address: string, trusted: boolean) {
1645
+ setSenderTrusted(this.state.storage.sql, address, trusted, Date.now());
1646
+ this.publishUpdate();
1647
+ return true;
1648
+ }
1649
+
1650
+ /** The newest incoming message of each conversation, for actions that need a sender. */
1651
+ latestIncomingEmails(conversationIds: string[]) {
1652
+ const ids = [...new Set(conversationIds)].slice(0, 100);
1653
+ if (!ids.length) return [];
1654
+ const rows = this.state.storage.sql.exec<{ id: string }>(`
1655
+ select (
1656
+ select message.id from emails message
1657
+ where message.conversation_id = requested.value and message.direction = 'incoming'
1658
+ order by message.timeline_at desc, message.id desc limit 1
1659
+ ) as id
1660
+ from json_each(?) requested
1661
+ `, stringListJson(ids)).toArray().flatMap((row) => row.id ? [row.id] : []);
1662
+ return rows.length ? this.db.select().from(emails).where(inArray(emails.id, rows)).all() : [];
1663
+ }
1664
+
1665
+ /**
1666
+ * Report spam or phishing: labels the message, records feedback for the
1667
+ * classifier, and moves its conversation to Spam. Never contacts the sender.
1668
+ */
1669
+ reportMessage(input: { emailId: string; kind: ReportKind; userId: string }) {
1670
+ const email = this.getEmail(input.emailId);
1671
+ if (!email || email.direction !== "incoming") return null;
1672
+ const address = email.fromJson[0]?.address ?? "";
1673
+ const moved = this.db.transaction((tx) => {
1674
+ tx.update(emails).set({ reportLabel: input.kind }).where(eq(emails.id, email.id)).run();
1675
+ if (address.includes("@")) {
1676
+ recordSenderFeedback(this.state.storage.sql, {
1677
+ id: createId("fbk"),
1678
+ address,
1679
+ emailId: email.id,
1680
+ label: input.kind,
1681
+ userId: input.userId,
1682
+ now: Date.now(),
1683
+ });
1684
+ }
1685
+ return tx.update(conversations)
1686
+ .set({ mailboxState: "spam", folderId: null })
1687
+ .where(and(
1688
+ eq(conversations.id, email.conversationId),
1689
+ inArray(conversations.mailboxState, ["active", "archive"]),
1690
+ ))
1691
+ .returning({ id: conversations.id })
1692
+ .all().length > 0;
1693
+ });
1694
+ this.recentParticipantSources = null;
1695
+ this.publishUpdate();
1696
+ return { email: this.getEmail(email.id)!, moved };
1697
+ }
1698
+
1699
+ /** "Not spam": back to Inbox, the sender becomes trusted, and the feedback is recorded. */
1700
+ markNotSpam(input: { emailId: string; userId: string }) {
1701
+ const email = this.getEmail(input.emailId);
1702
+ if (!email || email.direction !== "incoming") return null;
1703
+ const address = email.fromJson[0]?.address ?? "";
1704
+ const label: FeedbackLabel = "not_spam";
1705
+ const restored = this.db.transaction((tx) => {
1706
+ tx.update(emails).set({ reportLabel: null }).where(eq(emails.conversationId, email.conversationId)).run();
1707
+ if (address.includes("@")) {
1708
+ recordSenderFeedback(this.state.storage.sql, {
1709
+ id: createId("fbk"),
1710
+ address,
1711
+ emailId: email.id,
1712
+ label,
1713
+ userId: input.userId,
1714
+ now: Date.now(),
1715
+ });
1716
+ setSenderTrusted(this.state.storage.sql, address, true, Date.now());
1717
+ }
1718
+ return tx.update(conversations)
1719
+ .set({ mailboxState: "active" })
1720
+ .where(and(eq(conversations.id, email.conversationId), eq(conversations.mailboxState, "spam")))
1721
+ .returning({ id: conversations.id })
1722
+ .all().length > 0;
1723
+ });
1724
+ this.recentParticipantSources = null;
1725
+ this.publishUpdate();
1726
+ return { restored, address: address.includes("@") ? address : null };
1727
+ }
1728
+
1729
+ listRules() {
1730
+ return listMailRules(this.state.storage.sql);
1731
+ }
1732
+
1733
+ createRule(input: MailRuleInput) {
1734
+ const id = createId("rul");
1735
+ insertMailRule(this.state.storage.sql, id, input, Date.now());
1736
+ this.publishUpdate();
1737
+ return this.listRules().find((rule) => rule.id === id)!;
1738
+ }
1739
+
1740
+ updateRule(id: string, input: MailRuleInput) {
1741
+ const updated = updateMailRule(this.state.storage.sql, id, input, Date.now());
1742
+ if (updated) this.publishUpdate();
1743
+ return updated ? this.listRules().find((rule) => rule.id === id)! : null;
1744
+ }
1745
+
1746
+ deleteRule(id: string) {
1747
+ const deleted = deleteMailRule(this.state.storage.sql, id);
1748
+ if (deleted) this.publishUpdate();
1749
+ return deleted;
1750
+ }
1751
+
1752
+ reorderRules(ids: string[]) {
1753
+ const reordered = reorderMailRules(this.state.storage.sql, ids);
1754
+ if (reordered) this.publishUpdate();
1755
+ return reordered;
1756
+ }
1757
+
1758
+ /**
1759
+ * Applies one rule to mail already in the mailbox (newest 5,000 incoming
1760
+ * messages). Only Inbox conversations move, except Spam, which also takes
1761
+ * archived ones; "mark read" applies for the person who asked.
1762
+ */
1763
+ applyRuleToExisting(ruleId: string, userId: string) {
1764
+ const rule = this.listRules().find((candidate) => candidate.id === ruleId);
1765
+ if (!rule) return null;
1766
+ const rows = this.db
1767
+ .select({
1768
+ id: emails.id,
1769
+ conversationId: emails.conversationId,
1770
+ fromJson: emails.fromJson,
1771
+ toJson: emails.toJson,
1772
+ ccJson: emails.ccJson,
1773
+ subject: emails.subject,
1774
+ listId: emails.listId,
1775
+ attachmentsJson: emails.attachmentsJson,
1776
+ })
1777
+ .from(emails)
1778
+ .where(eq(emails.direction, "incoming"))
1779
+ .orderBy(desc(emails.timelineAt))
1780
+ .limit(5_000)
1781
+ .all();
1782
+ const matched = rows.filter((row) => ruleMatches(rule.conditions, emailRuleSubject(row)));
1783
+ const conversationIds = stringListJson([...new Set(matched.map((row) => row.conversationId))]);
1784
+ const destination = ruleDestination(rule.actions);
1785
+ const sql = this.state.storage.sql;
1786
+ const updated = this.db.transaction(() => {
1787
+ let count = 0;
1788
+ if (destination?.kind === "spam" && !rule.actions.neverSpam) {
1789
+ count = sql.exec(`update conversations set mailbox_state = 'spam', folder_id = null
1790
+ where id in (select value from json_each(?)) and mailbox_state in ('active', 'archive') returning id`,
1791
+ conversationIds).toArray().length;
1792
+ } else if (destination?.kind === "archive") {
1793
+ count = sql.exec(`update conversations set mailbox_state = 'archive', folder_id = null
1794
+ where id in (select value from json_each(?)) and mailbox_state = 'active' returning id`,
1795
+ conversationIds).toArray().length;
1796
+ } else if (destination?.kind === "folder" && this.getFolder(destination.folderId)?.kind === "custom") {
1797
+ count = sql.exec(`update conversations set folder_id = ?
1798
+ where id in (select value from json_each(?)) and mailbox_state = 'active' and has_incoming = 1
1799
+ and (folder_id is null or folder_id <> ?) returning id`,
1800
+ destination.folderId, conversationIds, destination.folderId).toArray().length;
1801
+ }
1802
+ if (rule.actions.markRead) {
1803
+ const readAt = Date.now();
1804
+ for (const row of matched) {
1805
+ sql.exec("insert into email_read_states (user_id, email_id, read_at) values (?, ?, ?) on conflict do nothing", userId, row.id, readAt);
1806
+ }
1807
+ }
1808
+ return count;
1809
+ });
1810
+ this.publishUpdate();
1811
+ return { matched: matched.length, updated };
1812
+ }
1813
+
1814
+ async submitOutgoing(email: NewEmail) {
1815
+ return this.state.blockConcurrencyWhile(async () => {
1816
+ if (email.direction !== "outgoing" || email.transportState !== "unconfirmed") {
1817
+ throw new Error("Only unconfirmed outgoing messages can be submitted");
1818
+ }
1819
+ if (!email.requestFingerprint) {
1820
+ throw new Error("An outgoing request fingerprint is required");
1821
+ }
1822
+ email = prepareOfficePreviews(email);
1823
+ if (hasPendingOfficePreviews(email)) await this.scheduleAlarm(Date.now() + ALARM_WAKE_DELAY_MS);
1824
+ const submission = this.db.transaction((tx): SubmitOutgoingResult => {
1825
+ const existing = tx
1826
+ .select()
1827
+ .from(emails)
1828
+ .where(eq(emails.id, email.id))
1829
+ .all()[0];
1830
+ if (existing) {
1831
+ const sameRequest = existing.direction === "outgoing"
1832
+ && existing.requestFingerprint === email.requestFingerprint;
1833
+ return {
1834
+ outcome: sameRequest ? "existing" : "conflict",
1835
+ email: existing,
1836
+ };
1837
+ }
1838
+
1839
+ tx.insert(emails).values(email).run();
1840
+ insertOfficePreviewJobs(this.state.storage.sql, email);
1841
+ recordCorrespondents(this.state.storage.sql, email);
1842
+ const inserted = tx
1843
+ .select()
1844
+ .from(emails)
1845
+ .where(eq(emails.id, email.id))
1846
+ .all()[0];
1847
+ if (!inserted) throw new Error("Outgoing message was not persisted");
1848
+ return { outcome: "inserted", email: inserted };
1849
+ });
1850
+ if (submission.outcome !== "inserted") return submission;
1851
+ this.recentParticipantSources = null;
1852
+ const result = {
1853
+ ...submission,
1854
+ email: await this.deliverOutgoing(submission.email.id),
1855
+ };
1856
+ this.publishUpdate();
1857
+ return result;
1858
+ });
1859
+ }
1860
+
1861
+ async resendOutgoing(id: string) {
1862
+ return this.state.blockConcurrencyWhile(async () => {
1863
+ const result = this.db
1864
+ .update(emails)
1865
+ .set({
1866
+ transportState: "unconfirmed",
1867
+ messageIdHeader: null,
1868
+ transportError: null,
1869
+ deliveryStatusJson: [],
1870
+ })
1871
+ .where(and(
1872
+ eq(emails.id, id),
1873
+ eq(emails.direction, "outgoing"),
1874
+ inArray(emails.transportState, ["failed", "unconfirmed"]),
1875
+ ))
1876
+ .returning({ id: emails.id })
1877
+ .all();
1878
+ if (!result.length) return null;
1879
+ const email = await this.deliverOutgoing(id);
1880
+ this.publishUpdate();
1881
+ return email;
1882
+ });
1883
+ }
1884
+
1885
+ seedMailbox(
1886
+ folderValues: NewFolder[],
1887
+ emailValues: NewEmail[],
1888
+ conversationValues: Array<{
1889
+ id: string;
1890
+ mailboxState?: MailboxState;
1891
+ folderId?: string | null;
1892
+ }> = [],
1893
+ senderValues: Array<typeof senders.$inferInsert> = [],
1894
+ ruleValues: Array<MailRuleInput & { id: string }> = [],
1895
+ ) {
1896
+ this.db.transaction((tx) => {
1897
+ for (const rule of ruleValues) {
1898
+ const exists = this.state.storage.sql.exec("select 1 from mail_rules where id = ?", rule.id).toArray().length;
1899
+ if (!exists) insertMailRule(this.state.storage.sql, rule.id, rule, Date.now());
1900
+ }
1901
+ // Prior history first: seeded messages then add to it exactly once.
1902
+ for (const sender of senderValues) {
1903
+ tx.insert(senders).values(sender).onConflictDoNothing().run();
1904
+ }
1905
+ for (const folder of folderValues) {
1906
+ tx.insert(folders).values(folder).onConflictDoNothing().run();
1907
+ }
1908
+ for (const email of emailValues) {
1909
+ const inserted = tx.insert(emails).values(email).onConflictDoNothing().returning({ id: emails.id }).all();
1910
+ if (inserted.length) recordCorrespondents(this.state.storage.sql, email);
1911
+ }
1912
+ for (const conversation of conversationValues) {
1913
+ tx.update(conversations)
1914
+ .set({
1915
+ mailboxState: conversation.mailboxState,
1916
+ folderId: conversation.folderId,
1917
+ })
1918
+ .where(eq(conversations.id, conversation.id))
1919
+ .run();
1920
+ }
1921
+ });
1922
+ this.recentParticipantSources = null;
1923
+ return { folders: folderValues.length, emails: emailValues.length };
1924
+ }
1925
+
1926
+ /**
1927
+ * Demo logins start from the same safety state every time: no reports,
1928
+ * unsubscribes, trust or extra rules, and only the seeded blocks.
1929
+ */
1930
+ resetDemoSafetyState(input: {
1931
+ emailIds: string[];
1932
+ reportLabels: Record<string, "spam" | "phishing">;
1933
+ blockedPatterns: Array<{ pattern: string; at: number }>;
1934
+ ruleIds: string[];
1935
+ }) {
1936
+ const sql = this.state.storage.sql;
1937
+ this.db.transaction(() => {
1938
+ sql.exec("update emails set report_label = null where id in (select value from json_each(?))", stringListJson(input.emailIds));
1939
+ for (const [emailId, label] of Object.entries(input.reportLabels)) {
1940
+ sql.exec("update emails set report_label = ? where id = ?", label, emailId);
1941
+ }
1942
+ sql.exec("delete from unsubscriptions");
1943
+ sql.exec("delete from sender_feedback");
1944
+ sql.exec("update senders set trusted_at = null, reported_at = null, blocked_at = null");
1945
+ for (const block of input.blockedPatterns) setSenderBlocked(sql, block.pattern, true, block.at);
1946
+ sql.exec("delete from mail_rules where id not in (select value from json_each(?))", stringListJson(input.ruleIds));
1947
+ });
1948
+ this.recentParticipantSources = null;
1949
+ }
1950
+
1951
+ bulkUpdateConversations(
1952
+ conversationIds: string[],
1953
+ sourceFolderId: string,
1954
+ update: ConversationUpdate,
1955
+ ) {
1956
+ const sourceFolder = this.getFolder(sourceFolderId);
1957
+ if (!sourceFolder) return null;
1958
+ if (update.folderId) {
1959
+ const targetFolder = this.getFolder(update.folderId);
1960
+ if (!targetFolder || targetFolder.kind !== "custom") return null;
1961
+ }
1962
+ const candidates = [...new Set(conversationIds)];
1963
+ if (!candidates.length) return 0;
1964
+ const assignments: string[] = [];
1965
+ const values: Array<string | null> = [];
1966
+ // A custom folder is a classification inside the active Inbox
1967
+ // distribution, so assigning one also restores the conversation.
1968
+ const mailboxState = update.folderId
1969
+ ? "active" as const
1970
+ : update.mailboxState;
1971
+ if (mailboxState !== undefined) {
1972
+ assignments.push("mailbox_state = ?");
1973
+ values.push(mailboxState);
1974
+ }
1975
+ if (update.folderId !== undefined) {
1976
+ assignments.push("folder_id = ?");
1977
+ values.push(update.folderId);
1978
+ }
1979
+ if (!assignments.length) return 0;
1980
+ const membership = folderMembership(sourceFolder);
1981
+ const candidatesJson = stringListJson(candidates);
1982
+ // Setting folderId means placing the conversation in the Inbox
1983
+ // distribution. Outgoing-only conversations must stay in Sent.
1984
+ const targetPredicate = update.folderId !== undefined
1985
+ ? "and c.has_incoming = 1"
1986
+ : "";
1987
+
1988
+ // Keep the source-folder guard in the update itself so a stale shared
1989
+ // selection cannot overwrite a teammate's newer move.
1990
+ const result = this.state.storage.sql.exec<{ id: string }>(`
1991
+ update conversations as c
1992
+ set ${assignments.join(", ")}
1993
+ where c.id in (
1994
+ select cast(value as text) from json_each(?)
1995
+ )
1996
+ and ${membership.predicate}
1997
+ ${targetPredicate}
1998
+ returning id
1999
+ `, ...values, candidatesJson, ...membership.bindings).toArray();
2000
+ if (result.length) this.publishUpdate();
2001
+ return result.length;
2002
+ }
2003
+
2004
+ async permanentlyDeleteConversations(conversationIds: string[]) {
2005
+ const candidates = [...new Set(conversationIds)];
2006
+ if (!candidates.length) {
2007
+ return { outcome: "not_found" as const, deletedCount: 0 };
2008
+ }
2009
+ const candidatesJson = stringListJson(candidates);
2010
+ const storedConversations = this.db
2011
+ .select({ id: conversations.id, mailboxState: conversations.mailboxState })
2012
+ .from(conversations)
2013
+ .where(sql`${conversations.id} in (
2014
+ select cast(value as text) from json_each(${candidatesJson})
2015
+ )`)
2016
+ .all();
2017
+ if (storedConversations.length !== candidates.length) {
2018
+ return { outcome: "not_found" as const, deletedCount: 0 };
2019
+ }
2020
+ if (storedConversations.some((conversation) => conversation.mailboxState !== "trash")) {
2021
+ return { outcome: "not_in_trash" as const, deletedCount: 0 };
2022
+ }
2023
+
2024
+ const storedEmails = this.db
2025
+ .select({
2026
+ id: emails.id,
2027
+ direction: emails.direction,
2028
+ bodyHtmlR2Key: emails.bodyHtmlR2Key,
2029
+ rawMimeR2Key: emails.rawMimeR2Key,
2030
+ attachmentsJson: emails.attachmentsJson,
2031
+ })
2032
+ .from(emails)
2033
+ .where(sql`${emails.conversationId} in (
2034
+ select cast(value as text) from json_each(${candidatesJson})
2035
+ )`)
2036
+ .all();
2037
+ const incomingEmailIds = new Set(
2038
+ storedEmails.flatMap((email) =>
2039
+ email.direction === "incoming" ? [email.id] : []
2040
+ ),
2041
+ );
2042
+ const pendingInboundIds = incomingEmailIds.size
2043
+ ? this.db
2044
+ .select({ id: pendingInbound.id })
2045
+ .from(pendingInbound)
2046
+ .all()
2047
+ .flatMap((job) =>
2048
+ incomingEmailIds.has(inboundMessageId(job.id)) ? [job.id] : []
2049
+ )
2050
+ : [];
2051
+ const pendingInboundIdsJson = stringListJson(pendingInboundIds);
2052
+ const objectKeys = [...new Set(storedEmails.flatMap((email) => [
2053
+ ...(email.bodyHtmlR2Key ? [email.bodyHtmlR2Key] : []),
2054
+ ...(email.rawMimeR2Key ? [email.rawMimeR2Key] : []),
2055
+ ...email.attachmentsJson.flatMap((attachment) => [
2056
+ attachment.r2Key,
2057
+ ...(officePreviewExtension(attachment.filename) ? [officePreviewKey(attachment.r2Key)] : []),
2058
+ ]),
2059
+ ]))];
2060
+
2061
+ const referencedObjectKeys = this.referencedObjectKeys(
2062
+ objectKeys,
2063
+ candidates,
2064
+ );
2065
+ const ownedObjectKeys = objectKeys.filter(
2066
+ (objectKey) => !referencedObjectKeys.has(objectKey),
2067
+ );
2068
+ const cleanupAt = new Date();
2069
+ // Schedule first so the cleanup rows committed below can never be left
2070
+ // without a retry after an interruption. The short delay prevents the
2071
+ // alarm from waking on setAlarm's await before the rows are inserted.
2072
+ const cleanupAlarmAt = cleanupAt.getTime() + ALARM_WAKE_DELAY_MS;
2073
+ if (ownedObjectKeys.length) {
2074
+ await this.scheduleAlarm(cleanupAlarmAt);
2075
+ }
2076
+ this.db.transaction((tx) => {
2077
+ tx.run(sql`
2078
+ delete from pending_office_previews where email_id in (
2079
+ select id from emails where conversation_id in (
2080
+ select cast(value as text) from json_each(${candidatesJson})
2081
+ )
2082
+ )
2083
+ `);
2084
+ for (const objectKey of ownedObjectKeys) {
2085
+ tx.insert(pendingObjectDeletions)
2086
+ .values({ objectKey, nextAttemptAt: cleanupAt })
2087
+ .onConflictDoNothing()
2088
+ .run();
2089
+ }
2090
+ if (pendingInboundIds.length) {
2091
+ tx.delete(pendingInbound)
2092
+ .where(sql`${pendingInbound.id} in (
2093
+ select cast(value as text) from json_each(${pendingInboundIdsJson})
2094
+ )`)
2095
+ .run();
2096
+ }
2097
+ tx.run(sql`
2098
+ delete from email_search where conversation_id in (
2099
+ select cast(value as text) from json_each(${candidatesJson})
2100
+ )
2101
+ `);
2102
+ tx.run(sql`
2103
+ delete from email_read_states
2104
+ where email_id in (
2105
+ select id from emails where conversation_id in (
2106
+ select cast(value as text) from json_each(${candidatesJson})
2107
+ )
2108
+ )
2109
+ `);
2110
+ tx.delete(conversations)
2111
+ .where(sql`${conversations.id} in (
2112
+ select cast(value as text) from json_each(${candidatesJson})
2113
+ )`)
2114
+ .run();
2115
+ tx.delete(emails)
2116
+ .where(sql`${emails.conversationId} in (
2117
+ select cast(value as text) from json_each(${candidatesJson})
2118
+ )`)
2119
+ .run();
2120
+ });
2121
+ this.recentParticipantSources = null;
2122
+ this.publishUpdate();
2123
+ return {
2124
+ outcome: "deleted" as const,
2125
+ deletedCount: storedConversations.length,
2126
+ };
2127
+ }
2128
+
2129
+ private referencedObjectKeys(
2130
+ objectKeys: string[],
2131
+ excludedConversationIds: string[],
2132
+ ) {
2133
+ if (!objectKeys.length) return new Set<string>();
2134
+ const rows = this.state.storage.sql.exec<{ object_key: string }>(`
2135
+ with
2136
+ candidate_keys(object_key) as (
2137
+ select cast(value as text) from json_each(?)
2138
+ ),
2139
+ excluded_conversations(id) as (
2140
+ select cast(value as text) from json_each(?)
2141
+ )
2142
+ select candidate.object_key
2143
+ from candidate_keys candidate
2144
+ where exists (
2145
+ select 1
2146
+ from emails message
2147
+ where message.conversation_id not in (
2148
+ select id from excluded_conversations
2149
+ )
2150
+ and (
2151
+ message.body_html_r2_key = candidate.object_key
2152
+ or message.raw_mime_r2_key = candidate.object_key
2153
+ or exists (
2154
+ select 1
2155
+ from json_each(message.attachments_json) attachment
2156
+ where json_extract(attachment.value, '$.r2Key') = candidate.object_key
2157
+ or json_extract(attachment.value, '$.r2Key') || '.preview.pdf' = candidate.object_key
2158
+ )
2159
+ )
2160
+ )
2161
+ `, stringListJson(objectKeys), stringListJson(excludedConversationIds)).toArray();
2162
+ return new Set(rows.map((row) => row.object_key));
2163
+ }
2164
+
2165
+ updateTransport(id: string, update: TransportUpdate) {
2166
+ const result = this.db
2167
+ .update(emails)
2168
+ .set({
2169
+ transportState: update.state,
2170
+ messageIdHeader: update.messageIdHeader,
2171
+ transportError: update.error,
2172
+ })
2173
+ .where(and(eq(emails.id, id), eq(emails.direction, "outgoing")))
2174
+ .returning({ id: emails.id })
2175
+ .all();
2176
+ return result.length > 0;
2177
+ }
2178
+
2179
+ recordDeliveryStatus(
2180
+ messageIdHeader: string,
2181
+ status: RecipientDeliveryStatus,
2182
+ ) {
2183
+ const email = this.getEmailByMessageId(messageIdHeader);
2184
+ if (!email || email.direction !== "outgoing") return "not_found" as const;
2185
+ const existing = email.deliveryStatusJson.find(
2186
+ (item) => item.recipient === status.recipient,
2187
+ );
2188
+ if (existing?.eventId === status.eventId) return "duplicate" as const;
2189
+ if (existing && existing.eventAt > status.eventAt) return "stale" as const;
2190
+
2191
+ const next = [
2192
+ ...email.deliveryStatusJson.filter(
2193
+ (item) => item.recipient !== status.recipient,
2194
+ ),
2195
+ status,
2196
+ ];
2197
+ this.db
2198
+ .update(emails)
2199
+ .set({ deliveryStatusJson: next })
2200
+ .where(eq(emails.id, email.id))
2201
+ .run();
2202
+ this.publishUpdate();
2203
+ return "updated" as const;
2204
+ }
2205
+
2206
+ async alarm() {
2207
+ const now = Date.now();
2208
+ await this.processInboundJobs(now);
2209
+ await dispatchOfficePreviewJobs(this.state.storage.sql, this.bindings, this.state.id.name ?? "unknown-mailbox");
2210
+ await this.processObjectDeletionJobs(now);
2211
+ await this.scheduleRemainingWork();
2212
+ }
2213
+
2214
+ /**
2215
+ * One message reaches a person once: a copy from a team list is dropped when
2216
+ * the same message already arrived directly (or the reverse), and a list
2217
+ * copy of the person's own post is dropped next to the sent message.
2218
+ */
2219
+ private isDuplicateDelivery(job: PendingInbound, rawMime: ArrayBuffer) {
2220
+ const messageId = rawMessageId(rawMime);
2221
+ if (!messageId) return false;
2222
+ const existing = this.getEmailByMessageId(messageId);
2223
+ if (!existing) return false;
2224
+ return existing.direction === "incoming" || Boolean(job.listAddress);
2225
+ }
2226
+
2227
+ private async processInboundJobs(now: number) {
2228
+ const jobs = this.db
2229
+ .select()
2230
+ .from(pendingInbound)
2231
+ .where(lte(pendingInbound.nextAttemptAt, new Date(now)))
2232
+ .orderBy(asc(pendingInbound.nextAttemptAt), asc(pendingInbound.receivedAt))
2233
+ // MIME parsing can be memory-heavy; the next due row schedules a quick
2234
+ // follow-up, so one delivery per wake keeps peak memory bounded.
2235
+ .limit(INBOUND_ALARM_BATCH_SIZE)
2236
+ .all();
2237
+ for (const job of jobs) {
2238
+ const messageId = inboundMessageId(job.id);
2239
+ const existing = this.getEmail(messageId);
2240
+ if (existing) {
2241
+ await this.finishStoredInbound(job, existing, now, false);
2242
+ continue;
2243
+ }
2244
+
2245
+ try {
2246
+ const rawMime = await readInboundRawMime(this.bindings, job);
2247
+ if (this.isDuplicateDelivery(job, rawMime)) {
2248
+ this.db.delete(pendingInbound).where(eq(pendingInbound.id, job.id)).run();
2249
+ continue;
2250
+ }
2251
+ const prepared = await prepareInboundEmail({
2252
+ env: this.bindings,
2253
+ mailboxId: this.state.id.name ?? "unknown-mailbox",
2254
+ job,
2255
+ rawMime,
2256
+ resolveParent: (inReplyTo, references) =>
2257
+ this.resolveParent(inReplyTo, references),
2258
+ });
2259
+ const { intel, delivery, signals, screening } = await this.inboundSenderSignals(prepared.email, prepared.linkDomains);
2260
+ const screeningJson: EmailScreening | null = screening.block || screening.rules.ruleIds.length
2261
+ ? {
2262
+ blocked: screening.block ? (screening.block.workspace ? "workspace" : "mailbox") : null,
2263
+ ruleIds: screening.rules.ruleIds,
2264
+ ruleDestination: Boolean(screening.rules.destination),
2265
+ }
2266
+ : null;
2267
+ const email: NewEmail = { ...prepared.email, senderIntelJson: intel, deliveryJson: delivery, screeningJson };
2268
+ let classification: EmailAiClassification | null = null;
2269
+ // Blocked senders and rules that choose a destination need no model.
2270
+ const skipAi = Boolean(screening.block || screening.rules.destination);
2271
+ try {
2272
+ if (!skipAi) classification = await this.classifyInbound(rawMime, signals);
2273
+ } catch (error) {
2274
+ if (!(error instanceof EmailAiClassificationError)) throw error;
2275
+ const aiAttempts = job.aiAttempts + 1;
2276
+ if (aiAttempts < MAILBOX_AI_MAX_ATTEMPTS) {
2277
+ this.db
2278
+ .update(pendingInbound)
2279
+ .set({
2280
+ aiAttempts,
2281
+ nextAttemptAt: new Date(now + retryDelay(aiAttempts)),
2282
+ })
2283
+ .where(eq(pendingInbound.id, job.id))
2284
+ .run();
2285
+ console.error(
2286
+ `Could not classify inbound delivery ${job.id}; retrying`,
2287
+ error,
2288
+ );
2289
+ continue;
2290
+ }
2291
+ console.error(
2292
+ `Could not classify inbound delivery ${job.id}; delivering to Inbox`,
2293
+ error,
2294
+ );
2295
+ }
2296
+ const stored = this.insertClassifiedInboundEmail(
2297
+ email,
2298
+ classification,
2299
+ this.inboundPlacement(screening, classification),
2300
+ );
2301
+ if (!stored) {
2302
+ throw new Error("Parsed inbound email was not persisted");
2303
+ }
2304
+ await this.finishStoredInbound(job, stored, now, true);
2305
+ } catch (error) {
2306
+ if (error instanceof UnprocessableInboundEmailError) {
2307
+ const fallback = prepareUnprocessableInboundEmail({
2308
+ job,
2309
+ reason: error.message,
2310
+ rawMimeR2Key: job.rawObjectKey,
2311
+ });
2312
+ const stored = await this.insertEmail(fallback);
2313
+ if (!stored) {
2314
+ throw new Error("Inbound fallback email was not persisted", {
2315
+ cause: error,
2316
+ });
2317
+ }
2318
+ await this.finishStoredInbound(job, stored, now, true);
2319
+ console.error(`Stored fallback for inbound delivery ${job.id}`, error);
2320
+ continue;
2321
+ }
2322
+ if (now - job.receivedAt.getTime() >= MAX_INBOUND_RETRY_AGE_MS) {
2323
+ const fallback = prepareUnprocessableInboundEmail({
2324
+ job,
2325
+ reason: "Message processing remained unavailable for 24 hours",
2326
+ rawMimeR2Key: error instanceof MissingRawMimeError
2327
+ ? null
2328
+ : job.rawObjectKey,
2329
+ });
2330
+ const stored = await this.insertEmail(fallback);
2331
+ if (!stored) {
2332
+ throw new Error("Inbound infrastructure fallback was not persisted", {
2333
+ cause: error,
2334
+ });
2335
+ }
2336
+ await this.finishStoredInbound(job, stored, now, true);
2337
+ console.error(
2338
+ `Stored infrastructure fallback for inbound delivery ${job.id}`,
2339
+ error,
2340
+ );
2341
+ continue;
2342
+ }
2343
+ const attempts = job.attempts + 1;
2344
+ this.db
2345
+ .update(pendingInbound)
2346
+ .set({
2347
+ attempts,
2348
+ nextAttemptAt: new Date(now + retryDelay(attempts)),
2349
+ })
2350
+ .where(eq(pendingInbound.id, job.id))
2351
+ .run();
2352
+ console.error(`Could not ingest inbound delivery ${job.id}`, error);
2353
+ }
2354
+ }
2355
+ }
2356
+
2357
+ private async processObjectDeletionJobs(now: number) {
2358
+ const jobs = this.db
2359
+ .select()
2360
+ .from(pendingObjectDeletions)
2361
+ .where(lte(pendingObjectDeletions.nextAttemptAt, new Date(now)))
2362
+ .orderBy(asc(pendingObjectDeletions.nextAttemptAt))
2363
+ .limit(OBJECT_DELETION_ALARM_BATCH_SIZE)
2364
+ .all();
2365
+ if (!jobs.length) return;
2366
+ const objectKeys = jobs.map((job) => job.objectKey);
2367
+ const referencedObjectKeys = this.referencedObjectKeys(objectKeys, []);
2368
+ const retainedKeys = objectKeys.filter((key) => referencedObjectKeys.has(key));
2369
+ if (retainedKeys.length) {
2370
+ this.db
2371
+ .delete(pendingObjectDeletions)
2372
+ .where(inArray(pendingObjectDeletions.objectKey, retainedKeys))
2373
+ .run();
2374
+ }
2375
+ const deletableJobs = jobs.filter(
2376
+ (job) => !referencedObjectKeys.has(job.objectKey),
2377
+ );
2378
+ if (!deletableJobs.length) return;
2379
+ const deletableKeys = deletableJobs.map((job) => job.objectKey);
2380
+ try {
2381
+ await this.bindings.MAIL_STORAGE.delete(deletableKeys);
2382
+ this.db
2383
+ .delete(pendingObjectDeletions)
2384
+ .where(inArray(pendingObjectDeletions.objectKey, deletableKeys))
2385
+ .run();
2386
+ } catch (error) {
2387
+ for (const job of deletableJobs) {
2388
+ const attempts = job.attempts + 1;
2389
+ this.db
2390
+ .update(pendingObjectDeletions)
2391
+ .set({
2392
+ attempts,
2393
+ nextAttemptAt: new Date(now + retryDelay(attempts)),
2394
+ })
2395
+ .where(eq(pendingObjectDeletions.objectKey, job.objectKey))
2396
+ .run();
2397
+ }
2398
+ console.error("Could not delete permanent message objects", error);
2399
+ }
2400
+ }
2401
+
2402
+ private async scheduleRemainingWork() {
2403
+ const nextInbound = this.db
2404
+ .select({ nextAttemptAt: pendingInbound.nextAttemptAt })
2405
+ .from(pendingInbound)
2406
+ .orderBy(asc(pendingInbound.nextAttemptAt))
2407
+ .limit(1)
2408
+ .all()[0];
2409
+ const nextObjectDeletion = this.db
2410
+ .select({ nextAttemptAt: pendingObjectDeletions.nextAttemptAt })
2411
+ .from(pendingObjectDeletions)
2412
+ .orderBy(asc(pendingObjectDeletions.nextAttemptAt))
2413
+ .limit(1)
2414
+ .all()[0];
2415
+ const officePreviewAttempt = nextOfficePreviewAttempt(this.state.storage.sql);
2416
+ const nextAttemptAt = [nextInbound, nextObjectDeletion,
2417
+ ...(officePreviewAttempt === undefined ? [] : [{ nextAttemptAt: new Date(officePreviewAttempt) }]),
2418
+ ]
2419
+ .flatMap((job) => job ? [job.nextAttemptAt.getTime()] : [])
2420
+ .reduce<number | null>(
2421
+ (earliest, value) => earliest === null ? value : Math.min(earliest, value),
2422
+ null,
2423
+ );
2424
+ if (nextAttemptAt !== null) {
2425
+ await this.scheduleAlarm(
2426
+ Math.max(Date.now() + ALARM_WAKE_DELAY_MS, nextAttemptAt),
2427
+ );
2428
+ }
2429
+ }
2430
+
2431
+ private async deliverOutgoing(id: string) {
2432
+ const message = this.getEmail(id);
2433
+ if (
2434
+ !message
2435
+ || message.direction !== "outgoing"
2436
+ || message.transportState !== "unconfirmed"
2437
+ ) {
2438
+ throw new Error("Unconfirmed outgoing message was not found");
2439
+ }
2440
+ let delivery: Awaited<ReturnType<typeof prepareOutboundDelivery>>;
2441
+ try {
2442
+ delivery = await prepareOutboundDelivery(this.bindings, message);
2443
+ } catch (error) {
2444
+ this.updateTransport(message.id, {
2445
+ state: "failed",
2446
+ error: errorMessage(error),
2447
+ });
2448
+ const failed = this.getEmail(id);
2449
+ if (!failed) throw new Error("Failed message was not persisted");
2450
+ return failed;
2451
+ }
2452
+ try {
2453
+ const result = await this.bindings.EMAIL.send(delivery);
2454
+ this.updateTransport(message.id, {
2455
+ state: "submitted",
2456
+ messageIdHeader: result.messageId,
2457
+ error: null,
2458
+ });
2459
+ } catch (error) {
2460
+ this.updateTransport(message.id, {
2461
+ state: "unconfirmed",
2462
+ error: errorMessage(error),
2463
+ });
2464
+ }
2465
+ const delivered = this.getEmail(id);
2466
+ if (!delivered) throw new Error("Submitted message was not persisted");
2467
+ return delivered;
2468
+ }
2469
+
2470
+ private async scheduleAlarm(at: number) {
2471
+ const current = await this.state.storage.getAlarm();
2472
+ if (current === null || current > at) await this.state.storage.setAlarm(at);
2473
+ }
2474
+
2475
+ /**
2476
+ * Message-ID is intentionally non-unique. Ordered header arrays decide which
2477
+ * locally known copy is the parent; References fall back newest-to-oldest.
2478
+ */
2479
+ resolveParent(inReplyTo: string[], references: string[]) {
2480
+ const candidates = [...new Set([...inReplyTo, ...references])];
2481
+ if (!candidates.length) return null;
2482
+ const rows = this.db
2483
+ .select()
2484
+ .from(emails)
2485
+ .where(inArray(emails.messageIdHeader, candidates))
2486
+ .orderBy(desc(emails.timelineAt))
2487
+ .all();
2488
+ const byMessageId = new Map<string, (typeof rows)[number]>();
2489
+ for (const row of rows) {
2490
+ if (row.messageIdHeader && !byMessageId.has(row.messageIdHeader)) {
2491
+ byMessageId.set(row.messageIdHeader, row);
2492
+ }
2493
+ }
2494
+ for (const messageId of inReplyTo) {
2495
+ const row = byMessageId.get(messageId);
2496
+ if (row) return row;
2497
+ }
2498
+ for (const messageId of references.toReversed()) {
2499
+ const row = byMessageId.get(messageId);
2500
+ if (row) return row;
2501
+ }
2502
+ return null;
2503
+ }
2504
+ }
2505
+
2506
+ export function mailboxStub(env: Env, mailboxId: string) {
2507
+ return env.MAILBOX.getByName(mailboxId);
2508
+ }