@aglyn/shared-util-email 1.0.0-beta.291 → 1.0.0-beta.293
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/package.json +3 -3
- package/src/lib/email-thread.d.ts +21 -5
- package/src/lib/email-thread.js +55 -17
- package/src/lib/email-thread.js.map +1 -1
- package/src/lib/system-email-catalog.js +2 -2
- package/src/lib/system-email-catalog.js.map +1 -1
- package/src/lib/tenant-emails.generated.js +4 -4
- package/src/lib/tenant-emails.generated.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.293",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.293",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.293",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
export interface EmailThread {
|
|
19
19
|
/** The latest message's `Message-ID`, angle brackets included: `<a.b@example.com>`. */
|
|
20
20
|
messageId: string;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The conversation's root ({@link emailThreadRootId}; a thread stored
|
|
23
|
+
* before roots has none), then every message's `Message-ID`, oldest first;
|
|
24
|
+
* the last is {@link messageId}.
|
|
25
|
+
*/
|
|
22
26
|
references: string[];
|
|
23
27
|
/** The subject the conversation runs under, without any `Re:`. */
|
|
24
28
|
subject: string;
|
|
@@ -44,6 +48,16 @@ export declare function mintEmailMessageId(domains: ReadonlyArray<string | null
|
|
|
44
48
|
atMs?: number;
|
|
45
49
|
random?: string;
|
|
46
50
|
}): string;
|
|
51
|
+
/**
|
|
52
|
+
* The root a conversation started by the message `messageId` runs under:
|
|
53
|
+
* `<thread.quote.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>` for
|
|
54
|
+
* `<quote.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>`. Derived, so the headers
|
|
55
|
+
* the first message goes out with and the thread kept after it agree without
|
|
56
|
+
* either being handed the other; distinct from every id a message carries,
|
|
57
|
+
* so a provider that replaces `Message-ID` cannot replace it. Null when
|
|
58
|
+
* `messageId` is not a `Message-ID`, or the root would be too long for one.
|
|
59
|
+
*/
|
|
60
|
+
export declare function emailThreadRootId(messageId: string): string | null;
|
|
47
61
|
/**
|
|
48
62
|
* A stored thread, checked, or null. Every id must be a well-formed
|
|
49
63
|
* `Message-ID`, because each is written back into a header: a value that
|
|
@@ -55,14 +69,16 @@ export declare function emailThreadSubject(thread: EmailThread | null | undefine
|
|
|
55
69
|
/**
|
|
56
70
|
* The headers that send a message with `Message-ID` `messageId` into
|
|
57
71
|
* `thread`: the id itself, and, when the thread has begun, `In-Reply-To` the
|
|
58
|
-
* latest message and `References` the chain. With no thread, the
|
|
59
|
-
* starts one and
|
|
72
|
+
* latest message and `References` the root and the chain. With no thread, the
|
|
73
|
+
* message starts one: its own id, and the root it starts
|
|
74
|
+
* ({@link emailThreadRootId}) in `References`.
|
|
60
75
|
*/
|
|
61
76
|
export declare function nextThreadHeaders(thread: EmailThread | null | undefined, messageId: string): Record<string, string>;
|
|
62
77
|
/**
|
|
63
78
|
* The thread once a message went out in it: the message is the latest, the
|
|
64
|
-
* chain grows by it, and the subject stays the thread's
|
|
65
|
-
*
|
|
79
|
+
* chain grows by it, and the subject stays the thread's. A message that
|
|
80
|
+
* started the thread gives it its root (the one {@link nextThreadHeaders}
|
|
81
|
+
* sent it with) ahead of its own id, and its subject.
|
|
66
82
|
*/
|
|
67
83
|
export declare function emailThreadAfterSend(thread: EmailThread | null | undefined, sent: {
|
|
68
84
|
messageId: string;
|
package/src/lib/email-thread.js
CHANGED
|
@@ -19,13 +19,25 @@
|
|
|
19
19
|
* A site that writes to one customer about one thing more than once (a
|
|
20
20
|
* request's confirmation, the quote, a revised quote, the invoice) keeps those
|
|
21
21
|
* messages in a single conversation by sending each one as a reply to the one
|
|
22
|
-
* before:
|
|
22
|
+
* before, all of them under one ROOT:
|
|
23
23
|
*
|
|
24
|
-
* - the first message carries a `Message-ID` minted here
|
|
25
|
-
*
|
|
24
|
+
* - the first message carries a `Message-ID` minted here and, in
|
|
25
|
+
* `References`, the conversation's root: an id derived from that one
|
|
26
|
+
* (`<thread.…@…>`, {@link emailThreadRootId}) which no message carries as
|
|
27
|
+
* its own. The record it is about keeps both as an {@link EmailThread},
|
|
28
|
+
* the root first;
|
|
26
29
|
* - every later message carries its own `Message-ID`, `In-Reply-To` naming
|
|
27
|
-
* the last one, and `References` naming the chain, and keeps
|
|
28
|
-
* subject ({@link nextThreadHeaders}, {@link emailThreadAfterSend}).
|
|
30
|
+
* the last one, and `References` naming the root and the chain, and keeps
|
|
31
|
+
* the thread's subject ({@link nextThreadHeaders}, {@link emailThreadAfterSend}).
|
|
32
|
+
*
|
|
33
|
+
* Why a root no message carries: a provider may replace the `Message-ID` a
|
|
34
|
+
* caller hands it, and in production one does. The ids a later message then
|
|
35
|
+
* replies to name no message the mailbox ever received, and on those alone
|
|
36
|
+
* the first message splits off into a conversation of its own. What every
|
|
37
|
+
* message still shares is the root in `References`: clients that thread by
|
|
38
|
+
* those headers group the messages under it as a missing parent, and clients
|
|
39
|
+
* that group on a shared reference (with the subject) group them by it. When
|
|
40
|
+
* a provider does keep the caller's id, the chain links them directly too.
|
|
29
41
|
*
|
|
30
42
|
* How clients group (what is documented or widely reported, not measured
|
|
31
43
|
* here): Apple Mail and Thunderbird thread by `In-Reply-To`/`References`;
|
|
@@ -34,9 +46,12 @@
|
|
|
34
46
|
* Outlook writes. Keeping the subject the same is what every one of them
|
|
35
47
|
* honors, which is why the thread remembers it.
|
|
36
48
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
49
|
+
* The sender records the provider's own id beside the one minted here, so a
|
|
50
|
+
* replaced one can be traced.
|
|
51
|
+
*
|
|
52
|
+
* A thread stored before roots were added has none: its later messages keep
|
|
53
|
+
* sharing its first message's id in `References`, as they always did, and
|
|
54
|
+
* nothing is migrated.
|
|
40
55
|
*
|
|
41
56
|
* Pure, and free of Node built-ins: this barrel is reached from the browser.
|
|
42
57
|
*=========================================*/ /** A conversation as the record it is about keeps it. */ /**
|
|
@@ -76,6 +91,19 @@
|
|
|
76
91
|
const random = String((_options_random = options.random) != null ? _options_random : randomHex()).toLowerCase().replace(/[^a-z0-9]+/g, '') || randomHex();
|
|
77
92
|
return `<${label}.${time}.${random}@${domain}>`;
|
|
78
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The root a conversation started by the message `messageId` runs under:
|
|
96
|
+
* `<thread.quote.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>` for
|
|
97
|
+
* `<quote.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>`. Derived, so the headers
|
|
98
|
+
* the first message goes out with and the thread kept after it agree without
|
|
99
|
+
* either being handed the other; distinct from every id a message carries,
|
|
100
|
+
* so a provider that replaces `Message-ID` cannot replace it. Null when
|
|
101
|
+
* `messageId` is not a `Message-ID`, or the root would be too long for one.
|
|
102
|
+
*/ export function emailThreadRootId(messageId) {
|
|
103
|
+
if (!isEmailMessageId(messageId)) return null;
|
|
104
|
+
const root = `<thread.${messageId.slice(1)}`;
|
|
105
|
+
return isEmailMessageId(root) ? root : null;
|
|
106
|
+
}
|
|
79
107
|
/** The root and the latest ids, at most {@link EMAIL_THREAD_MAX_REFERENCES}. */ function capReferences(ids) {
|
|
80
108
|
const unique = ids.filter((id, index)=>ids.indexOf(id) === index);
|
|
81
109
|
if (unique.length <= EMAIL_THREAD_MAX_REFERENCES) return unique;
|
|
@@ -114,14 +142,21 @@
|
|
|
114
142
|
/**
|
|
115
143
|
* The headers that send a message with `Message-ID` `messageId` into
|
|
116
144
|
* `thread`: the id itself, and, when the thread has begun, `In-Reply-To` the
|
|
117
|
-
* latest message and `References` the chain. With no thread, the
|
|
118
|
-
* starts one and
|
|
145
|
+
* latest message and `References` the root and the chain. With no thread, the
|
|
146
|
+
* message starts one: its own id, and the root it starts
|
|
147
|
+
* ({@link emailThreadRootId}) in `References`.
|
|
119
148
|
*/ export function nextThreadHeaders(thread, messageId) {
|
|
120
149
|
if (!isEmailMessageId(messageId)) throw new Error('nextThreadHeaders: not a Message-ID');
|
|
121
150
|
const current = readEmailThread(thread);
|
|
122
|
-
if (!current)
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
if (!current) {
|
|
152
|
+
const root = emailThreadRootId(messageId);
|
|
153
|
+
return root ? {
|
|
154
|
+
'Message-ID': messageId,
|
|
155
|
+
References: root
|
|
156
|
+
} : {
|
|
157
|
+
'Message-ID': messageId
|
|
158
|
+
};
|
|
159
|
+
}
|
|
125
160
|
return {
|
|
126
161
|
'Message-ID': messageId,
|
|
127
162
|
'In-Reply-To': current.messageId,
|
|
@@ -130,16 +165,19 @@
|
|
|
130
165
|
}
|
|
131
166
|
/**
|
|
132
167
|
* The thread once a message went out in it: the message is the latest, the
|
|
133
|
-
* chain grows by it, and the subject stays the thread's
|
|
134
|
-
*
|
|
168
|
+
* chain grows by it, and the subject stays the thread's. A message that
|
|
169
|
+
* started the thread gives it its root (the one {@link nextThreadHeaders}
|
|
170
|
+
* sent it with) ahead of its own id, and its subject.
|
|
135
171
|
*/ export function emailThreadAfterSend(thread, sent) {
|
|
136
|
-
var _ref;
|
|
137
172
|
if (!isEmailMessageId(sent.messageId)) throw new Error('emailThreadAfterSend: not a Message-ID');
|
|
138
173
|
const current = readEmailThread(thread);
|
|
174
|
+
const before = current ? current.references : [
|
|
175
|
+
emailThreadRootId(sent.messageId)
|
|
176
|
+
].filter(isEmailMessageId);
|
|
139
177
|
return {
|
|
140
178
|
messageId: sent.messageId,
|
|
141
179
|
references: capReferences([
|
|
142
|
-
...
|
|
180
|
+
...before,
|
|
143
181
|
sent.messageId
|
|
144
182
|
]),
|
|
145
183
|
subject: emailThreadSubject(current, sent.subject).slice(0, MAX_SUBJECT)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-thread.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ONE CONVERSATION IN THE CUSTOMER'S MAILBOX (AGL-4097).\n *\n * A site that writes to one customer about one thing more than once (a\n * request's confirmation, the quote, a revised quote, the invoice) keeps those\n * messages in a single conversation by sending each one as a reply to the one\n * before:\n *\n * - the first message carries a `Message-ID` minted here, and the record it\n * is about keeps it as an {@link EmailThread};\n * - every later message carries its own `Message-ID`, `In-Reply-To` naming\n * the last one, and `References` naming the chain, and keeps the thread's\n * subject ({@link nextThreadHeaders}, {@link emailThreadAfterSend}).\n *\n * How clients group (what is documented or widely reported, not measured\n * here): Apple Mail and Thunderbird thread by `In-Reply-To`/`References`;\n * Gmail groups by subject together with those headers; Outlook groups by\n * subject (its conversation topic) and its own `Thread-Index`, which only\n * Outlook writes. Keeping the subject the same is what every one of them\n * honors, which is why the thread remembers it.\n *\n * Whether a provider keeps a caller's `Message-ID` is the provider's call:\n * both built-in providers hand the header over unchanged, and the sender\n * records the provider's own id beside it so a replaced one can be traced.\n *\n * Pure, and free of Node built-ins: this barrel is reached from the browser.\n *=========================================*/\n\n/** A conversation as the record it is about keeps it. */\nexport interface EmailThread {\n /** The latest message's `Message-ID`, angle brackets included: `<a.b@example.com>`. */\n messageId: string\n /** Every `Message-ID` in the conversation, oldest first; the last is {@link messageId}. */\n references: string[]\n /** The subject the conversation runs under, without any `Re:`. */\n subject: string\n}\n\n/**\n * The most ids a `References` header carries. RFC 5322 sets no limit, but\n * some servers refuse very long header lines; the first id (the conversation's\n * root) and the latest ones are what threading reads, so the middle goes.\n */\nexport const EMAIL_THREAD_MAX_REFERENCES = 20\n\n/** The longest subject kept on a thread. */\nconst MAX_SUBJECT = 300\n\n/** `dot-atom-text` on each side of the `@`, as RFC 5322 §3.6.4 allows a `msg-id`. */\nconst MESSAGE_ID = /^<[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+>$/\n\n/** A host name a `Message-ID` may end in. */\nconst DOMAIN = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/\n\n/** Whether `value` is one well-formed `Message-ID`, brackets included. */\nexport function isEmailMessageId(value: unknown): value is string {\n return typeof value === 'string' && value.length <= 250 && MESSAGE_ID.test(value)\n}\n\n/** A domain as a `Message-ID` can carry it, or null. */\nfunction messageIdDomain(value: string | null | undefined): string | null {\n const raw = String(value ?? '').trim().toLowerCase()\n // A `From` value (\"Acme <hello@acme.com>\") gives its address's domain.\n const domain = raw.includes('@') ? raw.slice(raw.lastIndexOf('@') + 1).replace(/>.*$/, '') : raw\n return DOMAIN.test(domain) ? domain : null\n}\n\n/** Sixteen random hex characters, from the platform's crypto. */\nfunction randomHex(): string {\n const bytes = new Uint8Array(8)\n globalThis.crypto.getRandomValues(bytes)\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/**\n * A fresh `Message-ID` on the first usable domain of `domains` (the sending\n * domain first; an address such as a `From` value gives its domain):\n * `<label.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>`. The time and 64 random bits\n * keep it unique; nothing in it identifies the customer or the record.\n * Falls back to `localhost.localdomain` only when no domain is usable, which\n * a send on a resolved identity never meets.\n */\nexport function mintEmailMessageId(\n domains: ReadonlyArray<string | null | undefined>,\n options: { label?: string; atMs?: number; random?: string } = {},\n): string {\n const domain = domains.map(messageIdDomain).find(Boolean) ?? 'localhost.localdomain'\n const label = String(options.label ?? 'm').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'm'\n const time = Math.max(0, Math.floor(Number(options.atMs ?? Date.now()) || 0)).toString(36)\n const random = String(options.random ?? randomHex()).toLowerCase().replace(/[^a-z0-9]+/g, '') || randomHex()\n return `<${label}.${time}.${random}@${domain}>`\n}\n\n/** The root and the latest ids, at most {@link EMAIL_THREAD_MAX_REFERENCES}. */\nfunction capReferences(ids: readonly string[]): string[] {\n const unique = ids.filter((id, index) => ids.indexOf(id) === index)\n if (unique.length <= EMAIL_THREAD_MAX_REFERENCES) return unique\n return [unique[0], ...unique.slice(unique.length - (EMAIL_THREAD_MAX_REFERENCES - 1))]\n}\n\n/**\n * A stored thread, checked, or null. Every id must be a well-formed\n * `Message-ID`, because each is written back into a header: a value that\n * could carry a line break never reaches one.\n */\nexport function readEmailThread(raw: unknown): EmailThread | null {\n if (!raw || typeof raw !== 'object') return null\n const source = raw as Record<string, unknown>\n const messageId = source['messageId']\n if (!isEmailMessageId(messageId)) return null\n const listed = Array.isArray(source['references']) ? (source['references'] as unknown[]).filter(isEmailMessageId) : []\n const references = capReferences([...listed.filter((id) => id !== messageId), messageId])\n const subject = String(source['subject'] ?? '').replace(/[\\r\\n]+/g, ' ').trim().slice(0, MAX_SUBJECT)\n return { messageId, references, subject }\n}\n\n/** The subject a message in `thread` goes under: the thread's, else `fallback`. */\nexport function emailThreadSubject(thread: EmailThread | null | undefined, fallback: string): string {\n const own = readEmailThread(thread)?.subject\n return own || String(fallback ?? '').replace(/[\\r\\n]+/g, ' ').trim()\n}\n\n/**\n * The headers that send a message with `Message-ID` `messageId` into\n * `thread`: the id itself, and, when the thread has begun, `In-Reply-To` the\n * latest message and `References` the chain. With no thread, the message\n * starts one and carries only its own id.\n */\nexport function nextThreadHeaders(\n thread: EmailThread | null | undefined,\n messageId: string,\n): Record<string, string> {\n if (!isEmailMessageId(messageId)) throw new Error('nextThreadHeaders: not a Message-ID')\n const current = readEmailThread(thread)\n if (!current) return { 'Message-ID': messageId }\n return {\n 'Message-ID': messageId,\n 'In-Reply-To': current.messageId,\n References: capReferences(current.references).join(' '),\n }\n}\n\n/**\n * The thread once a message went out in it: the message is the latest, the\n * chain grows by it, and the subject stays the thread's (or becomes this\n * message's, when it started the thread).\n */\nexport function emailThreadAfterSend(\n thread: EmailThread | null | undefined,\n sent: { messageId: string; subject: string },\n): EmailThread {\n if (!isEmailMessageId(sent.messageId)) throw new Error('emailThreadAfterSend: not a Message-ID')\n const current = readEmailThread(thread)\n return {\n messageId: sent.messageId,\n references: capReferences([...(current?.references ?? []), sent.messageId]),\n subject: emailThreadSubject(current, sent.subject).slice(0, MAX_SUBJECT),\n }\n}\n"],"names":["EMAIL_THREAD_MAX_REFERENCES","MAX_SUBJECT","MESSAGE_ID","DOMAIN","isEmailMessageId","value","length","test","messageIdDomain","raw","String","trim","toLowerCase","domain","includes","slice","lastIndexOf","replace","randomHex","bytes","Uint8Array","globalThis","crypto","getRandomValues","Array","from","byte","toString","padStart","join","mintEmailMessageId","domains","options","map","find","Boolean","label","time","Math","max","floor","Number","atMs","Date","now","random","capReferences","ids","unique","filter","id","index","indexOf","readEmailThread","source","messageId","listed","isArray","references","subject","emailThreadSubject","thread","fallback","own","nextThreadHeaders","Error","current","References","emailThreadAfterSend","sent"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-thread.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ONE CONVERSATION IN THE CUSTOMER'S MAILBOX (AGL-4097).\n *\n * A site that writes to one customer about one thing more than once (a\n * request's confirmation, the quote, a revised quote, the invoice) keeps those\n * messages in a single conversation by sending each one as a reply to the one\n * before, all of them under one ROOT:\n *\n * - the first message carries a `Message-ID` minted here and, in\n * `References`, the conversation's root: an id derived from that one\n * (`<thread.…@…>`, {@link emailThreadRootId}) which no message carries as\n * its own. The record it is about keeps both as an {@link EmailThread},\n * the root first;\n * - every later message carries its own `Message-ID`, `In-Reply-To` naming\n * the last one, and `References` naming the root and the chain, and keeps\n * the thread's subject ({@link nextThreadHeaders}, {@link emailThreadAfterSend}).\n *\n * Why a root no message carries: a provider may replace the `Message-ID` a\n * caller hands it, and in production one does. The ids a later message then\n * replies to name no message the mailbox ever received, and on those alone\n * the first message splits off into a conversation of its own. What every\n * message still shares is the root in `References`: clients that thread by\n * those headers group the messages under it as a missing parent, and clients\n * that group on a shared reference (with the subject) group them by it. When\n * a provider does keep the caller's id, the chain links them directly too.\n *\n * How clients group (what is documented or widely reported, not measured\n * here): Apple Mail and Thunderbird thread by `In-Reply-To`/`References`;\n * Gmail groups by subject together with those headers; Outlook groups by\n * subject (its conversation topic) and its own `Thread-Index`, which only\n * Outlook writes. Keeping the subject the same is what every one of them\n * honors, which is why the thread remembers it.\n *\n * The sender records the provider's own id beside the one minted here, so a\n * replaced one can be traced.\n *\n * A thread stored before roots were added has none: its later messages keep\n * sharing its first message's id in `References`, as they always did, and\n * nothing is migrated.\n *\n * Pure, and free of Node built-ins: this barrel is reached from the browser.\n *=========================================*/\n\n/** A conversation as the record it is about keeps it. */\nexport interface EmailThread {\n /** The latest message's `Message-ID`, angle brackets included: `<a.b@example.com>`. */\n messageId: string\n /**\n * The conversation's root ({@link emailThreadRootId}; a thread stored\n * before roots has none), then every message's `Message-ID`, oldest first;\n * the last is {@link messageId}.\n */\n references: string[]\n /** The subject the conversation runs under, without any `Re:`. */\n subject: string\n}\n\n/**\n * The most ids a `References` header carries. RFC 5322 sets no limit, but\n * some servers refuse very long header lines; the first id (the conversation's\n * root) and the latest ones are what threading reads, so the middle goes.\n */\nexport const EMAIL_THREAD_MAX_REFERENCES = 20\n\n/** The longest subject kept on a thread. */\nconst MAX_SUBJECT = 300\n\n/** `dot-atom-text` on each side of the `@`, as RFC 5322 §3.6.4 allows a `msg-id`. */\nconst MESSAGE_ID = /^<[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)+>$/\n\n/** A host name a `Message-ID` may end in. */\nconst DOMAIN = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/\n\n/** Whether `value` is one well-formed `Message-ID`, brackets included. */\nexport function isEmailMessageId(value: unknown): value is string {\n return typeof value === 'string' && value.length <= 250 && MESSAGE_ID.test(value)\n}\n\n/** A domain as a `Message-ID` can carry it, or null. */\nfunction messageIdDomain(value: string | null | undefined): string | null {\n const raw = String(value ?? '').trim().toLowerCase()\n // A `From` value (\"Acme <hello@acme.com>\") gives its address's domain.\n const domain = raw.includes('@') ? raw.slice(raw.lastIndexOf('@') + 1).replace(/>.*$/, '') : raw\n return DOMAIN.test(domain) ? domain : null\n}\n\n/** Sixteen random hex characters, from the platform's crypto. */\nfunction randomHex(): string {\n const bytes = new Uint8Array(8)\n globalThis.crypto.getRandomValues(bytes)\n return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/**\n * A fresh `Message-ID` on the first usable domain of `domains` (the sending\n * domain first; an address such as a `From` value gives its domain):\n * `<label.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>`. The time and 64 random bits\n * keep it unique; nothing in it identifies the customer or the record.\n * Falls back to `localhost.localdomain` only when no domain is usable, which\n * a send on a resolved identity never meets.\n */\nexport function mintEmailMessageId(\n domains: ReadonlyArray<string | null | undefined>,\n options: { label?: string; atMs?: number; random?: string } = {},\n): string {\n const domain = domains.map(messageIdDomain).find(Boolean) ?? 'localhost.localdomain'\n const label = String(options.label ?? 'm').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'm'\n const time = Math.max(0, Math.floor(Number(options.atMs ?? Date.now()) || 0)).toString(36)\n const random = String(options.random ?? randomHex()).toLowerCase().replace(/[^a-z0-9]+/g, '') || randomHex()\n return `<${label}.${time}.${random}@${domain}>`\n}\n\n/**\n * The root a conversation started by the message `messageId` runs under:\n * `<thread.quote.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>` for\n * `<quote.k3x9m1.9f2c4e1a0b7d3e65@mail.acme.com>`. Derived, so the headers\n * the first message goes out with and the thread kept after it agree without\n * either being handed the other; distinct from every id a message carries,\n * so a provider that replaces `Message-ID` cannot replace it. Null when\n * `messageId` is not a `Message-ID`, or the root would be too long for one.\n */\nexport function emailThreadRootId(messageId: string): string | null {\n if (!isEmailMessageId(messageId)) return null\n const root = `<thread.${messageId.slice(1)}`\n return isEmailMessageId(root) ? root : null\n}\n\n/** The root and the latest ids, at most {@link EMAIL_THREAD_MAX_REFERENCES}. */\nfunction capReferences(ids: readonly string[]): string[] {\n const unique = ids.filter((id, index) => ids.indexOf(id) === index)\n if (unique.length <= EMAIL_THREAD_MAX_REFERENCES) return unique\n return [unique[0], ...unique.slice(unique.length - (EMAIL_THREAD_MAX_REFERENCES - 1))]\n}\n\n/**\n * A stored thread, checked, or null. Every id must be a well-formed\n * `Message-ID`, because each is written back into a header: a value that\n * could carry a line break never reaches one.\n */\nexport function readEmailThread(raw: unknown): EmailThread | null {\n if (!raw || typeof raw !== 'object') return null\n const source = raw as Record<string, unknown>\n const messageId = source['messageId']\n if (!isEmailMessageId(messageId)) return null\n const listed = Array.isArray(source['references']) ? (source['references'] as unknown[]).filter(isEmailMessageId) : []\n const references = capReferences([...listed.filter((id) => id !== messageId), messageId])\n const subject = String(source['subject'] ?? '').replace(/[\\r\\n]+/g, ' ').trim().slice(0, MAX_SUBJECT)\n return { messageId, references, subject }\n}\n\n/** The subject a message in `thread` goes under: the thread's, else `fallback`. */\nexport function emailThreadSubject(thread: EmailThread | null | undefined, fallback: string): string {\n const own = readEmailThread(thread)?.subject\n return own || String(fallback ?? '').replace(/[\\r\\n]+/g, ' ').trim()\n}\n\n/**\n * The headers that send a message with `Message-ID` `messageId` into\n * `thread`: the id itself, and, when the thread has begun, `In-Reply-To` the\n * latest message and `References` the root and the chain. With no thread, the\n * message starts one: its own id, and the root it starts\n * ({@link emailThreadRootId}) in `References`.\n */\nexport function nextThreadHeaders(\n thread: EmailThread | null | undefined,\n messageId: string,\n): Record<string, string> {\n if (!isEmailMessageId(messageId)) throw new Error('nextThreadHeaders: not a Message-ID')\n const current = readEmailThread(thread)\n if (!current) {\n const root = emailThreadRootId(messageId)\n return root ? { 'Message-ID': messageId, References: root } : { 'Message-ID': messageId }\n }\n return {\n 'Message-ID': messageId,\n 'In-Reply-To': current.messageId,\n References: capReferences(current.references).join(' '),\n }\n}\n\n/**\n * The thread once a message went out in it: the message is the latest, the\n * chain grows by it, and the subject stays the thread's. A message that\n * started the thread gives it its root (the one {@link nextThreadHeaders}\n * sent it with) ahead of its own id, and its subject.\n */\nexport function emailThreadAfterSend(\n thread: EmailThread | null | undefined,\n sent: { messageId: string; subject: string },\n): EmailThread {\n if (!isEmailMessageId(sent.messageId)) throw new Error('emailThreadAfterSend: not a Message-ID')\n const current = readEmailThread(thread)\n const before = current ? current.references : [emailThreadRootId(sent.messageId)].filter(isEmailMessageId)\n return {\n messageId: sent.messageId,\n references: capReferences([...before, sent.messageId]),\n subject: emailThreadSubject(current, sent.subject).slice(0, MAX_SUBJECT),\n }\n}\n"],"names":["EMAIL_THREAD_MAX_REFERENCES","MAX_SUBJECT","MESSAGE_ID","DOMAIN","isEmailMessageId","value","length","test","messageIdDomain","raw","String","trim","toLowerCase","domain","includes","slice","lastIndexOf","replace","randomHex","bytes","Uint8Array","globalThis","crypto","getRandomValues","Array","from","byte","toString","padStart","join","mintEmailMessageId","domains","options","map","find","Boolean","label","time","Math","max","floor","Number","atMs","Date","now","random","emailThreadRootId","messageId","root","capReferences","ids","unique","filter","id","index","indexOf","readEmailThread","source","listed","isArray","references","subject","emailThreadSubject","thread","fallback","own","nextThreadHeaders","Error","current","References","emailThreadAfterSend","sent","before"],"mappings":"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"}
|
|
@@ -1465,12 +1465,12 @@ const BASE_SYSTEM_EMAIL_TEMPLATES = [
|
|
|
1465
1465
|
},
|
|
1466
1466
|
{
|
|
1467
1467
|
name: 'notification.context',
|
|
1468
|
-
description: 'The site and workspace it is about, when it is about one',
|
|
1468
|
+
description: 'The site and workspace it is about, when it is about one, after what it is ' + 'when its sender names that ("Proof approved")',
|
|
1469
1469
|
sample: 'Copper Kettle Catering · Test Org'
|
|
1470
1470
|
},
|
|
1471
1471
|
{
|
|
1472
1472
|
name: 'notification.action',
|
|
1473
|
-
description: 'What the button opens: "View submission", "View order", or "Open in" the product',
|
|
1473
|
+
description: 'What the button opens: "View submission", "View order", what its sender names ' + '("View quote"), or "Open in" the product',
|
|
1474
1474
|
sample: 'View submission'
|
|
1475
1475
|
},
|
|
1476
1476
|
{
|