@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/alchemy.ts +123 -0
- package/containers/office-preview/Dockerfile +10 -0
- package/index.ts +3 -0
- package/migrations/0000_email.sql +53 -0
- package/migrations/meta/0000_snapshot.json +408 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +91 -4
- package/shared/attachment-preview.ts +67 -0
- package/shared/mail-authentication.ts +145 -0
- package/shared/mail-feedback.ts +90 -0
- package/shared/mail-headers.ts +250 -0
- package/shared/mail-received.ts +298 -0
- package/shared/mail-remote.ts +17 -0
- package/shared/mail-rules.ts +156 -0
- package/shared/mail-sender-intel.ts +125 -0
- package/shared/mail-translation.ts +2 -0
- package/shared/mail.ts +179 -0
- package/shared/office-preview.ts +26 -0
- package/worker/admin/report-routes.ts +164 -0
- package/worker/bindings.d.ts +11 -0
- package/worker/db/schema.ts +97 -0
- package/worker/mail/access.ts +30 -0
- package/worker/mail/ai-classification.ts +119 -0
- package/worker/mail/ai-translation.ts +31 -0
- package/worker/mail/auth-headers.ts +557 -0
- package/worker/mail/bimi.ts +172 -0
- package/worker/mail/classification-signals.ts +116 -0
- package/worker/mail/conversation-cursor.ts +43 -0
- package/worker/mail/delivery-events.ts +99 -0
- package/worker/mail/delivery-facts.ts +122 -0
- package/worker/mail/demo-routes.ts +33 -0
- package/worker/mail/doh.ts +74 -0
- package/worker/mail/downloads.ts +47 -0
- package/worker/mail/header-metadata.ts +77 -0
- package/worker/mail/impersonation.ts +175 -0
- package/worker/mail/inbound-delivery.ts +22 -0
- package/worker/mail/inbound-threading.ts +48 -0
- package/worker/mail/inbound.ts +255 -0
- package/worker/mail/ip-intel.ts +135 -0
- package/worker/mail/link-domains.ts +57 -0
- package/worker/mail/mailbox-deletion.ts +18 -0
- package/worker/mail/mailbox-directory.ts +73 -0
- package/worker/mail/mailing-list.ts +120 -0
- package/worker/mail/message-security.ts +190 -0
- package/worker/mail/message-view.ts +63 -0
- package/worker/mail/office-conversion.ts +38 -0
- package/worker/mail/office-preview-container.ts +57 -0
- package/worker/mail/office-preview-queue.ts +49 -0
- package/worker/mail/org-domain.ts +15 -0
- package/worker/mail/outbound-content.ts +208 -0
- package/worker/mail/outbound-delivery.ts +83 -0
- package/worker/mail/outbound-service.ts +145 -0
- package/worker/mail/outbound-threading.ts +70 -0
- package/worker/mail/outbound.ts +446 -0
- package/worker/mail/outgoing-context.ts +122 -0
- package/worker/mail/participants.ts +115 -0
- package/worker/mail/push-notifications.ts +484 -0
- package/worker/mail/quote-parser.ts +50 -0
- package/worker/mail/r2-presigned-urls.ts +69 -0
- package/worker/mail/receive.ts +59 -0
- package/worker/mail/recipients.ts +34 -0
- package/worker/mail/remote-proxy.ts +140 -0
- package/worker/mail/reply-plan.ts +136 -0
- package/worker/mail/reported-messages.ts +68 -0
- package/worker/mail/rfc.ts +73 -0
- package/worker/mail/routes.ts +1434 -0
- package/worker/mail/safety-routes.ts +350 -0
- package/worker/mail/schemas.ts +339 -0
- package/worker/mail/search.ts +14 -0
- package/worker/mail/security-settings.ts +38 -0
- package/worker/mail/sender-intel.ts +189 -0
- package/worker/mail/spamhaus.ts +97 -0
- package/worker/mail/unsubscribe.ts +118 -0
- package/worker/mail/uploads.ts +537 -0
- package/worker/mail/webhook-source.ts +71 -0
- package/worker/mail/workspace-safety.ts +92 -0
- package/worker/mailbox/demo-html.ts +136 -0
- package/worker/mailbox/demo-seed.ts +79 -0
- package/worker/mailbox/demo.ts +978 -0
- package/worker/mailbox/folder-model.ts +72 -0
- package/worker/mailbox/index.ts +2508 -0
- package/worker/mailbox/legacy-migrations.ts +41 -0
- package/worker/mailbox/migrations.ts +10 -0
- package/worker/mailbox/model.ts +65 -0
- package/worker/mailbox/office-preview-jobs.ts +73 -0
- package/worker/mailbox/rules.ts +110 -0
- package/worker/mailbox/schema.ts +353 -0
- package/worker/mailbox/senders.ts +240 -0
- package/worker/mcp/mail-action-tools.ts +210 -0
- package/worker/mcp/mail-compose-tools.ts +196 -0
- package/worker/mcp/mail-query-tools.ts +196 -0
- package/worker/mcp/mail-safety-tools.ts +452 -0
- package/worker/mcp/tools.ts +15 -0
- 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
|
+
}
|