@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,41 @@
1
+ /**
2
+ * Mailbox migrations from before the 0.1.0 baseline, for Durable Objects
3
+ * created by earlier deployments. Applied only to a mailbox whose drizzle
4
+ * journal predates the baseline; new mailboxes never run them.
5
+ */
6
+ export const legacyMailboxMigrations: ReadonlyArray<{ when: number; sql: string }> = [
7
+ { when: 1784825133044, sql: "CREATE TABLE `conversations` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`latest_email_id` text NOT NULL,\n\t`timeline_at` integer NOT NULL,\n\t`mailbox_state` text DEFAULT 'active' NOT NULL,\n\t`folder_id` text,\n\t`has_incoming` integer DEFAULT false NOT NULL,\n\t`has_outgoing` integer DEFAULT false NOT NULL,\n\tFOREIGN KEY (`latest_email_id`) REFERENCES `emails`(`id`) ON UPDATE no action ON DELETE no action,\n\tFOREIGN KEY (`folder_id`) REFERENCES `folders`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nCREATE INDEX `conversations_timeline_idx` ON `conversations` (`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE INDEX `conversations_inbox_timeline_idx` ON `conversations` (`mailbox_state`,`has_incoming`,`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE INDEX `conversations_sent_timeline_idx` ON `conversations` (`mailbox_state`,`has_outgoing`,`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE INDEX `conversations_folder_timeline_idx` ON `conversations` (`folder_id`,`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE TABLE `emails` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`request_fingerprint` text,\n\t`conversation_id` text NOT NULL,\n\t`direction` text NOT NULL,\n\t`message_id_header` text,\n\t`in_reply_to_json` text DEFAULT '[]' NOT NULL,\n\t`references_json` text DEFAULT '[]' NOT NULL,\n\t`from_json` text NOT NULL,\n\t`reply_to_json` text DEFAULT '[]' NOT NULL,\n\t`to_json` text DEFAULT '[]' NOT NULL,\n\t`cc_json` text DEFAULT '[]' NOT NULL,\n\t`bcc_json` text DEFAULT '[]' NOT NULL,\n\t`subject` text DEFAULT '(no subject)' NOT NULL,\n\t`preview` text DEFAULT '' NOT NULL,\n\t`body_text` text,\n\t`quoted_text` text,\n\t`body_html_r2_key` text,\n\t`raw_mime_r2_key` text,\n\t`attachments_json` text DEFAULT '[]' NOT NULL,\n\t`list_id` text,\n\t`list_post_address` text,\n\t`timeline_at` integer NOT NULL,\n\t`transport_state` text NOT NULL,\n\t`transport_error` text,\n\t`delivery_status_json` text DEFAULT '[]' NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `emails_conversation_timeline_idx` ON `emails` (`conversation_id`,`timeline_at`,`id`);--> statement-breakpoint\nCREATE INDEX `emails_message_id_header_idx` ON `emails` (`message_id_header`);--> statement-breakpoint\nCREATE TABLE `folders` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text NOT NULL,\n\t`sort_order` integer DEFAULT 100 NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `folders_name_unique` ON `folders` (`name`);--> statement-breakpoint\nCREATE INDEX `folders_order_idx` ON `folders` (`sort_order`,`name`);--> statement-breakpoint\nCREATE TABLE `pending_inbound` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`raw_object_key` text NOT NULL,\n\t`envelope_from` text NOT NULL,\n\t`envelope_to` text NOT NULL,\n\t`received_at` integer NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`next_attempt_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `pending_inbound_next_attempt_idx` ON `pending_inbound` (`next_attempt_at`,`received_at`);--> statement-breakpoint\n-- Runtime-only search/projection objects: Drizzle snapshots do not model them.\nCREATE VIRTUAL TABLE `email_search` USING fts5(\n\t`email_id` UNINDEXED,\n\t`conversation_id` UNINDEXED,\n\t`subject`,\n\t`addresses`,\n\t`body`,\n\ttokenize = 'unicode61 remove_diacritics 2'\n);--> statement-breakpoint\nCREATE TRIGGER `emails_after_insert`\nAFTER INSERT ON `emails`\nBEGIN\n\tINSERT INTO `conversations` (\n\t\t`id`,\n\t\t`latest_email_id`,\n\t\t`timeline_at`,\n\t\t`mailbox_state`,\n\t\t`folder_id`,\n\t\t`has_incoming`,\n\t\t`has_outgoing`\n\t)\n\tVALUES (\n\t\tNEW.`conversation_id`,\n\t\tNEW.`id`,\n\t\tNEW.`timeline_at`,\n\t\t'active',\n\t\tNULL,\n\t\tNEW.`direction` = 'incoming',\n\t\tNEW.`direction` = 'outgoing'\n\t)\n\tON CONFLICT(`id`) DO UPDATE SET\n\t\t`latest_email_id` = CASE\n\t\t\tWHEN NEW.`timeline_at` > `conversations`.`timeline_at`\n\t\t\t\tOR (\n\t\t\t\t\tNEW.`timeline_at` = `conversations`.`timeline_at`\n\t\t\t\t\tAND NEW.`id` > `conversations`.`latest_email_id`\n\t\t\t\t)\n\t\t\tTHEN NEW.`id`\n\t\t\tELSE `conversations`.`latest_email_id`\n\t\tEND,\n\t\t`timeline_at` = MAX(`conversations`.`timeline_at`, NEW.`timeline_at`),\n\t\t`mailbox_state` = CASE\n\t\t\tWHEN `conversations`.`mailbox_state` IN ('archive', 'trash')\n\t\t\t\tOR (\n\t\t\t\t\t`conversations`.`mailbox_state` = 'spam'\n\t\t\t\t\tAND NEW.`direction` = 'outgoing'\n\t\t\t\t)\n\t\t\tTHEN 'active'\n\t\t\tELSE `conversations`.`mailbox_state`\n\t\tEND,\n\t\t`has_incoming` = `conversations`.`has_incoming`\n\t\t\tOR NEW.`direction` = 'incoming',\n\t\t`has_outgoing` = `conversations`.`has_outgoing`\n\t\t\tOR NEW.`direction` = 'outgoing';\n\n\tINSERT INTO `email_search` (\n\t\t`email_id`,\n\t\t`conversation_id`,\n\t\t`subject`,\n\t\t`addresses`,\n\t\t`body`\n\t)\n\tVALUES (\n\t\tNEW.`id`,\n\t\tNEW.`conversation_id`,\n\t\tNEW.`subject`,\n\t\tCOALESCE(NEW.`from_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`reply_to_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`to_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`cc_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`bcc_json`, ''),\n\t\tCOALESCE(NEW.`body_text`, '') || ' ' || COALESCE(NEW.`preview`, '')\n\t);\nEND;\n" },
8
+ { when: 1787348208125, sql: "CREATE TABLE `email_read_states` (\n\t`user_id` text NOT NULL,\n\t`email_id` text NOT NULL,\n\t`read_at` integer NOT NULL,\n\tPRIMARY KEY(`user_id`, `email_id`),\n\tFOREIGN KEY (`email_id`) REFERENCES `emails`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE INDEX `email_read_states_email_idx` ON `email_read_states` (`email_id`);" },
9
+ { when: 1787422760085, sql: "CREATE TABLE `pending_object_deletions` (\n\t`object_key` text PRIMARY KEY NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`next_attempt_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `pending_object_deletions_next_attempt_idx` ON `pending_object_deletions` (`next_attempt_at`);" },
10
+ { when: 1787514819958, sql: "ALTER TABLE `emails` ADD `authentication_results_json` text;" },
11
+ { when: 1787522950002, sql: "CREATE TABLE `mailbox_ai_configuration` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`instructions` text DEFAULT '' NOT NULL,\n\t`confidence_threshold` integer DEFAULT 75 NOT NULL,\n\t`updated_at` integer NOT NULL\n);\n--> statement-breakpoint\nALTER TABLE `emails` ADD `ai_classification_json` text;--> statement-breakpoint\nALTER TABLE `pending_inbound` ADD `ai_attempts` integer DEFAULT 0 NOT NULL;\n" },
12
+ { when: 1791081433051, sql: "CREATE TABLE `pending_office_previews` (\n\t`source_key` text PRIMARY KEY NOT NULL,\n\t`email_id` text NOT NULL,\n\t`attachment_id` text NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`next_attempt_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `pending_office_previews_next_attempt_idx` ON `pending_office_previews` (`next_attempt_at`);" },
13
+ { when: 1793417083777, sql: "ALTER TABLE `emails` ADD `envelope_from` text;--> statement-breakpoint\nALTER TABLE `emails` ADD `envelope_to` text;--> statement-breakpoint\nALTER TABLE `emails` ADD `list_unsubscribe_json` text;" },
14
+ { when: 1793418627171, sql: "CREATE TABLE `senders` (\n\t`address` text PRIMARY KEY NOT NULL,\n\t`domain` text NOT NULL,\n\t`first_seen_at` integer,\n\t`last_seen_at` integer,\n\t`message_count` integer DEFAULT 0 NOT NULL,\n\t`outbound_count` integer DEFAULT 0 NOT NULL,\n\t`last_outbound_at` integer,\n\t`blocked_at` integer,\n\t`trusted_at` integer,\n\t`reported_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `senders_domain_idx` ON `senders` (`domain`);--> statement-breakpoint\nALTER TABLE `emails` ADD `sender_intel_json` text;--> statement-breakpoint\nINSERT INTO `senders` (`address`, `domain`, `first_seen_at`, `last_seen_at`, `message_count`)\nSELECT `address`, substr(`address`, instr(`address`, '@') + 1), min(`timeline_at`), max(`timeline_at`), count(*)\nFROM (\n\tSELECT lower(json_extract(`from_json`, '$[0].address')) AS `address`, `timeline_at`\n\tFROM `emails`\n\tWHERE `direction` = 'incoming'\n)\nWHERE `address` LIKE '%_@_%'\nGROUP BY `address`;--> statement-breakpoint\nINSERT INTO `senders` (`address`, `domain`, `outbound_count`, `last_outbound_at`)\nSELECT `address`, substr(`address`, instr(`address`, '@') + 1), count(*), max(`timeline_at`)\nFROM (\n\tSELECT lower(json_extract(`recipient`.`value`, '$.address')) AS `address`, `message`.`timeline_at`\n\tFROM `emails` AS `message`, json_each(`message`.`to_json`) AS `recipient`\n\tWHERE `message`.`direction` = 'outgoing'\n\tUNION ALL\n\tSELECT lower(json_extract(`recipient`.`value`, '$.address')), `message`.`timeline_at`\n\tFROM `emails` AS `message`, json_each(`message`.`cc_json`) AS `recipient`\n\tWHERE `message`.`direction` = 'outgoing'\n\tUNION ALL\n\tSELECT lower(json_extract(`recipient`.`value`, '$.address')), `message`.`timeline_at`\n\tFROM `emails` AS `message`, json_each(`message`.`bcc_json`) AS `recipient`\n\tWHERE `message`.`direction` = 'outgoing'\n)\nWHERE `address` LIKE '%_@_%'\nGROUP BY `address`\nON CONFLICT (`address`) DO UPDATE SET\n\t`outbound_count` = excluded.`outbound_count`,\n\t`last_outbound_at` = excluded.`last_outbound_at`;\n" },
15
+ { when: 1793439647535, sql: "CREATE TABLE `mail_rules` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text NOT NULL,\n\t`sort_order` integer DEFAULT 100 NOT NULL,\n\t`enabled` integer DEFAULT true NOT NULL,\n\t`conditions_json` text NOT NULL,\n\t`actions_json` text NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `mail_rules_order_idx` ON `mail_rules` (`sort_order`,`created_at`);--> statement-breakpoint\nCREATE TABLE `sender_feedback` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`address` text NOT NULL,\n\t`domain` text NOT NULL,\n\t`email_id` text,\n\t`label` text NOT NULL,\n\t`user_id` text,\n\t`created_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `sender_feedback_address_idx` ON `sender_feedback` (`address`,`created_at`);--> statement-breakpoint\nCREATE INDEX `sender_feedback_domain_idx` ON `sender_feedback` (`domain`);--> statement-breakpoint\nCREATE TABLE `unsubscriptions` (\n\t`list_key` text PRIMARY KEY NOT NULL,\n\t`email_id` text,\n\t`method` text NOT NULL,\n\t`status` text NOT NULL,\n\t`target` text,\n\t`detail` text,\n\t`user_id` text,\n\t`created_at` integer NOT NULL\n);\n--> statement-breakpoint\nALTER TABLE `emails` ADD `delivery_json` text;--> statement-breakpoint\nALTER TABLE `emails` ADD `screening_json` text;--> statement-breakpoint\nALTER TABLE `emails` ADD `report_label` text;" },
16
+ { when: 1793467839387, sql: "ALTER TABLE `pending_inbound` ADD `list_address` text;" },
17
+ ];
18
+
19
+ /**
20
+ * Brings a mailbox created before the baseline up to it: applies the legacy
21
+ * migrations it has not run, then records the baseline as applied so drizzle
22
+ * does not create its tables a second time. A no-op for new mailboxes.
23
+ */
24
+ export function upgradeLegacyMailbox(sql: SqlStorage, baselineWhen: number) {
25
+ const hasJournal = sql
26
+ .exec("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")
27
+ .toArray().length > 0;
28
+ if (!hasJournal) return;
29
+ const [{ last }] = sql
30
+ .exec<{ last: number | null }>("SELECT max(created_at) AS last FROM __drizzle_migrations")
31
+ .toArray();
32
+ if (last === null || Number(last) >= baselineWhen) return;
33
+ for (const migration of legacyMailboxMigrations) {
34
+ if (migration.when <= Number(last)) continue;
35
+ for (const statement of migration.sql.split("--> statement-breakpoint")) {
36
+ if (statement.trim()) sql.exec(statement);
37
+ }
38
+ sql.exec("INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', ?)", migration.when);
39
+ }
40
+ sql.exec("INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', ?)", baselineWhen);
41
+ }
@@ -0,0 +1,10 @@
1
+ // Generated by scripts/embed-mailbox-migrations.ts from drizzle/mailbox. Do not edit.
2
+ export default {
3
+ journal: {"version":"7","dialect":"sqlite","entries":[{"idx":0,"version":"6","when":1793490547954,"tag":"0000_mailbox","breakpoints":true}]},
4
+ migrations: {
5
+ m0000: "CREATE TABLE `conversations` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`latest_email_id` text NOT NULL,\n\t`timeline_at` integer NOT NULL,\n\t`mailbox_state` text DEFAULT 'active' NOT NULL,\n\t`folder_id` text,\n\t`has_incoming` integer DEFAULT false NOT NULL,\n\t`has_outgoing` integer DEFAULT false NOT NULL,\n\tFOREIGN KEY (`latest_email_id`) REFERENCES `emails`(`id`) ON UPDATE no action ON DELETE no action,\n\tFOREIGN KEY (`folder_id`) REFERENCES `folders`(`id`) ON UPDATE no action ON DELETE set null\n);\n--> statement-breakpoint\nCREATE INDEX `conversations_timeline_idx` ON `conversations` (`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE INDEX `conversations_inbox_timeline_idx` ON `conversations` (`mailbox_state`,`has_incoming`,`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE INDEX `conversations_sent_timeline_idx` ON `conversations` (`mailbox_state`,`has_outgoing`,`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE INDEX `conversations_folder_timeline_idx` ON `conversations` (`folder_id`,`timeline_at`,`latest_email_id`);--> statement-breakpoint\nCREATE TABLE `email_read_states` (\n\t`user_id` text NOT NULL,\n\t`email_id` text NOT NULL,\n\t`read_at` integer NOT NULL,\n\tPRIMARY KEY(`user_id`, `email_id`),\n\tFOREIGN KEY (`email_id`) REFERENCES `emails`(`id`) ON UPDATE no action ON DELETE cascade\n);\n--> statement-breakpoint\nCREATE INDEX `email_read_states_email_idx` ON `email_read_states` (`email_id`);--> statement-breakpoint\nCREATE TABLE `emails` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`request_fingerprint` text,\n\t`conversation_id` text NOT NULL,\n\t`direction` text NOT NULL,\n\t`message_id_header` text,\n\t`in_reply_to_json` text DEFAULT '[]' NOT NULL,\n\t`references_json` text DEFAULT '[]' NOT NULL,\n\t`from_json` text NOT NULL,\n\t`reply_to_json` text DEFAULT '[]' NOT NULL,\n\t`to_json` text DEFAULT '[]' NOT NULL,\n\t`cc_json` text DEFAULT '[]' NOT NULL,\n\t`bcc_json` text DEFAULT '[]' NOT NULL,\n\t`subject` text DEFAULT '(no subject)' NOT NULL,\n\t`preview` text DEFAULT '' NOT NULL,\n\t`body_text` text,\n\t`quoted_text` text,\n\t`body_html_r2_key` text,\n\t`raw_mime_r2_key` text,\n\t`authentication_results_json` text,\n\t`envelope_from` text,\n\t`envelope_to` text,\n\t`list_unsubscribe_json` text,\n\t`sender_intel_json` text,\n\t`ai_classification_json` text,\n\t`delivery_json` text,\n\t`screening_json` text,\n\t`report_label` text,\n\t`attachments_json` text DEFAULT '[]' NOT NULL,\n\t`list_id` text,\n\t`list_post_address` text,\n\t`timeline_at` integer NOT NULL,\n\t`transport_state` text NOT NULL,\n\t`transport_error` text,\n\t`delivery_status_json` text DEFAULT '[]' NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `emails_conversation_timeline_idx` ON `emails` (`conversation_id`,`timeline_at`,`id`);--> statement-breakpoint\nCREATE INDEX `emails_message_id_header_idx` ON `emails` (`message_id_header`);--> statement-breakpoint\nCREATE TABLE `folders` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text NOT NULL,\n\t`sort_order` integer DEFAULT 100 NOT NULL\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `folders_name_unique` ON `folders` (`name`);--> statement-breakpoint\nCREATE INDEX `folders_order_idx` ON `folders` (`sort_order`,`name`);--> statement-breakpoint\nCREATE TABLE `mail_rules` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`name` text NOT NULL,\n\t`sort_order` integer DEFAULT 100 NOT NULL,\n\t`enabled` integer DEFAULT true NOT NULL,\n\t`conditions_json` text NOT NULL,\n\t`actions_json` text NOT NULL,\n\t`created_at` integer NOT NULL,\n\t`updated_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `mail_rules_order_idx` ON `mail_rules` (`sort_order`,`created_at`);--> statement-breakpoint\nCREATE TABLE `mailbox_ai_configuration` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`instructions` text DEFAULT '' NOT NULL,\n\t`confidence_threshold` integer DEFAULT 75 NOT NULL,\n\t`updated_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE TABLE `pending_inbound` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`raw_object_key` text NOT NULL,\n\t`envelope_from` text NOT NULL,\n\t`envelope_to` text NOT NULL,\n\t`list_address` text,\n\t`received_at` integer NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`ai_attempts` integer DEFAULT 0 NOT NULL,\n\t`next_attempt_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `pending_inbound_next_attempt_idx` ON `pending_inbound` (`next_attempt_at`,`received_at`);--> statement-breakpoint\nCREATE TABLE `pending_object_deletions` (\n\t`object_key` text PRIMARY KEY NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`next_attempt_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `pending_object_deletions_next_attempt_idx` ON `pending_object_deletions` (`next_attempt_at`);--> statement-breakpoint\nCREATE TABLE `pending_office_previews` (\n\t`source_key` text PRIMARY KEY NOT NULL,\n\t`email_id` text NOT NULL,\n\t`attachment_id` text NOT NULL,\n\t`attempts` integer DEFAULT 0 NOT NULL,\n\t`next_attempt_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `pending_office_previews_next_attempt_idx` ON `pending_office_previews` (`next_attempt_at`);--> statement-breakpoint\nCREATE TABLE `sender_feedback` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`address` text NOT NULL,\n\t`domain` text NOT NULL,\n\t`email_id` text,\n\t`label` text NOT NULL,\n\t`user_id` text,\n\t`created_at` integer NOT NULL\n);\n--> statement-breakpoint\nCREATE INDEX `sender_feedback_address_idx` ON `sender_feedback` (`address`,`created_at`);--> statement-breakpoint\nCREATE INDEX `sender_feedback_domain_idx` ON `sender_feedback` (`domain`);--> statement-breakpoint\nCREATE TABLE `senders` (\n\t`address` text PRIMARY KEY NOT NULL,\n\t`domain` text NOT NULL,\n\t`first_seen_at` integer,\n\t`last_seen_at` integer,\n\t`message_count` integer DEFAULT 0 NOT NULL,\n\t`outbound_count` integer DEFAULT 0 NOT NULL,\n\t`last_outbound_at` integer,\n\t`blocked_at` integer,\n\t`trusted_at` integer,\n\t`reported_at` integer\n);\n--> statement-breakpoint\nCREATE INDEX `senders_domain_idx` ON `senders` (`domain`);--> statement-breakpoint\nCREATE TABLE `unsubscriptions` (\n\t`list_key` text PRIMARY KEY NOT NULL,\n\t`email_id` text,\n\t`method` text NOT NULL,\n\t`status` text NOT NULL,\n\t`target` text,\n\t`detail` text,\n\t`user_id` text,\n\t`created_at` integer NOT NULL\n);--> statement-breakpoint\n-- Search index and conversation projection triggers; drizzle does not model them.\nCREATE VIRTUAL TABLE `email_search` USING fts5(\n\t`email_id` UNINDEXED,\n\t`conversation_id` UNINDEXED,\n\t`subject`,\n\t`addresses`,\n\t`body`,\n\ttokenize = 'unicode61 remove_diacritics 2'\n);--> statement-breakpoint\nCREATE TRIGGER `emails_after_insert`\nAFTER INSERT ON `emails`\nBEGIN\n\tINSERT INTO `conversations` (\n\t\t`id`,\n\t\t`latest_email_id`,\n\t\t`timeline_at`,\n\t\t`mailbox_state`,\n\t\t`folder_id`,\n\t\t`has_incoming`,\n\t\t`has_outgoing`\n\t)\n\tVALUES (\n\t\tNEW.`conversation_id`,\n\t\tNEW.`id`,\n\t\tNEW.`timeline_at`,\n\t\t'active',\n\t\tNULL,\n\t\tNEW.`direction` = 'incoming',\n\t\tNEW.`direction` = 'outgoing'\n\t)\n\tON CONFLICT(`id`) DO UPDATE SET\n\t\t`latest_email_id` = CASE\n\t\t\tWHEN NEW.`timeline_at` > `conversations`.`timeline_at`\n\t\t\t\tOR (\n\t\t\t\t\tNEW.`timeline_at` = `conversations`.`timeline_at`\n\t\t\t\t\tAND NEW.`id` > `conversations`.`latest_email_id`\n\t\t\t\t)\n\t\t\tTHEN NEW.`id`\n\t\t\tELSE `conversations`.`latest_email_id`\n\t\tEND,\n\t\t`timeline_at` = MAX(`conversations`.`timeline_at`, NEW.`timeline_at`),\n\t\t`mailbox_state` = CASE\n\t\t\tWHEN `conversations`.`mailbox_state` IN ('archive', 'trash')\n\t\t\t\tOR (\n\t\t\t\t\t`conversations`.`mailbox_state` = 'spam'\n\t\t\t\t\tAND NEW.`direction` = 'outgoing'\n\t\t\t\t)\n\t\t\tTHEN 'active'\n\t\t\tELSE `conversations`.`mailbox_state`\n\t\tEND,\n\t\t`has_incoming` = `conversations`.`has_incoming`\n\t\t\tOR NEW.`direction` = 'incoming',\n\t\t`has_outgoing` = `conversations`.`has_outgoing`\n\t\t\tOR NEW.`direction` = 'outgoing';\n\n\tINSERT INTO `email_search` (\n\t\t`email_id`,\n\t\t`conversation_id`,\n\t\t`subject`,\n\t\t`addresses`,\n\t\t`body`\n\t)\n\tVALUES (\n\t\tNEW.`id`,\n\t\tNEW.`conversation_id`,\n\t\tNEW.`subject`,\n\t\tCOALESCE(NEW.`from_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`reply_to_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`to_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`cc_json`, '') || ' '\n\t\t\t|| COALESCE(NEW.`bcc_json`, ''),\n\t\tCOALESCE(NEW.`body_text`, '') || ' ' || COALESCE(NEW.`preview`, '')\n\t);\nEND;\n",
6
+ },
7
+ };
8
+
9
+ /** When the newest migration was generated; the drizzle journal's high-water mark. */
10
+ export const LATEST_MAILBOX_MIGRATION = 1793490547954;
@@ -0,0 +1,65 @@
1
+ export {
2
+ deliveryStatuses,
3
+ type DeliveryStatusName,
4
+ type RecipientDeliveryStatus,
5
+ } from "../../shared/mail";
6
+
7
+ export type MailAddress = {
8
+ address: string;
9
+ name: string | null;
10
+ };
11
+
12
+ export type {
13
+ InboundAuthentication,
14
+ ListUnsubscribe,
15
+ } from "../../shared/mail-authentication";
16
+ export type { DeliveryFacts, SenderIntel } from "../../shared/mail-sender-intel";
17
+
18
+ /**
19
+ * Rows written before header parsing carried Cloudflare GraphQL analytics.
20
+ * They are re-derived from the stored original on first view.
21
+ */
22
+ export type LegacyEmailAuthentication = { source: "cloudflare" };
23
+
24
+ export type StoredEmailAuthentication =
25
+ | import("../../shared/mail-authentication").InboundAuthentication
26
+ | LegacyEmailAuthentication;
27
+
28
+ export type MailboxAiConfiguration = {
29
+ instructions: string;
30
+ confidenceThreshold: number;
31
+ };
32
+
33
+ export type EmailAiClassification = {
34
+ /** "workers-ai" on results stored before models became configurable. */
35
+ source: "ai" | "workers-ai";
36
+ model: string;
37
+ processedAt: number;
38
+ spam: boolean;
39
+ spamConfidence: number;
40
+ folderId: string | null;
41
+ folderConfidence: number;
42
+ reason: string;
43
+ };
44
+
45
+ /** What blocks and rules decided for an incoming message at ingestion. */
46
+ export type EmailScreening = {
47
+ blocked: "mailbox" | "workspace" | null;
48
+ ruleIds: string[];
49
+ /** Matching rules chose the destination, so AI sorting was skipped. */
50
+ ruleDestination: boolean;
51
+ };
52
+
53
+ export type StoredAttachment = {
54
+ id: string;
55
+ r2Key: string;
56
+ filename: string;
57
+ contentType: string;
58
+ size: number;
59
+ contentId: string | null;
60
+ disposition: "attachment" | "inline";
61
+ delivery: "attached" | "download_link";
62
+ downloadTokenHash: string | null;
63
+ downloadExpiresAt: number | null;
64
+ pdfPreviewStatus?: import("../../shared/office-preview").PdfPreviewStatus;
65
+ };
@@ -0,0 +1,73 @@
1
+ import {
2
+ MAX_OFFICE_PREVIEW_BYTES,
3
+ officePreviewExtension,
4
+ type OfficePreviewJob,
5
+ } from "../../shared/office-preview";
6
+ import type { NewEmail } from "./schema";
7
+
8
+ export function prepareOfficePreviews(email: NewEmail): NewEmail {
9
+ return {
10
+ ...email,
11
+ attachmentsJson: email.attachmentsJson?.map((file) => ({
12
+ ...file,
13
+ pdfPreviewStatus: officePreviewExtension(file.filename)
14
+ ? file.size <= MAX_OFFICE_PREVIEW_BYTES ? "pending" : "failed"
15
+ : undefined,
16
+ })),
17
+ };
18
+ }
19
+
20
+ export function hasPendingOfficePreviews(email: NewEmail) {
21
+ return email.attachmentsJson?.some((file) => file.pdfPreviewStatus === "pending") ?? false;
22
+ }
23
+
24
+ export function insertOfficePreviewJobs(sql: SqlStorage, email: NewEmail) {
25
+ for (const file of email.attachmentsJson ?? []) {
26
+ if (file.pdfPreviewStatus !== "pending") continue;
27
+ // Runs on the same SQLite connection inside the email insert transaction.
28
+ sql.exec(
29
+ `insert into pending_office_previews
30
+ (source_key, email_id, attachment_id, attempts, next_attempt_at)
31
+ values (?, ?, ?, 0, ?) on conflict do nothing`,
32
+ file.r2Key, email.id, file.id, Date.now(),
33
+ );
34
+ }
35
+ }
36
+
37
+ export async function dispatchOfficePreviewJobs(sql: SqlStorage, env: Env, mailboxId: string) {
38
+ const jobs = sql.exec<{
39
+ source_key: string; email_id: string; attachment_id: string; attempts: number;
40
+ }>(
41
+ "select * from pending_office_previews where next_attempt_at <= ? order by next_attempt_at limit 10",
42
+ Date.now(),
43
+ ).toArray();
44
+ if (!jobs.length) return;
45
+ try {
46
+ await env.OFFICE_PREVIEWS.sendBatch(jobs.map((job) => ({
47
+ body: {
48
+ mailboxId,
49
+ messageId: job.email_id,
50
+ attachmentId: job.attachment_id,
51
+ sourceKey: job.source_key,
52
+ } satisfies OfficePreviewJob,
53
+ })));
54
+ for (const job of jobs) {
55
+ sql.exec("delete from pending_office_previews where source_key = ?", job.source_key);
56
+ }
57
+ } catch (error) {
58
+ for (const job of jobs) {
59
+ const delay = Math.min(3_600_000, 5_000 * 2 ** Math.min(job.attempts, 10));
60
+ sql.exec(
61
+ "update pending_office_previews set attempts = attempts + 1, next_attempt_at = ? where source_key = ?",
62
+ Date.now() + delay, job.source_key,
63
+ );
64
+ }
65
+ console.error("Could not queue Office previews", error);
66
+ }
67
+ }
68
+
69
+ export function nextOfficePreviewAttempt(sql: SqlStorage) {
70
+ return sql.exec<{ next_attempt_at: number }>(
71
+ "select next_attempt_at from pending_office_previews order by next_attempt_at limit 1",
72
+ ).toArray()[0]?.next_attempt_at;
73
+ }
@@ -0,0 +1,110 @@
1
+ import {
2
+ MAX_MAIL_RULES,
3
+ ruleSubject,
4
+ type MailRule,
5
+ type MailRuleActions,
6
+ type MailRuleConditions,
7
+ type RuleSubject,
8
+ } from "../../shared/mail-rules";
9
+ import type { Email, NewEmail } from "./schema";
10
+
11
+ type RuleRow = {
12
+ id: string;
13
+ name: string;
14
+ sort_order: number;
15
+ enabled: number;
16
+ conditions_json: string;
17
+ actions_json: string;
18
+ };
19
+
20
+ export type MailRuleInput = {
21
+ name: string;
22
+ enabled: boolean;
23
+ conditions: MailRuleConditions;
24
+ actions: MailRuleActions;
25
+ };
26
+
27
+ export const MAIL_RULE_LIMIT_MESSAGE = `A mailbox can have at most ${MAX_MAIL_RULES} rules`;
28
+
29
+ function toRule(row: RuleRow): MailRule {
30
+ return {
31
+ id: row.id,
32
+ name: row.name,
33
+ enabled: Boolean(row.enabled),
34
+ conditions: JSON.parse(row.conditions_json) as MailRuleConditions,
35
+ actions: JSON.parse(row.actions_json) as MailRuleActions,
36
+ };
37
+ }
38
+
39
+ export function listMailRules(sql: SqlStorage): MailRule[] {
40
+ return sql.exec<RuleRow>(
41
+ "select id, name, sort_order, enabled, conditions_json, actions_json from mail_rules order by sort_order, created_at, id",
42
+ ).toArray().map(toRule);
43
+ }
44
+
45
+ /** Drops a folder action whose folder no longer exists, so a rule never files into nothing. */
46
+ export function withExistingFolder(sql: SqlStorage, actions: MailRuleActions): MailRuleActions {
47
+ if (!actions.folderId) return actions;
48
+ const exists = sql.exec("select 1 from folders where id = ?", actions.folderId).toArray().length > 0;
49
+ if (exists) return actions;
50
+ const { folderId: _folderId, ...rest } = actions;
51
+ return rest;
52
+ }
53
+
54
+ export function insertMailRule(sql: SqlStorage, id: string, input: MailRuleInput, now: number) {
55
+ const count = Number(sql.exec<{ count: number }>("select count(*) as count from mail_rules").one().count);
56
+ if (count >= MAX_MAIL_RULES) throw new Error(MAIL_RULE_LIMIT_MESSAGE);
57
+ const last = sql.exec<{ value: number | null }>("select max(sort_order) as value from mail_rules").one().value;
58
+ sql.exec(
59
+ `insert into mail_rules (id, name, sort_order, enabled, conditions_json, actions_json, created_at, updated_at)
60
+ values (?, ?, ?, ?, ?, ?, ?, ?)`,
61
+ id,
62
+ input.name,
63
+ (last ?? 90) + 10,
64
+ input.enabled ? 1 : 0,
65
+ JSON.stringify(input.conditions),
66
+ JSON.stringify(withExistingFolder(sql, input.actions)),
67
+ now,
68
+ now,
69
+ );
70
+ }
71
+
72
+ export function updateMailRule(sql: SqlStorage, id: string, input: MailRuleInput, now: number) {
73
+ return sql.exec(
74
+ `update mail_rules set name = ?, enabled = ?, conditions_json = ?, actions_json = ?, updated_at = ?
75
+ where id = ? returning id`,
76
+ input.name,
77
+ input.enabled ? 1 : 0,
78
+ JSON.stringify(input.conditions),
79
+ JSON.stringify(withExistingFolder(sql, input.actions)),
80
+ now,
81
+ id,
82
+ ).toArray().length > 0;
83
+ }
84
+
85
+ export function deleteMailRule(sql: SqlStorage, id: string) {
86
+ return sql.exec("delete from mail_rules where id = ? returning id", id).toArray().length > 0;
87
+ }
88
+
89
+ /** Requires the complete current id list so a stale client cannot drop rules from the order. */
90
+ export function reorderMailRules(sql: SqlStorage, ids: string[]) {
91
+ const current = sql.exec<{ id: string }>("select id from mail_rules").toArray().map((row) => row.id);
92
+ if (current.length !== ids.length || new Set(ids).size !== ids.length || !ids.every((id) => current.includes(id))) {
93
+ return false;
94
+ }
95
+ ids.forEach((id, index) => {
96
+ sql.exec("update mail_rules set sort_order = ? where id = ?", (index + 1) * 10, id);
97
+ });
98
+ return true;
99
+ }
100
+
101
+ /** Rule facts for a stored or about-to-be-stored message. */
102
+ export function emailRuleSubject(email: Pick<NewEmail | Email, "fromJson" | "toJson" | "ccJson" | "subject" | "listId" | "attachmentsJson">): RuleSubject {
103
+ return ruleSubject({
104
+ from: email.fromJson[0]?.address,
105
+ recipients: [...(email.toJson ?? []), ...(email.ccJson ?? [])].map((recipient) => recipient.address),
106
+ subject: email.subject,
107
+ listId: email.listId,
108
+ hasAttachment: (email.attachmentsJson ?? []).some((attachment) => attachment.disposition === "attachment"),
109
+ });
110
+ }