@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,446 @@
1
+ import type { z } from "zod";
2
+ import {
3
+ attachmentsRequiringDownloadLinks,
4
+ composerAttachmentLimitError,
5
+ isComposerInlineImageContentType,
6
+ } from "../../shared/mail";
7
+ import { hashToken, randomToken } from "@open-workspace/core/worker/lib/crypto.ts";
8
+ import type { StoredAttachment } from "../mailbox/model";
9
+ import type { Email, NewEmail } from "../mailbox/schema";
10
+ import {
11
+ appendForwardedMessage,
12
+ contextForNewRecipient,
13
+ escapeHtml,
14
+ externalizeLinkedInlineImages,
15
+ htmlWithQuotedContext,
16
+ resolveLinkedAttachmentPlaceholders,
17
+ textWithQuotedContext,
18
+ } from "./outbound-content";
19
+ import {
20
+ address,
21
+ replyThreadHeaders,
22
+ } from "./rfc";
23
+ import type { composeSchema } from "./schemas";
24
+ import {
25
+ copyStoredAttachmentToMessage,
26
+ loadComposerUpload,
27
+ loadComposerUploadMetadata,
28
+ UploadValidationError,
29
+ } from "./uploads";
30
+
31
+ const DOWNLOAD_TTL_MS = 30 * 24 * 60 * 60 * 1000;
32
+
33
+ export type OutgoingMessageInput = z.infer<typeof composeSchema> & {
34
+ /** Set by the route from the authenticated session. */
35
+ userId: string;
36
+ };
37
+ type PreparedAttachment = StoredAttachment & {
38
+ downloadUrl: string | null;
39
+ sourceUploadId?: string;
40
+ sourceObjectKey?: string;
41
+ };
42
+
43
+ export class ComposerAttachmentLimitError extends Error {}
44
+
45
+ type OutgoingAttachmentDraft = Pick<
46
+ OutgoingMessageInput,
47
+ | "attachments"
48
+ | "bodyHtml"
49
+ | "bodyText"
50
+ | "mailboxId"
51
+ | "subject"
52
+ | "userId"
53
+ >;
54
+
55
+ export async function discardPreparedObjects(env: Env, storageKeys: string[]) {
56
+ if (!storageKeys.length) return;
57
+ try {
58
+ await env.MAIL_STORAGE.delete(storageKeys);
59
+ } catch (error) {
60
+ // Attempt-scoped keys cannot corrupt the winning idempotent request.
61
+ console.error("Could not remove unused outgoing mail objects", error);
62
+ }
63
+ }
64
+
65
+ export async function outgoingRequestFingerprint(compose: OutgoingMessageInput) {
66
+ const { userId: _, ...payload } = compose;
67
+ return hashToken(`outgoing-request-v2\0${JSON.stringify(payload)}`);
68
+ }
69
+
70
+ export { attachmentsRequiringDownloadLinks };
71
+
72
+ export function withDownloadLinks(
73
+ bodyText: string,
74
+ bodyHtml: string | undefined,
75
+ attachments: PreparedAttachment[],
76
+ expiresAt: Date,
77
+ ) {
78
+ const positioned = resolveLinkedAttachmentPlaceholders({
79
+ bodyText,
80
+ bodyHtml,
81
+ attachments: attachments.map((attachment) => ({
82
+ uploadId: attachment.sourceUploadId ?? null,
83
+ filename: attachment.filename,
84
+ downloadUrl: attachment.downloadUrl,
85
+ })),
86
+ });
87
+ const links = attachments.filter(
88
+ (attachment): attachment is PreparedAttachment & { downloadUrl: string } =>
89
+ attachment.downloadUrl !== null,
90
+ );
91
+ if (!links.length) {
92
+ return {
93
+ bodyText: positioned.bodyText,
94
+ bodyHtml: positioned.bodyHtml,
95
+ };
96
+ }
97
+
98
+ const expiry = expiresAt.toISOString().slice(0, 10);
99
+ const fallbackTextLinks = links.filter(
100
+ (attachment) =>
101
+ !attachment.sourceUploadId
102
+ || !positioned.textPlacedUploadIds.has(attachment.sourceUploadId),
103
+ );
104
+ const textBlock = fallbackTextLinks.length
105
+ ? [
106
+ `Attachments (download links expire ${expiry}):`,
107
+ ...fallbackTextLinks.map((attachment) =>
108
+ `- ${attachment.filename}: ${attachment.downloadUrl}`
109
+ ),
110
+ ].join("\n")
111
+ : "";
112
+ const nextText = textBlock
113
+ ? `${positioned.bodyText.trimEnd()}${positioned.bodyText.trimEnd() ? "\n\n" : ""}${textBlock}`
114
+ : positioned.bodyText;
115
+ if (!positioned.bodyHtml) {
116
+ return { bodyText: nextText, bodyHtml: positioned.bodyHtml };
117
+ }
118
+
119
+ const inlineImagePlacements = new Set(
120
+ links.flatMap((attachment) =>
121
+ attachment.disposition === "inline"
122
+ && attachment.contentId
123
+ && positioned.bodyHtml!.includes(`cid:${attachment.contentId}`)
124
+ ? [attachment.id]
125
+ : []
126
+ ),
127
+ );
128
+ const nextHtml = externalizeLinkedInlineImages(positioned.bodyHtml, links);
129
+ const fallbackHtmlLinks = links.filter(
130
+ (attachment) =>
131
+ (
132
+ !attachment.sourceUploadId
133
+ || !positioned.htmlPlacedUploadIds.has(attachment.sourceUploadId)
134
+ )
135
+ && !inlineImagePlacements.has(attachment.id),
136
+ );
137
+ if (!fallbackHtmlLinks.length) {
138
+ return { bodyText: nextText, bodyHtml: nextHtml };
139
+ }
140
+
141
+ const htmlBlock = [
142
+ `<p>Attachments (download links expire ${escapeHtml(expiry)}):</p>`,
143
+ "<ul>",
144
+ ...fallbackHtmlLinks.map((attachment) =>
145
+ `<li><a href="${escapeHtml(attachment.downloadUrl)}">${escapeHtml(attachment.filename)}</a></li>`,
146
+ ),
147
+ "</ul>",
148
+ ].join("");
149
+ return { bodyText: nextText, bodyHtml: `${nextHtml}${htmlBlock}` };
150
+ }
151
+
152
+ export async function messageIdForRequest(mailboxId: string, requestId: string) {
153
+ return `msg_${await hashToken(`${mailboxId}:${requestId}`)}`;
154
+ }
155
+
156
+ async function loadPreparedComposerAttachments(input: {
157
+ env: Env;
158
+ compose: OutgoingAttachmentDraft;
159
+ storagePrefix: string;
160
+ finalized: boolean;
161
+ }) {
162
+ const loadUpload = input.finalized
163
+ ? loadComposerUpload
164
+ : loadComposerUploadMetadata;
165
+ return Promise.all(
166
+ input.compose.attachments.map(async (file, index) => {
167
+ try {
168
+ const upload = await loadUpload({
169
+ env: input.env,
170
+ mailboxId: input.compose.mailboxId,
171
+ userId: input.compose.userId,
172
+ uploadId: file.uploadId,
173
+ });
174
+ if (
175
+ file.disposition === "inline"
176
+ && !isComposerInlineImageContentType(upload.contentType)
177
+ ) {
178
+ throw new UploadValidationError(
179
+ "Inline images must be PNG, JPEG, GIF, or WebP",
180
+ );
181
+ }
182
+ return {
183
+ id: `att_${index + 1}`,
184
+ sourceUploadId: file.uploadId,
185
+ sourceObjectKey: upload.r2Key,
186
+ r2Key: `${input.storagePrefix}/attachments/att_${index + 1}`,
187
+ filename: upload.filename,
188
+ contentType: upload.contentType,
189
+ size: upload.size,
190
+ contentId: file.contentId ?? null,
191
+ disposition: file.disposition,
192
+ delivery: "attached" as const,
193
+ downloadTokenHash: null,
194
+ downloadExpiresAt: null,
195
+ downloadUrl: null,
196
+ } satisfies PreparedAttachment;
197
+ } catch (error) {
198
+ if (error instanceof UploadValidationError) {
199
+ throw new ComposerAttachmentLimitError(error.message);
200
+ }
201
+ throw error;
202
+ }
203
+ }),
204
+ );
205
+ }
206
+
207
+ function planOutgoingAttachments(input: {
208
+ compose: OutgoingAttachmentDraft;
209
+ uploaded: PreparedAttachment[];
210
+ forwarded: Email | null;
211
+ related: Email | null;
212
+ includeRelatedContext: boolean;
213
+ storagePrefix?: string;
214
+ }) {
215
+ // A persisted forward gets message-owned copies. Preflight has no storage
216
+ // prefix and only needs attachment metadata for the delivery plan.
217
+ const forwardedAttachments: PreparedAttachment[] = (
218
+ input.forwarded?.attachmentsJson ?? []
219
+ ).map((file, index) => {
220
+ const id = `fwd_att_${index + 1}`;
221
+ return {
222
+ ...file,
223
+ id,
224
+ r2Key: input.storagePrefix
225
+ ? `${input.storagePrefix}/attachments/${id}`
226
+ : file.r2Key,
227
+ sourceObjectKey: input.storagePrefix ? file.r2Key : undefined,
228
+ contentId: null,
229
+ disposition: "attachment",
230
+ delivery: "attached",
231
+ downloadTokenHash: null,
232
+ downloadExpiresAt: null,
233
+ downloadUrl: null,
234
+ };
235
+ });
236
+ const attachments = [...input.uploaded, ...forwardedAttachments];
237
+ const limitError = composerAttachmentLimitError(attachments);
238
+ if (limitError) throw new ComposerAttachmentLimitError(limitError);
239
+ const forwardedContent = appendForwardedMessage(
240
+ input.compose.bodyText,
241
+ input.compose.bodyHtml,
242
+ input.forwarded,
243
+ );
244
+ const quotedText = input.includeRelatedContext && input.related
245
+ ? contextForNewRecipient(input.related)
246
+ : null;
247
+ const linkedIds = attachmentsRequiringDownloadLinks({
248
+ subject: input.compose.subject,
249
+ bodyText: textWithQuotedContext(forwardedContent.bodyText, quotedText),
250
+ bodyHtml: forwardedContent.bodyHtml
251
+ ? htmlWithQuotedContext(forwardedContent.bodyHtml, quotedText)
252
+ : undefined,
253
+ attachments,
254
+ });
255
+ return {
256
+ attachments,
257
+ forwardedContent,
258
+ linkedIds,
259
+ quotedText,
260
+ };
261
+ }
262
+
263
+ export async function preflightOutgoingAttachments(input: {
264
+ env: Env;
265
+ compose: OutgoingAttachmentDraft;
266
+ forwarded: Email | null;
267
+ related: Email | null;
268
+ includeRelatedContext: boolean;
269
+ }) {
270
+ const uploaded = await loadPreparedComposerAttachments({
271
+ env: input.env,
272
+ compose: input.compose,
273
+ storagePrefix: "preflight",
274
+ finalized: false,
275
+ });
276
+ const plan = planOutgoingAttachments({
277
+ ...input,
278
+ uploaded,
279
+ });
280
+ return {
281
+ externalizedAttachments: plan.linkedIds.size,
282
+ linkedUploadIds: uploaded.flatMap((attachment) =>
283
+ plan.linkedIds.has(attachment.id) && attachment.sourceUploadId
284
+ ? [attachment.sourceUploadId]
285
+ : []
286
+ ),
287
+ };
288
+ }
289
+
290
+ export async function prepareOutgoingEmail(input: {
291
+ env: Env;
292
+ requestUrl: string;
293
+ compose: OutgoingMessageInput;
294
+ requestFingerprint: string;
295
+ id: string;
296
+ conversationId: string;
297
+ related: Email | null;
298
+ forwarded: Email | null;
299
+ includeRelatedContext: boolean;
300
+ fromAddress: string;
301
+ fromName: string;
302
+ now: Date;
303
+ }) {
304
+ const { compose, id, related } = input;
305
+ const storageAttempt = randomToken();
306
+ const storagePrefix = `mailboxes/${compose.mailboxId}/messages/${id}/attempts/${storageAttempt}`;
307
+ const expiresAt = new Date(input.now.getTime() + DOWNLOAD_TTL_MS);
308
+
309
+ const uploaded = await loadPreparedComposerAttachments({
310
+ env: input.env,
311
+ compose,
312
+ storagePrefix,
313
+ finalized: true,
314
+ });
315
+ const plan = planOutgoingAttachments({
316
+ compose,
317
+ uploaded,
318
+ forwarded: input.forwarded,
319
+ related,
320
+ includeRelatedContext: input.includeRelatedContext,
321
+ storagePrefix,
322
+ });
323
+ const {
324
+ forwardedContent,
325
+ linkedIds,
326
+ quotedText,
327
+ } = plan;
328
+ const attachments: PreparedAttachment[] = await Promise.all(
329
+ plan.attachments.map(async (file) => {
330
+ if (!linkedIds.has(file.id)) return file;
331
+ const token = `${compose.mailboxId}.${id}.${file.id}.${randomToken()}`;
332
+ return {
333
+ ...file,
334
+ delivery: "download_link" as const,
335
+ downloadTokenHash: await hashToken(token),
336
+ downloadExpiresAt: expiresAt.getTime(),
337
+ downloadUrl: new URL(
338
+ `/api/downloads/mail/${encodeURIComponent(token)}`,
339
+ input.requestUrl,
340
+ ).toString(),
341
+ };
342
+ }),
343
+ );
344
+ const content = withDownloadLinks(
345
+ forwardedContent.bodyText,
346
+ forwardedContent.bodyHtml,
347
+ attachments,
348
+ expiresAt,
349
+ );
350
+ // The stored body is the authored content only. Quoted context is persisted
351
+ // separately and added to the transport payload at delivery time.
352
+ const storedBodyHtml = content.bodyHtml;
353
+ const bodyHtmlR2Key = storedBodyHtml
354
+ ? `${storagePrefix}/body.html`
355
+ : null;
356
+
357
+ const storageKeys = [
358
+ ...attachments.flatMap((file) =>
359
+ file.sourceObjectKey ? [file.r2Key] : []
360
+ ),
361
+ ...(bodyHtmlR2Key ? [bodyHtmlR2Key] : []),
362
+ ];
363
+ const writes = [
364
+ ...attachments.flatMap((file) => {
365
+ if (!file.sourceObjectKey) return [];
366
+ return [
367
+ copyStoredAttachmentToMessage({
368
+ env: input.env,
369
+ sourceKey: file.sourceObjectKey,
370
+ destinationKey: file.r2Key,
371
+ contentType: file.contentType,
372
+ }),
373
+ ];
374
+ }),
375
+ ...(bodyHtmlR2Key && storedBodyHtml
376
+ ? [input.env.MAIL_STORAGE.put(bodyHtmlR2Key, storedBodyHtml, {
377
+ httpMetadata: { contentType: "text/html; charset=utf-8" },
378
+ })]
379
+ : []),
380
+ ];
381
+ const writeResults = await Promise.allSettled(writes);
382
+ const failedWrite = writeResults.find(
383
+ (result): result is PromiseRejectedResult => result.status === "rejected",
384
+ );
385
+ if (failedWrite) {
386
+ await discardPreparedObjects(input.env, storageKeys);
387
+ throw failedWrite.reason;
388
+ }
389
+
390
+ const from = address(input.fromAddress, input.fromName);
391
+ const to = compose.to.map((value) => address(value));
392
+ const cc = compose.cc.map((value) => address(value));
393
+ const bcc = compose.bcc.map((value) => address(value));
394
+ const replyTo = compose.replyTo ? [address(compose.replyTo)] : [];
395
+ const threadHeaders = related
396
+ ? replyThreadHeaders(related)
397
+ : { inReplyTo: [], references: [] };
398
+ const continuedList = related?.conversationId === input.conversationId
399
+ ? related
400
+ : null;
401
+ const email: NewEmail = {
402
+ id,
403
+ requestFingerprint: input.requestFingerprint,
404
+ conversationId: input.conversationId,
405
+ direction: "outgoing",
406
+ messageIdHeader: null,
407
+ inReplyToJson: threadHeaders.inReplyTo,
408
+ referencesJson: threadHeaders.references,
409
+ fromJson: [from],
410
+ replyToJson: replyTo,
411
+ toJson: to,
412
+ ccJson: cc,
413
+ bccJson: bcc,
414
+ subject: compose.subject || "(no subject)",
415
+ preview: content.bodyText
416
+ .replace(/\s+/gu, " ")
417
+ .trim()
418
+ .slice(0, 220),
419
+ bodyText: content.bodyText,
420
+ quotedText,
421
+ bodyHtmlR2Key,
422
+ attachmentsJson: attachments.map(
423
+ ({
424
+ downloadUrl: _,
425
+ sourceUploadId: __,
426
+ sourceObjectKey: ___,
427
+ ...file
428
+ }) => file,
429
+ ),
430
+ listId: continuedList?.listId ?? null,
431
+ listPostAddress: continuedList?.listPostAddress ?? null,
432
+ timelineAt: input.now,
433
+ transportState: "unconfirmed",
434
+ deliveryStatusJson: [],
435
+ };
436
+ return {
437
+ email,
438
+ externalizedAttachments: linkedIds.size,
439
+ // Every key is owned by this attempt, including forwarded attachment
440
+ // copies, so rollback cannot remove an object owned by another message.
441
+ storageKeys,
442
+ composerUploadIds: uploaded.flatMap((file) =>
443
+ file.sourceUploadId ? [file.sourceUploadId] : []
444
+ ),
445
+ };
446
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ forwardSubject,
3
+ MAX_MAIL_RECIPIENTS,
4
+ replySubject,
5
+ type ReplyActionMode,
6
+ } from "../../shared/mail";
7
+ import { mailboxStub } from "../mailbox";
8
+ import type { Email } from "../mailbox/schema";
9
+ import { hasNewRecipients, shouldDetachOutboundReply } from "./outbound-threading";
10
+ import {
11
+ dedupeRecipientFields,
12
+ recipientCount,
13
+ type RecipientFields,
14
+ } from "./recipients";
15
+ import { buildReplyPlan, canReplyFrom } from "./reply-plan";
16
+
17
+ type OutgoingContextCode = "BAD_REQUEST" | "NOT_FOUND" | "NOT_READY";
18
+
19
+ export class OutgoingContextError extends Error {
20
+ constructor(
21
+ readonly status: 400 | 404 | 409,
22
+ readonly code: OutgoingContextCode,
23
+ message: string,
24
+ ) {
25
+ super(message);
26
+ }
27
+ }
28
+
29
+ export async function resolveForwardContext(input: {
30
+ env: Env;
31
+ mailboxId: string;
32
+ sourceEmailId: string;
33
+ }) {
34
+ const source = await mailboxStub(input.env, input.mailboxId)
35
+ .getEmail(input.sourceEmailId);
36
+ if (!source) {
37
+ throw new OutgoingContextError(
38
+ 404,
39
+ "NOT_FOUND",
40
+ "Forwarded message was not found",
41
+ );
42
+ }
43
+ return {
44
+ source,
45
+ subject: forwardSubject(source.subject),
46
+ };
47
+ }
48
+
49
+ export async function resolveReplyContext(input: {
50
+ env: Env;
51
+ mailboxId: string;
52
+ ownAddress: string;
53
+ sourceEmailId: string;
54
+ mode: ReplyActionMode;
55
+ cc?: string[];
56
+ bcc: string[];
57
+ }): Promise<{
58
+ source: Email;
59
+ recipients: RecipientFields;
60
+ subject: string;
61
+ detached: boolean;
62
+ includeRelatedContext: boolean;
63
+ }> {
64
+ const stub = mailboxStub(input.env, input.mailboxId);
65
+ const source = await stub.getEmail(input.sourceEmailId);
66
+ if (!source) {
67
+ throw new OutgoingContextError(
68
+ 404,
69
+ "NOT_FOUND",
70
+ "Reply source was not found",
71
+ );
72
+ }
73
+ if (!canReplyFrom(source)) {
74
+ throw new OutgoingContextError(
75
+ 409,
76
+ "NOT_READY",
77
+ "Wait until the parent message has a confirmed Message-ID",
78
+ );
79
+ }
80
+
81
+ const plan = buildReplyPlan(input.ownAddress, source);
82
+ const action = plan.actions.find(
83
+ (candidate) => candidate.mode === input.mode,
84
+ );
85
+ if (!action) {
86
+ throw new OutgoingContextError(
87
+ 400,
88
+ "BAD_REQUEST",
89
+ "Reply mode is not available",
90
+ );
91
+ }
92
+ const recipients = dedupeRecipientFields({
93
+ to: action.to,
94
+ cc: input.cc ?? action.cc,
95
+ bcc: input.bcc,
96
+ });
97
+ if (recipientCount(recipients) > MAX_MAIL_RECIPIENTS) {
98
+ throw new OutgoingContextError(
99
+ 400,
100
+ "BAD_REQUEST",
101
+ `An email can have at most ${MAX_MAIL_RECIPIENTS} recipients across To, Cc, and Bcc`,
102
+ );
103
+ }
104
+
105
+ const history = await stub.getConversation(source.conversationId);
106
+ return {
107
+ source,
108
+ recipients,
109
+ subject: replySubject(source.subject),
110
+ detached: shouldDetachOutboundReply({
111
+ ownAddress: input.ownAddress,
112
+ plan,
113
+ to: recipients.to,
114
+ cc: recipients.cc,
115
+ }),
116
+ includeRelatedContext: hasNewRecipients({
117
+ ownAddress: input.ownAddress,
118
+ history,
119
+ ...recipients,
120
+ }),
121
+ };
122
+ }
@@ -0,0 +1,115 @@
1
+ import { normalizeEmail, normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
2
+ import type { MailAddress } from "../mailbox/model";
3
+
4
+ export type ParticipantSource = {
5
+ fromJson: MailAddress[];
6
+ toJson: MailAddress[];
7
+ ccJson: MailAddress[];
8
+ bccJson: MailAddress[];
9
+ };
10
+
11
+ export type ParticipantSuggestion = {
12
+ address: string;
13
+ name: string | null;
14
+ };
15
+
16
+ export function normalizedParticipantSet(
17
+ values: Iterable<string>,
18
+ ownAddress: string,
19
+ ) {
20
+ const own = normalizeMailboxAddress(ownAddress);
21
+ const result = new Set<string>();
22
+ for (const value of values) {
23
+ const normalized = normalizeEmail(value);
24
+ if (
25
+ normalized
26
+ && normalizeMailboxAddress(normalized) !== own
27
+ ) {
28
+ result.add(normalized);
29
+ }
30
+ }
31
+ return result;
32
+ }
33
+
34
+ export function uniqueParticipantAddresses(
35
+ values: Iterable<MailAddress>,
36
+ ownAddress: string,
37
+ ) {
38
+ return [
39
+ ...normalizedParticipantSet(
40
+ Array.from(values, (value) => value.address),
41
+ ownAddress,
42
+ ),
43
+ ];
44
+ }
45
+
46
+ export function sameParticipantSet(
47
+ left: ReadonlySet<string>,
48
+ right: ReadonlySet<string>,
49
+ ) {
50
+ return left.size === right.size
51
+ && [...left].every((value) => right.has(value));
52
+ }
53
+
54
+ export function participantLabels(
55
+ values: Iterable<MailAddress>,
56
+ ownAddress: string,
57
+ ) {
58
+ const own = normalizeMailboxAddress(ownAddress);
59
+ const result = new Map<string, string>();
60
+ for (const participant of values) {
61
+ const normalized = normalizeEmail(participant.address);
62
+ if (
63
+ !normalized
64
+ || normalizeMailboxAddress(normalized) === own
65
+ || result.has(normalized)
66
+ ) continue;
67
+ result.set(
68
+ normalized,
69
+ participant.name?.trim() || participant.address,
70
+ );
71
+ }
72
+ return [...result.values()];
73
+ }
74
+
75
+ export function suggestParticipants(
76
+ messages: Iterable<ParticipantSource>,
77
+ ownAddress: string,
78
+ query: string,
79
+ limit: number,
80
+ /** Blocked or reported correspondents are never suggested. */
81
+ exclude: (address: string) => boolean = () => false,
82
+ ): ParticipantSuggestion[] {
83
+ const own = normalizeMailboxAddress(ownAddress);
84
+ const needle = query.trim().toLocaleLowerCase("en-US");
85
+ const result = new Map<string, ParticipantSuggestion>();
86
+
87
+ for (const message of messages) {
88
+ for (const participant of [
89
+ ...message.fromJson,
90
+ ...message.toJson,
91
+ ...message.ccJson,
92
+ ...message.bccJson,
93
+ ]) {
94
+ const address = normalizeEmail(participant.address);
95
+ if (
96
+ !address
97
+ || normalizeMailboxAddress(address) === own
98
+ || result.has(address)
99
+ || exclude(address.toLocaleLowerCase("en-US"))
100
+ ) continue;
101
+ const name = participant.name?.trim() || null;
102
+ if (
103
+ needle
104
+ && !address.toLocaleLowerCase("en-US").includes(needle)
105
+ && !name?.toLocaleLowerCase("en-US").includes(needle)
106
+ ) {
107
+ continue;
108
+ }
109
+ result.set(address, { address, name });
110
+ if (result.size >= limit) return [...result.values()];
111
+ }
112
+ }
113
+
114
+ return [...result.values()];
115
+ }