@open-workspace/email 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -2
  3. package/alchemy.ts +126 -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,353 @@
1
+ import {
2
+ index,
3
+ integer,
4
+ primaryKey,
5
+ sqliteTable,
6
+ text,
7
+ uniqueIndex,
8
+ } from "drizzle-orm/sqlite-core";
9
+ import { transportStates } from "../../shared/mail";
10
+ import type { MailRuleActions, MailRuleConditions } from "../../shared/mail-rules";
11
+ import { mailboxStates } from "./folder-model";
12
+ import type {
13
+ DeliveryFacts,
14
+ EmailAiClassification,
15
+ EmailScreening,
16
+ ListUnsubscribe,
17
+ MailAddress,
18
+ RecipientDeliveryStatus,
19
+ SenderIntel,
20
+ StoredAttachment,
21
+ StoredEmailAuthentication,
22
+ } from "./model";
23
+
24
+ /**
25
+ * Only custom classification folders are stored. Inbox, Sent, Archive, Spam,
26
+ * and Trash are computed views over `conversations`.
27
+ */
28
+ export const folders = sqliteTable(
29
+ "folders",
30
+ {
31
+ id: text("id").primaryKey(),
32
+ name: text("name").notNull(),
33
+ sortOrder: integer("sort_order").notNull().default(100),
34
+ },
35
+ (table) => [
36
+ uniqueIndex("folders_name_unique").on(table.name),
37
+ index("folders_order_idx").on(table.sortOrder, table.name),
38
+ ],
39
+ );
40
+
41
+ /**
42
+ * A row exists only while raw MIME still needs to be materialized as an email.
43
+ * Deterministic MIME errors are converted into a visible fallback message;
44
+ * this queue is reserved for retryable infrastructure failures.
45
+ */
46
+ export const pendingInbound = sqliteTable(
47
+ "pending_inbound",
48
+ {
49
+ id: text("id").primaryKey(),
50
+ rawObjectKey: text("raw_object_key").notNull(),
51
+ envelopeFrom: text("envelope_from").notNull(),
52
+ envelopeTo: text("envelope_to").notNull(),
53
+ /** The workspace mailing list this copy came through, if any. */
54
+ listAddress: text("list_address"),
55
+ receivedAt: integer("received_at", { mode: "timestamp_ms" }).notNull(),
56
+ attempts: integer("attempts").notNull().default(0),
57
+ aiAttempts: integer("ai_attempts").notNull().default(0),
58
+ nextAttemptAt: integer("next_attempt_at", { mode: "timestamp_ms" }).notNull(),
59
+ },
60
+ (table) => [
61
+ index("pending_inbound_next_attempt_idx").on(
62
+ table.nextAttemptAt,
63
+ table.receivedAt,
64
+ ),
65
+ ],
66
+ );
67
+
68
+ /**
69
+ * R2 cleanup is retried independently after a permanent conversation delete.
70
+ * The row is committed in the same SQLite transaction as the metadata delete,
71
+ * so a Worker interruption cannot leave untracked message objects behind.
72
+ */
73
+ export const pendingObjectDeletions = sqliteTable(
74
+ "pending_object_deletions",
75
+ {
76
+ objectKey: text("object_key").primaryKey(),
77
+ attempts: integer("attempts").notNull().default(0),
78
+ nextAttemptAt: integer("next_attempt_at", { mode: "timestamp_ms" }).notNull(),
79
+ },
80
+ (table) => [
81
+ index("pending_object_deletions_next_attempt_idx").on(table.nextAttemptAt),
82
+ ],
83
+ );
84
+
85
+ export const pendingOfficePreviews = sqliteTable(
86
+ "pending_office_previews",
87
+ {
88
+ sourceKey: text("source_key").primaryKey(),
89
+ emailId: text("email_id").notNull(),
90
+ attachmentId: text("attachment_id").notNull(),
91
+ attempts: integer("attempts").notNull().default(0),
92
+ nextAttemptAt: integer("next_attempt_at", { mode: "timestamp_ms" }).notNull(),
93
+ },
94
+ (table) => [index("pending_office_previews_next_attempt_idx").on(table.nextAttemptAt)],
95
+ );
96
+
97
+ /**
98
+ * One row is one message visible in this Durable Object's mailbox. RFC
99
+ * threading metadata and message content are immutable after insertion.
100
+ */
101
+ export const emails = sqliteTable(
102
+ "emails",
103
+ {
104
+ id: text("id").primaryKey(),
105
+ requestFingerprint: text("request_fingerprint"),
106
+ conversationId: text("conversation_id").notNull(),
107
+ direction: text("direction", { enum: ["incoming", "outgoing"] }).notNull(),
108
+
109
+ messageIdHeader: text("message_id_header"),
110
+ inReplyToJson: text("in_reply_to_json", { mode: "json" })
111
+ .$type<string[]>()
112
+ .notNull()
113
+ .default([]),
114
+ referencesJson: text("references_json", { mode: "json" })
115
+ .$type<string[]>()
116
+ .notNull()
117
+ .default([]),
118
+
119
+ fromJson: text("from_json", { mode: "json" })
120
+ .$type<MailAddress[]>()
121
+ .notNull(),
122
+ replyToJson: text("reply_to_json", { mode: "json" })
123
+ .$type<MailAddress[]>()
124
+ .notNull()
125
+ .default([]),
126
+ toJson: text("to_json", { mode: "json" })
127
+ .$type<MailAddress[]>()
128
+ .notNull()
129
+ .default([]),
130
+ ccJson: text("cc_json", { mode: "json" })
131
+ .$type<MailAddress[]>()
132
+ .notNull()
133
+ .default([]),
134
+ bccJson: text("bcc_json", { mode: "json" })
135
+ .$type<MailAddress[]>()
136
+ .notNull()
137
+ .default([]),
138
+
139
+ subject: text("subject").notNull().default("(no subject)"),
140
+ preview: text("preview").notNull().default(""),
141
+ bodyText: text("body_text"),
142
+ quotedText: text("quoted_text"),
143
+ bodyHtmlR2Key: text("body_html_r2_key"),
144
+ rawMimeR2Key: text("raw_mime_r2_key"),
145
+ /** Header-derived sender authentication for incoming mail. */
146
+ authenticationResultsJson: text("authentication_results_json", { mode: "json" })
147
+ .$type<StoredEmailAuthentication>(),
148
+ /** SMTP envelope as delivered by Email Routing (MAIL FROM / RCPT TO). */
149
+ envelopeFrom: text("envelope_from"),
150
+ envelopeTo: text("envelope_to"),
151
+ listUnsubscribeJson: text("list_unsubscribe_json", { mode: "json" })
152
+ .$type<ListUnsubscribe>(),
153
+ /** DNS facts about the delivering server, gathered at ingestion. */
154
+ senderIntelJson: text("sender_intel_json", { mode: "json" })
155
+ .$type<SenderIntel>(),
156
+ aiClassificationJson: text("ai_classification_json", { mode: "json" })
157
+ .$type<EmailAiClassification>(),
158
+ /** Who the message was written for and whether it was forwarded, from ingestion. */
159
+ deliveryJson: text("delivery_json", { mode: "json" })
160
+ .$type<DeliveryFacts>(),
161
+ /** Block and rule decisions taken at ingestion, before AI classification. */
162
+ screeningJson: text("screening_json", { mode: "json" })
163
+ .$type<EmailScreening>(),
164
+ /** Set when someone in this mailbox reported the message. */
165
+ reportLabel: text("report_label", { enum: ["spam", "phishing"] }),
166
+ attachmentsJson: text("attachments_json", { mode: "json" })
167
+ .$type<StoredAttachment[]>()
168
+ .notNull()
169
+ .default([]),
170
+
171
+ listId: text("list_id"),
172
+ listPostAddress: text("list_post_address"),
173
+ timelineAt: integer("timeline_at", { mode: "timestamp_ms" }).notNull(),
174
+
175
+ transportState: text("transport_state", { enum: transportStates }).notNull(),
176
+ transportError: text("transport_error"),
177
+ deliveryStatusJson: text("delivery_status_json", { mode: "json" })
178
+ .$type<RecipientDeliveryStatus[]>()
179
+ .notNull()
180
+ .default([]),
181
+ },
182
+ (table) => [
183
+ index("emails_conversation_timeline_idx").on(
184
+ table.conversationId,
185
+ table.timelineAt,
186
+ table.id,
187
+ ),
188
+ index("emails_message_id_header_idx").on(table.messageIdHeader),
189
+ ],
190
+ );
191
+
192
+ /**
193
+ * Small mutable projection used by folder lists. A single insert trigger keeps
194
+ * it synchronized with immutable email rows.
195
+ */
196
+ export const conversations = sqliteTable(
197
+ "conversations",
198
+ {
199
+ id: text("id").primaryKey(),
200
+ latestEmailId: text("latest_email_id")
201
+ .notNull()
202
+ .references(() => emails.id),
203
+ timelineAt: integer("timeline_at", { mode: "timestamp_ms" }).notNull(),
204
+ mailboxState: text("mailbox_state", { enum: mailboxStates })
205
+ .notNull()
206
+ .default("active"),
207
+ folderId: text("folder_id").references(() => folders.id, {
208
+ onDelete: "set null",
209
+ }),
210
+ hasIncoming: integer("has_incoming", { mode: "boolean" })
211
+ .notNull()
212
+ .default(false),
213
+ hasOutgoing: integer("has_outgoing", { mode: "boolean" })
214
+ .notNull()
215
+ .default(false),
216
+ },
217
+ (table) => [
218
+ index("conversations_timeline_idx").on(table.timelineAt, table.latestEmailId),
219
+ index("conversations_inbox_timeline_idx").on(
220
+ table.mailboxState,
221
+ table.hasIncoming,
222
+ table.timelineAt,
223
+ table.latestEmailId,
224
+ ),
225
+ index("conversations_sent_timeline_idx").on(
226
+ table.mailboxState,
227
+ table.hasOutgoing,
228
+ table.timelineAt,
229
+ table.latestEmailId,
230
+ ),
231
+ index("conversations_folder_timeline_idx").on(
232
+ table.folderId,
233
+ table.timelineAt,
234
+ table.latestEmailId,
235
+ ),
236
+ ],
237
+ );
238
+
239
+ /**
240
+ * Correspondent history of this mailbox, keyed by lowercased address. Inbound
241
+ * counters drive the first-time-sender hint; outbound counters record that the
242
+ * mailbox wrote to the address. `blocked_at` sends future mail to Spam;
243
+ * rows keyed `@domain` block a whole domain. `trusted_at` ("Not spam")
244
+ * suppresses caution-level hints and the AI spam verdict.
245
+ */
246
+ export const senders = sqliteTable(
247
+ "senders",
248
+ {
249
+ address: text("address").primaryKey(),
250
+ domain: text("domain").notNull(),
251
+ firstSeenAt: integer("first_seen_at", { mode: "timestamp_ms" }),
252
+ lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }),
253
+ messageCount: integer("message_count").notNull().default(0),
254
+ outboundCount: integer("outbound_count").notNull().default(0),
255
+ lastOutboundAt: integer("last_outbound_at", { mode: "timestamp_ms" }),
256
+ blockedAt: integer("blocked_at", { mode: "timestamp_ms" }),
257
+ trustedAt: integer("trusted_at", { mode: "timestamp_ms" }),
258
+ reportedAt: integer("reported_at", { mode: "timestamp_ms" }),
259
+ },
260
+ (table) => [index("senders_domain_idx").on(table.domain)],
261
+ );
262
+
263
+ /**
264
+ * Spam, phishing and not-spam reports made in this mailbox. They feed the
265
+ * AI classifier as sender history and are never sent anywhere by themselves.
266
+ */
267
+ export const senderFeedback = sqliteTable(
268
+ "sender_feedback",
269
+ {
270
+ id: text("id").primaryKey(),
271
+ address: text("address").notNull(),
272
+ domain: text("domain").notNull(),
273
+ emailId: text("email_id"),
274
+ label: text("label", { enum: ["spam", "phishing", "not_spam"] }).notNull(),
275
+ userId: text("user_id"),
276
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
277
+ },
278
+ (table) => [
279
+ index("sender_feedback_address_idx").on(table.address, table.createdAt),
280
+ index("sender_feedback_domain_idx").on(table.domain),
281
+ ],
282
+ );
283
+
284
+ /** Latest unsubscribe attempt per list (List-Id, else the From organizational domain). */
285
+ export const unsubscriptions = sqliteTable("unsubscriptions", {
286
+ listKey: text("list_key").primaryKey(),
287
+ emailId: text("email_id"),
288
+ method: text("method", { enum: ["one_click", "mailto", "link"] }).notNull(),
289
+ status: text("status", { enum: ["done", "requested", "opened", "failed"] }).notNull(),
290
+ target: text("target"),
291
+ detail: text("detail"),
292
+ userId: text("user_id"),
293
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
294
+ });
295
+
296
+ /** Ordered deterministic filters; they run at ingestion before AI sorting. */
297
+ export const mailRules = sqliteTable(
298
+ "mail_rules",
299
+ {
300
+ id: text("id").primaryKey(),
301
+ name: text("name").notNull(),
302
+ sortOrder: integer("sort_order").notNull().default(100),
303
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
304
+ conditionsJson: text("conditions_json", { mode: "json" }).$type<MailRuleConditions>().notNull(),
305
+ actionsJson: text("actions_json", { mode: "json" }).$type<MailRuleActions>().notNull(),
306
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
307
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
308
+ },
309
+ (table) => [index("mail_rules_order_idx").on(table.sortOrder, table.createdAt)],
310
+ );
311
+
312
+ export const mailboxAiConfiguration = sqliteTable(
313
+ "mailbox_ai_configuration",
314
+ {
315
+ id: text("id").primaryKey(),
316
+ instructions: text("instructions").notNull().default(""),
317
+ confidenceThreshold: integer("confidence_threshold").notNull().default(75),
318
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
319
+ },
320
+ );
321
+
322
+ /**
323
+ * Read state belongs to a person, not to the mailbox. This keeps shared
324
+ * mailboxes independent: one teammate viewing a message cannot clear another
325
+ * teammate's unread state.
326
+ */
327
+ export const emailReadStates = sqliteTable(
328
+ "email_read_states",
329
+ {
330
+ userId: text("user_id").notNull(),
331
+ emailId: text("email_id")
332
+ .notNull()
333
+ .references(() => emails.id, { onDelete: "cascade" }),
334
+ readAt: integer("read_at", { mode: "timestamp_ms" }).notNull(),
335
+ },
336
+ (table) => [
337
+ primaryKey({ columns: [table.userId, table.emailId] }),
338
+ index("email_read_states_email_idx").on(table.emailId),
339
+ ],
340
+ );
341
+
342
+ export type Email = typeof emails.$inferSelect;
343
+ export type SenderRecord = typeof senders.$inferSelect;
344
+ export type MailRuleRecord = typeof mailRules.$inferSelect;
345
+ export type UnsubscriptionRecord = typeof unsubscriptions.$inferSelect;
346
+ export type NewEmail = typeof emails.$inferInsert;
347
+ export type EmailReadState = typeof emailReadStates.$inferSelect;
348
+ export type PendingInbound = typeof pendingInbound.$inferSelect;
349
+ export type PendingObjectDeletion = typeof pendingObjectDeletions.$inferSelect;
350
+ export type NewPendingInbound = typeof pendingInbound.$inferInsert;
351
+ export type ConversationRecord = typeof conversations.$inferSelect;
352
+ export type FolderRecord = typeof folders.$inferSelect;
353
+ export type NewFolder = typeof folders.$inferInsert;
@@ -0,0 +1,240 @@
1
+ import {
2
+ blockPatternsFor,
3
+ type FeedbackLabel,
4
+ type SenderBlock,
5
+ type SenderStatus,
6
+ } from "../../shared/mail-feedback";
7
+ import type { SenderHistory } from "../mail/message-security";
8
+ import type { NewEmail } from "./schema";
9
+
10
+ function normalized(address: string | null | undefined) {
11
+ const value = address?.trim().toLocaleLowerCase() ?? "";
12
+ const at = value.lastIndexOf("@");
13
+ return at > 0 && at < value.length - 1 ? { address: value, domain: value.slice(at + 1) } : null;
14
+ }
15
+
16
+ function timestamp(value: NewEmail["timelineAt"]) {
17
+ return value instanceof Date ? value.getTime() : Number(value);
18
+ }
19
+
20
+ /**
21
+ * Upserts the correspondent counters for one stored message. Call it inside
22
+ * the transaction that inserted the row so counts never drift from `emails`.
23
+ */
24
+ export function recordCorrespondents(sql: SqlStorage, email: NewEmail) {
25
+ const at = timestamp(email.timelineAt);
26
+ if (email.direction === "incoming") {
27
+ const sender = normalized(email.fromJson[0]?.address);
28
+ if (!sender) return;
29
+ sql.exec(
30
+ `insert into senders (address, domain, first_seen_at, last_seen_at, message_count)
31
+ values (?, ?, ?, ?, 1)
32
+ on conflict (address) do update set
33
+ first_seen_at = min(coalesce(first_seen_at, excluded.first_seen_at), excluded.first_seen_at),
34
+ last_seen_at = max(coalesce(last_seen_at, excluded.last_seen_at), excluded.last_seen_at),
35
+ message_count = message_count + 1`,
36
+ sender.address,
37
+ sender.domain,
38
+ at,
39
+ at,
40
+ );
41
+ return;
42
+ }
43
+ const recipients = new Map<string, string>();
44
+ for (const recipient of [...(email.toJson ?? []), ...(email.ccJson ?? []), ...(email.bccJson ?? [])]) {
45
+ const value = normalized(recipient.address);
46
+ if (value) recipients.set(value.address, value.domain);
47
+ }
48
+ for (const [address, domain] of recipients) {
49
+ sql.exec(
50
+ `insert into senders (address, domain, outbound_count, last_outbound_at)
51
+ values (?, ?, 1, ?)
52
+ on conflict (address) do update set
53
+ outbound_count = outbound_count + 1,
54
+ last_outbound_at = max(coalesce(last_outbound_at, excluded.last_outbound_at), excluded.last_outbound_at)`,
55
+ address,
56
+ domain,
57
+ at,
58
+ );
59
+ }
60
+ }
61
+
62
+ type SenderHistoryRow = {
63
+ address: string;
64
+ message_count: number;
65
+ outbound_count: number;
66
+ };
67
+
68
+ export function senderHistory(sql: SqlStorage, addresses: string[]) {
69
+ const unique = [...new Set(addresses.map((address) => address.toLocaleLowerCase()))].slice(0, 100);
70
+ const result = new Map<string, SenderHistory>();
71
+ if (!unique.length) return result;
72
+ const rows = sql.exec<SenderHistoryRow>(
73
+ `select address, message_count, outbound_count from senders where address in (${unique.map(() => "?").join(", ")})`,
74
+ ...unique,
75
+ ).toArray();
76
+ for (const row of rows) {
77
+ result.set(row.address, {
78
+ inboundCount: Number(row.message_count),
79
+ outboundCount: Number(row.outbound_count),
80
+ });
81
+ }
82
+ return result;
83
+ }
84
+
85
+ type BlockRow = { address: string; blocked_at: number };
86
+
87
+ /** The most specific mailbox block covering an address, if any. */
88
+ export function mailboxBlockFor(sql: SqlStorage, address: string): SenderBlock | null {
89
+ const patterns = blockPatternsFor(address);
90
+ if (!patterns.length) return null;
91
+ const rows = sql.exec<BlockRow>(
92
+ `select address, blocked_at from senders
93
+ where blocked_at is not null and address in (${patterns.map(() => "?").join(", ")})`,
94
+ ...patterns,
95
+ ).toArray();
96
+ const pattern = patterns.find((candidate) => rows.some((row) => row.address === candidate));
97
+ const row = rows.find((candidate) => candidate.address === pattern);
98
+ return row ? blockFromPattern(row.address, Number(row.blocked_at), false) : null;
99
+ }
100
+
101
+ export function blockFromPattern(pattern: string, at: number, workspace: boolean): SenderBlock {
102
+ return { pattern, scope: pattern.startsWith("@") ? "domain" : "address", workspace, at };
103
+ }
104
+
105
+ /** First workspace-wide pattern (address before domains) that covers an address. */
106
+ export function workspaceBlockFor(address: string, patterns: ReadonlyMap<string, number>) {
107
+ const pattern = blockPatternsFor(address).find((candidate) => patterns.has(candidate));
108
+ return pattern ? blockFromPattern(pattern, patterns.get(pattern)!, true) : null;
109
+ }
110
+
111
+ type StatusRow = { address: string; trusted_at: number | null; reported_at: number | null };
112
+
113
+ /** Block, trust and report state of each address in this mailbox (lowercased keys). */
114
+ export function senderStatuses(sql: SqlStorage, addresses: string[]) {
115
+ const unique = [...new Set(addresses.map((address) => address.toLocaleLowerCase()))].slice(0, 100);
116
+ const result = new Map<string, SenderStatus>();
117
+ if (!unique.length) return result;
118
+ const rows = sql.exec<StatusRow>(
119
+ `select address, trusted_at, reported_at from senders where address in (${unique.map(() => "?").join(", ")})`,
120
+ ...unique,
121
+ ).toArray();
122
+ const byAddress = new Map(rows.map((row) => [row.address, row]));
123
+ for (const address of unique) {
124
+ const row = byAddress.get(address);
125
+ result.set(address, {
126
+ block: mailboxBlockFor(sql, address),
127
+ trustedAt: row?.trusted_at == null ? null : Number(row.trusted_at),
128
+ reportedAt: row?.reported_at == null ? null : Number(row.reported_at),
129
+ });
130
+ }
131
+ return result;
132
+ }
133
+
134
+ function patternDomain(pattern: string) {
135
+ return pattern.startsWith("@") ? pattern.slice(1) : pattern.slice(pattern.lastIndexOf("@") + 1);
136
+ }
137
+
138
+ /** Blocking clears trust: the two decisions are mutually exclusive. */
139
+ export function setSenderBlocked(sql: SqlStorage, pattern: string, blocked: boolean, now: number) {
140
+ if (!blocked) {
141
+ sql.exec("update senders set blocked_at = null where address = ?", pattern);
142
+ return;
143
+ }
144
+ sql.exec(
145
+ `insert into senders (address, domain, blocked_at) values (?, ?, ?)
146
+ on conflict (address) do update set blocked_at = excluded.blocked_at, trusted_at = null`,
147
+ pattern,
148
+ patternDomain(pattern),
149
+ now,
150
+ );
151
+ }
152
+
153
+ /** Trusting an address also lifts a block on that exact address. */
154
+ export function setSenderTrusted(sql: SqlStorage, address: string, trusted: boolean, now: number) {
155
+ const value = address.toLocaleLowerCase();
156
+ if (!trusted) {
157
+ sql.exec("update senders set trusted_at = null where address = ?", value);
158
+ return;
159
+ }
160
+ sql.exec(
161
+ `insert into senders (address, domain, trusted_at) values (?, ?, ?)
162
+ on conflict (address) do update set trusted_at = excluded.trusted_at, blocked_at = null`,
163
+ value,
164
+ patternDomain(value),
165
+ now,
166
+ );
167
+ }
168
+
169
+ export type BlockedSenderItem = {
170
+ pattern: string;
171
+ scope: "address" | "domain";
172
+ blockedAt: number;
173
+ messageCount: number;
174
+ };
175
+
176
+ export function listBlockedSenders(sql: SqlStorage): BlockedSenderItem[] {
177
+ return sql.exec<{ address: string; blocked_at: number; message_count: number }>(
178
+ "select address, blocked_at, message_count from senders where blocked_at is not null order by blocked_at desc limit 500",
179
+ ).toArray().map((row) => ({
180
+ pattern: row.address,
181
+ scope: row.address.startsWith("@") ? "domain" : "address",
182
+ blockedAt: Number(row.blocked_at),
183
+ messageCount: Number(row.message_count),
184
+ }));
185
+ }
186
+
187
+ export function recordSenderFeedback(sql: SqlStorage, input: {
188
+ id: string;
189
+ address: string;
190
+ emailId: string | null;
191
+ label: FeedbackLabel;
192
+ userId: string | null;
193
+ now: number;
194
+ }) {
195
+ const address = input.address.toLocaleLowerCase();
196
+ const domain = patternDomain(address);
197
+ sql.exec(
198
+ `insert into sender_feedback (id, address, domain, email_id, label, user_id, created_at)
199
+ values (?, ?, ?, ?, ?, ?, ?)`,
200
+ input.id,
201
+ address,
202
+ domain,
203
+ input.emailId,
204
+ input.label,
205
+ input.userId,
206
+ input.now,
207
+ );
208
+ if (input.label === "not_spam") return;
209
+ sql.exec(
210
+ `insert into senders (address, domain, reported_at) values (?, ?, ?)
211
+ on conflict (address) do update set reported_at = excluded.reported_at, trusted_at = null`,
212
+ address,
213
+ domain,
214
+ input.now,
215
+ );
216
+ }
217
+
218
+ /** Report history for the classifier: what people in this mailbox said about the sender before. */
219
+ export function senderFeedbackSummary(sql: SqlStorage, address: string) {
220
+ const value = address.toLocaleLowerCase();
221
+ const domain = patternDomain(value);
222
+ const rows = sql.exec<{ label: FeedbackLabel; same_address: number; count: number }>(
223
+ `select label, address = ? as same_address, count(*) as count
224
+ from sender_feedback where domain = ? group by label, address = ?`,
225
+ value,
226
+ domain,
227
+ value,
228
+ ).toArray();
229
+ const count = (label: FeedbackLabel, sameAddress: boolean) => rows
230
+ .filter((row) => row.label === label && (!sameAddress || Boolean(row.same_address)))
231
+ .reduce((total, row) => total + Number(row.count), 0);
232
+ return {
233
+ spamReports: count("spam", true),
234
+ phishingReports: count("phishing", true),
235
+ notSpam: count("not_spam", true),
236
+ domainReports: count("spam", false) + count("phishing", false),
237
+ };
238
+ }
239
+
240
+ export type SenderFeedbackSummary = ReturnType<typeof senderFeedbackSummary>;