@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,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("&", "&")
|
|
17
|
+
.replaceAll("<", "<")
|
|
18
|
+
.replaceAll(">", ">")
|
|
19
|
+
.replaceAll('"', """)
|
|
20
|
+
.replaceAll("'", "'");
|
|
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
|
+
}
|