@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,38 @@
1
+ import { MAX_OFFICE_PREVIEW_BYTES, officePreviewKey } from "../../shared/office-preview";
2
+
3
+ export type OfficeConversionResult = "ready" | "failed";
4
+
5
+ export async function convertOfficeDocument(
6
+ bucket: R2Bucket,
7
+ send: (request: Request) => Promise<Response>,
8
+ sourceKey: string,
9
+ extension: string,
10
+ ): Promise<OfficeConversionResult> {
11
+ const source = await bucket.get(sourceKey);
12
+ if (!source) return "failed";
13
+ // FormData buffers the upload in the Worker; cap it before reading the body.
14
+ if (source.size > MAX_OFFICE_PREVIEW_BYTES) {
15
+ await source.body.cancel();
16
+ return "failed";
17
+ }
18
+ const form = new FormData();
19
+ form.append("files", new Blob([await source.arrayBuffer()]), `document.${extension}`);
20
+ const response = await send(new Request("http://container/forms/libreoffice/convert", {
21
+ method: "POST",
22
+ body: form,
23
+ signal: AbortSignal.timeout(120_000),
24
+ }));
25
+ if (!response.ok) {
26
+ await response.body?.cancel();
27
+ if ([400, 403, 415, 422].includes(response.status)) return "failed";
28
+ throw new Error(`Office conversion failed with status ${response.status}`);
29
+ }
30
+ if (!response.body || response.headers.get("content-type")?.split(";", 1)[0] !== "application/pdf") {
31
+ await response.body?.cancel();
32
+ throw new Error("Office converter did not return a PDF");
33
+ }
34
+ await bucket.put(officePreviewKey(sourceKey), response.body, {
35
+ httpMetadata: { contentType: "application/pdf" },
36
+ });
37
+ return "ready";
38
+ }
@@ -0,0 +1,57 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { officePreviewExtension, officePreviewKey } from "../../shared/office-preview";
3
+ import { convertOfficeDocument, type OfficeConversionResult } from "./office-conversion";
4
+
5
+ export class OfficePreviewContainer extends DurableObject<Env> {
6
+ private conversion: Promise<OfficeConversionResult> | null = null;
7
+
8
+ async convert(sourceKey: string, extension: string) {
9
+ if (officePreviewExtension(`document.${extension}`) !== extension) return "failed" as const;
10
+ // Queue redelivery can overlap. Each attachment uses its own Durable Object,
11
+ // so duplicate requests share one conversion and cannot stop each other's container.
12
+ if (this.conversion) return this.conversion;
13
+ this.conversion = this.runConversion(sourceKey, extension);
14
+ try {
15
+ return await this.conversion;
16
+ } finally {
17
+ this.conversion = null;
18
+ }
19
+ }
20
+
21
+ private async runConversion(sourceKey: string, extension: string): Promise<OfficeConversionResult> {
22
+ if (await this.env.MAIL_STORAGE.head(officePreviewKey(sourceKey))) return "ready";
23
+ const container = this.ctx.container;
24
+ if (!container) throw new Error("Office preview container is not configured");
25
+ try {
26
+ if (!container.running) container.start({ enableInternet: false });
27
+ await container.setInactivityTimeout(10_000);
28
+ const port = container.getTcpPort(3000);
29
+ await waitForConverter(port);
30
+ return await convertOfficeDocument(
31
+ this.env.MAIL_STORAGE,
32
+ (request) => port.fetch(request),
33
+ sourceKey,
34
+ extension,
35
+ );
36
+ } finally {
37
+ // R2 has consumed the PDF before this runs; no container is kept warm.
38
+ await container.destroy();
39
+ }
40
+ }
41
+ }
42
+
43
+ async function waitForConverter(port: Fetcher) {
44
+ for (let attempt = 0; attempt < 120; attempt += 1) {
45
+ try {
46
+ const response = await port.fetch("http://container/health", {
47
+ signal: AbortSignal.timeout(1_000),
48
+ });
49
+ await response.body?.cancel();
50
+ if (response.ok) return;
51
+ } catch {
52
+ // The container process starts before its HTTP port is ready.
53
+ }
54
+ await scheduler.wait(500);
55
+ }
56
+ throw new Error("Office preview container did not become ready");
57
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { officePreviewExtension } from "../../shared/office-preview";
3
+ import { mailboxStub } from "../mailbox";
4
+
5
+ const jobSchema = z.object({
6
+ mailboxId: z.string().min(1),
7
+ messageId: z.string().min(1),
8
+ attachmentId: z.string().min(1),
9
+ sourceKey: z.string().min(1),
10
+ });
11
+
12
+ export async function consumeOfficePreviews(batch: MessageBatch<unknown>, env: Env) {
13
+ for (const message of batch.messages) {
14
+ const parsed = jobSchema.safeParse(message.body);
15
+ if (!parsed.success) {
16
+ message.ack();
17
+ continue;
18
+ }
19
+ const job = parsed.data;
20
+ const mailbox = mailboxStub(env, job.mailboxId);
21
+ try {
22
+ const file = await mailbox.getAttachment(job.messageId, job.attachmentId);
23
+ if (!file || file.r2Key !== job.sourceKey) {
24
+ // A previous attempt may have produced a PDF after the email was deleted.
25
+ await mailbox.completeOfficePreview(job.messageId, job.attachmentId, job.sourceKey, "failed");
26
+ message.ack();
27
+ continue;
28
+ }
29
+ if (file.pdfPreviewStatus !== "pending") {
30
+ message.ack();
31
+ continue;
32
+ }
33
+ const extension = officePreviewExtension(file.filename);
34
+ const status = extension
35
+ ? await env.OFFICE_PREVIEW_CONTAINER.getByName(job.sourceKey).convert(job.sourceKey, extension)
36
+ : "failed";
37
+ await mailbox.completeOfficePreview(job.messageId, job.attachmentId, job.sourceKey, status);
38
+ message.ack();
39
+ } catch (error) {
40
+ console.error("Could not generate Office preview", { messageId: job.messageId, error });
41
+ if (message.attempts < 3) {
42
+ message.retry({ delaySeconds: 30 * message.attempts });
43
+ continue;
44
+ }
45
+ await mailbox.completeOfficePreview(job.messageId, job.attachmentId, job.sourceKey, "failed");
46
+ message.ack();
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,15 @@
1
+ import { getDomain } from "tldts";
2
+ import { addressDomain } from "../../shared/mail-headers";
3
+
4
+ /** Organizational domain (Public Suffix List registrable domain) of a host or address. */
5
+ export function organizationalDomain(value: string | null | undefined) {
6
+ const domain = addressDomain(value);
7
+ if (!domain) return null;
8
+ return getDomain(domain) ?? domain;
9
+ }
10
+
11
+ /** DMARC relaxed alignment: both sides share one organizational domain. */
12
+ export function relaxedAligned(left: string | null | undefined, right: string | null | undefined) {
13
+ const a = organizationalDomain(left);
14
+ return Boolean(a) && a === organizationalDomain(right);
15
+ }
@@ -0,0 +1,208 @@
1
+ import type { Email } from "../mailbox/schema";
2
+ import {
3
+ LINKED_ATTACHMENT_TEXT_PREFIX,
4
+ linkedAttachmentTextToken,
5
+ } from "../../shared/mail";
6
+
7
+ const MAX_CONTEXT_CHARACTERS = 24_000;
8
+
9
+ type ContentSource = Pick<
10
+ Email,
11
+ "fromJson" | "toJson" | "ccJson" | "subject" | "timelineAt" | "bodyText" | "preview"
12
+ >;
13
+
14
+ export function escapeHtml(value: string) {
15
+ return value
16
+ .replaceAll("&", "&amp;")
17
+ .replaceAll("<", "&lt;")
18
+ .replaceAll(">", "&gt;")
19
+ .replaceAll('"', "&quot;")
20
+ .replaceAll("'", "&#039;");
21
+ }
22
+
23
+ function displayAddress(email: ContentSource) {
24
+ const from = email.fromJson[0];
25
+ if (!from) return "Unknown sender";
26
+ return from.name ? `${from.name} <${from.address}>` : from.address;
27
+ }
28
+
29
+ function clippedBody(email: ContentSource) {
30
+ const value = email.bodyText?.trim() || email.preview.trim() || "No text body";
31
+ if (value.length <= MAX_CONTEXT_CHARACTERS) return value;
32
+ return `${value.slice(0, MAX_CONTEXT_CHARACTERS).trimEnd()}\n[Context truncated]`;
33
+ }
34
+
35
+ function metadataLines(email: ContentSource) {
36
+ return [
37
+ `From: ${displayAddress(email)}`,
38
+ `Date: ${email.timelineAt.toISOString()}`,
39
+ `Subject: ${email.subject}`,
40
+ ...(email.toJson.length
41
+ ? [`To: ${email.toJson.map((item) => item.address).join(", ")}`]
42
+ : []),
43
+ ...(email.ccJson.length
44
+ ? [`Cc: ${email.ccJson.map((item) => item.address).join(", ")}`]
45
+ : []),
46
+ ];
47
+ }
48
+
49
+ export function contextForNewRecipient(email: ContentSource) {
50
+ return [...metadataLines(email), "", clippedBody(email)].join("\n");
51
+ }
52
+
53
+ export function forwardedMessageText(email: ContentSource) {
54
+ return ["Forwarded message", ...metadataLines(email), "", clippedBody(email)].join("\n");
55
+ }
56
+
57
+ export function forwardedMessageHtml(email: ContentSource) {
58
+ const metadata = metadataLines(email)
59
+ .map((line) => `<div>${escapeHtml(line)}</div>`)
60
+ .join("");
61
+ return `<section data-forwarded-message><strong>Forwarded message</strong>${metadata}<pre style="white-space:pre-wrap">${escapeHtml(clippedBody(email))}</pre></section>`;
62
+ }
63
+
64
+ export function appendForwardedMessage(
65
+ bodyText: string,
66
+ bodyHtml: string | undefined,
67
+ forwarded: ContentSource | null,
68
+ ) {
69
+ if (!forwarded) return { bodyText, bodyHtml };
70
+ const text = forwardedMessageText(forwarded);
71
+ const nextText = `${bodyText.trimEnd()}${bodyText.trimEnd() ? "\n\n" : ""}${text}`;
72
+ return {
73
+ bodyText: nextText,
74
+ bodyHtml: bodyHtml
75
+ ? `${bodyHtml}${forwardedMessageHtml(forwarded)}`
76
+ : undefined,
77
+ };
78
+ }
79
+
80
+ export function textWithQuotedContext(bodyText: string, quotedText: string | null) {
81
+ if (!quotedText) return bodyText;
82
+ const quote = quotedText.split("\n").map((line) => `> ${line}`).join("\n");
83
+ return `${bodyText.trimEnd()}${bodyText.trimEnd() ? "\n\n" : ""}${quote}`;
84
+ }
85
+
86
+ export function htmlWithQuotedContext(bodyHtml: string, quotedText: string | null) {
87
+ if (!quotedText) return bodyHtml;
88
+ return `${bodyHtml}<blockquote><pre style="white-space:pre-wrap">${escapeHtml(quotedText)}</pre></blockquote>`;
89
+ }
90
+
91
+ export function externalizeLinkedInlineImages(
92
+ bodyHtml: string,
93
+ attachments: Array<{
94
+ disposition: "attachment" | "inline";
95
+ contentId: string | null;
96
+ filename: string;
97
+ downloadUrl: string;
98
+ }>,
99
+ ) {
100
+ let nextHtml = bodyHtml;
101
+ for (const attachment of attachments) {
102
+ if (attachment.disposition !== "inline" || !attachment.contentId) continue;
103
+ const escapedContentId = attachment.contentId.replace(
104
+ /[.*+?^${}()|[\]\\]/gu,
105
+ "\\$&",
106
+ );
107
+ const image = new RegExp(
108
+ `<img(?=[^>]*\\bsrc=(["'])cid:${escapedContentId}\\1)[^>]*>`,
109
+ "giu",
110
+ );
111
+ nextHtml = nextHtml.replace(
112
+ image,
113
+ `<a href="${escapeHtml(attachment.downloadUrl)}">${escapeHtml(attachment.filename)}</a>`,
114
+ );
115
+ }
116
+ return nextHtml;
117
+ }
118
+
119
+ type PositionedLinkedAttachment = {
120
+ uploadId: string | null;
121
+ filename: string;
122
+ downloadUrl: string | null;
123
+ };
124
+
125
+ function escapeRegExp(value: string) {
126
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
127
+ }
128
+
129
+ const unresolvedLinkedAttachmentText = new RegExp(
130
+ `\\[\\[${escapeRegExp(LINKED_ATTACHMENT_TEXT_PREFIX)}[^\\]]+\\]\\]`,
131
+ "gu",
132
+ );
133
+ const unresolvedLinkedAttachmentHtml = new RegExp(
134
+ `<span(?=[^>]*\\bdata-linked-attachment=(["'])[^"']*\\1)[^>]*>[\\s\\S]*?<\\/span>`,
135
+ "giu",
136
+ );
137
+
138
+ function linkedAttachmentHtml(filename: string, downloadUrl: string) {
139
+ return [
140
+ '<span data-linked-attachment-card style="display:block;margin:12px 0">',
141
+ `<a href="${escapeHtml(downloadUrl)}" style="display:inline-block;box-sizing:border-box;max-width:100%;padding:10px 12px;border:1px solid #dadce0;border-radius:8px;color:#1967d2;text-decoration:none;font-family:Arial,sans-serif">`,
142
+ `<strong style="color:#202124">${escapeHtml(filename)}</strong>`,
143
+ '<span style="margin-left:8px;font-size:12px;color:#5f6368">Download file · link expires in 30 days</span>',
144
+ "</a>",
145
+ "</span>",
146
+ ].join("");
147
+ }
148
+
149
+ /**
150
+ * Download URLs only exist once the outgoing message has an id and token.
151
+ * Composer nodes therefore carry an upload-id placeholder that is resolved
152
+ * here, while preserving the position chosen in the editor.
153
+ */
154
+ export function resolveLinkedAttachmentPlaceholders(input: {
155
+ bodyText: string;
156
+ bodyHtml: string | undefined;
157
+ attachments: PositionedLinkedAttachment[];
158
+ }) {
159
+ let bodyText = input.bodyText;
160
+ let bodyHtml = input.bodyHtml;
161
+ const textPlacedUploadIds = new Set<string>();
162
+ const htmlPlacedUploadIds = new Set<string>();
163
+
164
+ for (const attachment of input.attachments) {
165
+ if (!attachment.uploadId) continue;
166
+ const textToken = linkedAttachmentTextToken(attachment.uploadId);
167
+ if (bodyText.includes(textToken)) {
168
+ bodyText = bodyText.replaceAll(
169
+ textToken,
170
+ attachment.downloadUrl
171
+ ? `${attachment.filename}: ${attachment.downloadUrl}`
172
+ : "",
173
+ );
174
+ if (attachment.downloadUrl) {
175
+ textPlacedUploadIds.add(attachment.uploadId);
176
+ }
177
+ }
178
+ if (!bodyHtml) continue;
179
+
180
+ const marker = escapeRegExp(attachment.uploadId);
181
+ const placeholder = new RegExp(
182
+ `<span(?=[^>]*\\bdata-linked-attachment=(["'])${marker}\\1)[^>]*>[\\s\\S]*?<\\/span>`,
183
+ "giu",
184
+ );
185
+ let matched = false;
186
+ bodyHtml = bodyHtml.replace(placeholder, () => {
187
+ matched = true;
188
+ return attachment.downloadUrl
189
+ ? linkedAttachmentHtml(attachment.filename, attachment.downloadUrl)
190
+ : "";
191
+ });
192
+ if (matched && attachment.downloadUrl) {
193
+ htmlPlacedUploadIds.add(attachment.uploadId);
194
+ }
195
+ }
196
+
197
+ bodyText = bodyText.replace(unresolvedLinkedAttachmentText, "");
198
+ if (bodyHtml) {
199
+ bodyHtml = bodyHtml.replace(unresolvedLinkedAttachmentHtml, "");
200
+ }
201
+
202
+ return {
203
+ bodyText: bodyText.replace(/\n{3,}/gu, "\n\n"),
204
+ bodyHtml,
205
+ textPlacedUploadIds,
206
+ htmlPlacedUploadIds,
207
+ };
208
+ }
@@ -0,0 +1,83 @@
1
+ import type { Email } from "../mailbox/schema";
2
+ import {
3
+ htmlWithQuotedContext,
4
+ textWithQuotedContext,
5
+ } from "./outbound-content";
6
+ import { dedupeRecipientFields } from "./recipients";
7
+
8
+ export function emailDestinations(
9
+ message: Pick<Email, "toJson" | "ccJson" | "bccJson">,
10
+ ) {
11
+ const { to, cc, bcc } = dedupeRecipientFields({
12
+ to: message.toJson.map((item) => item.address),
13
+ cc: message.ccJson.map((item) => item.address),
14
+ bcc: message.bccJson.map((item) => item.address),
15
+ });
16
+ if (to.length) {
17
+ return {
18
+ to,
19
+ cc: cc.length ? cc : undefined,
20
+ bcc: bcc.length ? bcc : undefined,
21
+ };
22
+ }
23
+ if (cc.length) return { cc, bcc: bcc.length ? bcc : undefined };
24
+ if (bcc.length) return { bcc };
25
+ throw new Error("Queued message has no recipients");
26
+ }
27
+
28
+ export async function prepareOutboundDelivery(env: Env, message: Email) {
29
+ const storedHtml = message.bodyHtmlR2Key
30
+ ? await env.MAIL_STORAGE.get(message.bodyHtmlR2Key).then(
31
+ (object) => object?.text(),
32
+ )
33
+ : undefined;
34
+ const html = storedHtml
35
+ ? htmlWithQuotedContext(storedHtml, message.quotedText)
36
+ : undefined;
37
+ const attachments: EmailAttachment[] = await Promise.all(
38
+ message.attachmentsJson
39
+ .filter((attachment) => attachment.delivery === "attached")
40
+ .map(async (attachment) => {
41
+ const object = await env.MAIL_STORAGE.get(attachment.r2Key);
42
+ if (!object) throw new Error(`Attachment ${attachment.filename} is missing`);
43
+ const content = await object.arrayBuffer();
44
+ return attachment.disposition === "inline"
45
+ ? {
46
+ disposition: "inline" as const,
47
+ contentId: attachment.contentId ?? attachment.id,
48
+ filename: attachment.filename,
49
+ type: attachment.contentType,
50
+ content,
51
+ }
52
+ : {
53
+ disposition: "attachment" as const,
54
+ filename: attachment.filename,
55
+ type: attachment.contentType,
56
+ content,
57
+ };
58
+ }),
59
+ );
60
+ const from = message.fromJson[0];
61
+ if (!from) throw new Error("Outgoing message has no From address");
62
+
63
+ return {
64
+ from: { name: from.name ?? "", email: from.address },
65
+ ...emailDestinations(message),
66
+ replyTo: message.replyToJson[0]?.address,
67
+ subject: message.subject,
68
+ text: textWithQuotedContext(
69
+ message.bodyText ?? "",
70
+ message.quotedText,
71
+ ) || undefined,
72
+ html,
73
+ attachments: attachments.length ? attachments : undefined,
74
+ headers: {
75
+ ...(message.inReplyToJson.length
76
+ ? { "In-Reply-To": message.inReplyToJson.join(" ") }
77
+ : {}),
78
+ ...(message.referencesJson.length
79
+ ? { References: message.referencesJson.join(" ") }
80
+ : {}),
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,145 @@
1
+ import { mailboxStub } from "../mailbox";
2
+ import type { Email } from "../mailbox/schema";
3
+ import {
4
+ discardPreparedObjects,
5
+ messageIdForRequest,
6
+ outgoingRequestFingerprint,
7
+ prepareOutgoingEmail,
8
+ type OutgoingMessageInput,
9
+ } from "./outbound";
10
+ import { discardComposerUploads } from "./uploads";
11
+ import {
12
+ deferWebhookTask,
13
+ emailWebhookEventId,
14
+ queueWebhookEvent,
15
+ } from "@open-workspace/core/worker/webhooks/service.ts";
16
+
17
+ export class OutgoingRequestConflictError extends Error {}
18
+
19
+ function deferComposerUploadCleanup(input: {
20
+ env: Env;
21
+ compose: OutgoingMessageInput;
22
+ uploadIds: string[];
23
+ defer: (task: Promise<unknown>) => void;
24
+ }) {
25
+ const uploadIds = [...new Set(input.uploadIds)];
26
+ if (!uploadIds.length) return;
27
+ input.defer(discardComposerUploads({
28
+ env: input.env,
29
+ mailboxId: input.compose.mailboxId,
30
+ userId: input.compose.userId,
31
+ uploadIds,
32
+ }));
33
+ }
34
+
35
+ export function deferEmailSentWebhook(input: {
36
+ env: Env;
37
+ mailboxId: string;
38
+ email: Pick<Email, "id" | "timelineAt" | "transportState">;
39
+ defer: (task: Promise<unknown>) => void;
40
+ }) {
41
+ if (input.email.transportState !== "submitted") return;
42
+ deferWebhookTask(input.defer, () => queueWebhookEvent(input.env, {
43
+ eventId: emailWebhookEventId(
44
+ "email.sent",
45
+ input.mailboxId,
46
+ input.email.id,
47
+ ),
48
+ eventType: "email.sent",
49
+ occurredAt: input.email.timelineAt.getTime(),
50
+ source: {
51
+ kind: "email",
52
+ mailboxId: input.mailboxId,
53
+ messageId: input.email.id,
54
+ },
55
+ }));
56
+ }
57
+
58
+ export async function submitOutgoing(input: {
59
+ env: Env;
60
+ requestUrl: string;
61
+ fromAddress: string;
62
+ fromName: string;
63
+ compose: OutgoingMessageInput;
64
+ conversationId: string;
65
+ related: Email | null;
66
+ forwarded: Email | null;
67
+ includeRelatedContext: boolean;
68
+ defer: (task: Promise<unknown>) => void;
69
+ }) {
70
+ const { compose } = input;
71
+ const stub = mailboxStub(input.env, compose.mailboxId);
72
+ const id = await messageIdForRequest(compose.mailboxId, compose.requestId);
73
+ const requestFingerprint = await outgoingRequestFingerprint(compose);
74
+ const existing = await stub.getEmail(id);
75
+ if (existing) {
76
+ if (
77
+ existing.direction !== "outgoing"
78
+ || existing.requestFingerprint !== requestFingerprint
79
+ ) {
80
+ // The conflicting request was not accepted, so its composer still owns
81
+ // these immutable uploads and may retry them with a fresh attempt id.
82
+ throw new OutgoingRequestConflictError(
83
+ "Request identifier is already in use with different content",
84
+ );
85
+ }
86
+ deferComposerUploadCleanup({
87
+ ...input,
88
+ uploadIds: compose.attachments.map((attachment) => attachment.uploadId),
89
+ });
90
+ deferEmailSentWebhook({
91
+ env: input.env,
92
+ mailboxId: compose.mailboxId,
93
+ email: existing,
94
+ defer: input.defer,
95
+ });
96
+ return { email: existing, inserted: false };
97
+ }
98
+
99
+ const prepared = await prepareOutgoingEmail({
100
+ env: input.env,
101
+ requestUrl: input.requestUrl,
102
+ compose,
103
+ requestFingerprint,
104
+ id,
105
+ conversationId: input.conversationId,
106
+ related: input.related,
107
+ forwarded: input.forwarded,
108
+ includeRelatedContext: input.includeRelatedContext,
109
+ fromAddress: input.fromAddress,
110
+ fromName: input.fromName,
111
+ now: new Date(),
112
+ });
113
+
114
+ let submission: Awaited<ReturnType<typeof stub.submitOutgoing>>;
115
+ try {
116
+ submission = await stub.submitOutgoing(prepared.email);
117
+ } catch (error) {
118
+ await discardPreparedObjects(input.env, prepared.storageKeys);
119
+ throw error;
120
+ }
121
+ if (submission.outcome !== "inserted") {
122
+ await discardPreparedObjects(input.env, prepared.storageKeys);
123
+ if (submission.outcome === "conflict") {
124
+ // Do not clean the losing request's composer uploads. Only prepared
125
+ // message-scoped copies belong to this failed submission attempt.
126
+ throw new OutgoingRequestConflictError(
127
+ "Request identifier is already in use with different content",
128
+ );
129
+ }
130
+ }
131
+ deferComposerUploadCleanup({
132
+ ...input,
133
+ uploadIds: prepared.composerUploadIds,
134
+ });
135
+ deferEmailSentWebhook({
136
+ env: input.env,
137
+ mailboxId: compose.mailboxId,
138
+ email: submission.email,
139
+ defer: input.defer,
140
+ });
141
+ return {
142
+ email: submission.email,
143
+ inserted: submission.outcome === "inserted",
144
+ };
145
+ }
@@ -0,0 +1,70 @@
1
+ import type { ReplyPlan } from "../../shared/mail";
2
+ import type { Email } from "../mailbox/schema";
3
+ import {
4
+ normalizedParticipantSet,
5
+ sameParticipantSet,
6
+ } from "./participants";
7
+
8
+ type ParticipantEmail = Pick<
9
+ Email,
10
+ "fromJson" | "toJson" | "ccJson" | "bccJson" | "listPostAddress"
11
+ >;
12
+
13
+ function normalizedRecipients(values: string[], ownAddress: string) {
14
+ return normalizedParticipantSet(values, ownAddress);
15
+ }
16
+
17
+ /**
18
+ * RFC headers may still reference the source message, but a one-person answer
19
+ * from a visible group is a separate local chat. Matching List-Post is the one
20
+ * intentional single-address exception because that address represents a list.
21
+ */
22
+ export function shouldDetachOutboundReply(input: {
23
+ ownAddress: string;
24
+ plan: Pick<ReplyPlan, "isGroup" | "actions">;
25
+ to: string[];
26
+ cc: string[];
27
+ }) {
28
+ if (!input.plan.isGroup) return false;
29
+
30
+ const actual = normalizedRecipients([...input.to, ...input.cc], input.ownAddress);
31
+ if (actual.size > 1) return false;
32
+
33
+ const listAction = input.plan.actions.find((action) => action.mode === "reply_list");
34
+ const matchesListPost = listAction
35
+ ? sameParticipantSet(
36
+ actual,
37
+ normalizedRecipients([...listAction.to, ...listAction.cc], input.ownAddress),
38
+ )
39
+ : false;
40
+ return !matchesListPost;
41
+ }
42
+
43
+ function previousParticipants(ownAddress: string, history: ParticipantEmail[]) {
44
+ const values = history.flatMap((message) => [
45
+ ...message.fromJson,
46
+ ...message.toJson,
47
+ ...message.ccJson,
48
+ ...message.bccJson,
49
+ ...(message.listPostAddress
50
+ ? [{ address: message.listPostAddress, name: null }]
51
+ : []),
52
+ ]).map((item) => item.address);
53
+ return normalizedRecipients(values, ownAddress);
54
+ }
55
+
56
+ export function hasNewRecipients(input: {
57
+ ownAddress: string;
58
+ history: ParticipantEmail[];
59
+ to: string[];
60
+ cc: string[];
61
+ bcc: string[];
62
+ }) {
63
+ if (!input.history.length) return false;
64
+ const previous = previousParticipants(input.ownAddress, input.history);
65
+ const next = normalizedRecipients(
66
+ [...input.to, ...input.cc, ...input.bcc],
67
+ input.ownAddress,
68
+ );
69
+ return [...next].some((recipient) => !previous.has(recipient));
70
+ }