@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,140 @@
|
|
|
1
|
+
const BLOCKED_HOST_SUFFIXES = [".localhost", ".local", ".internal"];
|
|
2
|
+
const BLOCKED_HOSTS = new Set([
|
|
3
|
+
"localhost",
|
|
4
|
+
"metadata.google.internal",
|
|
5
|
+
"metadata",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const PRIVATE_IPV4 =
|
|
9
|
+
/^(?:0\.|10\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/u;
|
|
10
|
+
|
|
11
|
+
export const PROXYABLE_REMOTE_MEDIA_TYPES = new Set([
|
|
12
|
+
"image/apng",
|
|
13
|
+
"image/avif",
|
|
14
|
+
"image/gif",
|
|
15
|
+
"image/jpeg",
|
|
16
|
+
"image/jpg",
|
|
17
|
+
"image/png",
|
|
18
|
+
"image/webp",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export const MAX_REMOTE_PROXY_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
export const REMOTE_PROXY_TIMEOUT_MS = 12_000;
|
|
23
|
+
export const REMOTE_PROXY_MAX_REDIRECTS = 3;
|
|
24
|
+
|
|
25
|
+
function isIpv4(hostname: string) {
|
|
26
|
+
return /^(?:\d{1,3}\.){3}\d{1,3}$/u.test(hostname);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isIpv6(hostname: string) {
|
|
30
|
+
return hostname.includes(":");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isBlockedIp(hostname: string) {
|
|
34
|
+
if (isIpv4(hostname)) {
|
|
35
|
+
return PRIVATE_IPV4.test(hostname) || hostname === "255.255.255.255";
|
|
36
|
+
}
|
|
37
|
+
if (isIpv6(hostname)) {
|
|
38
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
|
39
|
+
return (
|
|
40
|
+
normalized === "::1"
|
|
41
|
+
|| normalized === "::"
|
|
42
|
+
|| normalized.startsWith("fc")
|
|
43
|
+
|| normalized.startsWith("fd")
|
|
44
|
+
|| normalized.startsWith("fe8")
|
|
45
|
+
|| normalized.startsWith("fe9")
|
|
46
|
+
|| normalized.startsWith("fea")
|
|
47
|
+
|| normalized.startsWith("feb")
|
|
48
|
+
|| normalized.startsWith("::ffff:127.")
|
|
49
|
+
|| normalized.startsWith("::ffff:10.")
|
|
50
|
+
|| normalized.startsWith("::ffff:192.168.")
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function assertProxyableRemoteUrl(value: string): URL {
|
|
57
|
+
let url: URL;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(value);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error("Remote URL is invalid");
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
64
|
+
throw new Error("Only http(s) remote URLs are allowed");
|
|
65
|
+
}
|
|
66
|
+
if (url.username || url.password) {
|
|
67
|
+
throw new Error("Remote URLs must not include credentials");
|
|
68
|
+
}
|
|
69
|
+
const host = url.hostname.toLowerCase();
|
|
70
|
+
if (!host || BLOCKED_HOSTS.has(host)) {
|
|
71
|
+
throw new Error("Remote host is not allowed");
|
|
72
|
+
}
|
|
73
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
|
74
|
+
throw new Error("Remote host is not allowed");
|
|
75
|
+
}
|
|
76
|
+
if (isBlockedIp(host)) {
|
|
77
|
+
throw new Error("Remote host is not allowed");
|
|
78
|
+
}
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function mediaType(contentType: string | null) {
|
|
83
|
+
return contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isProxyableRemoteMediaType(contentType: string | null) {
|
|
87
|
+
const type = mediaType(contentType);
|
|
88
|
+
if (PROXYABLE_REMOTE_MEDIA_TYPES.has(type)) return true;
|
|
89
|
+
// Some CDNs send image/jpg; normalize already in set. image/x-png etc. rejected.
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function fetchProxiedRemoteMedia(remoteUrl: string) {
|
|
94
|
+
let current = assertProxyableRemoteUrl(remoteUrl);
|
|
95
|
+
for (let hop = 0; hop <= REMOTE_PROXY_MAX_REDIRECTS; hop += 1) {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const timer = setTimeout(() => controller.abort(), REMOTE_PROXY_TIMEOUT_MS);
|
|
98
|
+
let response: Response;
|
|
99
|
+
try {
|
|
100
|
+
response = await fetch(current.toString(), {
|
|
101
|
+
method: "GET",
|
|
102
|
+
redirect: "manual",
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
headers: {
|
|
105
|
+
accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
106
|
+
"user-agent": "OpenWorkspaceMailProxy/1.0",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
114
|
+
const location = response.headers.get("location");
|
|
115
|
+
if (!location) throw new Error("Remote redirect is missing a location");
|
|
116
|
+
current = assertProxyableRemoteUrl(new URL(location, current).toString());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`Remote fetch failed (${response.status})`);
|
|
122
|
+
}
|
|
123
|
+
if (!isProxyableRemoteMediaType(response.headers.get("content-type"))) {
|
|
124
|
+
throw new Error("Remote content type is not an allowed image");
|
|
125
|
+
}
|
|
126
|
+
const lengthHeader = response.headers.get("content-length");
|
|
127
|
+
if (lengthHeader && Number(lengthHeader) > MAX_REMOTE_PROXY_BYTES) {
|
|
128
|
+
throw new Error("Remote content is too large");
|
|
129
|
+
}
|
|
130
|
+
const buffer = await response.arrayBuffer();
|
|
131
|
+
if (buffer.byteLength > MAX_REMOTE_PROXY_BYTES) {
|
|
132
|
+
throw new Error("Remote content is too large");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
body: buffer,
|
|
136
|
+
contentType: mediaType(response.headers.get("content-type")),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw new Error("Too many remote redirects");
|
|
140
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { normalizeEmail, normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
|
|
2
|
+
import type { MailAddress } from "../mailbox/model";
|
|
3
|
+
import type { Email } from "../mailbox/schema";
|
|
4
|
+
import type { ReplyAction, ReplyPlan } from "../../shared/mail";
|
|
5
|
+
import { uniqueParticipantAddresses } from "./participants";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
ReplyAction,
|
|
9
|
+
ReplyActionMode,
|
|
10
|
+
ReplyPlan,
|
|
11
|
+
} from "../../shared/mail";
|
|
12
|
+
|
|
13
|
+
function uniqueAddresses(values: MailAddress[], ownAddress: string) {
|
|
14
|
+
return uniqueParticipantAddresses(values, ownAddress);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function without(values: string[], excluded: string[]) {
|
|
18
|
+
const blocked = new Set(excluded.map(normalizeEmail));
|
|
19
|
+
return values.filter((value) => !blocked.has(normalizeEmail(value)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ReplySource = Pick<
|
|
23
|
+
Email,
|
|
24
|
+
| "direction"
|
|
25
|
+
| "messageIdHeader"
|
|
26
|
+
| "transportState"
|
|
27
|
+
| "fromJson"
|
|
28
|
+
| "replyToJson"
|
|
29
|
+
| "toJson"
|
|
30
|
+
| "ccJson"
|
|
31
|
+
| "listId"
|
|
32
|
+
| "listPostAddress"
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
export function canReplyFrom(email: Pick<ReplySource, "direction" | "messageIdHeader" | "transportState">) {
|
|
36
|
+
if (email.direction === "incoming") return true;
|
|
37
|
+
return Boolean(email.messageIdHeader)
|
|
38
|
+
&& email.transportState === "submitted";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildReplyPlan(ownAddress: string, email: ReplySource): ReplyPlan {
|
|
42
|
+
if (email.direction === "outgoing") {
|
|
43
|
+
const to = uniqueAddresses(email.toJson, ownAddress);
|
|
44
|
+
const cc = without(uniqueAddresses(email.ccJson, ownAddress), to);
|
|
45
|
+
const participants = [...to, ...cc];
|
|
46
|
+
return {
|
|
47
|
+
defaultMode: "continue",
|
|
48
|
+
isGroup: participants.length > 1,
|
|
49
|
+
participants,
|
|
50
|
+
actions: canReplyFrom(email)
|
|
51
|
+
? [{
|
|
52
|
+
mode: "continue",
|
|
53
|
+
label: "Continue",
|
|
54
|
+
to,
|
|
55
|
+
cc,
|
|
56
|
+
}]
|
|
57
|
+
: [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// List managers commonly rewrite Reply-To to the list itself. A private
|
|
62
|
+
// reply must still target the actual author; List-Post is handled separately.
|
|
63
|
+
const replyTargets = uniqueAddresses(
|
|
64
|
+
email.listId
|
|
65
|
+
? email.fromJson
|
|
66
|
+
: email.replyToJson.length ? email.replyToJson : email.fromJson,
|
|
67
|
+
ownAddress,
|
|
68
|
+
);
|
|
69
|
+
const visibleParticipants = uniqueAddresses(
|
|
70
|
+
[
|
|
71
|
+
...email.fromJson,
|
|
72
|
+
...email.toJson,
|
|
73
|
+
...email.ccJson,
|
|
74
|
+
],
|
|
75
|
+
ownAddress,
|
|
76
|
+
);
|
|
77
|
+
const replyAllParticipants = uniqueAddresses(
|
|
78
|
+
[
|
|
79
|
+
...(email.listId
|
|
80
|
+
? email.fromJson
|
|
81
|
+
: email.replyToJson.length ? email.replyToJson : email.fromJson),
|
|
82
|
+
...email.toJson,
|
|
83
|
+
...email.ccJson,
|
|
84
|
+
],
|
|
85
|
+
ownAddress,
|
|
86
|
+
);
|
|
87
|
+
const visibleIsGroup = Boolean(email.listId) || replyAllParticipants.length > 1;
|
|
88
|
+
const own = normalizeMailboxAddress(ownAddress);
|
|
89
|
+
const visibleRecipients = [...email.toJson, ...email.ccJson].some(
|
|
90
|
+
(recipient) => normalizeMailboxAddress(recipient.address) === own,
|
|
91
|
+
);
|
|
92
|
+
const undisclosedLocalRecipient = !email.listId && !visibleRecipients;
|
|
93
|
+
const isGroup = visibleIsGroup && !undisclosedLocalRecipient;
|
|
94
|
+
const actions: ReplyAction[] = [{
|
|
95
|
+
mode: "reply",
|
|
96
|
+
label: "Reply",
|
|
97
|
+
to: replyTargets,
|
|
98
|
+
cc: [],
|
|
99
|
+
}];
|
|
100
|
+
const listTargets = uniqueAddresses(
|
|
101
|
+
email.listPostAddress
|
|
102
|
+
? [{ address: email.listPostAddress, name: null }]
|
|
103
|
+
: [],
|
|
104
|
+
ownAddress,
|
|
105
|
+
).slice(0, 1);
|
|
106
|
+
if (!email.listId && visibleIsGroup) {
|
|
107
|
+
actions.push({
|
|
108
|
+
mode: "reply_all",
|
|
109
|
+
label: "Reply all",
|
|
110
|
+
to: replyTargets,
|
|
111
|
+
cc: without(replyAllParticipants, replyTargets),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (email.listId && listTargets.length) {
|
|
116
|
+
actions.push({
|
|
117
|
+
mode: "reply_list",
|
|
118
|
+
label: "Reply to list",
|
|
119
|
+
to: listTargets,
|
|
120
|
+
cc: [],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
defaultMode: listTargets.length
|
|
126
|
+
? "reply_list"
|
|
127
|
+
: email.listId
|
|
128
|
+
? "reply"
|
|
129
|
+
: undisclosedLocalRecipient
|
|
130
|
+
? "reply"
|
|
131
|
+
: isGroup ? "reply_all" : "reply",
|
|
132
|
+
isGroup,
|
|
133
|
+
participants: undisclosedLocalRecipient ? replyTargets : visibleParticipants,
|
|
134
|
+
actions,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { and, eq } from "drizzle-orm";
|
|
2
|
+
import type { MessageSecurity } from "../../shared/mail-authentication";
|
|
3
|
+
import type { ReportSnapshot } from "../../shared/mail-feedback";
|
|
4
|
+
import type { SenderIntel } from "../../shared/mail-sender-intel";
|
|
5
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
6
|
+
import { reportedMessages } from "../db/schema";
|
|
7
|
+
import { createId } from "@open-workspace/core/worker/lib/ids.ts";
|
|
8
|
+
import type { Email } from "../mailbox/schema";
|
|
9
|
+
import { linkDomains } from "./link-domains";
|
|
10
|
+
|
|
11
|
+
export function reportSnapshot(input: {
|
|
12
|
+
email: Email;
|
|
13
|
+
security: MessageSecurity | null;
|
|
14
|
+
intel: SenderIntel | null;
|
|
15
|
+
bodyHtml: string | null;
|
|
16
|
+
}): ReportSnapshot {
|
|
17
|
+
const { email, security, intel } = input;
|
|
18
|
+
const ai = email.aiClassificationJson;
|
|
19
|
+
return {
|
|
20
|
+
fromName: email.fromJson[0]?.name ?? null,
|
|
21
|
+
replyTo: email.replyToJson.map((item) => item.address),
|
|
22
|
+
to: [...email.toJson, ...email.ccJson].map((item) => item.address).slice(0, 20),
|
|
23
|
+
verdict: security?.verdict ?? "unavailable",
|
|
24
|
+
verdictReason: security?.verdictReason ?? "missing_results",
|
|
25
|
+
warnings: security?.warnings.map((warning) => warning.kind) ?? [],
|
|
26
|
+
spamScore: email.authenticationResultsJson?.source === "headers"
|
|
27
|
+
? email.authenticationResultsJson.spamScore
|
|
28
|
+
: null,
|
|
29
|
+
ip: intel?.ip ?? null,
|
|
30
|
+
network: intel?.network.asn ? `AS${intel.network.asn} ${intel.network.asName ?? ""}`.trim() : null,
|
|
31
|
+
country: intel?.network.country ?? null,
|
|
32
|
+
ai: ai ? { spam: ai.spam, spamConfidence: ai.spamConfidence, reason: ai.reason } : null,
|
|
33
|
+
linkDomains: linkDomains({ html: input.bodyHtml, text: [email.bodyText] }),
|
|
34
|
+
preview: email.preview.slice(0, 280),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One admin report per message and mailbox; reporting again keeps the first one. */
|
|
39
|
+
export async function recordReportedMessage(binding: D1Database, input: {
|
|
40
|
+
mailboxId: string;
|
|
41
|
+
email: Email;
|
|
42
|
+
reporterUserId: string;
|
|
43
|
+
snapshot: ReportSnapshot;
|
|
44
|
+
}) {
|
|
45
|
+
const from = input.email.fromJson[0]?.address.toLocaleLowerCase() ?? "unknown@invalid";
|
|
46
|
+
const db = createDb(binding);
|
|
47
|
+
const id = createId("rpt");
|
|
48
|
+
await db.insert(reportedMessages).values({
|
|
49
|
+
id,
|
|
50
|
+
mailboxId: input.mailboxId,
|
|
51
|
+
emailId: input.email.id,
|
|
52
|
+
reporterUserId: input.reporterUserId,
|
|
53
|
+
senderAddress: from,
|
|
54
|
+
senderDomain: from.slice(from.lastIndexOf("@") + 1),
|
|
55
|
+
subject: input.email.subject,
|
|
56
|
+
receivedAt: input.email.timelineAt,
|
|
57
|
+
snapshotJson: input.snapshot,
|
|
58
|
+
status: "open",
|
|
59
|
+
createdAt: new Date(),
|
|
60
|
+
}).onConflictDoNothing();
|
|
61
|
+
const [row] = await db
|
|
62
|
+
.select({ id: reportedMessages.id })
|
|
63
|
+
.from(reportedMessages)
|
|
64
|
+
.where(and(eq(reportedMessages.mailboxId, input.mailboxId), eq(reportedMessages.emailId, input.email.id)))
|
|
65
|
+
.limit(1);
|
|
66
|
+
return row?.id ?? id;
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Header } from "postal-mime";
|
|
2
|
+
import { normalizeEmail } from "@open-workspace/core/worker/lib/ids.ts";
|
|
3
|
+
import type { MailAddress } from "../mailbox/model";
|
|
4
|
+
|
|
5
|
+
const MAX_REFERENCE_IDS = 100;
|
|
6
|
+
const MAX_REFERENCE_BYTES = 12 * 1024;
|
|
7
|
+
const MAX_MESSAGE_ID_BYTES = 900;
|
|
8
|
+
const textEncoder = new TextEncoder();
|
|
9
|
+
|
|
10
|
+
export function boundedMessageIds(values: string[]) {
|
|
11
|
+
const unique = [...new Set(values.map((item) => item.trim()).filter(Boolean))];
|
|
12
|
+
const newestFirst: string[] = [];
|
|
13
|
+
let bytes = 0;
|
|
14
|
+
for (let index = unique.length - 1; index >= 0; index -= 1) {
|
|
15
|
+
if (newestFirst.length >= MAX_REFERENCE_IDS) break;
|
|
16
|
+
const value = unique[index]!;
|
|
17
|
+
const valueBytes = textEncoder.encode(value).byteLength;
|
|
18
|
+
if (valueBytes > MAX_MESSAGE_ID_BYTES) continue;
|
|
19
|
+
const nextBytes = valueBytes + (newestFirst.length ? 1 : 0);
|
|
20
|
+
if (bytes + nextBytes > MAX_REFERENCE_BYTES) continue;
|
|
21
|
+
newestFirst.push(value);
|
|
22
|
+
bytes += nextBytes;
|
|
23
|
+
}
|
|
24
|
+
return newestFirst.reverse();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function messageIds(value: string | undefined) {
|
|
28
|
+
if (!value) return [];
|
|
29
|
+
const bracketed = value.match(/<[^<>]+>/gu);
|
|
30
|
+
const values = bracketed ?? value.split(/\s+/u);
|
|
31
|
+
return boundedMessageIds(values);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function replyThreadHeaders(parent: {
|
|
35
|
+
messageIdHeader: string | null;
|
|
36
|
+
inReplyToJson: string[];
|
|
37
|
+
referencesJson: string[];
|
|
38
|
+
}) {
|
|
39
|
+
const parentMessageId = messageIds(parent.messageIdHeader ?? undefined)[0];
|
|
40
|
+
if (!parentMessageId) return { inReplyTo: [], references: [] };
|
|
41
|
+
const ancestry = parent.referencesJson.length
|
|
42
|
+
? parent.referencesJson
|
|
43
|
+
: parent.inReplyToJson.length === 1 ? parent.inReplyToJson : [];
|
|
44
|
+
return {
|
|
45
|
+
inReplyTo: [parentMessageId],
|
|
46
|
+
references: boundedMessageIds([...ancestry, parentMessageId]),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function address(address: string, name: string | null = null): MailAddress {
|
|
51
|
+
return { address: normalizeEmail(address), name: name?.trim() || null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function headerValue(headers: Header[], name: string) {
|
|
55
|
+
return headers.find((header) => header.key === name)?.value.trim() || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listId(headers: Header[]) {
|
|
59
|
+
const value = headerValue(headers, "list-id");
|
|
60
|
+
return value?.match(/<([^<>]+)>/u)?.[1] ?? value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listPostAddress(value: string | null | undefined) {
|
|
64
|
+
if (!value) return null;
|
|
65
|
+
const match = /<mailto:([^?>]+)(?:\?[^>]*)?>/iu.exec(value);
|
|
66
|
+
if (!match?.[1]) return null;
|
|
67
|
+
try {
|
|
68
|
+
return normalizeEmail(decodeURIComponent(match[1]));
|
|
69
|
+
} catch {
|
|
70
|
+
// Invalid list metadata must not make the containing message unreadable.
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|